replica-bridge 0.3.20 → 0.3.22
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.
- package/package.json +1 -1
- package/src/bridge/board.js +5 -2
- package/src/bridge/main.js +126 -5
- package/src/bridge/pair.js +2 -0
- package/src/bridge/ring.js +427 -0
- package/src/bridge/service.js +402 -25
- package/src/bridge/state.js +53 -1
- package/src/http.js +5 -2
- package/src/protocol.js +704 -3
- package/src/relay/config.js +8 -0
package/src/protocol.js
CHANGED
|
@@ -232,8 +232,8 @@ export class FrameCipher {
|
|
|
232
232
|
|
|
233
233
|
/** What is inside a data frame. */
|
|
234
234
|
|
|
235
|
-
|
|
236
235
|
|
|
236
|
+
|
|
237
237
|
|
|
238
238
|
|
|
239
239
|
|
|
@@ -245,7 +245,8 @@ export class FrameCipher {
|
|
|
245
245
|
|
|
246
246
|
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
|
|
249
|
+
|
|
249
250
|
|
|
250
251
|
/** Agens Work's main conversation on this computer, or why there is none (PROTOCOL.md "Work", 0.3.19). */
|
|
251
252
|
|
|
@@ -331,7 +332,7 @@ export class ChunkAssembler {
|
|
|
331
332
|
|
|
332
333
|
/** Relay ↔ host multiplexing: one host WebSocket carries every phone connection. */
|
|
333
334
|
|
|
334
|
-
|
|
335
|
+
|
|
335
336
|
|
|
336
337
|
|
|
337
338
|
|
|
@@ -385,3 +386,703 @@ export class StreamBuffer {
|
|
|
385
386
|
return true;
|
|
386
387
|
}
|
|
387
388
|
}
|
|
389
|
+
|
|
390
|
+
// MARK: - Device ring entries (ring-v1, PROTOCOL.md "设备环条目")
|
|
391
|
+
//
|
|
392
|
+
// The owner's devices form one ring: a log of entries, each signed with a signer's Ed25519 key and chained to the one
|
|
393
|
+
// before by its hash. Whoever holds the log replays it with RingState and gets the same members. Only the entry format
|
|
394
|
+
// and its rules are here; how the relay stores the log and how peers exchange it is still a design
|
|
395
|
+
// (docs/design/DEVICE-RING.md), and nothing on the wire uses these yet. The relay-v1 handshake is unchanged.
|
|
396
|
+
|
|
397
|
+
export const RING_PROTOCOL = "agens-ring-v1";
|
|
398
|
+
/** First field of every entry, so an entry signature can never be mistaken for another message signed with the same key. */
|
|
399
|
+
export const RING_ENTRY_TAG = `${RING_PROTOCOL}/entry`;
|
|
400
|
+
export const RING_ENTRY_MAX_BYTES = 16 * 1024;
|
|
401
|
+
export const RING_NAME_MAX_BYTES = 128;
|
|
402
|
+
export const RING_MACHINE_MEMBERS_MAX = 64;
|
|
403
|
+
const RING_NONCE_BYTES = 16;
|
|
404
|
+
const RING_RECOVERY_MAX_BYTES = 1024;
|
|
405
|
+
const RING_SSH_KEY_MAX_BYTES = 1024;
|
|
406
|
+
const ZERO_HASH = Buffer.alloc(32);
|
|
407
|
+
|
|
408
|
+
// Ed25519 keys as raw 32-byte values (CryptoKit's Curve25519.Signing rawRepresentation): the private key is the seed.
|
|
409
|
+
const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
410
|
+
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
411
|
+
|
|
412
|
+
export function signingKeyPairFromPrivate(privateKey ) {
|
|
413
|
+
if (privateKey.length !== 32) throw new Error("Ed25519 private keys are 32 bytes");
|
|
414
|
+
const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, privateKey]), format: "der", type: "pkcs8" });
|
|
415
|
+
const spki = crypto.createPublicKey(key).export({ format: "der", type: "spki" });
|
|
416
|
+
return { privateKey, publicKey: Buffer.from(spki.subarray(SPKI_ED25519_PREFIX.length)) };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export const generateSigningKeyPair = () => signingKeyPairFromPrivate(crypto.randomBytes(32));
|
|
420
|
+
|
|
421
|
+
/** Ed25519 (RFC 8032, deterministic). CryptoKit's signatures are randomized, so they differ byte for byte but verify the same. */
|
|
422
|
+
export function ed25519Sign(privateKey , message ) {
|
|
423
|
+
return crypto.sign(null, message, crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, privateKey]), format: "der", type: "pkcs8" }));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Ed25519 verification (RFC 8032, strict), for a runtime whose node:crypto lacks it (bridge/embedded.ts). */
|
|
427
|
+
|
|
428
|
+
let ed25519Verifier = null;
|
|
429
|
+
export function useEd25519Verifier(verifier ) { ed25519Verifier = verifier; }
|
|
430
|
+
|
|
431
|
+
// Strict Ed25519 (PROTOCOL.md "签名的严格规则"): libraries disagree on small-order and non-canonical points and on
|
|
432
|
+
// S >= L (CryptoKit accepts a small-order public key that OpenSSL refuses), and a log that one device accepts and
|
|
433
|
+
// another refuses splits the ring for good. So these are checked here, byte by byte, before any library sees the
|
|
434
|
+
// signature, and the answer never depends on which library or version runs.
|
|
435
|
+
|
|
436
|
+
/** p = 2^255 - 19 and the group order L, little-endian as they are compared. */
|
|
437
|
+
const ED25519_P = (1n << 255n) - 19n;
|
|
438
|
+
export const ED25519_L = (1n << 252n) + 27742317777372353535851937790883648493n;
|
|
439
|
+
const leInt = (bytes ) => { let n = 0n; for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]); return n; };
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The y-coordinates of the eight points of small order (1, 2, 4 and 8), as 255-bit little-endian integers: an
|
|
443
|
+
* encoding whose low 255 bits are one of these is refused whatever its sign bit (the sign-bit forms of y = 1 and
|
|
444
|
+
* y = p - 1 are also the non-canonical "-0"). Same list as libsodium's has_small_order, without its y >= p entries,
|
|
445
|
+
* which the canonical check below refuses.
|
|
446
|
+
*/
|
|
447
|
+
export const ED25519_SMALL_ORDER_Y = [
|
|
448
|
+
0n, // order 4: (±sqrt(-1), 0)
|
|
449
|
+
1n, // order 1: the identity
|
|
450
|
+
ED25519_P - 1n, // order 2
|
|
451
|
+
leInt(Buffer.from("26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", "hex")), // order 8
|
|
452
|
+
leInt(Buffer.from("c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", "hex")), // order 8
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
/** Why a 32-byte point encoding is refused: "non-canonical" (y >= p), "small-order", or null when it is acceptable. */
|
|
456
|
+
export function ed25519PointFault(encoded ) {
|
|
457
|
+
if (encoded.length !== 32) return "non-canonical";
|
|
458
|
+
const y = leInt(encoded) & ((1n << 255n) - 1n);
|
|
459
|
+
if (y >= ED25519_P) return "non-canonical";
|
|
460
|
+
if (ED25519_SMALL_ORDER_Y.includes(y)) return "small-order";
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The checks before verification: A and R canonical and not of small order, S < L. Null when they pass. */
|
|
465
|
+
export function ed25519StrictFault(publicKey , signature ) {
|
|
466
|
+
if (publicKey.length !== 32 || signature.length !== 64) return "a public key is 32 bytes and a signature 64";
|
|
467
|
+
const a = ed25519PointFault(publicKey);
|
|
468
|
+
if (a) return `the public key is ${a}`;
|
|
469
|
+
const r = ed25519PointFault(signature.subarray(0, 32));
|
|
470
|
+
if (r) return `R is ${r}`;
|
|
471
|
+
if (leInt(signature.subarray(32)) >= ED25519_L) return "S is not below L";
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function ed25519Verify(publicKey , message , signature ) {
|
|
476
|
+
if (ed25519StrictFault(publicKey, signature)) return false;
|
|
477
|
+
if (ed25519Verifier) { try { return ed25519Verifier(publicKey, message, signature); } catch { return false; } }
|
|
478
|
+
try {
|
|
479
|
+
return crypto.verify(null, message, crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, publicKey]), format: "der", type: "spki" }), signature);
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** A ring entry's fault, by a code both the Node and the Swift side report (test-vectors/device-ring-v1.json `invalid`). */
|
|
486
|
+
export class RingError extends Error {
|
|
487
|
+
code ;
|
|
488
|
+
constructor(code , message ) { super(`${code}: ${message}`); this.code = code; }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The canonical encoding: every value is a byte string with a 4-byte big-endian length in front, numbers are 8-byte
|
|
492
|
+
// big-endian, and nothing may follow the last field. Entries travel as these bytes, so nobody re-encodes before checking.
|
|
493
|
+
// Every text rule below is defined on bytes or code points (PROTOCOL.md), never through regex anchors or trim, whose
|
|
494
|
+
// meaning differs between JavaScript, Python and Swift.
|
|
495
|
+
|
|
496
|
+
const u32 = (value ) => { const out = Buffer.alloc(4); out.writeUInt32BE(value); return out; };
|
|
497
|
+
const u64 = (value ) => { const out = Buffer.alloc(8); out.writeBigUInt64BE(BigInt(value)); return out; };
|
|
498
|
+
const bstr = (value ) => { const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value; return Buffer.concat([u32(bytes.length), bytes]); };
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Canonical base64url without padding, or `bad_encoding`: only [A-Za-z0-9_-], a length that is not 1 modulo 4, and
|
|
502
|
+
* zero unused bits in the last character (so every byte string has exactly one spelling). Buffer.from(…, "base64url")
|
|
503
|
+
* alone skips characters it does not know.
|
|
504
|
+
*/
|
|
505
|
+
export function ringB64(text ) {
|
|
506
|
+
if (typeof text !== "string" || text.length % 4 === 1) throw new RingError("bad_encoding", "not canonical base64url");
|
|
507
|
+
for (let i = 0; i < text.length; i++) {
|
|
508
|
+
const c = text.charCodeAt(i);
|
|
509
|
+
const ok = (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39) || c === 0x2d || c === 0x5f;
|
|
510
|
+
if (!ok) throw new RingError("bad_encoding", "not canonical base64url");
|
|
511
|
+
}
|
|
512
|
+
const bytes = Buffer.from(text, "base64url");
|
|
513
|
+
if (b64url(bytes) !== text) throw new RingError("bad_encoding", "not canonical base64url");
|
|
514
|
+
return bytes;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** What an entry says, before it is signed. */
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
/** An entry on the wire and on disk: the exact encoded bytes and the signer's signature over them. */
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
const isLower = (b ) => b >= 0x61 && b <= 0x7a;
|
|
536
|
+
const isDigit = (b ) => b >= 0x30 && b <= 0x39;
|
|
537
|
+
/** `kind`: 1–32 bytes, the first a–z, the rest a–z, 0–9 or `-`. */
|
|
538
|
+
function isKind(bytes ) {
|
|
539
|
+
if (bytes.length < 1 || bytes.length > 32 || !isLower(bytes[0])) return false;
|
|
540
|
+
for (const b of bytes) if (!isLower(b) && !isDigit(b) && b !== 0x2d) return false;
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
/** `type`: 1–32 bytes, each a–z, 0–9 or `-`. */
|
|
544
|
+
function isType(bytes ) {
|
|
545
|
+
if (bytes.length < 1 || bytes.length > 32) return false;
|
|
546
|
+
for (const b of bytes) if (!isLower(b) && !isDigit(b) && b !== 0x2d) return false;
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function encodeRingEntry(body ) {
|
|
551
|
+
for (const n of [body.seq, body.at]) if (!Number.isSafeInteger(n) || n < 0) throw new RingError("bad_encoding", "seq and at are non-negative integers");
|
|
552
|
+
return Buffer.concat([
|
|
553
|
+
bstr(RING_ENTRY_TAG), u64(body.seq), bstr(body.prev), bstr(body.kind), bstr(body.signer), u64(body.at),
|
|
554
|
+
u32(body.fields.length), ...body.fields.map((field) => bstr(field)),
|
|
555
|
+
]);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Strict: the size, the tag, every length, no bytes left over, then prev, signer and kind. */
|
|
559
|
+
export function decodeRingEntry(bytes ) {
|
|
560
|
+
if (bytes.length > RING_ENTRY_MAX_BYTES) throw new RingError("too_large", `an entry is at most ${RING_ENTRY_MAX_BYTES} bytes`);
|
|
561
|
+
let offset = 0;
|
|
562
|
+
const need = (n ) => { if (offset + n > bytes.length) throw new RingError("bad_encoding", "entry ends early"); };
|
|
563
|
+
const readU32 = () => { need(4); const v = bytes.readUInt32BE(offset); offset += 4; return v; };
|
|
564
|
+
const readU64 = () => {
|
|
565
|
+
need(8); const v = bytes.readBigUInt64BE(offset); offset += 8;
|
|
566
|
+
if (v > BigInt(Number.MAX_SAFE_INTEGER)) throw new RingError("bad_encoding", "number out of range");
|
|
567
|
+
return Number(v);
|
|
568
|
+
};
|
|
569
|
+
const readBytes = () => { const n = readU32(); need(n); const v = bytes.subarray(offset, offset + n); offset += n; return Buffer.from(v); };
|
|
570
|
+
if (!readBytes().equals(Buffer.from(RING_ENTRY_TAG))) throw new RingError("bad_encoding", "not a ring-v1 entry");
|
|
571
|
+
const seq = readU64();
|
|
572
|
+
const prev = readBytes();
|
|
573
|
+
const kind = readBytes();
|
|
574
|
+
const signer = readBytes();
|
|
575
|
+
const at = readU64();
|
|
576
|
+
const count = readU32();
|
|
577
|
+
const fields = [];
|
|
578
|
+
for (let i = 0; i < count; i++) fields.push(readBytes());
|
|
579
|
+
if (offset !== bytes.length) throw new RingError("bad_encoding", "bytes after the last field");
|
|
580
|
+
if (prev.length !== 32 || signer.length !== 32) throw new RingError("bad_encoding", "prev and signer are 32 bytes");
|
|
581
|
+
if (!isKind(kind)) throw new RingError("bad_encoding", "kind is 1-32 bytes of a-z, 0-9 and -, starting with a-z");
|
|
582
|
+
return { seq, prev, kind: kind.toString("latin1"), signer, at, fields };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** An entry's id: SHA-256 of its encoded bytes. The signature is not part of it, so a re-signed copy keeps its id. */
|
|
586
|
+
export const ringEntryId = (bytes ) => sha256(bytes);
|
|
587
|
+
|
|
588
|
+
export function signRingEntry(signing , body ) {
|
|
589
|
+
const bytes = encodeRingEntry({ ...body, signer: signing.publicKey });
|
|
590
|
+
return { e: b64url(bytes), s: b64url(ed25519Sign(signing.privateKey, bytes)) };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
/** `x25519`: a relay-v1 key (Hs or Ds). `ssh`: an SSH server's host key, the OpenSSH public key blob; members only. */
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
/** Who a genesis or add entry brings in. */
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
const memberFields = (m ) => [
|
|
619
|
+
Buffer.from(m.keyType), m.key, m.signingKey ?? Buffer.alloc(0), Buffer.from(m.role), Buffer.from(m.name, "utf8"), Buffer.from(m.type),
|
|
620
|
+
];
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Code points a name may not contain anywhere: C0 and C1 controls, DEL, the bidi marks and overrides the relay strips
|
|
624
|
+
* (server.ts cleanName), the line and paragraph separators, and U+FEFF.
|
|
625
|
+
*/
|
|
626
|
+
function nameForbidden(cp ) {
|
|
627
|
+
return cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f) || cp === 0x061c || cp === 0x200e || cp === 0x200f || cp === 0x2028 || cp === 0x2029
|
|
628
|
+
|| (cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069) || cp === 0xfeff;
|
|
629
|
+
}
|
|
630
|
+
/** Spaces a name may not start or end with (the rest of Unicode's Zs; the other whitespace is forbidden anywhere). */
|
|
631
|
+
function nameEdgeSpace(cp ) {
|
|
632
|
+
return cp === 0x20 || cp === 0xa0 || cp === 0x1680 || (cp >= 0x2000 && cp <= 0x200a) || cp === 0x202f || cp === 0x205f || cp === 0x3000;
|
|
633
|
+
}
|
|
634
|
+
const strictUTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
635
|
+
|
|
636
|
+
/** The name rules (PROTOCOL.md): 1–128 bytes (0 allowed where noted), strict UTF-8, no forbidden code points, no edge spaces. */
|
|
637
|
+
function checkName(bytes , allowEmpty = false) {
|
|
638
|
+
if (bytes.length > RING_NAME_MAX_BYTES || (!allowEmpty && bytes.length === 0)) throw new RingError("bad_fields", `a name is 1 to ${RING_NAME_MAX_BYTES} bytes`);
|
|
639
|
+
let name ;
|
|
640
|
+
try { name = strictUTF8.decode(bytes); } catch { throw new RingError("bad_fields", "a name is UTF-8"); }
|
|
641
|
+
const points = Array.from(name, (c) => c.codePointAt(0) );
|
|
642
|
+
if (points.some(nameForbidden)) throw new RingError("bad_fields", "a name has no control, bidi, separator or U+FEFF characters");
|
|
643
|
+
if (points.length && (nameEdgeSpace(points[0]) || nameEdgeSpace(points[points.length - 1]))) throw new RingError("bad_fields", "a name does not start or end with a space");
|
|
644
|
+
return name;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Something a person can see: not whitespace, not a default-ignorable or format character, not a lone mark. */
|
|
648
|
+
const VISIBLE = /[^\p{White_Space}\p{Default_Ignorable_Code_Point}\p{Cc}\p{Cf}\p{M}]/u;
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Makes a name the rules accept, for a signer about to sign one it did not type (a computer's name from the relay):
|
|
652
|
+
* drops forbidden code points and lone surrogates, drops edge spaces, and cuts at a code point boundary to fit 128
|
|
653
|
+
* UTF-8 bytes. A name with nothing visible left becomes `fallback`. The result is exactly what it encodes to (no
|
|
654
|
+
* U+FFFD stands in for anything), so the bytes signed are the string shown.
|
|
655
|
+
*/
|
|
656
|
+
export function ringName(text , fallback ) {
|
|
657
|
+
let points = Array.from(text).filter((c) => {
|
|
658
|
+
const cp = c.codePointAt(0) ;
|
|
659
|
+
return !nameForbidden(cp) && !(cp >= 0xd800 && cp <= 0xdfff);
|
|
660
|
+
});
|
|
661
|
+
const trim = () => {
|
|
662
|
+
while (points.length && nameEdgeSpace(points[0].codePointAt(0) )) points.shift();
|
|
663
|
+
while (points.length && nameEdgeSpace(points[points.length - 1].codePointAt(0) )) points.pop();
|
|
664
|
+
};
|
|
665
|
+
trim();
|
|
666
|
+
let bytes = 0;
|
|
667
|
+
const kept = [];
|
|
668
|
+
for (const c of points) {
|
|
669
|
+
bytes += Buffer.byteLength(c, "utf8");
|
|
670
|
+
if (bytes > RING_NAME_MAX_BYTES) break;
|
|
671
|
+
kept.push(c);
|
|
672
|
+
}
|
|
673
|
+
points = kept;
|
|
674
|
+
trim();
|
|
675
|
+
const name = points.join("");
|
|
676
|
+
return VISIBLE.test(name) ? name : fallback;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const bytesAre = (bytes , text ) => bytes.equals(Buffer.from(text, "latin1"));
|
|
680
|
+
|
|
681
|
+
/** keyType, key, role, the signing key for that role, type, then name; every fault is bad_fields. */
|
|
682
|
+
function parseMemberFields(fields ) {
|
|
683
|
+
const [keyTypeBytes, key, signingKeyBytes, roleBytes, nameBytes, typeBytes] = fields;
|
|
684
|
+
let keyType ;
|
|
685
|
+
if (bytesAre(keyTypeBytes, "x25519")) { keyType = "x25519"; if (key.length !== 32) throw new RingError("bad_fields", "an x25519 key is 32 bytes"); }
|
|
686
|
+
else if (bytesAre(keyTypeBytes, "ssh")) { keyType = "ssh"; if (key.length < 1 || key.length > RING_SSH_KEY_MAX_BYTES) throw new RingError("bad_fields", "an ssh key is 1 to 1024 bytes"); }
|
|
687
|
+
else throw new RingError("bad_fields", "keyType is x25519 or ssh");
|
|
688
|
+
let role ;
|
|
689
|
+
if (bytesAre(roleBytes, "signer")) role = "signer";
|
|
690
|
+
else if (bytesAre(roleBytes, "member")) role = "member";
|
|
691
|
+
else throw new RingError("bad_fields", "role is signer or member");
|
|
692
|
+
if (role === "signer" && (signingKeyBytes.length !== 32 || keyType !== "x25519")) throw new RingError("bad_fields", "a signer has a 32-byte signing key and an x25519 key");
|
|
693
|
+
// A small-order signing key lets anyone sign for it under some libraries and nobody under others: the ring splits.
|
|
694
|
+
if (role === "signer" && ed25519PointFault(signingKeyBytes)) throw new RingError("bad_fields", `the signing key is ${ed25519PointFault(signingKeyBytes)}`);
|
|
695
|
+
if (role === "member" && signingKeyBytes.length !== 0) throw new RingError("bad_fields", "a member has no signing key");
|
|
696
|
+
if (!isType(typeBytes)) throw new RingError("bad_fields", "type is 1-32 bytes of a-z, 0-9 and -");
|
|
697
|
+
const name = checkName(nameBytes);
|
|
698
|
+
return { keyType, key, signingKey: role === "signer" ? signingKeyBytes : null, role, name, type: typeBytes.toString("latin1") };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const signingKeyId = (key ) => `ed25519:${b64url(key)}`;
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Checks a member the way an `add` entry with these fields would (bad_fields, RingError), without a ring: what a
|
|
705
|
+
* relay checks in a join claim, so the signer is never handed a name or key it could not sign into the ring.
|
|
706
|
+
*/
|
|
707
|
+
export function checkRingMember(info ) {
|
|
708
|
+
parseMemberFields(memberFields(info));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The members a log describes. `apply` checks one entry against everything before it and changes nothing when the
|
|
713
|
+
* entry is refused, so the relay, a bridge and a phone that replay the same log end with the same state.
|
|
714
|
+
*/
|
|
715
|
+
export class RingState {
|
|
716
|
+
/** The genesis entry's id; null until genesis is applied. */
|
|
717
|
+
ringId = null;
|
|
718
|
+
/** The last applied entry's seq and id. */
|
|
719
|
+
seq = -1;
|
|
720
|
+
head = ZERO_HASH;
|
|
721
|
+
members = new Map ();
|
|
722
|
+
machines = new Map ();
|
|
723
|
+
/** Keys revoked in this ring (`x25519:…`, `ssh:…`, `ed25519:…`); they never come back, the device makes new ones. */
|
|
724
|
+
retired = new Set ();
|
|
725
|
+
/** Entries of a kind this version does not know (controller and controller-revoke included): chained, no effect. */
|
|
726
|
+
ignored = 0;
|
|
727
|
+
|
|
728
|
+
get active() { return [...this.members.values()].filter((m) => m.revokedAt === null); }
|
|
729
|
+
get signers() { return this.active.filter((m) => m.role === "signer"); }
|
|
730
|
+
|
|
731
|
+
/** The active signers' X25519 keys (signers always have one). */
|
|
732
|
+
signerKeys() { return this.signers.map((m) => m.key); }
|
|
733
|
+
|
|
734
|
+
isRevokedKey(keyType , key ) { return this.retired.has(`${keyType}:${b64url(key)}`); }
|
|
735
|
+
|
|
736
|
+
activeMemberWithKey(keyType , key ) {
|
|
737
|
+
return this.active.find((m) => m.keyType === keyType && m.key.equals(key));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** An independent copy: applying to it leaves this one as it is (the relay checks a request on a copy). */
|
|
741
|
+
clone() {
|
|
742
|
+
const copy = new RingState();
|
|
743
|
+
copy.ringId = this.ringId;
|
|
744
|
+
copy.seq = this.seq;
|
|
745
|
+
copy.head = this.head;
|
|
746
|
+
copy.ignored = this.ignored;
|
|
747
|
+
for (const [id, member] of this.members) copy.members.set(id, { ...member });
|
|
748
|
+
for (const [id, machine] of this.machines) copy.machines.set(id, { ...machine, members: [...machine.members] });
|
|
749
|
+
for (const key of this.retired) copy.retired.add(key);
|
|
750
|
+
return copy;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Checks and applies one entry; returns its id. Throws RingError and leaves the state as it was when refused.
|
|
755
|
+
* `verified`: the caller already checked this exact signature (the relay replaying entries it stored after checking
|
|
756
|
+
* them); every other rule still runs. Never for an entry that came from anyone else.
|
|
757
|
+
*/
|
|
758
|
+
apply(entry , options = {}) {
|
|
759
|
+
const bytes = ringB64(entry.e);
|
|
760
|
+
const signature = ringB64(entry.s);
|
|
761
|
+
const body = decodeRingEntry(bytes);
|
|
762
|
+
const id = ringEntryId(bytes);
|
|
763
|
+
if (body.seq !== this.seq + 1) throw new RingError("wrong_seq", `expected seq ${this.seq + 1}, got ${body.seq}`);
|
|
764
|
+
if (!body.prev.equals(this.head)) throw new RingError("wrong_prev", "prev is not the id of the entry before");
|
|
765
|
+
if (!options.verified && !ed25519Verify(body.signer, bytes, signature)) throw new RingError("bad_signature", "the signature does not verify");
|
|
766
|
+
if ((this.ringId === null) !== (body.kind === "genesis")) throw new RingError("bad_genesis", "genesis is the first entry and only the first");
|
|
767
|
+
const effect = this.ringId === null ? this.genesis(body, id) : this.entry(body, id);
|
|
768
|
+
effect();
|
|
769
|
+
this.seq = body.seq;
|
|
770
|
+
this.head = id;
|
|
771
|
+
return id;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** The member a 32-byte id names, if it is still active. */
|
|
775
|
+
activeMember(idBytes ) {
|
|
776
|
+
if (idBytes.length !== 32) throw new RingError("bad_fields", "a member id is 32 bytes");
|
|
777
|
+
const member = this.members.get(b64url(idBytes));
|
|
778
|
+
if (!member || member.revokedAt !== null) throw new RingError("unknown_member", "no active member has this id");
|
|
779
|
+
return member;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
checkNewKeys(info ) {
|
|
783
|
+
if (this.isRevokedKey(info.keyType, info.key) || (info.signingKey && this.retired.has(signingKeyId(info.signingKey)))) {
|
|
784
|
+
throw new RingError("retired_key", "a key revoked in this ring cannot come back");
|
|
785
|
+
}
|
|
786
|
+
for (const m of this.active) {
|
|
787
|
+
if ((m.keyType === info.keyType && m.key.equals(info.key)) || (info.signingKey && m.signingKey?.equals(info.signingKey))) {
|
|
788
|
+
throw new RingError("duplicate_key", "an active member already has this key");
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Each returns a closure that makes the change, so nothing changes until every check has passed. The checks run in
|
|
794
|
+
// the order PROTOCOL.md lists, which decides the error code when an entry has several faults.
|
|
795
|
+
|
|
796
|
+
genesis(body , id ) {
|
|
797
|
+
if (body.fields.length !== 8) throw new RingError("bad_fields", "genesis has 8 fields");
|
|
798
|
+
const [nonce, recovery, ...rest] = body.fields;
|
|
799
|
+
if (nonce.length !== RING_NONCE_BYTES) throw new RingError("bad_fields", "the genesis nonce is 16 bytes");
|
|
800
|
+
if (recovery.length > RING_RECOVERY_MAX_BYTES) throw new RingError("bad_fields", "the recovery field is at most 1024 bytes");
|
|
801
|
+
const info = parseMemberFields(rest);
|
|
802
|
+
if (info.role !== "signer" || !info.signingKey .equals(body.signer)) throw new RingError("bad_genesis", "genesis is signed by the signer it founds");
|
|
803
|
+
return () => {
|
|
804
|
+
this.ringId = id;
|
|
805
|
+
this.members.set(b64url(id), { ...info, id: b64url(id), addedAt: 0, revokedAt: null });
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
entry(body , entryId ) {
|
|
810
|
+
const by = this.signers.find((m) => m.signingKey .equals(body.signer));
|
|
811
|
+
if (!by) throw new RingError("not_signer", "the entry is not signed by an active signer");
|
|
812
|
+
const { fields } = body;
|
|
813
|
+
switch (body.kind) {
|
|
814
|
+
case "add": {
|
|
815
|
+
if (fields.length !== 6) throw new RingError("bad_fields", "add has 6 fields");
|
|
816
|
+
const info = parseMemberFields(fields);
|
|
817
|
+
this.checkNewKeys(info);
|
|
818
|
+
const id = b64url(entryId);
|
|
819
|
+
return () => { this.members.set(id, { ...info, id, addedAt: body.seq, revokedAt: null }); };
|
|
820
|
+
}
|
|
821
|
+
case "revoke": {
|
|
822
|
+
if (fields.length !== 2) throw new RingError("bad_fields", "revoke has 2 fields");
|
|
823
|
+
const member = this.activeMember(fields[0]);
|
|
824
|
+
checkName(fields[1], true);
|
|
825
|
+
if (member.role === "signer" && this.signers.length === 1) throw new RingError("last_signer", "a ring keeps at least one signer");
|
|
826
|
+
return () => {
|
|
827
|
+
member.revokedAt = body.seq;
|
|
828
|
+
this.retired.add(`${member.keyType}:${b64url(member.key)}`);
|
|
829
|
+
if (member.signingKey) this.retired.add(signingKeyId(member.signingKey));
|
|
830
|
+
this.leaveMachines([member.id]);
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
case "machine": {
|
|
834
|
+
if (fields.length < 2 || fields.length > 2 + RING_MACHINE_MEMBERS_MAX) throw new RingError("bad_fields", `machine has 2 to ${2 + RING_MACHINE_MEMBERS_MAX} fields`);
|
|
835
|
+
const [machineIdBytes, nameBytes, ...memberIds] = fields;
|
|
836
|
+
if (machineIdBytes.length !== RING_NONCE_BYTES) throw new RingError("bad_fields", "a machine id is 16 bytes");
|
|
837
|
+
const machineId = b64url(machineIdBytes);
|
|
838
|
+
const members = memberIds.map((m) => this.activeMember(m).id);
|
|
839
|
+
if (new Set(members).size !== members.length) throw new RingError("bad_fields", "a machine lists a member once");
|
|
840
|
+
const name = checkName(nameBytes, members.length === 0);
|
|
841
|
+
if (members.length === 0 && !this.machines.has(machineId)) throw new RingError("unknown_machine", "only an existing machine can be emptied");
|
|
842
|
+
return () => {
|
|
843
|
+
this.machines.delete(machineId);
|
|
844
|
+
this.leaveMachines(members);
|
|
845
|
+
if (members.length) this.machines.set(machineId, { id: machineId, name, members });
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
default:
|
|
849
|
+
// Kinds this version does not know, including the reserved controller and controller-revoke: their fields are
|
|
850
|
+
// whatever a later version defines, so v1 checks none of them and gives them no effect.
|
|
851
|
+
return () => { this.ignored++; };
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
leaveMachines(memberIds ) {
|
|
856
|
+
for (const machine of [...this.machines.values()]) {
|
|
857
|
+
machine.members = machine.members.filter((m) => !memberIds.includes(m));
|
|
858
|
+
if (machine.members.length === 0) this.machines.delete(machine.id);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Replays a whole log from genesis. */
|
|
864
|
+
export function replayRing(entries ) {
|
|
865
|
+
const state = new RingState();
|
|
866
|
+
for (const entry of entries) state.apply(entry);
|
|
867
|
+
return state;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Builders for the signer's side. Each signs on top of `state` (its seq and head); the caller applies the result.
|
|
871
|
+
|
|
872
|
+
export function ringGenesis(signing , founder , options ) {
|
|
873
|
+
const nonce = options.nonce ?? crypto.randomBytes(RING_NONCE_BYTES);
|
|
874
|
+
const fields = [nonce, Buffer.alloc(0), ...memberFields({ ...founder, signingKey: signing.publicKey, role: "signer" })];
|
|
875
|
+
return signRingEntry(signing, { seq: 0, prev: ZERO_HASH, kind: "genesis", at: options.at, fields });
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function ringNext(signing , state , kind , fields , at ) {
|
|
879
|
+
if (state.ringId === null) throw new Error("the ring has no genesis yet");
|
|
880
|
+
return signRingEntry(signing, { seq: state.seq + 1, prev: state.head, kind, at, fields });
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export const ringAdd = (signing , state , member , at ) => ringNext(signing, state, "add", memberFields(member), at);
|
|
884
|
+
export const ringRevoke = (signing , state , memberId , reason , at ) =>
|
|
885
|
+
ringNext(signing, state, "revoke", [memberId, Buffer.from(reason, "utf8")], at);
|
|
886
|
+
/** Says these members are one machine (replacing what the machine listed before); no members dissolves it. */
|
|
887
|
+
export const ringMachine = (signing , state , machineId , name , memberIds , at ) =>
|
|
888
|
+
ringNext(signing, state, "machine", [machineId, Buffer.from(name, "utf8"), ...memberIds], at);
|
|
889
|
+
|
|
890
|
+
// MARK: - Device ring on the relay (PROTOCOL.md "设备环:中转服务器")
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* GET /v1/ring/capabilities; an older relay answers 404. `join` is there on a relay with the join endpoints
|
|
894
|
+
* (PROTOCOL.md "设备环:加入新的签名者"); a relay from before them leaves it out, and the app offers no join.
|
|
895
|
+
*/
|
|
896
|
+
|
|
897
|
+
/** A ring's head as the relay has it: the answer to a create or append, and the start of a read. */
|
|
898
|
+
|
|
899
|
+
/** GET /v1/ring/<ring>?after=&wait=: up to 256 entries after `after`; `more` when there are further ones. */
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Relay to a computer connected with /v1/host?ring=1: the ring it is in (or was, when this entry removed it) has this
|
|
903
|
+
* head now. Also sent once when such a computer connects. Bridges that did not ask never get it.
|
|
904
|
+
*/
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
// MARK: - Device ring on the session (PROTOCOL.md "设备环:电脑一侧", bridge 0.3.21)
|
|
908
|
+
//
|
|
909
|
+
// A phone that says `ring` in resume, and a bridge that says it in ready, compare their logs right after the
|
|
910
|
+
// handshake and the older one catches up from the other, inside the encrypted session where the relay can neither
|
|
911
|
+
// hold entries back nor change them. A board connection has no resume: the bridge lists `ring` in board/state's caps.
|
|
912
|
+
|
|
913
|
+
/** Most entries in one ring/entries frame. */
|
|
914
|
+
export const RING_ENTRIES_PER_FRAME = 256;
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Where a peer's log is. `ring: null` is no ring (or, from a bridge, not anchored yet); a bridge waiting for its owner
|
|
918
|
+
* to confirm an anchor names that ring in `pending`. `key` is a signer's Ed25519 key, which only signers send.
|
|
919
|
+
*/
|
|
920
|
+
|
|
921
|
+
/** Asks for the entries after `after`; without it, from genesis. */
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Six digits a person compares between the computer and the app before a computer anchors to a ring: like the
|
|
928
|
+
* pairing safety code, SHA-256 under its own label, the first four bytes big-endian modulo 1,000,000.
|
|
929
|
+
*/
|
|
930
|
+
export function ringFingerprint(ringId ) {
|
|
931
|
+
const digest = sha256(Buffer.from(`${RING_PROTOCOL}|fingerprint`), ringId);
|
|
932
|
+
return String(digest.readUInt32BE(0) % 1_000_000).padStart(6, "0");
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// MARK: - Joining a new signer (PROTOCOL.md "设备环:加入新的签名者")
|
|
936
|
+
//
|
|
937
|
+
// A signer already in the ring shows chatreplica://ring?…; the new device (a second iPhone, an iPad, agens-desktop)
|
|
938
|
+
// scans it, makes its Ed25519 signing key, and hands the relay its keys with three proofs: `proof` (it saw the QR
|
|
939
|
+
// code, and these are its keys, name and type: an HMAC under the QR's secret, which the relay never has), `pop` (it
|
|
940
|
+
// holds the signing key) and `dhProof` (it holds the X25519 key: a MAC keyed by X25519 between it and the signer).
|
|
941
|
+
// The signer checks all three on its own device and then appends an ordinary `add` with role signer; the relay checks
|
|
942
|
+
// only `pop`, and stores and forwards.
|
|
943
|
+
|
|
944
|
+
/** First field of the join proof of possession, so it can never be taken for an entry or another message. */
|
|
945
|
+
export const RING_JOIN_TAG = `${RING_PROTOCOL}/join`;
|
|
946
|
+
|
|
947
|
+
/** The signing tag of `pop`, the tags of `proof` and `dhProof`, and the HKDF label of the `dhProof` key. */
|
|
948
|
+
const RING_JOIN_PROOF_TAG = `${RING_PROTOCOL}/join-proof`;
|
|
949
|
+
const RING_JOIN_DH_TAG = `${RING_PROTOCOL}/join-dh`;
|
|
950
|
+
const RING_JOIN_DH_KEY_INFO = `${RING_PROTOCOL}/join-dh-key`;
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
/** chatreplica://ring?v=1&r=<relay URL>&g=<ring id>&c=<code>&s=<secret>&k=<signer X25519 key>&n=<signer name> */
|
|
965
|
+
export function buildRingJoinLink(link ) {
|
|
966
|
+
const params = new URLSearchParams({ v: "1", r: link.relay, g: b64url(link.ring), c: normalizeCode(link.code), s: b64url(link.secret), k: b64url(link.signerKey) });
|
|
967
|
+
if (link.name) params.set("n", link.name);
|
|
968
|
+
return `chatreplica://ring?${params}`;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export function parseRingJoinLink(text ) {
|
|
972
|
+
const url = new URL(text.trim());
|
|
973
|
+
if (url.protocol !== LINK_SCHEME || url.hostname !== "ring") throw new Error("not a chatreplica://ring link");
|
|
974
|
+
const get = (name ) => {
|
|
975
|
+
const value = url.searchParams.get(name);
|
|
976
|
+
if (!value) throw new Error(`ring link is missing ${name}`);
|
|
977
|
+
return value;
|
|
978
|
+
};
|
|
979
|
+
if (get("v") !== "1") throw new Error("unsupported ring link version");
|
|
980
|
+
const relay = new URL(get("r"));
|
|
981
|
+
if (relay.protocol !== "https:" && relay.protocol !== "http:") throw new Error("relay must be an http(s) URL");
|
|
982
|
+
const ring = ringB64(get("g"));
|
|
983
|
+
const secret = ringB64(get("s"));
|
|
984
|
+
const signerKey = ringB64(get("k"));
|
|
985
|
+
if (ring.length !== 32 || secret.length !== 32 || signerKey.length !== 32) throw new Error("ring link id, secret or key has the wrong length");
|
|
986
|
+
return { relay: get("r"), ring, code: normalizeCode(get("c")), secret, signerKey, name: url.searchParams.get("n") ?? undefined };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** What a claim says about the new device, which `proof` and `dhProof` cover. `name` is the exact UTF-8 the add will carry. */
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* P = bstr("agens-ring-v1/join-proof") ‖ bstr(code) ‖ bstr(X25519 key) ‖ bstr(signing key) ‖ bstr(name) ‖ bstr(type)
|
|
994
|
+
* proof = HMAC-SHA256(key: secret, P): the device that claims saw the QR code, and these are the keys, name and type it
|
|
995
|
+
* claims with. The relay never has the secret (not even its hash), so it can neither swap the keys nor relabel the
|
|
996
|
+
* device; only the signer that showed the code can check it.
|
|
997
|
+
*/
|
|
998
|
+
export function ringJoinProofMessage(code , claim ) {
|
|
999
|
+
return Buffer.concat([bstr(RING_JOIN_PROOF_TAG), bstr(normalizeCode(code)), bstr(claim.key), bstr(claim.signingKey), bstr(claim.name), bstr(claim.type)]);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export function ringJoinProof(secret , code , claim ) {
|
|
1003
|
+
return crypto.createHmac("sha256", secret).update(ringJoinProofMessage(code, claim)).digest();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export function verifyRingJoinProof(secret , code , claim , proof ) {
|
|
1007
|
+
const expected = ringJoinProof(secret, code, claim);
|
|
1008
|
+
return proof.length === expected.length && crypto.timingSafeEqual(proof, expected);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* dhProof: the new device holds the private half of its X25519 key (`pop` only covers the signing key, and the relay
|
|
1013
|
+
* never checks a key's private half when a phone signs in).
|
|
1014
|
+
* Z = X25519(new device's private key, signer's X25519 key from the QR) = X25519(signer's private key, new device's key)
|
|
1015
|
+
* K = HKDF-SHA256(ikm: Z, salt: ring id, info: "agens-ring-v1/join-dh-key", 32 bytes)
|
|
1016
|
+
* T = bstr("agens-ring-v1/join-dh") ‖ bstr(ring id) ‖ bstr(code) ‖ bstr(signer's X25519 key) ‖ bstr(new X25519 key)
|
|
1017
|
+
* ‖ bstr(signing key) ‖ bstr(name) ‖ bstr(type)
|
|
1018
|
+
* dhProof = HMAC-SHA256(key: K, T)
|
|
1019
|
+
* Only the two devices can compute Z; the relay cannot. A Z of 32 zero bytes (a small-order key) is refused.
|
|
1020
|
+
*/
|
|
1021
|
+
export function ringJoinDhMessage(ring , code , signerKey , claim ) {
|
|
1022
|
+
return Buffer.concat([
|
|
1023
|
+
bstr(RING_JOIN_DH_TAG), bstr(ring), bstr(normalizeCode(code)), bstr(signerKey), bstr(claim.key), bstr(claim.signingKey), bstr(claim.name), bstr(claim.type),
|
|
1024
|
+
]);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** K from Z; throws on an all-zero Z. */
|
|
1028
|
+
export function ringJoinDhKey(shared , ring ) {
|
|
1029
|
+
if (shared.length !== 32 || shared.every((b) => b === 0)) throw new Error("the X25519 shared secret is all zero");
|
|
1030
|
+
return Buffer.from(crypto.hkdfSync("sha256", shared, ring, Buffer.from(RING_JOIN_DH_KEY_INFO), 32));
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** The new device's side: Z from its own private key and the signer's key in the QR. */
|
|
1034
|
+
export function ringJoinDhProof(devicePrivateKey , ring , code , signerKey , claim ) {
|
|
1035
|
+
let shared ;
|
|
1036
|
+
try { shared = dh(devicePrivateKey, signerKey); } catch { throw new Error("the X25519 shared secret is all zero"); }
|
|
1037
|
+
return crypto.createHmac("sha256", ringJoinDhKey(shared, ring)).update(ringJoinDhMessage(ring, code, signerKey, claim)).digest();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** The signer's side: Z from its own private key and the claimed key. False on any fault, an all-zero Z included. */
|
|
1041
|
+
export function verifyRingJoinDhProof(signer , ring , code , claim , mac ) {
|
|
1042
|
+
let key ;
|
|
1043
|
+
try { key = ringJoinDhKey(dh(signer.privateKey, claim.key), ring); } catch { return false; }
|
|
1044
|
+
const expected = crypto.createHmac("sha256", key).update(ringJoinDhMessage(ring, code, signer.publicKey, claim)).digest();
|
|
1045
|
+
return mac.length === expected.length && crypto.timingSafeEqual(mac, expected);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** What `pop` signs: bstr("agens-ring-v1/join") ‖ bstr(ring id) ‖ bstr(code) ‖ bstr(X25519 key). */
|
|
1049
|
+
export function ringJoinPopMessage(ring , code , key ) {
|
|
1050
|
+
return Buffer.concat([bstr(RING_JOIN_TAG), bstr(ring), bstr(normalizeCode(code)), bstr(key)]);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** pop = Ed25519(the new signing key, ringJoinPopMessage): the device holds the signing key it claims with. */
|
|
1054
|
+
export const ringJoinPop = (signing , ring , code , key ) =>
|
|
1055
|
+
ed25519Sign(signing.privateKey, ringJoinPopMessage(ring, code, key));
|
|
1056
|
+
|
|
1057
|
+
/** Strict Ed25519 (PROTOCOL.md "签名的严格规则"), as for entries: a small-order or non-canonical signing key never verifies. */
|
|
1058
|
+
export const verifyRingJoinPop = (signingKey , ring , code , key , pop ) =>
|
|
1059
|
+
ed25519Verify(signingKey, ringJoinPopMessage(ring, code, key), pop);
|
|
1060
|
+
|
|
1061
|
+
/** POST /v1/ring/<ring>/join/start {} (no secret, nor its hash, goes to the relay) */
|
|
1062
|
+
|
|
1063
|
+
/** POST /v1/ring/join/claim; every key and the proofs in base64url. `key` is the claiming phone's own X25519 key. */
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
/** `code` is the join's code, by which status and approval find it (the QR's code, normalized). */
|
|
1068
|
+
|
|
1069
|
+
/** GET /v1/ring/join/claim?code=, for the signer that started the join. The claim's fields are there once claimed. */
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
/** GET /v1/ring/join/status?code=, for the device that claimed. */
|
|
1088
|
+
|