@plane31/blind-relay 0.1.1 → 0.1.3

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,104 @@
1
+ /** The portable backend over the noble libraries (audited pure JavaScript). */
2
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
3
+ import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa";
4
+ import { ed25519, x25519 } from "@noble/curves/ed25519";
5
+ import { blake2b } from "@noble/hashes/blake2b";
6
+ import { sha512 } from "@noble/hashes/sha512";
7
+ import { randomBytes } from "@noble/hashes/utils";
8
+ import type { BoxKeypair, CryptoBackend, SignKeypair } from "./backend.js";
9
+
10
+ // "expand 32-byte k" as little-endian u32 words: the Salsa constant.
11
+ const SIGMA = new Uint32Array([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
12
+
13
+ function u32(bytes: Uint8Array): Uint32Array {
14
+ const out = new Uint32Array(bytes.length / 4);
15
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
16
+ for (let i = 0; i < out.length; i++) out[i] = dv.getUint32(i * 4, true);
17
+ return out;
18
+ }
19
+
20
+ function fromU32(words: Uint32Array): Uint8Array {
21
+ const out = new Uint8Array(words.length * 4);
22
+ const dv = new DataView(out.buffer);
23
+ for (let i = 0; i < words.length; i++) dv.setUint32(i * 4, words[i] ?? 0, true);
24
+ return out;
25
+ }
26
+
27
+ /** `crypto_box_beforenm`: HSalsa20 of the X25519 shared secret with a zero nonce. */
28
+ function beforeNm(publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array {
29
+ const shared = x25519.getSharedSecret(secretKey, publicKey);
30
+ const out = new Uint32Array(8);
31
+ hsalsa(SIGMA, u32(shared), u32(new Uint8Array(16)), out);
32
+ return fromU32(out);
33
+ }
34
+
35
+ function isCanonicalY(bytes: Uint8Array): boolean {
36
+ // y < 2^255 - 19, read little-endian with the sign bit cleared.
37
+ const y = Uint8Array.from(bytes);
38
+ y[31] = (y[31] ?? 0) & 0x7f;
39
+ const P = Uint8Array.from([
40
+ 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f
41
+ ]);
42
+ for (let i = 31; i >= 0; i--) {
43
+ const a = y[i] ?? 0;
44
+ const b = P[i] ?? 0;
45
+ if (a < b) return true;
46
+ if (a > b) return false;
47
+ }
48
+ return false;
49
+ }
50
+
51
+ export const nobleBackend: CryptoBackend = {
52
+ name: "noble",
53
+ blake2b: (data, outLen, key) => blake2b(data, key ? { dkLen: outLen, key } : { dkLen: outLen }),
54
+ randomBytes: (length) => randomBytes(length),
55
+ signKeypairFromSeed(seed): SignKeypair {
56
+ const publicKey = ed25519.getPublicKey(seed);
57
+ const secretKey = new Uint8Array(64);
58
+ secretKey.set(seed, 0);
59
+ secretKey.set(publicKey, 32);
60
+ return { publicKey, secretKey };
61
+ },
62
+ signDetached: (message, secretKey) => ed25519.sign(message, secretKey.subarray(0, 32)),
63
+ verifyDetached(signature, message, publicKey) {
64
+ try {
65
+ return ed25519.verify(signature, message, publicKey, { zip215: false });
66
+ } catch {
67
+ return false;
68
+ }
69
+ },
70
+ isValidSignPoint(publicKey) {
71
+ if (publicKey.length !== 32 || !isCanonicalY(publicKey)) return false;
72
+ try {
73
+ const p = ed25519.ExtendedPoint.fromHex(publicKey);
74
+ p.assertValidity();
75
+ return !p.equals(ed25519.ExtendedPoint.ZERO) && !p.isSmallOrder() && p.isTorsionFree();
76
+ } catch {
77
+ return false;
78
+ }
79
+ },
80
+ boxKeypairFromSeed(seed): BoxKeypair {
81
+ const secretKey = sha512(seed).slice(0, 32);
82
+ return { publicKey: x25519.getPublicKey(secretKey), secretKey };
83
+ },
84
+ boxKeypair(): BoxKeypair {
85
+ const secretKey = randomBytes(32);
86
+ return { publicKey: x25519.getPublicKey(secretKey), secretKey };
87
+ },
88
+ scalarMult(secretKey, publicKey) {
89
+ const shared = x25519.getSharedSecret(secretKey, publicKey); // throws on an all-zero result
90
+ let acc = 0;
91
+ for (const b of shared) acc |= b;
92
+ if (acc === 0) throw new Error("low-order point");
93
+ return shared;
94
+ },
95
+ boxEasy(message, nonce, publicKey, secretKey) {
96
+ return xsalsa20poly1305(beforeNm(publicKey, secretKey), nonce).encrypt(message); // mac || ciphertext
97
+ },
98
+ boxOpenEasy(box, nonce, publicKey, secretKey) {
99
+ return xsalsa20poly1305(beforeNm(publicKey, secretKey), nonce).decrypt(box);
100
+ },
101
+ aeadEncrypt: (plaintext, aad, nonce, key) => xchacha20poly1305(key, nonce, aad).encrypt(plaintext),
102
+ aeadDecrypt: (ciphertextAndTag, aad, nonce, key) => xchacha20poly1305(key, nonce, aad).decrypt(ciphertextAndTag),
103
+ memzero: (bytes) => bytes.fill(0)
104
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The one Node API the optional `/sodium` entry uses, declared here so the protocol package keeps
3
+ * no dependency on Node's type definitions: the rest of core imports no platform module.
4
+ */
5
+ declare module "node:module" {
6
+ export function createRequire(from: string | URL): (id: string) => unknown;
7
+ }
8
+
9
+ interface ImportMeta {
10
+ readonly url: string;
11
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The libsodium backend (wasm). Loaded only when asked for, so the default bundle never carries
3
+ * the wasm; use it on Node, where it is faster than the portable backend, and to cross-check it.
4
+ */
5
+ import { createRequire } from "node:module";
6
+ import type { BoxKeypair, CryptoBackend, SignKeypair } from "./backend.js";
7
+
8
+ type Sodium = typeof import("libsodium-wrappers-sumo");
9
+
10
+ /**
11
+ * Node only. The wrapper is loaded through its `require` condition, the CommonJS build, which is
12
+ * self-contained; its ESM build has shipped broken (0.7.16 imports a sibling file the package does
13
+ * not contain), and a consumer's plain `import` would hit that. The release smoke test loads this
14
+ * entry from the packed tarball in a clean Node to keep it so.
15
+ */
16
+ export async function loadSodiumBackend(): Promise<CryptoBackend> {
17
+ const require = createRequire(import.meta.url);
18
+ const mod = require("libsodium-wrappers-sumo") as { default?: Sodium } & Sodium;
19
+ const sodium: Sodium = mod.default ?? mod;
20
+ await sodium.ready;
21
+ return {
22
+ name: "sodium",
23
+ blake2b: (data, outLen, key) => sodium.crypto_generichash(outLen, data, key ?? null),
24
+ randomBytes: (length) => sodium.randombytes_buf(length),
25
+ signKeypairFromSeed(seed): SignKeypair {
26
+ const kp = sodium.crypto_sign_seed_keypair(seed);
27
+ return { publicKey: kp.publicKey, secretKey: kp.privateKey };
28
+ },
29
+ signDetached: (message, secretKey) => sodium.crypto_sign_detached(message, secretKey),
30
+ verifyDetached(signature, message, publicKey) {
31
+ try {
32
+ return sodium.crypto_sign_verify_detached(signature, message, publicKey);
33
+ } catch {
34
+ return false;
35
+ }
36
+ },
37
+ isValidSignPoint: (publicKey) => publicKey.length === 32 && sodium.crypto_core_ed25519_is_valid_point(publicKey),
38
+ boxKeypairFromSeed(seed): BoxKeypair {
39
+ const kp = sodium.crypto_box_seed_keypair(seed);
40
+ return { publicKey: kp.publicKey, secretKey: kp.privateKey };
41
+ },
42
+ boxKeypair(): BoxKeypair {
43
+ const kp = sodium.crypto_box_keypair();
44
+ return { publicKey: kp.publicKey, secretKey: kp.privateKey };
45
+ },
46
+ scalarMult: (secretKey, publicKey) => sodium.crypto_scalarmult(secretKey, publicKey),
47
+ boxEasy: (message, nonce, publicKey, secretKey) => sodium.crypto_box_easy(message, nonce, publicKey, secretKey),
48
+ boxOpenEasy: (box, nonce, publicKey, secretKey) => sodium.crypto_box_open_easy(box, nonce, publicKey, secretKey),
49
+ aeadEncrypt: (plaintext, aad, nonce, key) => sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, aad, null, nonce, key),
50
+ aeadDecrypt: (ciphertextAndTag, aad, nonce, key) => sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertextAndTag, aad, nonce, key),
51
+ memzero: (bytes) => sodium.memzero(bytes)
52
+ };
53
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The one cipher suite of Kavach v1 (protocol section 3) over a primitive backend.
3
+ *
4
+ * Everything here is a thin, checked wrapper: no algorithm choice, no fallback, no key
5
+ * derivation other than the three the protocol names. Call `ready()` once before anything else;
6
+ * it selects the portable backend unless `setCryptoBackend` chose another first.
7
+ */
8
+ import { concat, isAllZero, u32, encodeName } from "./bytes.js";
9
+ import { KavachError } from "./errors.js";
10
+ import { currentBackend, setCryptoBackend, type BoxKeypair, type CryptoBackend, type SignKeypair as SigningKeypair } from "./crypto/backend.js";
11
+ import { nobleBackend } from "./crypto/noble.js";
12
+
13
+ export type { CryptoBackend, BoxKeypair, SigningKeypair };
14
+ export { setCryptoBackend };
15
+
16
+ /** Kept from the protocol's earlier name on purpose: changing it changes every vector. */
17
+ export const LABEL_PREFIX = "SealedStream/5/";
18
+ export const WIRE_VERSION = 5;
19
+ export const HASH_BYTES = 32;
20
+ export const KEY_BYTES = 32;
21
+ export const SEED_BYTES = 32;
22
+ export const NONCE_BYTES = 24;
23
+ export const TAG_BYTES = 16;
24
+ export const SIGNATURE_BYTES = 64;
25
+ export const PUBLIC_KEY_BYTES = 32;
26
+ export const SEALED_BOX_BYTES = 80;
27
+ export const DEVICE_ID_BYTES = 16;
28
+ /** Recovery code prefix, also kept from the earlier name. */
29
+ export const RECOVERY_CODE_PREFIX = "KV1R-";
30
+
31
+ export type Label =
32
+ | "identity"
33
+ | "stream"
34
+ | "record-id"
35
+ | "doc-hash"
36
+ | "device-sign"
37
+ | "device-box"
38
+ | "account-sig"
39
+ | "snapshot-sig"
40
+ | "epoch-sig"
41
+ | "record-sig"
42
+ | "reset-sig"
43
+ | "reset-keys-sig"
44
+ | "epoch-key"
45
+ | "wrap"
46
+ | "record-aad"
47
+ | "vault-aad";
48
+
49
+ /** Select a backend if none is set (the portable one) and resolve. Idempotent. */
50
+ export async function ready(): Promise<void> {
51
+ if (!currentBackend()) {
52
+ // The portable backend draws randomness from the Web Crypto global; Hermes has none until the
53
+ // app installs one (the Expo helper does, from expo-crypto). Say so, rather than fail later.
54
+ const g = globalThis as { crypto?: { getRandomValues?: unknown } };
55
+ if (typeof g.crypto?.getRandomValues !== "function") throw new KavachError("INVALID_KEY", "ready", "no crypto.getRandomValues: install a secure random source before opening Kavach");
56
+ setCryptoBackend(nobleBackend);
57
+ }
58
+ }
59
+
60
+ /** The portable backend needs no loading, so it is selected on first use when nothing else was. */
61
+ function be(): CryptoBackend {
62
+ const b = currentBackend();
63
+ if (b) return b;
64
+ setCryptoBackend(nobleBackend);
65
+ return nobleBackend;
66
+ }
67
+
68
+ export function backendName(): CryptoBackend["name"] {
69
+ return be().name;
70
+ }
71
+
72
+ function ascii(text: string): Uint8Array {
73
+ const out = new Uint8Array(text.length);
74
+ for (let i = 0; i < text.length; i++) {
75
+ const c = text.charCodeAt(i);
76
+ if (c > 0x7f) throw new RangeError("label is not ASCII");
77
+ out[i] = c;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** `C(label)` = ASCII("SealedStream/5/" + label) || 0x00. */
83
+ export function C(label: Label): Uint8Array {
84
+ return concat(ascii(LABEL_PREFIX + label), Uint8Array.of(0));
85
+ }
86
+
87
+ /** `H(x)`: BLAKE2b, 32-byte output, unkeyed. */
88
+ export function H(data: Uint8Array): Uint8Array {
89
+ return be().blake2b(data, HASH_BYTES);
90
+ }
91
+
92
+ /** `D(seed, label)`: BLAKE2b keyed with the seed over `C(label)`, 32-byte output. */
93
+ export function D(seed: Uint8Array, label: Label): Uint8Array {
94
+ if (seed.length !== SEED_BYTES) throw new KavachError("INVALID_KEY", "derive", "seed must be 32 bytes");
95
+ return be().blake2b(C(label), HASH_BYTES, seed);
96
+ }
97
+
98
+ export function bodyHash(body: Uint8Array): Uint8Array {
99
+ return H(concat(C("doc-hash"), body));
100
+ }
101
+
102
+ export function identityId(accountName: string, accountPublicKey: Uint8Array): Uint8Array {
103
+ return H(concat(C("identity"), encodeName(accountName), accountPublicKey));
104
+ }
105
+
106
+ export function streamId(identity: Uint8Array, deviceId: Uint8Array): Uint8Array {
107
+ return H(concat(C("stream"), identity, deviceId));
108
+ }
109
+
110
+ export function recordId(stream: Uint8Array, epoch: number, counter: number): Uint8Array {
111
+ return H(concat(C("record-id"), stream, u32(epoch), u32(counter)));
112
+ }
113
+
114
+ export function wrapHash(wrap: Uint8Array): Uint8Array {
115
+ return H(concat(C("wrap"), wrap));
116
+ }
117
+
118
+ export function keyCommitment(stream: Uint8Array, epoch: number, epochKey: Uint8Array): Uint8Array {
119
+ return H(concat(C("epoch-key"), stream, u32(epoch), epochKey));
120
+ }
121
+
122
+ // Keys ---------------------------------------------------------------------------------------
123
+
124
+ /** `AK` from `accountSeed`. The only thing the account seed ever derives. */
125
+ export function accountKeypair(accountSeed: Uint8Array): SigningKeypair {
126
+ return signingKeypairFromSeed(accountSeed);
127
+ }
128
+
129
+ /** `DSK` = Ed25519 from `D(deviceSeed, "device-sign")`. */
130
+ export function deviceSigningKeypair(deviceSeed: Uint8Array): SigningKeypair {
131
+ return signingKeypairFromSeed(D(deviceSeed, "device-sign"));
132
+ }
133
+
134
+ /** `DEK` = `crypto_box_seed_keypair(D(deviceSeed, "device-box"))`. The seed is never used as a scalar. */
135
+ export function deviceBoxKeypair(deviceSeed: Uint8Array): BoxKeypair {
136
+ return be().boxKeypairFromSeed(D(deviceSeed, "device-box"));
137
+ }
138
+
139
+ export function signingKeypairFromSeed(seed: Uint8Array): SigningKeypair {
140
+ if (seed.length !== SEED_BYTES) throw new KavachError("INVALID_KEY", "keypair", "seed must be 32 bytes");
141
+ return be().signKeypairFromSeed(seed);
142
+ }
143
+
144
+ /** An Ed25519 public key must be a canonical point on the main subgroup and not the identity. */
145
+ export function assertValidSigningKey(publicKey: Uint8Array, stage = "verify"): void {
146
+ if (publicKey.length !== PUBLIC_KEY_BYTES || !be().isValidSignPoint(publicKey)) {
147
+ throw new KavachError("INVALID_KEY", stage, "invalid Ed25519 public key");
148
+ }
149
+ }
150
+
151
+ /**
152
+ * An X25519 public key must not yield an all-zero shared secret. A fixed non-secret scalar
153
+ * probes it; libsodium refuses the low-order result.
154
+ */
155
+ const PROBE_SCALAR = new Uint8Array(32).fill(0x42);
156
+ export function assertValidBoxKey(publicKey: Uint8Array, stage = "verify"): void {
157
+ if (publicKey.length !== PUBLIC_KEY_BYTES || isAllZero(publicKey)) {
158
+ throw new KavachError("INVALID_KEY", stage, "invalid X25519 public key");
159
+ }
160
+ try {
161
+ const shared = be().scalarMult(PROBE_SCALAR, publicKey);
162
+ if (isAllZero(shared)) throw new Error("zero");
163
+ } catch {
164
+ throw new KavachError("INVALID_KEY", stage, "low-order X25519 public key");
165
+ }
166
+ }
167
+
168
+ // Signatures ----------------------------------------------------------------------------------
169
+
170
+ /** Detached Ed25519 over `C(label) || body`. Not Ed25519ph, no intermediate hash. */
171
+ export function sign(secretKey: Uint8Array, label: Label, body: Uint8Array): Uint8Array {
172
+ return be().signDetached(concat(C(label), body), secretKey);
173
+ }
174
+
175
+ // The Ed25519 group order L, little-endian, for the canonical-scalar check.
176
+ const L = Uint8Array.from([
177
+ 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0x00, 0x00, 0x00,
178
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
179
+ ]);
180
+
181
+ function isCanonicalScalar(s: Uint8Array): boolean {
182
+ // s < L, comparing little-endian from the most significant byte.
183
+ for (let i = 31; i >= 0; i--) {
184
+ const a = s[i] ?? 0;
185
+ const b = L[i] ?? 0;
186
+ if (a < b) return true;
187
+ if (a > b) return false;
188
+ }
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * Verify a detached signature. Throws `INVALID_KEY` for an invalid or small-order key and
194
+ * `BAD_SIGNATURE` for anything else that does not verify, including a non-canonical scalar.
195
+ */
196
+ export function verify(publicKey: Uint8Array, label: Label, body: Uint8Array, signature: Uint8Array, stage = "verify"): void {
197
+ assertValidSigningKey(publicKey, stage);
198
+ if (signature.length !== SIGNATURE_BYTES || !isCanonicalScalar(signature.subarray(32))) {
199
+ throw new KavachError("BAD_SIGNATURE", stage);
200
+ }
201
+ if (!be().verifyDetached(signature, concat(C(label), body), publicKey)) throw new KavachError("BAD_SIGNATURE", stage);
202
+ }
203
+
204
+ // AEAD ---------------------------------------------------------------------------------------
205
+
206
+ /** XChaCha20-Poly1305: returns ciphertext with the 16-byte tag appended. */
207
+ export function aeadSeal(key: Uint8Array, nonce: Uint8Array, aad: Uint8Array, plaintext: Uint8Array): Uint8Array {
208
+ if (key.length !== KEY_BYTES || nonce.length !== NONCE_BYTES) throw new KavachError("INVALID_KEY", "aead");
209
+ return be().aeadEncrypt(plaintext, aad, nonce, key);
210
+ }
211
+
212
+ export function aeadOpen(key: Uint8Array, nonce: Uint8Array, aad: Uint8Array, ciphertextAndTag: Uint8Array, stage = "decrypt"): Uint8Array {
213
+ if (key.length !== KEY_BYTES || nonce.length !== NONCE_BYTES) throw new KavachError("INVALID_KEY", stage);
214
+ try {
215
+ return be().aeadDecrypt(ciphertextAndTag, aad, nonce, key);
216
+ } catch {
217
+ throw new KavachError("DECRYPT_FAILED", stage);
218
+ }
219
+ }
220
+
221
+ // Sealed boxes -------------------------------------------------------------------------------
222
+
223
+ /** The sealed-box nonce: BLAKE2b-24 over the ephemeral and recipient public keys, as libsodium defines it. */
224
+ export function sealedBoxNonce(ephemeralPublicKey: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array {
225
+ return be().blake2b(concat(ephemeralPublicKey, recipientPublicKey), NONCE_BYTES);
226
+ }
227
+
228
+ /** `crypto_box_seal` with a given ephemeral keypair (tests reproduce fixture wraps with it). */
229
+ export function sealBoxWith(epochKey: Uint8Array, recipientPublicKey: Uint8Array, ephemeral: BoxKeypair): Uint8Array {
230
+ const nonce = sealedBoxNonce(ephemeral.publicKey, recipientPublicKey);
231
+ return concat(ephemeral.publicKey, be().boxEasy(epochKey, nonce, recipientPublicKey, ephemeral.secretKey));
232
+ }
233
+
234
+ /** `crypto_box_seal` of a 32-byte epoch key: exactly 80 bytes. */
235
+ export function sealBox(epochKey: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array {
236
+ if (epochKey.length !== KEY_BYTES) throw new KavachError("INVALID_KEY", "seal");
237
+ assertValidBoxKey(recipientPublicKey, "seal");
238
+ return sealBoxWith(epochKey, recipientPublicKey, be().boxKeypair());
239
+ }
240
+
241
+ export function openSealedBox(box: Uint8Array, recipient: BoxKeypair, stage = "open-wrap"): Uint8Array {
242
+ if (box.length !== SEALED_BOX_BYTES) throw new KavachError("MALFORMED", stage, "wrap must be 80 bytes");
243
+ try {
244
+ const ephemeralPublic = box.subarray(0, PUBLIC_KEY_BYTES);
245
+ const nonce = sealedBoxNonce(ephemeralPublic, recipient.publicKey);
246
+ return be().boxOpenEasy(box.subarray(PUBLIC_KEY_BYTES), nonce, ephemeralPublic, recipient.secretKey);
247
+ } catch {
248
+ throw new KavachError("DECRYPT_FAILED", stage);
249
+ }
250
+ }
251
+
252
+ /** `crypto_box_seed_keypair`, exposed for tests that rebuild fixture wraps from their ephemeral seeds. */
253
+ export function boxKeypairFromSeed(seed: Uint8Array): BoxKeypair {
254
+ return be().boxKeypairFromSeed(seed);
255
+ }
256
+
257
+ // Randomness and hygiene ----------------------------------------------------------------------
258
+
259
+ export function randomBytes(length: number): Uint8Array {
260
+ return be().randomBytes(length);
261
+ }
262
+
263
+ /** Best-effort zeroisation. JavaScript gives no guarantee; the protocol says so. */
264
+ export function zero(bytes: Uint8Array): void {
265
+ be().memzero(bytes);
266
+ }
package/src/epoch.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Epochs, wraps, publication and the reader side (protocol section 8).
3
+ */
4
+ import { equalBytes, toHex } from "./bytes.js";
5
+ import type { ResolvedAudience } from "./audience.js";
6
+ import { DocumentType, buildEpochManifest, expectType, parseDocument, verifyDocument, type EpochManifest, type Parsed } from "./codec.js";
7
+ import { KEY_BYTES, bodyHash, keyCommitment, openSealedBox, randomBytes, sealBox, wrapHash, type BoxKeypair } from "./crypto.js";
8
+ import { KavachError } from "./errors.js";
9
+
10
+ export const MAX_COUNTER = 0xffff_ffff;
11
+ export const MAX_EPOCH = 0xffff_ffff;
12
+
13
+ export interface AuthorKeys {
14
+ readonly identityId: Uint8Array;
15
+ readonly certificateHash: Uint8Array;
16
+ readonly streamId: Uint8Array;
17
+ readonly signingSecretKey: Uint8Array;
18
+ }
19
+
20
+ /** Everything T4 persists before any network call. */
21
+ export interface PreparedEpoch {
22
+ readonly epoch: number;
23
+ readonly manifest: Uint8Array;
24
+ readonly manifestHash: Uint8Array;
25
+ /** Wrap per recipient identity id (hex), the author's own included. */
26
+ readonly wraps: ReadonlyMap<string, Uint8Array>;
27
+ readonly epochKey: Uint8Array;
28
+ readonly capturedRevision: bigint;
29
+ readonly audienceKey: string;
30
+ readonly createdAt: bigint;
31
+ }
32
+
33
+ /** Prepare an epoch: fresh random key, one sealed box per row, one signature. */
34
+ export function prepareEpoch(input: {
35
+ readonly author: AuthorKeys;
36
+ readonly epoch: number;
37
+ readonly createdAt: bigint;
38
+ readonly audience: ResolvedAudience;
39
+ readonly desiredRevision: bigint;
40
+ readonly epochKey?: Uint8Array;
41
+ }): PreparedEpoch {
42
+ const stage = "prepareEpoch";
43
+ if (input.epoch < 1 || input.epoch > MAX_EPOCH) throw new KavachError("COUNTER_EXHAUSTED", stage, "epoch space exhausted; replace the stream");
44
+ const epochKey = input.epochKey ?? randomBytes(KEY_BYTES);
45
+ const wraps = new Map<string, Uint8Array>();
46
+ const rows = input.audience.rows.map((row) => {
47
+ const wrap = sealBox(epochKey, row.boxKey);
48
+ wraps.set(toHex(row.identityId), wrap);
49
+ return { identityId: row.identityId, certificateHash: row.certificateHash, wrapHash: wrapHash(wrap) };
50
+ });
51
+ if (!rows.some((r) => equalBytes(r.identityId, input.author.identityId))) throw new KavachError("MALFORMED", stage, "author missing from audience");
52
+ const manifest = buildEpochManifest(
53
+ {
54
+ streamId: input.author.streamId,
55
+ authorIdentityId: input.author.identityId,
56
+ authorCertificateHash: input.author.certificateHash,
57
+ epoch: input.epoch,
58
+ createdAt: input.createdAt,
59
+ keyCommitment: keyCommitment(input.author.streamId, input.epoch, epochKey),
60
+ audience: rows
61
+ },
62
+ input.author.signingSecretKey
63
+ );
64
+ return {
65
+ epoch: input.epoch,
66
+ manifest,
67
+ manifestHash: bodyHash(manifest.subarray(0, manifest.length - 64)),
68
+ wraps,
69
+ epochKey,
70
+ capturedRevision: input.desiredRevision,
71
+ audienceKey: input.audience.key,
72
+ createdAt: input.createdAt
73
+ };
74
+ }
75
+
76
+ // Publication ----------------------------------------------------------------------------------
77
+
78
+ export type PublishOutcome =
79
+ | { readonly status: "COMMITTED" }
80
+ | { readonly status: "ALREADY_COMMITTED" }
81
+ | { readonly status: "CONFLICT"; readonly currentEpoch: number; readonly currentManifest: Uint8Array | undefined }
82
+ | { readonly status: "RETRYABLE" }
83
+ | { readonly status: "UNAUTHORIZED" }
84
+ | { readonly status: "PERMANENT_FAILURE" };
85
+
86
+ export type PublishResolution = "COMMITTED" | "RETRY";
87
+
88
+ /**
89
+ * Resolve a publish outcome for a prepared epoch. Identical bytes at the slot mean committed; a
90
+ * different valid body at the same epoch is `EQUIVOCATION`; a higher committed epoch than the one
91
+ * prepared means local state was lost and the device must replace its stream.
92
+ */
93
+ export function resolvePublish(prepared: PreparedEpoch, outcome: PublishOutcome): PublishResolution {
94
+ const stage = "publishEpoch";
95
+ switch (outcome.status) {
96
+ case "COMMITTED":
97
+ case "ALREADY_COMMITTED":
98
+ return "COMMITTED";
99
+ case "RETRYABLE":
100
+ return "RETRY";
101
+ case "UNAUTHORIZED":
102
+ throw new KavachError("NOT_AUTHORIZED", stage);
103
+ case "PERMANENT_FAILURE":
104
+ throw new KavachError("STORAGE_FAILURE", stage, "host refused the epoch permanently");
105
+ case "CONFLICT": {
106
+ if (outcome.currentEpoch === prepared.epoch && outcome.currentManifest) {
107
+ if (equalBytes(outcome.currentManifest, prepared.manifest)) return "COMMITTED";
108
+ const theirs = parseDocument(outcome.currentManifest);
109
+ if (theirs.type === DocumentType.EpochManifest && theirs.epoch === prepared.epoch) {
110
+ throw new KavachError("EQUIVOCATION", stage, "a different manifest is committed at this epoch");
111
+ }
112
+ }
113
+ if (outcome.currentEpoch >= prepared.epoch) {
114
+ throw new KavachError("RECOVERY_REQUIRED", stage, "the host holds a higher epoch for this stream; local state was lost");
115
+ }
116
+ return "RETRY";
117
+ }
118
+ }
119
+ }
120
+
121
+ export type PromotionOutcome = "PROMOTED" | "OBSOLETE";
122
+
123
+ /** After commit: promote only if the captured revision and the exact audience still match. */
124
+ export function promote(prepared: PreparedEpoch, currentDesiredRevision: bigint, currentAudienceKey: string): PromotionOutcome {
125
+ return prepared.capturedRevision === currentDesiredRevision && prepared.audienceKey === currentAudienceKey ? "PROMOTED" : "OBSOLETE";
126
+ }
127
+
128
+ // Rotation triggers -----------------------------------------------------------------------------
129
+
130
+ export type RotationReason = "counterExhausted" | "hygiene" | "clockReversal";
131
+
132
+ export interface ActiveEpochView {
133
+ readonly createdAt: bigint;
134
+ readonly nextCounter: number;
135
+ readonly lastSentAt: bigint | undefined;
136
+ }
137
+
138
+ export function rotationReason(active: ActiveEpochView, now: bigint, hygieneRotationMs: bigint): RotationReason | undefined {
139
+ if (active.nextCounter > MAX_COUNTER) return "counterExhausted";
140
+ if (active.lastSentAt !== undefined && now < active.lastSentAt) return "clockReversal";
141
+ if (now - active.createdAt > hygieneRotationMs) return "hygiene";
142
+ return undefined;
143
+ }
144
+
145
+ // Reader side ----------------------------------------------------------------------------------
146
+
147
+ export interface AuthorCertificateView {
148
+ readonly certificateHash: Uint8Array;
149
+ readonly signingKey: Uint8Array;
150
+ readonly streamId: Uint8Array;
151
+ readonly current: boolean;
152
+ }
153
+
154
+ export interface AuthorView {
155
+ readonly identityId: Uint8Array;
156
+ /** The author's current certificate and any archived ones the reader still holds, by hash (hex). */
157
+ readonly certificates: ReadonlyMap<string, AuthorCertificateView>;
158
+ }
159
+
160
+ export interface SelfView {
161
+ readonly identityId: Uint8Array;
162
+ readonly certificateHash: Uint8Array;
163
+ readonly box: BoxKeypair;
164
+ }
165
+
166
+ export interface OpenedEpoch {
167
+ readonly manifest: Parsed<EpochManifest>;
168
+ readonly epochKey: Uint8Array;
169
+ /** The manifest belongs to a device of the author that is no longer current: history only. */
170
+ readonly superseded: boolean;
171
+ }
172
+
173
+ /**
174
+ * Every check of section 8 before a key is stored: manifest signature under the certificate it
175
+ * names, that certificate's identity and stream, the author's own row, the reader's own row at
176
+ * its current certificate, the wrap hash, the sealed box and the key commitment.
177
+ */
178
+ export function openEpoch(manifestWire: Uint8Array, author: AuthorView, self: SelfView, wrap: Uint8Array): OpenedEpoch {
179
+ const stage = "openEpoch";
180
+ const parsed = expectType<EpochManifest>(parseDocument(manifestWire), DocumentType.EpochManifest, stage);
181
+ const cert = author.certificates.get(toHex(parsed.authorCertificateHash));
182
+ if (!cert) throw new KavachError("SUPERSEDED_DEVICE", stage, "manifest names a certificate that is not the author's current one");
183
+ const manifest = expectType<EpochManifest>(verifyDocument(manifestWire, cert.signingKey), DocumentType.EpochManifest, stage);
184
+ if (!equalBytes(manifest.authorIdentityId, author.identityId) || !equalBytes(manifest.streamId, cert.streamId)) {
185
+ throw new KavachError("MALFORMED", stage, "manifest identity or stream does not match the certificate");
186
+ }
187
+ const authorRow = manifest.audience.find((r) => equalBytes(r.identityId, author.identityId));
188
+ if (!authorRow || !equalBytes(authorRow.certificateHash, manifest.authorCertificateHash)) {
189
+ throw new KavachError("MALFORMED", stage, "author row does not name the author certificate");
190
+ }
191
+ const selfRow = manifest.audience.find((r) => equalBytes(r.identityId, self.identityId));
192
+ if (!selfRow || !equalBytes(selfRow.certificateHash, self.certificateHash)) {
193
+ throw new KavachError("NOT_AUTHORIZED", stage, "this device is not addressed by the epoch");
194
+ }
195
+ if (!equalBytes(wrapHash(wrap), selfRow.wrapHash)) throw new KavachError("WRAP_HASH_MISMATCH", stage);
196
+ const epochKey = openSealedBox(wrap, self.box, stage);
197
+ if (!equalBytes(keyCommitment(manifest.streamId, manifest.epoch, epochKey), manifest.keyCommitment)) {
198
+ throw new KavachError("KEY_COMMITMENT_MISMATCH", stage);
199
+ }
200
+ return { manifest, epochKey, superseded: !cert.current };
201
+ }