raz-encrypt 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +488 -448
  3. package/dist/canonicalJson.d.ts +15 -0
  4. package/dist/canonicalJson.js +73 -13
  5. package/dist/canonicalJson.js.map +1 -1
  6. package/dist/core.d.ts +4 -2
  7. package/dist/core.js +3 -1
  8. package/dist/core.js.map +1 -1
  9. package/dist/crypto/classical.d.ts +2 -0
  10. package/dist/crypto/classical.js +4 -0
  11. package/dist/crypto/classical.js.map +1 -1
  12. package/dist/crypto/confirmedSession.d.ts +47 -0
  13. package/dist/crypto/confirmedSession.js +84 -0
  14. package/dist/crypto/confirmedSession.js.map +1 -0
  15. package/dist/crypto/frameCipher.js +8 -2
  16. package/dist/crypto/frameCipher.js.map +1 -1
  17. package/dist/crypto/frameSession.d.ts +40 -0
  18. package/dist/crypto/frameSession.js +96 -0
  19. package/dist/crypto/frameSession.js.map +1 -0
  20. package/dist/crypto/hybrid.d.ts +95 -8
  21. package/dist/crypto/hybrid.js +374 -49
  22. package/dist/crypto/hybrid.js.map +1 -1
  23. package/dist/crypto/nodeHybrid.d.ts +6 -4
  24. package/dist/crypto/nodeHybrid.js +3 -2
  25. package/dist/crypto/nodeHybrid.js.map +1 -1
  26. package/dist/crypto/pqc.d.ts +7 -2
  27. package/dist/crypto/pqc.js +13 -4
  28. package/dist/crypto/pqc.js.map +1 -1
  29. package/dist/crypto/ratchet.js +5 -0
  30. package/dist/crypto/ratchet.js.map +1 -1
  31. package/dist/fileCrypto.d.ts +44 -2
  32. package/dist/fileCrypto.js +198 -28
  33. package/dist/fileCrypto.js.map +1 -1
  34. package/dist/identity.d.ts +69 -4
  35. package/dist/identity.js +148 -13
  36. package/dist/identity.js.map +1 -1
  37. package/dist/index.d.ts +8 -7
  38. package/dist/index.js +4 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/passphrase.d.ts +27 -0
  41. package/dist/passphrase.js +69 -0
  42. package/dist/passphrase.js.map +1 -0
  43. package/dist/store.d.ts +43 -6
  44. package/dist/store.js +296 -23
  45. package/dist/store.js.map +1 -1
  46. package/dist/trust.d.ts +170 -2
  47. package/dist/trust.js +527 -33
  48. package/dist/trust.js.map +1 -1
  49. package/package.json +68 -69
package/dist/trust.js CHANGED
@@ -1,50 +1,363 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { gcm } from "@noble/ciphers/aes.js";
2
+ import { equalBytes } from "@noble/ciphers/utils.js";
3
+ import { hkdf } from "@noble/hashes/hkdf.js";
4
+ import { hmac } from "@noble/hashes/hmac.js";
5
+ import { sha384 } from "@noble/hashes/sha2.js";
6
+ import { bytesToHex, concatBytes, hexToBytes, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
7
+ import { readFile } from "node:fs/promises";
8
+ import { join, resolve } from "node:path";
9
+ import { canonicalize } from "./canonicalJson.js";
10
+ import { isValidIsoInstant, signCore, verifyCore } from "./crypto/hybrid.js";
11
+ import { writeFileAtomic } from "./fileCrypto.js";
3
12
  import { fingerprint } from "./fingerprint.js";
13
+ import { exportPublicIdentity } from "./identity.js";
14
+ import { appendLogEntry } from "./store.js";
15
+ /**
16
+ * Thrown when trust.json cannot be trusted: its MAC does not verify (edited,
17
+ * truncated, copied from another identity), it is not valid JSON, or it is an
18
+ * unauthenticated legacy file that was never explicitly adopted with
19
+ * `migrateLegacyTrustStore`. Everything that consults the trust list fails
20
+ * closed on this rather than reading a file anyone with filesystem access
21
+ * could have written.
22
+ */
23
+ export class TrustStoreIntegrityError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = "TrustStoreIntegrityError";
27
+ }
28
+ }
29
+ const TRUST_STORE_HMAC_INFO = utf8ToBytes("raz-encrypt-trust-store-v1|hmac-key");
30
+ // A separate key from the MAC key, same reason the audit log's head key is
31
+ // separate from its entry key (ADR 0019) — independent HKDF outputs per use
32
+ // (ADR 0027).
33
+ const TRUST_STORE_ENC_INFO = utf8ToBytes("raz-encrypt-trust-store-v1|enc-key");
34
+ // Third independent key, for the freshness sidecar (ADR 0030).
35
+ const TRUST_STORE_SEQ_INFO = utf8ToBytes("raz-encrypt-trust-store-v1|seq-key");
4
36
  function trustFilePath(storageDir) {
5
37
  return join(storageDir, "trust.json");
6
38
  }
