@plane31/blind-relay 0.1.1 → 0.1.2

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.
@@ -1,7 +1,9 @@
1
+ import type { CryptoBackend } from "./backend.js";
1
2
  /**
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.
3
+ * Node only. The wrapper is loaded through its `require` condition, the CommonJS build, which is
4
+ * self-contained; its ESM build has shipped broken (0.7.16 imports a sibling file the package does
5
+ * not contain), and a consumer's plain `import` would hit that. The release smoke test loads this
6
+ * entry from the packed tarball in a clean Node to keep it so.
4
7
  */
5
- import type { CryptoBackend } from "./backend.js";
6
8
  export declare function loadSodiumBackend(): Promise<CryptoBackend>;
7
9
  //# sourceMappingURL=sodium.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sodium.d.ts","sourceRoot":"","sources":["../../src/crypto/sodium.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAc,aAAa,EAAe,MAAM,cAAc,CAAC;AAI3E,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,aAAa,CAAC,CAoChE"}
1
+ {"version":3,"file":"sodium.d.ts","sourceRoot":"","sources":["../../src/crypto/sodium.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAc,aAAa,EAAe,MAAM,cAAc,CAAC;AAI3E;;;;;GAKG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,aAAa,CAAC,CAqChE"}
@@ -1,5 +1,17 @@
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
+ /**
7
+ * Node only. The wrapper is loaded through its `require` condition, the CommonJS build, which is
8
+ * self-contained; its ESM build has shipped broken (0.7.16 imports a sibling file the package does
9
+ * not contain), and a consumer's plain `import` would hit that. The release smoke test loads this
10
+ * entry from the packed tarball in a clean Node to keep it so.
11
+ */
1
12
  export async function loadSodiumBackend() {
2
- const mod = (await import("libsodium-wrappers-sumo"));
13
+ const require = createRequire(import.meta.url);
14
+ const mod = require("libsodium-wrappers-sumo");
3
15
  const sodium = mod.default ?? mod;
4
16
  await sodium.ready;
5
17
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"sodium.js","sourceRoot":"","sources":["../../src/crypto/sodium.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sodium.js","sourceRoot":"","sources":["../../src/crypto/sodium.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plane31/blind-relay",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Kavach v1 protocol: documents, cryptography, state machine. Pure TypeScript, no platform imports.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -46,7 +46,8 @@
46
46
  }
47
47
  },
48
48
  "files": [
49
- "dist"
49
+ "dist",
50
+ "src"
50
51
  ],
51
52
  "sideEffects": false,
52
53
  "dependencies": {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @plane31/blind-relay/adapters: the contracts an adapter or host author implements, and what their
3
+ * tests need to drive them. Applications import `@plane31/blind-relay` instead.
4
+ *
5
+ * A `Store` and `SecretStore` adapter implements the row tables and the transaction interface;
6
+ * a `Transport` adapter implements the directory and relay calls; a `RecoveryProvider` keeps
7
+ * the recovery key with the platform; a host parses and verifies the signed documents the
8
+ * transport carries. The in-memory implementations are the behavioural reference.
9
+ */
10
+
11
+ // Bytes --------------------------------------------------------------------------------------------
12
+ export { Reader, Writer, compareBytes, concat, equalBytes, fromHex, toHex, u16, u32, u64 } from "../bytes.js";
13
+
14
+ // Cipher suite: readiness, randomness and the identifiers a host derives --------------------------
15
+ export { bodyHash, identityId, randomBytes, ready, recordId, signingKeypairFromSeed, streamId, type SigningKeypair } from "../crypto.js";
16
+
17
+ // Errors -------------------------------------------------------------------------------------------
18
+ export { KavachError, isKavachError, type ErrorCode } from "../errors.js";
19
+
20
+ // Signed documents, as a host or transport sees them ----------------------------------------------
21
+ export {
22
+ DocumentType,
23
+ buildResetKeyList,
24
+ buildResetReceipt,
25
+ expectType,
26
+ parseDocument,
27
+ parseVaultHeader,
28
+ verifyDocument,
29
+ type AccountDevice,
30
+ type AccountResetReceipt,
31
+ type CircleSnapshot,
32
+ type EncryptedRecord,
33
+ type EpochManifest,
34
+ type Parsed,
35
+ type ResetKeyEntry,
36
+ type ResetKeyList,
37
+ type SignedDocument,
38
+ type VaultHeader
39
+ } from "../codec.js";
40
+
41
+ // Store --------------------------------------------------------------------------------------------
42
+ export {
43
+ MemorySecretStore,
44
+ MemoryStore,
45
+ invokeSync,
46
+ type ActivationRow,
47
+ type CertificateRow,
48
+ type CircleRow,
49
+ type ClockRow,
50
+ type EpochRow,
51
+ type EpochStatus,
52
+ type HistoryRow,
53
+ type NewestRow,
54
+ type MemoryStoreOptions,
55
+ type MetaRow,
56
+ type OutboxRow,
57
+ type OutboxStatus,
58
+ type EnqueueKeyRow,
59
+ type HistoryIndexRow,
60
+ type KeyRange,
61
+ inKeyRange,
62
+ type PendingResetRow,
63
+ type PinRow,
64
+ type QuarantineRow,
65
+ type ReaderEpochRow,
66
+ type ResetListRow,
67
+ type ResetReceiptRow,
68
+ type RevisionRow,
69
+ type SecretName,
70
+ type SecretStore,
71
+ type SelfRow,
72
+ type Store,
73
+ type StoreState,
74
+ type StreamRow,
75
+ type SubscriptionRow,
76
+ type TableName,
77
+ type Tables,
78
+ type Tx,
79
+ type UnresolvedRow,
80
+ type VaultRow
81
+ } from "../store.js";
82
+
83
+ // Transport ----------------------------------------------------------------------------------------
84
+ export type {
85
+ AppendOutcome,
86
+ AppendStatus,
87
+ Directory,
88
+ EpochBundle,
89
+ JoinRequest,
90
+ MutationOutcome,
91
+ MutationStatus,
92
+ ReadRecordsResult,
93
+ Relay,
94
+ ResolvedAccount,
95
+ Transport
96
+ } from "../transport.js";
97
+
98
+ // Recovery provider --------------------------------------------------------------------------------
99
+ export type { BackupState, ProviderLoadResult, ProviderSaveResult, ProviderStatus, ProviderStatusResult, RecoveryProvider } from "../vault.js";
100
+
101
+ // The transactions an adapter test drives against a store -----------------------------------------
102
+ export { openStore, t1Activate, t1Header, t1Secrets, t3ApplyChange, type ChangeResult, type OpenedState } from "../transactions.js";
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Audience resolution and the desired revision (protocol section 7).
3
+ */
4
+ import { compareBytes, equalBytes, toHex } from "./bytes.js";
5
+ import type { CircleState } from "./circle.js";
6
+ import { KavachError } from "./errors.js";
7
+ import type { AccountPin } from "./identity.js";
8
+
9
+ export const MAX_AUDIENCE = 100;
10
+
11
+ export interface AudienceEntry {
12
+ readonly identityId: Uint8Array;
13
+ readonly certificateHash: Uint8Array;
14
+ readonly boxKey: Uint8Array;
15
+ }
16
+
17
+ export interface ResolvedAudience {
18
+ /** Sorted by identity id, self included. */
19
+ readonly rows: readonly AudienceEntry[];
20
+ /** Rows other than self. */
21
+ readonly recipientCount: number;
22
+ /** A digest-friendly description of the exact certificate-bound audience. */
23
+ readonly key: string;
24
+ }
25
+
26
+ export function isEligible(circle: CircleState, selfIdentityId: Uint8Array): boolean {
27
+ return (
28
+ circle.status === "member" &&
29
+ circle.enabled &&
30
+ circle.revision > 0n &&
31
+ circle.members.some((m) => equalBytes(m, selfIdentityId))
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Self plus every member of every eligible circle, each at its current certificate. Anything
37
+ * missing is `METADATA_REQUIRED`; more than 100 distinct accounts is `RECIPIENT_LIMIT`. The
38
+ * author never guesses, omits a recipient or reuses an obsolete key.
39
+ */
40
+ export function resolveAudience(self: AccountPin, circles: readonly CircleState[], pins: ReadonlyMap<string, AccountPin>, retired: ReadonlySet<string> = new Set()): ResolvedAudience {
41
+ const stage = "resolveAudience";
42
+ const wanted = new Map<string, Uint8Array>();
43
+ wanted.set(toHex(self.identityId), self.identityId);
44
+ for (const c of circles) {
45
+ if (!isEligible(c, self.identityId)) continue;
46
+ // An identity retired by a validated reset receipt is no longer a recipient.
47
+ for (const m of c.members) if (!retired.has(toHex(m))) wanted.set(toHex(m), m);
48
+ }
49
+ if (wanted.size > MAX_AUDIENCE) throw new KavachError("RECIPIENT_LIMIT", stage, `${wanted.size} accounts`);
50
+ const rows: AudienceEntry[] = [];
51
+ for (const [hex, iid] of wanted) {
52
+ const pin = hex === toHex(self.identityId) ? self : pins.get(hex);
53
+ if (!pin) throw new KavachError("METADATA_REQUIRED", stage, "no certificate for a member");
54
+ rows.push({ identityId: iid, certificateHash: pin.certificateHash, boxKey: pin.boxKey });
55
+ }
56
+ rows.sort((a, b) => compareBytes(a.identityId, b.identityId));
57
+ return { rows, recipientCount: rows.length - 1, key: rows.map((r) => toHex(r.identityId) + ":" + toHex(r.certificateHash)).join(",") };
58
+ }
59
+
60
+ // Desired revision -----------------------------------------------------------------------------
61
+
62
+ export type AudienceEvent =
63
+ | { readonly kind: "snapshot"; readonly circleId: Uint8Array; readonly revision: bigint }
64
+ | { readonly kind: "deviceChanged"; readonly identityId: Uint8Array; readonly certificateHash: Uint8Array }
65
+ | { readonly kind: "ownActivation" }
66
+ | { readonly kind: "join"; readonly circleId: Uint8Array }
67
+ | { readonly kind: "leave"; readonly circleId: Uint8Array }
68
+ | { readonly kind: "enable"; readonly circleId: Uint8Array }
69
+ | { readonly kind: "disable"; readonly circleId: Uint8Array }
70
+ | { readonly kind: "counterExhausted" }
71
+ | { readonly kind: "hygiene" }
72
+ | { readonly kind: "clockReversal" };
73
+
74
+ export interface RevisionState {
75
+ readonly desiredRevision: bigint;
76
+ /** Highest snapshot revision processed per circle, so reprocessing does not increment. */
77
+ readonly seenSnapshots: ReadonlyMap<string, bigint>;
78
+ /** Certificate hash last accepted per recipient, so a replayed certificate does not increment. */
79
+ readonly seenCertificates: ReadonlyMap<string, string>;
80
+ }
81
+
82
+ export const initialRevisionState: RevisionState = { desiredRevision: 1n, seenSnapshots: new Map(), seenCertificates: new Map() };
83
+
84
+ export interface Invalidation {
85
+ /** The active epoch is no longer usable; allocated counters are burned. */
86
+ readonly clearActiveEpoch: true;
87
+ /** Outbox rows sealed under the obsolete revision and not yet handed off are cancelled. */
88
+ readonly cancelObsoleteOutbox: true;
89
+ readonly paused: "rotation";
90
+ }
91
+
92
+ export const INVALIDATION: Invalidation = { clearActiveEpoch: true, cancelObsoleteOutbox: true, paused: "rotation" };
93
+
94
+ /**
95
+ * Apply one event. Returns the new state and whether the desired revision moved. Every move
96
+ * is a rotation: the caller performs the invalidation in the same transaction (T3).
97
+ */
98
+ export function applyAudienceEvent(state: RevisionState, event: AudienceEvent): { state: RevisionState; changed: boolean } {
99
+ const bump = (extra?: Partial<RevisionState>): { state: RevisionState; changed: true } => ({
100
+ state: { ...state, ...extra, desiredRevision: state.desiredRevision + 1n },
101
+ changed: true
102
+ });
103
+ switch (event.kind) {
104
+ case "snapshot": {
105
+ const key = toHex(event.circleId);
106
+ const seen = state.seenSnapshots.get(key) ?? 0n;
107
+ if (event.revision <= seen) return { state, changed: false };
108
+ const seenSnapshots = new Map(state.seenSnapshots);
109
+ seenSnapshots.set(key, event.revision);
110
+ return bump({ seenSnapshots });
111
+ }
112
+ case "deviceChanged": {
113
+ const key = toHex(event.identityId);
114
+ const hash = toHex(event.certificateHash);
115
+ if (state.seenCertificates.get(key) === hash) return { state, changed: false };
116
+ const seenCertificates = new Map(state.seenCertificates);
117
+ seenCertificates.set(key, hash);
118
+ return bump({ seenCertificates });
119
+ }
120
+ default:
121
+ return bump();
122
+ }
123
+ }
package/src/bytes.ts ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Fixed-width big-endian encoding, protocol section 3.
3
+ * u64 values are BigInt end to end; nothing is ever rounded through a double.
4
+ */
5
+ import { KavachError } from "./errors.js";
6
+
7
+ export const MAX_DOCUMENT_BYTES = 16_384;
8
+ export const MAX_PAYLOAD_BYTES = 4_096;
9
+ const U32_MAX = 0xffff_ffff;
10
+ const U64_MAX = (1n << 64n) - 1n;
11
+ const NAME_RULE = /^[A-Za-z0-9_-]{1,64}$/;
12
+
13
+ export function concat(...parts: readonly Uint8Array[]): Uint8Array {
14
+ let length = 0;
15
+ for (const part of parts) length += part.length;
16
+ const out = new Uint8Array(length);
17
+ let offset = 0;
18
+ for (const part of parts) {
19
+ out.set(part, offset);
20
+ offset += part.length;
21
+ }
22
+ return out;
23
+ }
24
+
25
+ export function u8(value: number): Uint8Array {
26
+ if (!Number.isInteger(value) || value < 0 || value > 0xff) throw new RangeError("u8 out of range");
27
+ return Uint8Array.of(value);
28
+ }
29
+
30
+ export function u16(value: number): Uint8Array {
31
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) throw new RangeError("u16 out of range");
32
+ return Uint8Array.of(value >>> 8, value & 0xff);
33
+ }
34
+
35
+ export function u32(value: number): Uint8Array {
36
+ if (!Number.isInteger(value) || value < 0 || value > U32_MAX) throw new RangeError("u32 out of range");
37
+ const out = new Uint8Array(4);
38
+ new DataView(out.buffer).setUint32(0, value, false);
39
+ return out;
40
+ }
41
+
42
+ export function u64(value: bigint): Uint8Array {
43
+ if (typeof value !== "bigint" || value < 0n || value > U64_MAX) throw new RangeError("u64 out of range");
44
+ const out = new Uint8Array(8);
45
+ new DataView(out.buffer).setBigUint64(0, value, false);
46
+ return out;
47
+ }
48
+
49
+ export function i32(value: number): Uint8Array {
50
+ if (!Number.isInteger(value) || value < -0x8000_0000 || value > 0x7fff_ffff) throw new RangeError("i32 out of range");
51
+ const out = new Uint8Array(4);
52
+ new DataView(out.buffer).setInt32(0, value, false);
53
+ return out;
54
+ }
55
+
56
+ /** The `accountName` rule: 1 to 64 ASCII letters, digits, underscore or hyphen, case-sensitive. */
57
+ export function isValidName(name: string): boolean {
58
+ return NAME_RULE.test(name);
59
+ }
60
+
61
+ /** `B(name)`: u8 byte length followed by the ASCII bytes. */
62
+ export function encodeName(name: string): Uint8Array {
63
+ if (!isValidName(name)) throw new KavachError("MALFORMED", "encodeName", "account name violates the name rule");
64
+ const bytes = new Uint8Array(name.length);
65
+ for (let i = 0; i < name.length; i++) bytes[i] = name.charCodeAt(i);
66
+ return concat(u8(bytes.length), bytes);
67
+ }
68
+
69
+ export function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
70
+ if (a.length !== b.length) return false;
71
+ let diff = 0;
72
+ for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
73
+ return diff === 0;
74
+ }
75
+
76
+ export function isAllZero(bytes: Uint8Array): boolean {
77
+ let acc = 0;
78
+ for (const b of bytes) acc |= b;
79
+ return acc === 0;
80
+ }
81
+
82
+ const HEX = /^[0-9a-f]*$/i;
83
+
84
+ export function toHex(bytes: Uint8Array): string {
85
+ let s = "";
86
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
87
+ return s;
88
+ }
89
+
90
+ export function fromHex(hex: string): Uint8Array {
91
+ if (hex.length % 2 !== 0 || !HEX.test(hex)) throw new RangeError("bad hex");
92
+ const out = new Uint8Array(hex.length / 2);
93
+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(2 * i, 2 * i + 2), 16);
94
+ return out;
95
+ }
96
+
97
+ /** Compare two byte strings lexicographically, as the strict sort orders in section 3 require. */
98
+ export function compareBytes(a: Uint8Array, b: Uint8Array): number {
99
+ const n = Math.min(a.length, b.length);
100
+ for (let i = 0; i < n; i++) {
101
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
102
+ if (d !== 0) return d;
103
+ }
104
+ return a.length - b.length;
105
+ }
106
+
107
+ /**
108
+ * A bounded reader. Every take is checked against the end; `end()` refuses trailing bytes.
109
+ * All failures are `MALFORMED`, which is what the codec reports before any cryptography.
110
+ */
111
+ export class Reader {
112
+ private offset = 0;
113
+
114
+ constructor(
115
+ private readonly data: Uint8Array,
116
+ private readonly stage = "parse"
117
+ ) {}
118
+
119
+ get position(): number {
120
+ return this.offset;
121
+ }
122
+
123
+ get remaining(): number {
124
+ return this.data.length - this.offset;
125
+ }
126
+
127
+ take(n: number): Uint8Array {
128
+ if (!Number.isInteger(n) || n < 0 || this.offset + n > this.data.length) {
129
+ throw new KavachError("MALFORMED", this.stage, "truncated");
130
+ }
131
+ const out = this.data.slice(this.offset, this.offset + n);
132
+ this.offset += n;
133
+ return out;
134
+ }
135
+
136
+ u8(): number {
137
+ return this.take(1)[0] ?? 0;
138
+ }
139
+
140
+ u16(): number {
141
+ const b = this.take(2);
142
+ return ((b[0] ?? 0) << 8) | (b[1] ?? 0);
143
+ }
144
+
145
+ u32(): number {
146
+ const b = this.take(4);
147
+ return new DataView(b.buffer, b.byteOffset, 4).getUint32(0, false);
148
+ }
149
+
150
+ u64(): bigint {
151
+ const b = this.take(8);
152
+ return new DataView(b.buffer, b.byteOffset, 8).getBigUint64(0, false);
153
+ }
154
+
155
+ i32(): number {
156
+ const b = this.take(4);
157
+ return new DataView(b.buffer, b.byteOffset, 4).getInt32(0, false);
158
+ }
159
+
160
+ /** `B(name)`, enforcing the name rule. */
161
+ name(): string {
162
+ const n = this.u8();
163
+ const bytes = this.take(n);
164
+ let text = "";
165
+ for (const b of bytes) text += String.fromCharCode(b);
166
+ if (!isValidName(text)) throw new KavachError("MALFORMED", this.stage, "bad account name");
167
+ return text;
168
+ }
169
+
170
+ /** The bytes consumed so far, for AAD and body hashing. */
171
+ consumed(): Uint8Array {
172
+ return this.data.slice(0, this.offset);
173
+ }
174
+
175
+ end(): void {
176
+ if (this.offset !== this.data.length) throw new KavachError("MALFORMED", this.stage, "trailing bytes");
177
+ }
178
+ }
179
+
180
+ /** An append-only writer for building bodies. */
181
+ export class Writer {
182
+ private readonly parts: Uint8Array[] = [];
183
+
184
+ bytes(value: Uint8Array): this {
185
+ this.parts.push(value);
186
+ return this;
187
+ }
188
+ u8(value: number): this {
189
+ return this.bytes(u8(value));
190
+ }
191
+ u16(value: number): this {
192
+ return this.bytes(u16(value));
193
+ }
194
+ u32(value: number): this {
195
+ return this.bytes(u32(value));
196
+ }
197
+ u64(value: bigint): this {
198
+ return this.bytes(u64(value));
199
+ }
200
+ i32(value: number): this {
201
+ return this.bytes(i32(value));
202
+ }
203
+ name(value: string): this {
204
+ return this.bytes(encodeName(value));
205
+ }
206
+ finish(): Uint8Array {
207
+ return concat(...this.parts);
208
+ }
209
+ }
package/src/circle.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Circle membership and owner approval (protocol section 6).
3
+ *
4
+ * Membership is one owner-signed snapshot. Join and leave requests are host state with no
5
+ * signature; the owner builds every successor from trusted local state plus one change.
6
+ */
7
+ import { compareBytes, equalBytes, toHex } from "./bytes.js";
8
+ import { DocumentType, buildCircleSnapshot, expectType, verifyDocument, type CircleSnapshot, type Parsed } from "./codec.js";
9
+ import { randomBytes } from "./crypto.js";
10
+ import { KavachError } from "./errors.js";
11
+
12
+ export type CircleStatus = "requested" | "member" | "left" | "quarantined" | "closed";
13
+
14
+ /** What the SDK persists per circle it has pinned. */
15
+ export interface CircleState {
16
+ readonly circleId: Uint8Array;
17
+ readonly ownerIdentityId: Uint8Array;
18
+ /** 0n until the first verified snapshot. */
19
+ readonly revision: bigint;
20
+ readonly bodyHash: Uint8Array | undefined;
21
+ readonly snapshot: Uint8Array | undefined;
22
+ readonly members: readonly Uint8Array[];
23
+ readonly enabled: boolean;
24
+ readonly status: CircleStatus;
25
+ /** When the current snapshot was verified, for showing its age. */
26
+ readonly verifiedAt: bigint;
27
+ }
28
+
29
+ export type SnapshotOutcome =
30
+ | { readonly outcome: "ACCEPTED"; readonly state: CircleState; readonly snapshot: Parsed<CircleSnapshot> }
31
+ | { readonly outcome: "DUPLICATE"; readonly state: CircleState };
32
+
33
+ export function newCircleId(): Uint8Array {
34
+ return randomBytes(16);
35
+ }
36
+
37
+ export function pinCircle(circleId: Uint8Array, ownerIdentityId: Uint8Array, enabled: boolean, status: CircleStatus = "requested"): CircleState {
38
+ return { circleId, ownerIdentityId, revision: 0n, bodyHash: undefined, snapshot: undefined, members: [], enabled, status, verifiedAt: 0n };
39
+ }
40
+
41
+ /**
42
+ * Accept a snapshot for a pinned circle under the pinned owner's account key.
43
+ * A lower revision is `ROLLBACK`; the same revision with the same body is a duplicate and with a
44
+ * different valid body is `EQUIVOCATION`, which quarantines the circle. Membership status comes
45
+ * from the snapshot: the account is a member when it is listed and waiting when it is not.
46
+ */
47
+ export function acceptSnapshot(state: CircleState, wire: Uint8Array, ownerAccountKey: Uint8Array, selfIdentityId: Uint8Array, now: bigint): SnapshotOutcome {
48
+ const stage = "acceptSnapshot";
49
+ const doc = expectType<CircleSnapshot>(verifyDocument(wire, ownerAccountKey), DocumentType.CircleSnapshot, stage);
50
+ if (!equalBytes(doc.circleId, state.circleId) || !equalBytes(doc.ownerIdentityId, state.ownerIdentityId)) {
51
+ throw new KavachError("MALFORMED", stage, "snapshot is for another circle or owner");
52
+ }
53
+ if (doc.revision < state.revision) throw new KavachError("ROLLBACK", stage);
54
+ if (doc.revision === state.revision) {
55
+ if (state.bodyHash && equalBytes(doc.bodyHash, state.bodyHash)) return { outcome: "DUPLICATE", state };
56
+ throw new KavachError("EQUIVOCATION", stage, "two snapshots at one revision");
57
+ }
58
+ // A member that is no longer listed has been removed, whether by a removal or a closure; the
59
+ // two are indistinguishable to a member and mean the same thing locally. Only the owner's own
60
+ // explicit close sets "closed".
61
+ const listed = doc.members.some((m) => equalBytes(m, selfIdentityId));
62
+ let status: CircleStatus = state.status;
63
+ if (state.status === "quarantined") status = "quarantined";
64
+ else if (listed) status = "member";
65
+ else if (state.status === "member") status = "left";
66
+ return {
67
+ outcome: "ACCEPTED",
68
+ snapshot: doc,
69
+ state: { ...state, revision: doc.revision, bodyHash: doc.bodyHash, snapshot: wire, members: doc.members, status, verifiedAt: now }
70
+ };
71
+ }
72
+
73
+ /** Two different account keys for one account name among resolved members is a forgery attempt. */
74
+ export function checkMemberResolution(resolved: ReadonlyMap<string, { accountName: string; accountKey: Uint8Array }>): void {
75
+ const byName = new Map<string, string>();
76
+ for (const [iid, r] of resolved) {
77
+ const seen = byName.get(r.accountName);
78
+ if (seen !== undefined && seen !== toHex(r.accountKey)) {
79
+ throw new KavachError("EQUIVOCATION", "resolveMembers", `two keys for ${r.accountName}`);
80
+ }
81
+ byName.set(r.accountName, toHex(r.accountKey));
82
+ void iid;
83
+ }
84
+ }
85
+
86
+ // Owner side -----------------------------------------------------------------------------------
87
+
88
+ export interface OwnerKeys {
89
+ readonly identityId: Uint8Array;
90
+ readonly accountSecretKey: Uint8Array;
91
+ }
92
+
93
+ /** Revision 1 with the owner as sole member. */
94
+ export function createCircleSnapshot(owner: OwnerKeys, circleId: Uint8Array): Uint8Array {
95
+ return buildCircleSnapshot({ circleId, ownerIdentityId: owner.identityId, revision: 1n, members: [owner.identityId] }, owner.accountSecretKey);
96
+ }
97
+
98
+ export type MembershipChange = { readonly admit: Uint8Array } | { readonly remove: Uint8Array } | { readonly close: true };
99
+
100
+ /**
101
+ * The successor snapshot from trusted local state plus one change. Returns `undefined` when the
102
+ * change is already reflected (approval of an admitted account is idempotent).
103
+ */
104
+ export function successorSnapshot(state: CircleState, change: MembershipChange, owner: OwnerKeys): Uint8Array | undefined {
105
+ if (!equalBytes(state.ownerIdentityId, owner.identityId)) throw new KavachError("NOT_AUTHORIZED", "successorSnapshot", "not the owner");
106
+ if (state.status === "closed") throw new KavachError("NOT_AUTHORIZED", "successorSnapshot", "circle closed");
107
+ let members: Uint8Array[];
108
+ if ("admit" in change) {
109
+ if (state.members.some((m) => equalBytes(m, change.admit))) return undefined;
110
+ members = [...state.members, change.admit];
111
+ if (members.length > 100) throw new KavachError("RECIPIENT_LIMIT", "successorSnapshot");
112
+ } else if ("remove" in change) {
113
+ if (equalBytes(change.remove, owner.identityId)) throw new KavachError("NOT_AUTHORIZED", "successorSnapshot", "the owner cannot leave without closing");
114
+ if (!state.members.some((m) => equalBytes(m, change.remove))) return undefined;
115
+ members = state.members.filter((m) => !equalBytes(m, change.remove));
116
+ } else {
117
+ members = [owner.identityId];
118
+ if (state.members.length === 1) return undefined;
119
+ }
120
+ members.sort(compareBytes);
121
+ return buildCircleSnapshot({ circleId: state.circleId, ownerIdentityId: owner.identityId, revision: state.revision + 1n, members }, owner.accountSecretKey);
122
+ }
123
+
124
+ /** The owner's restored device signs revision plus one over the current snapshot; the revision never resets. */
125
+ export function isOwner(state: CircleState, selfIdentityId: Uint8Array): boolean {
126
+ return equalBytes(state.ownerIdentityId, selfIdentityId);
127
+ }