ciphermesh 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ // ── Provisioning a second device ────────────────────────────────
2
+ //
3
+ // Step 5 of multi-device (docs/design/multi-device.md, item 4 of #481).
4
+ //
5
+ // **The identity secret never moves.** A second device generates its own box
6
+ // keypair and receives only the identity's *public* key plus a device list
7
+ // signed by it. That costs one thing and buys two: a secondary cannot add or
8
+ // revoke devices — only the device holding the identity secret can — and a
9
+ // stolen phone is a stolen phone rather than a stolen identity. Today's
10
+ // `/backup` does the opposite, copying the whole identity, which is the
11
+ // configuration this arc exists to replace.
12
+ //
13
+ // Because neither side can sign for the other, provisioning is two hops and
14
+ // there is no way around that: the new device has to say what its key is before
15
+ // the identity can sign for it, and it has to be told what identity it now
16
+ // belongs to afterwards.
17
+ //
18
+ // B: /device request → ciphermesh-device://request/… (~150 bytes)
19
+ // A: /device add <request> → ciphermesh-device://grant/… (~740 bytes)
20
+ // B: /device accept <grant>
21
+ //
22
+ // A byte-mode QR code holds about 2 200 characters, so both fit with room for
23
+ // more devices. Neither is secret: a request is a public key, and a grant is a signed statement that
24
+ // was going to be broadcast to every peer anyway. Interception achieves
25
+ // nothing; substitution is caught, because a grant only applies if it names the
26
+ // exact device that asked.
27
+
28
+ const SCHEME = 'ciphermesh-device://';
29
+ const MAX_ENCODED = 8192; // a grant with the maximum eight devices, with room
30
+
31
+ const DEVICE_ID = /^[0-9a-f]{32}$/;
32
+
33
+ function encode(kind, body) {
34
+ const json = Buffer.from(JSON.stringify(body), 'utf-8');
35
+ return `${SCHEME}${kind}/${json.toString('base64url')}`;
36
+ }
37
+
38
+ function decode(kind, text) {
39
+ if (typeof text !== 'string') {
40
+ return null;
41
+ }
42
+ const prefix = `${SCHEME}${kind}/`;
43
+ if (!text.startsWith(prefix) || text.length > MAX_ENCODED) {
44
+ return null;
45
+ }
46
+ try {
47
+ const parsed = JSON.parse(
48
+ Buffer.from(text.slice(prefix.length), 'base64url').toString('utf-8'),
49
+ );
50
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * What a new device says about itself: the two fields the identity has to sign
58
+ * over. Nothing secret, and nothing the peer could not have learned anyway.
59
+ */
60
+ export function buildDeviceRequest({ deviceId, boxPk, label = '' }) {
61
+ if (!DEVICE_ID.test(deviceId ?? '') || typeof boxPk !== 'string' || boxPk.length === 0) {
62
+ return null;
63
+ }
64
+ return encode('request', { deviceId, boxPk, label: String(label).slice(0, 32) });
65
+ }
66
+
67
+ /** @returns {{deviceId: string, boxPk: string, label: string}|null} */
68
+ export function parseDeviceRequest(text) {
69
+ const body = decode('request', text);
70
+ if (!body || !DEVICE_ID.test(body.deviceId ?? '')) {
71
+ return null;
72
+ }
73
+ if (typeof body.boxPk !== 'string' || body.boxPk.length === 0) {
74
+ return null;
75
+ }
76
+ if (body.label !== undefined && typeof body.label !== 'string') {
77
+ return null;
78
+ }
79
+ return { deviceId: body.deviceId, boxPk: body.boxPk, label: body.label ?? '' };
80
+ }
81
+
82
+ /**
83
+ * What the identity hands back: which identity this device now belongs to, and
84
+ * the signed list saying so.
85
+ *
86
+ * The identity key is in here as well as inside the list because the receiving
87
+ * device has no other way to learn it — and it is checked against the list's
88
+ * own `identityPk` on the way in, so the two cannot disagree.
89
+ */
90
+ export function buildDeviceGrant({ identityPk, list }) {
91
+ if (typeof identityPk !== 'string' || !list || typeof list !== 'object') {
92
+ return null;
93
+ }
94
+ return encode('grant', { identityPk, list });
95
+ }
96
+
97
+ /** @returns {{identityPk: string, list: object}|null} */
98
+ export function parseDeviceGrant(text) {
99
+ const body = decode('grant', text);
100
+ if (!body || typeof body.identityPk !== 'string') {
101
+ return null;
102
+ }
103
+ if (!body.list || typeof body.list !== 'object' || Array.isArray(body.list)) {
104
+ return null;
105
+ }
106
+ // A grant whose envelope disagrees with the list it carries is malformed, not
107
+ // a decision to make later.
108
+ if (body.list.identityPk !== body.identityPk) {
109
+ return null;
110
+ }
111
+ return { identityPk: body.identityPk, list: body.list };
112
+ }