39
+ /**
40
+ * HKDF-derived from BOTH the classical and PQC signing secrets, same
41
+ * composition as the audit log's key (ADR 0009) but under its own info label
42
+ * so the two MAC keys are independent. Recomputed on every use, never persisted.
43
+ */
44
+ function deriveTrustStoreKey(identity) {
45
+ return hkdf(sha384, concatBytes(identity.classical.secretKey, identity.pqcSign.secretKey), undefined, TRUST_STORE_HMAC_INFO, 32);
46
+ }
47
+ /** Same derivation shape as deriveTrustStoreKey, under its own info label (ADR 0027) — never derived from a passphrase, since trustParty/etc. only ever receive an Identity. */
48
+ function deriveTrustStoreEncKey(identity) {
49
+ return hkdf(sha384, concatBytes(identity.classical.secretKey, identity.pqcSign.secretKey), undefined, TRUST_STORE_ENC_INFO, 32);
50
+ }
51
+ // `seq` joins the MAC input only when present, so files written before ADR 0030 keep verifying unchanged.
52
+ function computeMac(identity, entries, seq) {
53
+ const body = seq === undefined ? { version: 3, entries } : { version: 3, entries, seq };
54
+ return bytesToHex(hmac(sha384, deriveTrustStoreKey(identity), utf8ToBytes(canonicalize(body))));
55
+ }
56
+ /**
57
+ * Exported (ADR 0026) so the timing-safety test can exercise this exact
58
+ * compare in isolation, without the file I/O its only internal callers do.
59
+ */
60
+ export function macMatches(identity, entries, mac, seq) {
61
+ if (typeof mac !== "string" || !/^[0-9a-f]{96}$/.test(mac))
62
+ return false;
63
+ return equalBytes(hexToBytes(mac), hexToBytes(computeMac(identity, entries, seq)));
64
+ }
65
+ /** v4's MAC covers the ciphertext blob instead of the plaintext entries (ADR 0027) — same chain-authentication mechanism, different bytes underneath it. */
66
+ function computeMacV4(identity, nonce, ciphertext, seq) {
67
+ const body = seq === undefined ? { version: 4, nonce, ciphertext } : { version: 4, nonce, ciphertext, seq };
68
+ return bytesToHex(hmac(sha384, deriveTrustStoreKey(identity), utf8ToBytes(canonicalize(body))));
69
+ }
70
+ function macMatchesV4(identity, nonce, ciphertext, mac, seq) {
71
+ if (typeof mac !== "string" || !/^[0-9a-f]{96}$/.test(mac))
72
+ return false;
73
+ return equalBytes(hexToBytes(mac), hexToBytes(computeMacV4(identity, nonce, ciphertext, seq)));
74
+ }
75
+ /** Associated data for the entries AEAD: binds the ciphertext to the format version and its own nonce, same pattern as fileCrypto.ts's headerAad. */
76
+ function entriesAad(version, nonce) {
77
+ return utf8ToBytes(canonicalize({ version, nonce }));
78
+ }
79
+ function encryptEntries(identity, entries) {
80
+ const nonce = randomBytes(12);
81
+ const nonceHex = bytesToHex(nonce);
82
+ const ciphertext = gcm(deriveTrustStoreEncKey(identity), nonce, entriesAad(4, nonceHex)).encrypt(utf8ToBytes(canonicalize(entries)));
83
+ return { nonce: nonceHex, ciphertext: bytesToHex(ciphertext) };
84
+ }
85
+ /** Throws TrustStoreIntegrityError (never a raw AEAD error) if the ciphertext doesn't authenticate or doesn't decode as entries. */
86
+ function decryptEntries(identity, nonce, ciphertext) {
87
+ try {
88
+ const plaintext = gcm(deriveTrustStoreEncKey(identity), hexToBytes(nonce), entriesAad(4, nonce)).decrypt(hexToBytes(ciphertext));
89
+ const entries = JSON.parse(new TextDecoder().decode(plaintext));
90
+ if (typeof entries !== "object" || entries === null || Array.isArray(entries))
91
+ throw new Error("decrypted trust store is not an entries object");
92
+ return entries;
93
+ }
94
+ catch {
95
+ throw new TrustStoreIntegrityError("trust.json failed its integrity check: it was modified, truncated, or belongs to a different identity.");
96
+ }
97
+ }
98
+ function isV3(data) {
99
+ if (typeof data !== "object" || data === null || data.version !== 3)
100
+ return false;
101
+ const entries = data.entries;
102
+ return typeof entries === "object" && entries !== null && !Array.isArray(entries) && validSeq(data.seq);
103
+ }
104
+ function validSeq(seq) {
105
+ return seq === undefined || (typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 1);
106
+ }
107
+ function isHex96(value) {
108
+ return typeof value === "string" && /^[0-9a-f]{96}$/.test(value);
109
+ }
110
+ function isV4(data) {
111
+ if (typeof data !== "object" || data === null || data.version !== 4)
112
+ return false;
113
+ const d = data;
114
+ return typeof d.nonce === "string" && /^[0-9a-f]{24}$/.test(d.nonce) && typeof d.ciphertext === "string" && /^[0-9a-f]*$/.test(d.ciphertext) && isHex96(d.mac) && validSeq(data.seq);
115
+ }
7
116
  function isV2(data) {
8
117
  return typeof data === "object" && data !== null && data.version === 2;
9
118
  }
