aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/transcripts.js
blob: 0b6d0c0201d96c54d00267ad93b0a8c4c8a7ba2f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
/**
 * What a node identity signs, built here from named fields — never bytes the
 * page chose.
 *
 * The page parses content from nodes, which is attacker-controlled input
 * (docs/MESHBAY_DESIGN.md §8.2). Were it able to hand this process bytes to
 * sign, a script there would get a signature over anything: the approval of a
 * device key of its own, which outlives every session, or an operation this
 * application would otherwise have asked the person about. So the page names a
 * kind and gives the fields, the bytes are built here — with this identity's
 * own public keys wherever a transcript names them — and a kind outside this
 * list is not signed at all.
 *
 * Byte for byte the transcripts of meshbay_common (join.py, device.py,
 * adminop.py, chatbox.py) and of the page (crypto.js, transcriptFor);
 * test_desktop_keyring.py holds the three together.
 */

'use strict';

const enc = (s) => Buffer.from(String(s), 'utf8');

function lenPrefixed(prefix, parts) {
  const chunks = [Buffer.from(prefix)];
  for (const p of parts) {
    const len = Buffer.alloc(4);
    len.writeUInt32BE(p.length, 0);
    chunks.push(len, Buffer.from(p));
  }
  return Buffer.concat(chunks);
}

// ── Field checks ──────────────────────────────────────────────────────────
//
// Shapes, not trust: what is checked here is that a field is what its name
// says, so that nothing unexpected reaches a transcript or a dialog.

function refuse(what) { throw new Error(`Refused: ${what}`); }

function bytes(v, what, { min = 1, max = 64 } = {}) {
  const s = String(v ?? '');
  if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s)) refuse(`${what} is not base64`);
  const b = Buffer.from(s, 'base64');
  if (b.length < min || b.length > max) refuse(`${what} has the wrong length`);
  return b;
}

function key32(v, what) {
  bytes(v, what, { min: 32, max: 32 });
  return String(v);
}

function text(v, what, max = 256) {
  const s = String(v ?? '');
  if (s.length > max) refuse(`${what} is too long`);
  return s;
}

function groupId(v) {
  const s = String(v ?? '');
  if (s && !/^[A-Za-z0-9_-]{1,64}$/.test(s)) refuse('not a group id');
  return s;
}

// The node's clock and ours: a signature for a moment far from now is one to
// keep for later.
const TS_SLACK_S = 600;
function timestamp(v) {
  const n = Number(v);
  if (!Number.isInteger(n) || Math.abs(n - Date.now() / 1000) > TS_SLACK_S) {
    refuse('the timestamp is not now');
  }
  return n;
}

// ── Transcripts ───────────────────────────────────────────────────────────

const PREFIX = {
  join: 'meshbay:join:v1',
  device_request: 'meshbay:device_req:v1',
  device_add: 'meshbay:device_add:v1',
  device_revoke: 'meshbay:device_revoke:v1',
  device_hello: 'meshbay:device_hello:v1',
  chat: 'meshbay:chat:v1',
  admin: 'meshbay:admin:v1',
};

/**
 * `ctx`: what this process knows and the page does not get to say — the
 * account (`userId`), the node (`nodePk`) and this identity's public keys
 * (`pkEdB64`, `pkXB64`). A field naming another account or another node is
 * refused rather than signed.
 */
function transcriptFor(kind, f, ctx) {
  const fields = f && typeof f === 'object' ? f : {};
  const sameNode = () => {
    if (String(fields.nodePk ?? '') !== ctx.nodePk) refuse('another node');
    return ctx.nodePk;
  };
  const sameUser = () => {
    if (String(fields.userId ?? '') !== ctx.userId) refuse('another account');
    return ctx.userId;
  };
  const nonceNode = () => bytes(fields.nonceNode, 'the node nonce', { min: 16, max: 64 });

  switch (kind) {
    case 'join':
      return lenPrefixed(PREFIX.join, [
        enc(sameNode()), enc(groupId(fields.groupId)), enc(sameUser()),
        enc(ctx.pkEdB64), enc(ctx.pkXB64), nonceNode(), enc(timestamp(fields.ts)),
      ]);
    case 'device_hello':
      return lenPrefixed(PREFIX.device_hello, [
        enc(sameNode()), enc(groupId(fields.groupId)), enc(sameUser()),
        enc(ctx.pkEdB64), nonceNode(), enc(timestamp(fields.ts)),
      ]);
    case 'device_request': {
      const codeHash = String(fields.codeHash ?? '');
      if (!/^[0-9a-f]{64}$/.test(codeHash)) refuse('not a request hash');
      return lenPrefixed(PREFIX.device_request, [
        enc(sameNode()), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64),
        enc(codeHash), nonceNode(), enc(timestamp(fields.ts)),
      ]);
    }
    case 'device_add':
      return lenPrefixed(PREFIX.device_add, [
        enc(sameNode()), enc(sameUser()), enc(key32(fields.pkEd, 'the device key')),
        enc(key32(fields.pkX, 'the device key')), nonceNode(), enc(timestamp(fields.ts)),
      ]);
    case 'device_revoke':
      return lenPrefixed(PREFIX.device_revoke, [
        enc(sameNode()), enc(sameUser()), enc(key32(fields.pkEd, 'the device key')),
        nonceNode(), enc(timestamp(fields.ts)),
      ]);
    case 'chat': {
      const epoch = Number(fields.epoch);
      if (!Number.isInteger(epoch) || epoch < 0) refuse('not an epoch');
      return lenPrefixed(PREFIX.chat, [
        enc(groupId(fields.groupId)), enc(epoch), Buffer.from(ctx.pkEdB64, 'base64'),
        bytes(fields.nonce, 'the message nonce', { min: 12, max: 24 }),
        bytes(fields.ct, 'the message', { min: 1, max: 8 * 1024 * 1024 }),
      ]);
    }
    case 'admin': {
      const op = String(fields.op ?? '');
      if (!/^[a-z_]{1,32}$/.test(op)) refuse('not an operation');
      return lenPrefixed(PREFIX.admin, [
        enc(op), enc(sameNode()), enc(groupId(fields.groupId)),
        enc(text(fields.subject, 'the subject', 16384)),
        bytes(fields.nonce, 'the challenge nonce', { min: 16, max: 64 }),
        enc(timestamp(fields.ts)),
      ]);
    }
    default:
      return refuse(`nothing is signed as "${String(kind).slice(0, 32)}"`);
  }
}

module.exports = { transcriptFor };