10
- /** Migrates a legacy alias-keyed store to the fingerprint-keyed v2 shape. */
11
- function migrateLegacy(legacy) {
119
+ /** Converts an unauthenticated legacy file (v1 alias map, or v2 without a MAC) to fingerprint-keyed entries. */
120
+ function legacyEntries(data) {
121
+ if (isV2(data))
122
+ return data.entries;
123
+ if (typeof data !== "object" || data === null) {
124
+ // Object.values(null) throws TypeError; fail closed with the same error
125
+ // every other unauthenticated-shape rejection in this module uses,
126
+ // rather than crashing the caller (found by fuzzing, issue #42).
127
+ throw new TrustStoreIntegrityError("trust.json is not a valid legacy trust-store shape (expected an object).");
128
+ }
12
129
  const entries = {};
13
- for (const card of Object.values(legacy)) {
130
+ for (const card of Object.values(data)) {
14
131
  entries[fingerprint(card)] = { card, trustedAt: new Date().toISOString() };
15
132
  }
16
- return { version: 2, entries };
133
+ return entries;
17
134
  }
18
- async function readTrustStore(storageDir) {
135
+ async function readRaw(storageDir) {
136
+ try {
137
+ return await readFile(trustFilePath(storageDir), "utf8");
138
+ }
139
+ catch (err) {
140
+ if (err.code === "ENOENT")
141
+ return undefined;
142
+ throw err;
143
+ }
144
+ }
145
+ function parse(raw) {
146
+ try {
147
+ return JSON.parse(raw);
148
+ }
149
+ catch {
150
+ throw new TrustStoreIntegrityError("trust.json is not valid JSON; refusing to use it.");
151
+ }
152
+ }
153
+ const ROLLBACK_MESSAGE = "trust.json is older than the last recorded trust change: it was rolled back (or its sequence file was removed). Refusing to use it. Restore the newest copy, or investigate before re-trusting anyone.";
154
+ function seqFilePath(storageDir) {
155
+ return join(storageDir, "trust.seq.json");
156
+ }
157
+ function computeSeqMac(identity, seq, storeMac) {
158
+ const key = hkdf(sha384, concatBytes(identity.classical.secretKey, identity.pqcSign.secretKey), undefined, TRUST_STORE_SEQ_INFO, 32);
159
+ return bytesToHex(hmac(sha384, key, utf8ToBytes(canonicalize({ version: 1, seq, storeMac }))));
160
+ }
161
+ /** The sidecar's contents, or undefined if absent. Throws TrustStoreIntegrityError if it exists but is malformed or fails its MAC. */
162
+ async function readSeqRecord(identity) {
19
163
  let raw;
20
164
  try {
21
- raw = await readFile(trustFilePath(storageDir), "utf8");
165
+ raw = await readFile(seqFilePath(identity.storageDir), "utf8");
22
166
  }
23
167
  catch (err) {
24
168
  if (err.code === "ENOENT")
25
- return { version: 2, entries: {} };
169
+ return undefined;
26
170
  throw err;
27
171
  }
28
- const data = JSON.parse(raw);
29
- return isV2(data) ? data : migrateLegacy(data);
172
+ const bad = () => new TrustStoreIntegrityError("trust.seq.json failed its integrity check: it was modified or belongs to a different identity.");
173
+ let data;
174
+ try {
175
+ data = JSON.parse(raw);
176
+ }
177
+ catch {
178
+ throw bad();
179
+ }
180
+ const d = data;
181
+ if (typeof d !== "object" || d === null || d.version !== 1 || !validSeq(d.seq) || d.seq === undefined || !isHex96(d.storeMac) || !isHex96(d.mac))
182
+ throw bad();
183
+ if (!equalBytes(hexToBytes(d.mac), hexToBytes(computeSeqMac(identity, d.seq, d.storeMac))))
184
+ throw bad();
185
+ return d;
186
+ }
187
+ /**
188
+ * Reads and AUTHENTICATES the trust store, transparently decrypting a v4 (issue #43) file, and
189
+ * checks its freshness against the sequence sidecar (ADR 0030): a store whose `seq` is below the
190
+ * sidecar's, or equal with a different MAC, is a rollback. A store AHEAD of the sidecar is a crash
191
+ * between the two writes and is accepted (the next write repairs it). Missing file = empty store.
192
+ */
193
+ async function readStoreState(identity) {
194
+ const raw = await readRaw(identity.storageDir);
195
+ if (raw === undefined)
196
+ return { entries: {}, seq: 0, mac: "" };
197
+ const data = parse(raw);
198
+ let state;
199
+ if (isV4(data)) {
200
+ if (!macMatchesV4(identity, data.nonce, data.ciphertext, data.mac, data.seq)) {
201
+ throw new TrustStoreIntegrityError("trust.json failed its integrity check: it was modified, truncated, or belongs to a different identity.");
202
+ }
203
+ state = { entries: decryptEntries(identity, data.nonce, data.ciphertext), seq: data.seq ?? 0, mac: data.mac };
204
+ }
205
+ else if (!isV3(data)) {
206
+ throw new TrustStoreIntegrityError("trust.json is an unauthenticated legacy file (written before trust-store MACs). Review it, then call migrateLegacyTrustStore(identity) to adopt it.");
207
+ }
208
+ else {
209
+ if (!macMatches(identity, data.entries, data.mac, data.seq)) {
210
+ throw new TrustStoreIntegrityError("trust.json failed its integrity check: it was modified, truncated, or belongs to a different identity.");
211
+ }
212
+ state = { entries: data.entries, seq: data.seq ?? 0, mac: data.mac };
213
+ }
214
+ const record = await readSeqRecord(identity);
215
+ if (record === undefined) {
216
+ // Only a first-ever write can legitimately leave a store with no sidecar (crash between the two writes).
217
+ if (state.seq > 1)
218
+ throw new TrustStoreIntegrityError(ROLLBACK_MESSAGE);
219
+ }
220
+ else if (state.seq < record.seq || (state.seq === record.seq && state.mac !== record.storeMac)) {
221
+ throw new TrustStoreIntegrityError(ROLLBACK_MESSAGE);
222
+ }
223
+ return state;
224
+ }
225
+ async function readTrustStore(identity) {
226
+ return (await readStoreState(identity)).entries;
227
+ }
228
+ /** The current `{ seq, mac }` of the trust store. Store it off-box after changes you care about. */
229
+ export async function getTrustStoreAnchor(identity) {
230
+ const { seq, mac } = await readStoreState(identity);
231
+ return { seq, mac };
232
+ }
233
+ /**
234
+ * Throws TrustStoreIntegrityError if the local trust store is older than `anchor`, or has the
235
+ * same sequence number with different contents. Newer is fine. This is the only check that
236
+ * catches a rollback where trust.json and trust.seq.json were restored together (ADR 0030).
237
+ */
238
+ export async function verifyTrustStoreAnchor(identity, anchor) {
239
+ const { seq, mac } = await readStoreState(identity);
240
+ if (seq < anchor.seq || (seq === anchor.seq && mac !== anchor.mac)) {
241
+ throw new TrustStoreIntegrityError("the trust store does not match the anchor you supplied: it was rolled back or replaced.");
242
+ }
30
243
  }
31
- async function writeTrustStore(storageDir, store) {
32
- await mkdir(storageDir, { recursive: true });
33
- await writeFile(trustFilePath(storageDir), JSON.stringify(store, null, 2), { mode: 0o600 });
244
+ async function writeStoreFile(identity, store, seq) {
245
+ // Temp file with a unique name, fsync of file and directory, then rename (ADR 0028):
246
+ // a crash leaves the complete old file or the complete new one, never a torn one.
247
+ await writeFileAtomic(trustFilePath(identity.storageDir), JSON.stringify(store, null, 2));
248
+ // Then the freshness sidecar (ADR 0030). A crash between the two leaves the store one ahead, which reads accept.
249
+ const record = { version: 1, seq, storeMac: store.mac, mac: computeSeqMac(identity, seq, store.mac) };
250
+ await writeFileAtomic(seqFilePath(identity.storageDir), JSON.stringify(record));
251
+ }
252
+ /**
253
+ * Serializes every read-modify-write of one identity's trust store (ADR
254
+ * 0028, issue #56). Without this, two overlapping mutations each read the
255
+ * same snapshot and the later write erased the earlier one, so a revocation
256
+ * could be lost while the revoked party stayed trusted. In-process only, like
257
+ * the audit log's queue (ADR 0011): separate OS processes sharing one
258
+ * storageDir are not coordinated.
259
+ *
260
+ * A failure in one task never blocks the next. The map entry is removed once
261
+ * its chain drains, so it does not grow with every storageDir ever touched.
262
+ */
263
+ const mutationQueues = new Map();
264
+ function serialized(identity, task) {
265
+ const key = resolve(identity.storageDir);
266
+ const run = (mutationQueues.get(key) ?? Promise.resolve()).then(task);
267
+ const tail = run.then(() => undefined, () => undefined);
268
+ mutationQueues.set(key, tail);
269
+ void tail.then(() => {
270
+ if (mutationQueues.get(key) === tail)
271
+ mutationQueues.delete(key);
272
+ });
273
+ return run;
274
+ }
275
+ /**
276
+ * Writes the store with a fresh MAC, atomically (temp file in the same
277
+ * directory, then rename), mode 0o600. Preserves whatever format is
278
+ * currently on disk (issue #43): once `migrateTrustStoreToEncrypted` has
279
+ * been called, every subsequent write here stays v4; a brand-new or
280
+ * never-migrated store stays v3, exactly as before this issue.
281
+ */
282
+ async function writeTrustStore(identity, entries) {
283
+ const raw = await readRaw(identity.storageDir);
284
+ const current = raw === undefined ? undefined : parse(raw);
285
+ const seq = (current?.seq ?? 0) + 1;
286
+ if (isV4(current)) {
287
+ await writeTrustStore4(identity, entries, seq);
288
+ return;
289
+ }
290
+ await writeStoreFile(identity, { version: 3, entries, seq, mac: computeMac(identity, entries, seq) }, seq);
291
+ }
292
+ async function writeTrustStore4(identity, entries, seq) {
293
+ const next = seq ?? (await currentSeq(identity)) + 1;
294
+ const { nonce, ciphertext } = encryptEntries(identity, entries);
295
+ await writeStoreFile(identity, { version: 4, nonce, ciphertext, seq: next, mac: computeMacV4(identity, nonce, ciphertext, next) }, next);
296
+ }
297
+ async function currentSeq(identity) {
298
+ const raw = await readRaw(identity.storageDir);
299
+ const seq = raw === undefined ? undefined : parse(raw).seq;
300
+ return typeof seq === "number" ? seq : 0;
301
+ }
302
+ /**
303
+ * One-time, explicit opt-in (issue #43/ADR 0027) to encrypt `trust.json`'s
304
+ * entries, so filesystem access alone no longer reveals who this identity
305
+ * trusts. A no-op if the store is already v4. A legacy (unauthenticated) file
306
+ * is not implicitly adopted here — call `migrateLegacyTrustStore` first, same
307
+ * as reading it would require.
308
+ */
309
+ export function migrateTrustStoreToEncrypted(identity) {
310
+ return serialized(identity, async () => {
311
+ const raw = await readRaw(identity.storageDir);
312
+ const current = raw === undefined ? undefined : parse(raw);
313
+ const entries = await readTrustStore(identity); // verifies/decrypts whichever format; throws on a legacy or tampered file
314
+ if (isV4(current))
315
+ return; // already encrypted — true no-op, same spirit as migrateLegacyTrustStore's
316
+ await writeTrustStore4(identity, entries);
317
+ });
318
+ }
319
+ /**
320
+ * One-time, explicit adoption of a trust.json written before trust-store
321
+ * MACs existed (a v1 alias map or a v2 fingerprint-keyed file). Those files
322
+ * carry no proof of who wrote them, so they are never read implicitly (an
323
+ * attacker could otherwise plant one and bypass the MAC). Calling this says
324
+ * "I have reviewed the current file and it is mine": its records are kept
325
+ * and the file is rewritten authenticated. Returns the number of records
326
+ * adopted; returns 0 and changes nothing if the file is missing or already
327
+ * authenticated (a modified authenticated file still throws).
328
+ */
329
+ export function migrateLegacyTrustStore(identity) {
330
+ return serialized(identity, async () => {
331
+ const raw = await readRaw(identity.storageDir);
332
+ if (raw === undefined)
333
+ return 0;
334
+ const data = parse(raw);
335
+ if (isV3(data) || isV4(data)) {
336
+ await readTrustStore(identity); // verifies (and decrypts, for v4); throws if tampered
337
+ return 0;
338
+ }
339
+ const entries = legacyEntries(data);
340
+ await writeTrustStore(identity, entries);
341
+ return Object.keys(entries).length;
342
+ });
34
343
  }
35
344
  /**
36
345
  * Records another party's public card as trusted, keyed by the card's
37
346
  * fingerprint (not alias — two different real parties could otherwise pick
38
347
  * the same alias and silently overwrite each other). Public keys are not
39
348
  * secret, so this file is plain JSON (unlike identity.enc.json), though it's
40
- * still permission-restricted to the owning user.
349
+ * permission-restricted to the owning user and, since ADR 0015, carries an
350
+ * HMAC (keyed from the Identity's secret keys) so edits are detected.
41
351
  *
42
352
  * Trusting a card again after it was revoked reinstates it (clears revokedAt).
43
353
  */
44
- export async function trustParty(identity, card) {
45
- const store = await readTrustStore(identity.storageDir);
46
- store.entries[fingerprint(card)] = { card, trustedAt: new Date().toISOString() };
47
- await writeTrustStore(identity.storageDir, store);
354
+ export function trustParty(identity, card) {
355
+ return serialized(identity, async () => {
356
+ const entries = await readTrustStore(identity);
357
+ entries[fingerprint(card)] = { card, trustedAt: new Date().toISOString() };
358
+ await writeTrustStore(identity, entries);
359
+ await appendLogEntry(identity, { action: "trust", counterpartAlias: card.alias });
360
+ });
48
361
  }
49
362
  /**
50
363
  * Revokes every currently-trusted card recorded under `alias` — e.g. after a
@@ -53,26 +366,49 @@ export async function trustParty(identity, card) {
53
366
  * lost, and so verify() can report "revoked-signer" distinctly from
54
367
  * "untrusted-signer" (never trusted at all).
55
368
  */
56
- export async function untrustParty(identity, alias) {
57
- const store = await readTrustStore(identity.storageDir);
58
- const revokedAt = new Date().toISOString();
59
- for (const record of Object.values(store.entries)) {
60
- if (record.card.alias === alias && !record.revokedAt)
61
- record.revokedAt = revokedAt;
62
- }
63
- await writeTrustStore(identity.storageDir, store);
369
+ export function untrustParty(identity, alias) {
370
+ return serialized(identity, async () => {
371
+ const entries = await readTrustStore(identity);
372
+ const revokedAt = new Date().toISOString();
373
+ for (const record of Object.values(entries)) {
374
+ if (record.card.alias === alias && !record.revokedAt)
375
+ record.revokedAt = revokedAt;
376
+ }
377
+ await writeTrustStore(identity, entries);
378
+ await appendLogEntry(identity, { action: "untrust", counterpartAlias: alias });
379
+ });
380
+ }
381
+ /**
382
+ * Revokes exactly one card, identified by its `fingerprint(card)` string
383
+ * (spaces and case are ignored). Unlike `untrustParty`, parties that merely
384
+ * share an alias are untouched — alias is a non-unique display label.
385
+ * Returns true if a currently-trusted record was revoked, false if no such
386
+ * trusted record exists (a typo'd fingerprint is reported, not ignored).
387
+ */
388
+ export function untrustFingerprint(identity, fingerprintValue) {
389
+ const normalize = (value) => value.replace(/\s+/g, "").toUpperCase();
390
+ const wanted = normalize(fingerprintValue);
391
+ return serialized(identity, async () => {
392
+ const entries = await readTrustStore(identity);
393
+ const key = Object.keys(entries).find((candidate) => normalize(candidate) === wanted);
394
+ if (key === undefined || entries[key].revokedAt)
395
+ return false;
396
+ entries[key].revokedAt = new Date().toISOString();
397
+ await writeTrustStore(identity, entries);
398
+ await appendLogEntry(identity, { action: "untrust", counterpartAlias: entries[key].card.alias });
399
+ return true;
400
+ });
64
401
  }
65
402
  /** Every currently-trusted (non-revoked) card. */
66
403
  export async function listTrustedParties(identity) {
67
- const store = await readTrustStore(identity.storageDir);
68
- return Object.values(store.entries)
404
+ const entries = await readTrustStore(identity);
405
+ return Object.values(entries)
69
406
  .filter((record) => !record.revokedAt)
70
407
  .map((record) => record.card);
71
408
  }
72
409
  /** Every trust record ever made, revoked or not — for audit/history purposes. */
73
410
  export async function listTrustRecords(identity) {
74
- const store = await readTrustStore(identity.storageDir);
75
- return Object.values(store.entries);
411
+ return Object.values(await readTrustStore(identity));
76
412
  }
77
413
  /** The first currently-trusted (non-revoked) card recorded under this alias, if any. */
78
414
  export async function getTrustedParty(identity, alias) {
@@ -92,7 +428,165 @@ export async function getTrustedPartiesByAlias(identity, alias) {
92
428
  }
93
429
  /** All trust records (revoked or not) recorded under this alias. */
94
430
  export async function getTrustRecordsByAlias(identity, alias) {
95
- const store = await readTrustStore(identity.storageDir);
96
- return Object.values(store.entries).filter((record) => record.card.alias === alias);
431
+ return Object.values(await readTrustStore(identity)).filter((record) => record.card.alias === alias);
432
+ }
433
+ // --- Authenticated key lifecycle (issue #37 / ADR 0023) -----------------
434
+ //
435
+ // Everything below is an ordinary Certificate over a new document shape,
436
+ // verified by reusing verifyCore — never a new signing or verification
437
+ // primitive. The common trick: a SELF-signed document's trust basis isn't a
438
+ // trust-store lookup, it's self-consistency (does the certificate's signer
439
+ // match the card it's attached to?), so `cardMatchesSigner` stands in for
440
+ // checkTrust wherever one of these functions verifies a self-signed card.
441
+ /** True iff `signer` (a Certificate's signer fields) is the SAME party `card` claims to be — not a trust-store lookup, a self-consistency check. */
442
+ function cardMatchesSigner(card, signer) {
443
+ return card.alias === signer.alias && card.classicalPublicKey === signer.classicalPublicKey && card.pqcSignPublicKey === signer.pqcSignPublicKey;
444
+ }
445
+ /**
446
+ * Verifies a self-signed card (from `exportSignedCard`) — checks the
447
+ * signature is self-consistent (the certificate's signer IS the card it's
448
+ * attached to) and, if the card carries an `expiresAt`, that it hasn't
449
+ * passed. Only on success does this call the ordinary `trustParty` to
450
+ * actually record it — self-signing proves the card is authentic and
451
+ * current, not that you've decided to trust it (Fingerprint confirmation
452
+ * is still the human-level trust decision, same as a bare card).
453
+ */
454
+ export async function trustSignedCard(identity, card, certificate) {
455
+ const result = await verifyCore(identity, card, certificate, {
456
+ checkTrust: async (signer) => (cardMatchesSigner(card, signer) ? "trusted" : "untrusted"),
457
+ });
458
+ if (result.valid)
459
+ await trustParty(identity, card);
460
+ return result;
461
+ }
462
+ /**
463
+ * Starts a key rotation: the OLD identity signs a statement naming `newCard`
464
+ * as its successor. Hand the result to `completeRotation`, called with the
465
+ * NEW identity, to get a complete, dual-signed RotationStatement — a single
466
+ * signature from the old key alone is never enough to redirect a peer's
467
+ * trust (see `applyRotation`), so this function's output is not yet usable
468
+ * on its own. Even a dual-signed statement is only as trustworthy as the new
469
+ * fingerprint the receiver confirms out of band (`applyRotation`).
470
+ */
471
+ export async function beginRotation(oldIdentity, newCard, options = {}) {
472
+ const rotatedAt = options.rotatedAt ?? new Date().toISOString();
473
+ if (!isValidIsoInstant(rotatedAt))
474
+ throw new Error(`rotatedAt must be a valid ISO-8601 instant in the exact format new Date().toISOString() produces; got ${JSON.stringify(rotatedAt)}.`);
475
+ const document = { oldCard: exportPublicIdentity(oldIdentity), newCard, rotatedAt };
476
+ const oldCertificate = await signCore(oldIdentity, document);
477
+ return { document, oldCertificate };
478
+ }
479
+ /**
480
+ * Finishes a key rotation: the NEW identity signs the SAME document
481
+ * `beginRotation` produced, proving the new key exists and agrees to be
482
+ * named as successor. This proves the statement's author controls the new
483
+ * key; it does NOT prove the new key belongs to the real owner — an attacker
484
+ * who stole the old key can mint their own "new" identity and complete the
485
+ * statement themselves. Receivers close that gap with `applyRotation`'s
486
+ * out-of-band `confirmNewFingerprint`.
487
+ */
488
+ export async function completeRotation(newIdentity, draft) {
489
+ const newCertificate = await signCore(newIdentity, draft.document);
490
+ return { document: draft.document, oldCertificate: draft.oldCertificate, newCertificate };
491
+ }
492
+ /**
493
+ * Verifies a RotationStatement (both signatures self-consistent, both
494
+ * certificates over the identical document) and, only if `oldCard` already
495
+ * has an existing Trust Record, moves trust to `newCard` and tombstones
496
+ * `oldCard`'s record (revokedAt set, plus rotatedTo pointing at newCard's
497
+ * fingerprint). A statement for a card you never trusted is a no-op — this
498
+ * is not a way to seed your trust store with a card you never decided to
499
+ * trust in the first place.
500
+ *
501
+ * `options.confirmNewFingerprint` is REQUIRED: the new card's fingerprint as
502
+ * the real owner gave it to you over a channel the statement did not travel
503
+ * on (phone call, in person). Dual signatures alone only prove the author
504
+ * holds both keys; an attacker with a stolen old key supplies their own
505
+ * "new" key and signs both. Spacing and case are ignored when comparing.
506
+ * A mismatch returns `{ valid: false, reason: "fingerprint-mismatch" }` and
507
+ * changes nothing; a missing value throws (ADR 0029).
508
+ */
509
+ export async function applyRotation(identity, statement, options) {
510
+ if (typeof options?.confirmNewFingerprint !== "string" || options.confirmNewFingerprint.trim() === "") {
511
+ throw new Error("applyRotation requires options.confirmNewFingerprint: the new card's fingerprint, confirmed with its owner out of band (ADR 0029).");
512
+ }
513
+ const { document, oldCertificate, newCertificate } = statement;
514
+ const oldResult = await verifyCore(identity, document, oldCertificate, {
515
+ checkTrust: async (signer) => (cardMatchesSigner(document.oldCard, signer) ? "trusted" : "untrusted"),
516
+ });
517
+ if (!oldResult.valid)
518
+ return oldResult;
519
+ const newResult = await verifyCore(identity, document, newCertificate, {
520
+ checkTrust: async (signer) => (cardMatchesSigner(document.newCard, signer) ? "trusted" : "untrusted"),
521
+ });
522
+ if (!newResult.valid)
523
+ return newResult;
524
+ const normalize = (value) => value.replace(/\s+/g, "").toUpperCase();
525
+ if (normalize(fingerprint(document.newCard)) !== normalize(options.confirmNewFingerprint)) {
526
+ return { valid: false, reason: "fingerprint-mismatch" };
527
+ }
528
+ // Signature checks above need no store access; only the read-modify-write is serialized.
529
+ return serialized(identity, async () => {
530
+ const entries = await readTrustStore(identity);
531
+ const oldKey = fingerprint(document.oldCard);
532
+ const existing = entries[oldKey];
533
+ if (!existing || existing.revokedAt)
534
+ return { valid: true }; // nothing trusted to migrate from — no-op
535
+ const revokedAt = new Date().toISOString();
536
+ entries[oldKey] = { ...existing, revokedAt, rotatedTo: fingerprint(document.newCard) };
537
+ entries[fingerprint(document.newCard)] = { card: document.newCard, trustedAt: revokedAt };
538
+ await writeTrustStore(identity, entries);
539
+ await appendLogEntry(identity, { action: "rotate", counterpartAlias: document.oldCard.alias });
540
+ return { valid: true };
541
+ });
542
+ }
543
+ /**
544
+ * Produces a self-signed, portable revocation notice for `identity`'s own
545
+ * card — verifiable by any peer independently of their (or your) local
546
+ * trust-store state. The same function works whether called reactively
547
+ * (retiring a key now) or right after createIdentity, to be stored
548
+ * separately and used later if this identity becomes inaccessible — that's
549
+ * what actually makes "recovery" possible for a lost passphrase or a lost
550
+ * identity file (ADR 0023): there is no special "pre-generated" mode, it's
551
+ * the same notice either way.
552
+ */
553
+ export async function createRevocationNotice(identity, options = {}) {
554
+ const revokedAt = new Date().toISOString();
555
+ const document = {
556
+ revokedCard: exportPublicIdentity(identity),
557
+ revokedAt,
558
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
559
+ };
560
+ const certificate = await signCore(identity, document);
561
+ return { document, certificate };
562
+ }
563
+ /**
564
+ * Verifies a revocation notice (self-consistent, well-formed, `revokedAt`
565
+ * a valid ISO instant) and, only if the named card already has an existing
566
+ * Trust Record, tombstones it (revokedAt set) — exactly like
567
+ * `untrustFingerprint`. A notice for a card you never trusted is a no-op,
568
+ * for the same reason `applyRotation` is: this is not a pre-emptive
569
+ * denylist feature.
570
+ */
571
+ export async function applyRevocationNotice(identity, notice) {
572
+ const { document, certificate } = notice;
573
+ if (!isValidIsoInstant(document.revokedAt))
574
+ return { valid: false, reason: "malformed-certificate" };
575
+ const result = await verifyCore(identity, document, certificate, {
576
+ checkTrust: async (signer) => (cardMatchesSigner(document.revokedCard, signer) ? "trusted" : "untrusted"),
577
+ });
578
+ if (!result.valid)
579
+ return result;
580
+ return serialized(identity, async () => {
581
+ const entries = await readTrustStore(identity);
582
+ const key = fingerprint(document.revokedCard);
583
+ const existing = entries[key];
584
+ if (!existing || existing.revokedAt)
585
+ return { valid: true }; // nothing trusted to revoke — no-op
586
+ entries[key] = { ...existing, revokedAt: document.revokedAt };
587
+ await writeTrustStore(identity, entries);
588
+ await appendLogEntry(identity, { action: "revoke", counterpartAlias: document.revokedCard.alias });
589
+ return { valid: true };
590
+ });
97
591
  }
98
592
  //# sourceMappingURL=trust.js.map