@interop/wallet-core 0.5.0 → 0.6.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 (81) hide show
  1. package/dist/enrollment/enrollment.d.ts +144 -0
  2. package/dist/enrollment/enrollment.d.ts.map +1 -0
  3. package/dist/enrollment/enrollment.js +296 -0
  4. package/dist/enrollment/enrollment.js.map +1 -0
  5. package/dist/enrollment/index.d.ts +20 -0
  6. package/dist/enrollment/index.d.ts.map +1 -0
  7. package/dist/enrollment/index.js +19 -0
  8. package/dist/enrollment/index.js.map +1 -0
  9. package/dist/identity/index.d.ts +0 -5
  10. package/dist/identity/index.d.ts.map +1 -1
  11. package/dist/identity/index.js +0 -4
  12. package/dist/identity/index.js.map +1 -1
  13. package/dist/index.d.ts +13 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +13 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/keyring/fetch.d.ts +22 -0
  18. package/dist/keyring/fetch.d.ts.map +1 -0
  19. package/dist/keyring/fetch.js +35 -0
  20. package/dist/keyring/fetch.js.map +1 -0
  21. package/dist/keyring/index.d.ts +30 -0
  22. package/dist/keyring/index.d.ts.map +1 -0
  23. package/dist/keyring/index.js +28 -0
  24. package/dist/keyring/index.js.map +1 -0
  25. package/dist/keyring/kdf.d.ts +102 -0
  26. package/dist/keyring/kdf.d.ts.map +1 -0
  27. package/dist/keyring/kdf.js +148 -0
  28. package/dist/keyring/kdf.js.map +1 -0
  29. package/dist/keyring/record.d.ts +98 -0
  30. package/dist/keyring/record.d.ts.map +1 -0
  31. package/dist/keyring/record.js +119 -0
  32. package/dist/keyring/record.js.map +1 -0
  33. package/dist/keyring/unlockSpace.d.ts +101 -0
  34. package/dist/keyring/unlockSpace.d.ts.map +1 -0
  35. package/dist/keyring/unlockSpace.js +226 -0
  36. package/dist/keyring/unlockSpace.js.map +1 -0
  37. package/dist/keys/index.d.ts +24 -0
  38. package/dist/keys/index.d.ts.map +1 -0
  39. package/dist/keys/index.js +22 -0
  40. package/dist/keys/index.js.map +1 -0
  41. package/dist/keys/puk.d.ts +42 -0
  42. package/dist/keys/puk.d.ts.map +1 -0
  43. package/dist/keys/puk.js +56 -0
  44. package/dist/keys/puk.js.map +1 -0
  45. package/dist/keys/pukRoster.d.ts +210 -0
  46. package/dist/keys/pukRoster.d.ts.map +1 -0
  47. package/dist/keys/pukRoster.js +263 -0
  48. package/dist/keys/pukRoster.js.map +1 -0
  49. package/dist/keys/rosterStore.d.ts +18 -0
  50. package/dist/keys/rosterStore.d.ts.map +1 -0
  51. package/dist/keys/rosterStore.js +38 -0
  52. package/dist/keys/rosterStore.js.map +1 -0
  53. package/dist/space/collections.d.ts +57 -0
  54. package/dist/space/collections.d.ts.map +1 -1
  55. package/dist/space/collections.js +48 -0
  56. package/dist/space/collections.js.map +1 -1
  57. package/dist/space/index.d.ts +4 -0
  58. package/dist/space/index.d.ts.map +1 -1
  59. package/dist/space/index.js +4 -0
  60. package/dist/space/index.js.map +1 -1
  61. package/dist/webvh/didWeb.d.ts +41 -0
  62. package/dist/webvh/didWeb.d.ts.map +1 -0
  63. package/dist/webvh/didWeb.js +24 -0
  64. package/dist/webvh/didWeb.js.map +1 -0
  65. package/dist/webvh/didWebvh.d.ts +251 -0
  66. package/dist/webvh/didWebvh.d.ts.map +1 -0
  67. package/dist/webvh/didWebvh.js +775 -0
  68. package/dist/webvh/didWebvh.js.map +1 -0
  69. package/dist/webvh/index.d.ts +27 -0
  70. package/dist/webvh/index.d.ts.map +1 -0
  71. package/dist/webvh/index.js +24 -0
  72. package/dist/webvh/index.js.map +1 -0
  73. package/dist/webvh/zcap.d.ts +104 -0
  74. package/dist/webvh/zcap.d.ts.map +1 -0
  75. package/dist/webvh/zcap.js +117 -0
  76. package/dist/webvh/zcap.js.map +1 -0
  77. package/package.json +30 -4
  78. package/dist/identity/collectionKeys.d.ts +0 -36
  79. package/dist/identity/collectionKeys.d.ts.map +0 -1
  80. package/dist/identity/collectionKeys.js +0 -92
  81. package/dist/identity/collectionKeys.js.map +0 -1
@@ -0,0 +1,775 @@
1
+ /*!
2
+ * Copyright (c) 2026 Interop Alliance. All rights reserved.
3
+ */
4
+ /**
5
+ * did:webvh hosting: provisions and publishes a hash-chained, self-certifying
6
+ * did:webvh DID log alongside the did:web document, in the same `id`
7
+ * collection of the user's WAS Space, with the log's update keys held by the
8
+ * wallet client itself (never by the KMS).
9
+ *
10
+ * The log (`did.jsonl`) is one more WAS Resource in the world-readable `id`
11
+ * collection (which carries a collection-level `PublicCanRead` policy), so
12
+ * hosting needs zero server changes:
13
+ * `did:webvh:<scid>:<host>:space:<spaceId>:id` resolves to
14
+ * `https://<host>/space/<spaceId>/id/did.jsonl`. Adopting the parallel
15
+ * `webDoc` (`did:web:` projection with `alsoKnownAs` cross-links) as the new
16
+ * `did.json` makes the log the single source of truth.
17
+ *
18
+ * The document is the enrolled-client roster: each enrolled client contributes
19
+ * its Ed25519 signing key (published under `authentication`,
20
+ * `assertionMethod`, `capabilityInvocation` and `capabilityDelegation`) and
21
+ * its X25519 key-agreement key (under `keyAgreement`, the source of record for
22
+ * PUK-wrap recipient keys -- which is why no server-held key appears there).
23
+ * The KMS-held `authentication` and `assertionMethod` keys stay as
24
+ * server-side conveniences.
25
+ *
26
+ * All protocol logic lives in `@interop/did-method-webvh`; this module is the
27
+ * glue: a `Signer` bridge over a client-held update-key seed, the idempotent,
28
+ * crash-resumable provisioning flow (`ensureDidWebvh`), the per-client
29
+ * update-key rotation ceremony (`rotateWebvhUpdateKey`), the two-entry client
30
+ * enrollment ceremony (`enrollWebvhClient`), and the lost-`keys.json` recovery
31
+ * path for the did:web relationship bindings (`repairKeyBindings`). Update-key
32
+ * seeds are minted here but persisted by the caller -- with client-held keys a
33
+ * lost seed is lost update authority, so every publish is preceded by a
34
+ * caller-durable write.
35
+ *
36
+ * The Space-side I/O runs through the narrow {@link WebvhIdStore} seam, which
37
+ * each wallet app satisfies with its own remote-store class.
38
+ *
39
+ * Prerotation convention: `nextKeyHashes` commits the hash of every client's
40
+ * ACTIVE update key alongside every staged key (the carry-over commitments).
41
+ * The resolver re-checks each entry's full (re-stated) `updateKeys` against
42
+ * the previous entry's `nextKeyHashes`, so without the active-key hashes no
43
+ * entry could ever keep a key authorized -- a multi-client log (an enrollment
44
+ * commit, a rotation that preserves the other clients' keys) depends on them.
45
+ * The trade is deliberate: an active update key can author non-rotating
46
+ * entries, where the original single-key convention forced every entry to
47
+ * reveal the staged key.
48
+ */
49
+ import { createDID, deriveNextKeyHash, logToJsonlString, readLogFromString, resolveDIDFromLog, SCID_PLACEHOLDER, signerFromExternalKey, updateDID } from '@interop/did-method-webvh';
50
+ import { Ed25519VerificationKey } from '@interop/ed25519-verification-key';
51
+ import { DID_DOCUMENT_RESOURCE, DID_LOG_RESOURCE, ID_COLLECTION } from '../space/collections.js';
52
+ import { multibaseOf } from './didWeb.js';
53
+ /**
54
+ * The Multikey verification-method type the did:webvh data model uses for both
55
+ * the Ed25519 (authentication/assertionMethod/capability*) and X25519
56
+ * (keyAgreement) keys. The same key material and multibase are carried as by
57
+ * the 2020 suite types, only `type` and `@context` change, and credential
58
+ * verifiers verify `Ed25519Signature2020` / `eddsa-rdfc-2022` proofs against
59
+ * it.
60
+ */
61
+ const MULTIKEY_VM_TYPE = 'Multikey';
62
+ /**
63
+ * The byte length of a did:webvh update-key seed (an Ed25519 secret seed).
64
+ */
65
+ const UPDATE_SEED_BYTES = 32;
66
+ /**
67
+ * The `did:webvh:{SCID}:<host>:space:<spaceId>:id` controller template, with
68
+ * the literal `{SCID}` placeholder the library replaces at creation. The host
69
+ * segment percent-encodes a port (`localhost:8080` becomes `localhost%3A8080`),
70
+ * matching the library's `toDidDomainComponent`.
71
+ *
72
+ * @param options {object}
73
+ * @param options.wasServerUrl {string}
74
+ * @param options.spaceId {string}
75
+ * @returns {string}
76
+ */
77
+ export function didWebvhControllerTemplate({ wasServerUrl, spaceId }) {
78
+ const { host } = new URL(wasServerUrl);
79
+ return `did:webvh:${SCID_PLACEHOLDER}:${encodeURIComponent(host)}:space:${spaceId}:id`;
80
+ }
81
+ /**
82
+ * Mints a fresh pair of client-held update-key seeds (active + staged) for a
83
+ * brand-new did:webvh log. The caller owns persistence: these seeds are the
84
+ * only update authority the log will ever accept, so they must be durable
85
+ * before {@link ensureDidWebvh} publishes anything.
86
+ *
87
+ * @returns {Promise<ClientWebvhUpdateKeys>}
88
+ */
89
+ export async function mintClientWebvhUpdateKeys() {
90
+ return {
91
+ updateSeed: randomSeed(),
92
+ stagedSeed: randomSeed()
93
+ };
94
+ }
95
+ /**
96
+ * A fresh 32-byte Ed25519 update-key seed.
97
+ *
98
+ * @returns {Uint8Array}
99
+ */
100
+ function randomSeed() {
101
+ return crypto.getRandomValues(new Uint8Array(UPDATE_SEED_BYTES));
102
+ }
103
+ /**
104
+ * The `publicKeyMultibase` of the Ed25519 update key a seed derives, as it
105
+ * appears in the log's `parameters.updateKeys`.
106
+ *
107
+ * @param options {object}
108
+ * @param options.seed {Uint8Array} a 32-byte Ed25519 seed
109
+ * @returns {Promise<string>}
110
+ */
111
+ export async function updateKeyMultibase({ seed }) {
112
+ const keyPair = await Ed25519VerificationKey.generate({ seed });
113
+ return keyPair.publicKeyMultibase;
114
+ }
115
+ /**
116
+ * Bridges a client-held update-key seed to the did:webvh `Signer` interface via
117
+ * the library's `signerFromExternalKey`. The only wallet-side seam is the shape
118
+ * adapter: the key pair's `signer().sign({ data })` matches the factory's
119
+ * `sign({ data })` bridge exactly, so the proof-value multibase encoding and
120
+ * the load-bearing `did:key:<pkm>#<pkm>` verification-method id (which the
121
+ * resolver matches against the entry's authorized `updateKeys`) are owned by
122
+ * the library, not duplicated here.
123
+ *
124
+ * @param options {object}
125
+ * @param options.seed {Uint8Array} the 32-byte update-key seed
126
+ * @returns {Promise<Signer>}
127
+ */
128
+ async function updateKeySigner({ seed }) {
129
+ const keyPair = await Ed25519VerificationKey.generate({ seed });
130
+ const { publicKeyMultibase } = keyPair;
131
+ // The key pair refuses to hand out a signer without an id; the did:key form
132
+ // is also the verification-method id the resolver matches against the log's
133
+ // authorized updateKeys.
134
+ keyPair.id = `did:key:${publicKeyMultibase}#${publicKeyMultibase}`;
135
+ const keySigner = keyPair.signer();
136
+ return signerFromExternalKey({
137
+ publicKeyMultibase,
138
+ sign: async ({ data }) => {
139
+ const signature = await keySigner.sign({ data });
140
+ // Re-wrap as a plain Uint8Array: a signer may return a Node Buffer (or
141
+ // a cross-realm view), which the library's strict byte check rejects.
142
+ return new Uint8Array(signature.buffer, signature.byteOffset, signature.byteLength);
143
+ }
144
+ });
145
+ }
146
+ /**
147
+ * Assembles the document's verification methods as `{SCID}`-templated Multikey
148
+ * entries for the create entry: the KMS-held authentication and assertionMethod
149
+ * keys (server-side conveniences), plus the enrolled client's own key set --
150
+ * its Ed25519 signing key under all four Ed25519 relationships and its X25519
151
+ * key-agreement key as the sole `keyAgreement` entry. No server-held key is a
152
+ * wrap target, so the KMS keyAgreement key is deliberately absent.
153
+ *
154
+ * Each id carries the full `publicKeyMultibase` fragment, so `createDID` mints
155
+ * `did:webvh:<scid>:...#<multibase>` ids -- no KMS read.
156
+ *
157
+ * @param options {object}
158
+ * @param options.controllerTemplate {string} the `{SCID}` controller id
159
+ * @param options.didWebKeys {DidWebKeyMap}
160
+ * @param options.clientKeys {WebvhClientKeys}
161
+ * @returns {object} `verificationMethods` + relationship arrays for createDID
162
+ */
163
+ function assembleWebvhVerificationMethods({ controllerTemplate, didWebKeys, clientKeys }) {
164
+ const vmId = (publicKeyMultibase) => `${controllerTemplate}#${publicKeyMultibase}`;
165
+ const method = (publicKeyMultibase) => ({
166
+ id: vmId(publicKeyMultibase),
167
+ type: MULTIKEY_VM_TYPE,
168
+ controller: controllerTemplate,
169
+ publicKeyMultibase
170
+ });
171
+ const kmsMultibase = (key) => multibaseOf(key.vmId);
172
+ const kmsAuthentication = kmsMultibase(didWebKeys.authentication);
173
+ const kmsAssertionMethod = kmsMultibase(didWebKeys.assertionMethod);
174
+ const { signingKeyMultibase, keyAgreementKeyMultibase } = clientKeys;
175
+ return {
176
+ verificationMethods: [
177
+ method(kmsAuthentication),
178
+ method(kmsAssertionMethod),
179
+ method(signingKeyMultibase),
180
+ method(keyAgreementKeyMultibase)
181
+ ],
182
+ authentication: [vmId(kmsAuthentication), vmId(signingKeyMultibase)],
183
+ assertionMethod: [vmId(kmsAssertionMethod), vmId(signingKeyMultibase)],
184
+ keyAgreement: [vmId(keyAgreementKeyMultibase)],
185
+ capabilityInvocation: [vmId(signingKeyMultibase)],
186
+ capabilityDelegation: [vmId(signingKeyMultibase)]
187
+ };
188
+ }
189
+ /**
190
+ * Creates the one-entry did:webvh log and its parallel `webDoc`. `portable:
191
+ * true` is set at entry 1 (it can only be enabled there); the document's
192
+ * verification methods come from {@link assembleWebvhVerificationMethods},
193
+ * signed by the client-held active update key with prerotation committed to
194
+ * the staged key.
195
+ *
196
+ * @param options {object}
197
+ * @param options.wasServerUrl {string}
198
+ * @param options.spaceId {string}
199
+ * @param options.didWebKeys {DidWebKeyMap}
200
+ * @param options.clientKeys {WebvhClientKeys}
201
+ * @param options.updateKeyPublicKeyMultibase {string}
202
+ * @param options.nextKeyHashes {string[]}
203
+ * @param options.signer {Signer}
204
+ * @returns {Promise<{ log: DIDLog; webDoc: object; did: string }>}
205
+ */
206
+ async function createWebvhLog({ wasServerUrl, spaceId, didWebKeys, clientKeys, updateKeyPublicKeyMultibase, nextKeyHashes, signer }) {
207
+ const { host } = new URL(wasServerUrl);
208
+ const controllerTemplate = didWebvhControllerTemplate({
209
+ wasServerUrl,
210
+ spaceId
211
+ });
212
+ const { verificationMethods, authentication, assertionMethod, keyAgreement, capabilityInvocation, capabilityDelegation } = assembleWebvhVerificationMethods({
213
+ controllerTemplate,
214
+ didWebKeys,
215
+ clientKeys
216
+ });
217
+ const result = await createDID({
218
+ address: host,
219
+ paths: ['space', spaceId, ID_COLLECTION.id],
220
+ signer,
221
+ updateKeys: [updateKeyPublicKeyMultibase],
222
+ nextKeyHashes,
223
+ verificationMethods,
224
+ authentication,
225
+ assertionMethod,
226
+ keyAgreement,
227
+ capabilityInvocation,
228
+ capabilityDelegation,
229
+ alsoKnownAsWeb: true,
230
+ portable: true
231
+ });
232
+ if (!result.webDoc) {
233
+ throw new Error('createDID did not return a webDoc despite alsoKnownAsWeb.');
234
+ }
235
+ return { log: result.log, webDoc: result.webDoc, did: result.did };
236
+ }
237
+ /**
238
+ * Publishes an already-created log: PUT `did.jsonl` (`text/jsonl`) then PUT
239
+ * `did.json` from `webDoc` (`application/did+json`, adopting the webvh
240
+ * projection). Both land in the `id` collection, whose collection-level
241
+ * `PublicCanRead` policy (set at provisioning) makes them world-readable, so
242
+ * the publish tail no longer sets per-resource policies. The shared publish
243
+ * tail of the create and rotate paths.
244
+ *
245
+ * @param options {object}
246
+ * @param options.idStore {WebvhIdStore}
247
+ * @param options.log {DIDLog}
248
+ * @param options.webDoc {object}
249
+ * @returns {Promise<void>}
250
+ */
251
+ async function publishWebvhLog({ idStore, log, webDoc }) {
252
+ await idStore.putIdResource({
253
+ resourceId: DID_LOG_RESOURCE,
254
+ content: logToJsonlString(log),
255
+ contentType: 'text/jsonl'
256
+ });
257
+ await idStore.putIdResource({
258
+ resourceId: DID_DOCUMENT_RESOURCE,
259
+ content: webDoc,
260
+ contentType: 'application/did+json'
261
+ });
262
+ }
263
+ /**
264
+ * Writes `keys.json` v2: the did:web relationship map plus the `webvh` block,
265
+ * preserving the three did:web relationships.
266
+ *
267
+ * @param options {object}
268
+ * @param options.idStore {WebvhIdStore}
269
+ * @param options.didWebKeys {DidWebKeyMap}
270
+ * @param options.webvh {DidWebvhBlock}
271
+ * @returns {Promise<void>}
272
+ */
273
+ async function writeKeysJson({ idStore, didWebKeys, webvh }) {
274
+ const content = { ...didWebKeys, webvh };
275
+ await idStore.putKeyMap({ content });
276
+ }
277
+ /**
278
+ * Reads and resolves the published `did.jsonl`, or returns `undefined` when
279
+ * the log is not published. A log that exists but fails to resolve throws --
280
+ * a published-but-broken log is never silently re-created over.
281
+ *
282
+ * @param options {object}
283
+ * @param options.idStore {WebvhIdStore}
284
+ * @returns {Promise<PublishedWebvhLog | undefined>}
285
+ */
286
+ async function readPublishedLog({ idStore }) {
287
+ const logText = await idStore.getIdResourceRaw({
288
+ resourceId: DID_LOG_RESOURCE
289
+ });
290
+ if (logText === undefined) {
291
+ return undefined;
292
+ }
293
+ const log = readLogFromString(logText);
294
+ const resolved = await resolveDIDFromLog(log);
295
+ if (resolved.meta.error || !resolved.did || !resolved.doc) {
296
+ throw new Error(`did:webvh: existing did.jsonl failed to resolve (${resolved.meta.error}).`);
297
+ }
298
+ return {
299
+ log,
300
+ did: resolved.did,
301
+ doc: resolved.doc,
302
+ updateKeys: resolved.meta.updateKeys ?? [],
303
+ nextKeyHashes: resolved.meta.nextKeyHashes ?? []
304
+ };
305
+ }
306
+ /**
307
+ * The `publicKeyMultibase` of every update-key seed this client holds, in
308
+ * role order (active, staged, pending).
309
+ *
310
+ * @param options {object}
311
+ * @param options.updateKeys {ClientWebvhUpdateKeys}
312
+ * @returns {Promise<{ update: string; staged: string; pendingStaged?: string }>}
313
+ */
314
+ async function updateKeyMultibases({ updateKeys }) {
315
+ return {
316
+ update: await updateKeyMultibase({ seed: updateKeys.updateSeed }),
317
+ staged: await updateKeyMultibase({ seed: updateKeys.stagedSeed }),
318
+ pendingStaged: updateKeys.pendingStagedSeed
319
+ ? await updateKeyMultibase({ seed: updateKeys.pendingStagedSeed })
320
+ : undefined
321
+ };
322
+ }
323
+ /**
324
+ * The published-but-not-finalized state of a rotation, if the log is in one:
325
+ * the seed the log has already promoted to active, plus the seed it committed
326
+ * as the next key (absent when this client no longer holds it, which is
327
+ * unrecoverable).
328
+ *
329
+ * @param options {object}
330
+ * @param options.published {object} the resolved log's authorized updateKeys
331
+ * @param options.multibases {object} this client's update-key multibases
332
+ * @param options.updateKeys {ClientWebvhUpdateKeys}
333
+ * @returns {object | undefined}
334
+ */
335
+ function advancedSeeds({ published, multibases, updateKeys }) {
336
+ if (published.updateKeys.includes(multibases.staged)) {
337
+ return {
338
+ updateSeed: updateKeys.stagedSeed,
339
+ stagedSeed: updateKeys.pendingStagedSeed
340
+ };
341
+ }
342
+ if (multibases.pendingStaged &&
343
+ updateKeys.pendingStagedSeed &&
344
+ published.updateKeys.includes(multibases.pendingStaged)) {
345
+ return { updateSeed: updateKeys.pendingStagedSeed };
346
+ }
347
+ return undefined;
348
+ }
349
+ /**
350
+ * Idempotently provisions and publishes the user's did:webvh DID log, run
351
+ * directly after the did:web provisioning (non-fatal). The durable anchor is
352
+ * the caller-persisted update-key seeds, so the flow is a simple probe:
353
+ *
354
+ * - `did.jsonl` published: sanity-check that the log's authorized `updateKeys`
355
+ * still name one of this client's seeds (active, staged, or pending -- a
356
+ * rotation in flight finalizes separately), adopt the resolved DID, and
357
+ * write the `keys.json` webvh block if it is missing or stale.
358
+ * - `did.jsonl` absent: create the log with the active update key, prerotation
359
+ * committed to the staged key, publish log + `did.json`, then record the DID
360
+ * in `keys.json`.
361
+ *
362
+ * A published log whose `updateKeys` match none of the seeds is fatal: with
363
+ * client-held update keys a lost seed is lost update authority, and no KMS
364
+ * repair path exists by design.
365
+ *
366
+ * @param options {object}
367
+ * @param options.idStore {WebvhIdStore}
368
+ * @param options.wasServerUrl {string}
369
+ * @param options.spaceId {string}
370
+ * @param options.didWebKeys {DidWebKeyMapV2} the parsed keys.json (with any
371
+ * webvh block) returned by the did:web provisioning.
372
+ * @param options.clientKeys {WebvhClientKeys} this client's published keys
373
+ * @param options.updateKeys {ClientWebvhUpdateKeys} already durably persisted
374
+ * @returns {Promise<{ did: string }>}
375
+ */
376
+ export async function ensureDidWebvh({ idStore, wasServerUrl, spaceId, didWebKeys, clientKeys, updateKeys }) {
377
+ const published = await readPublishedLog({ idStore });
378
+ const multibases = await updateKeyMultibases({ updateKeys });
379
+ if (published) {
380
+ // Adoption: the log is already public (this client provisioned it, or a
381
+ // torn earlier run published before recording the did). Accept any of the
382
+ // three roles -- an interrupted rotation leaves the log at the staged or
383
+ // pending key, and rotateWebvhUpdateKey finalizes it.
384
+ const authorized = [
385
+ multibases.update,
386
+ multibases.staged,
387
+ multibases.pendingStaged
388
+ ].some(publicKeyMultibase => publicKeyMultibase !== undefined &&
389
+ published.updateKeys.includes(publicKeyMultibase));
390
+ if (!authorized) {
391
+ throw new Error("did:webvh: the published did.jsonl authorizes none of this client's " +
392
+ 'update keys -- the update-key seed is lost and the log can never ' +
393
+ 'be updated again.');
394
+ }
395
+ if (didWebKeys.webvh?.did !== published.did) {
396
+ await writeKeysJson({
397
+ idStore,
398
+ didWebKeys,
399
+ webvh: { did: published.did }
400
+ });
401
+ }
402
+ return { did: published.did };
403
+ }
404
+ const signer = await updateKeySigner({ seed: updateKeys.updateSeed });
405
+ const created = await createWebvhLog({
406
+ wasServerUrl,
407
+ spaceId,
408
+ didWebKeys,
409
+ clientKeys,
410
+ updateKeyPublicKeyMultibase: multibases.update,
411
+ // The active key's own hash is committed beside the staged key's (the
412
+ // carry-over convention in the module doc): the resolver checks every
413
+ // later entry's re-stated updateKeys against these commitments, so
414
+ // without it no non-rotating entry (an enrollment commit) could follow.
415
+ nextKeyHashes: [
416
+ await deriveNextKeyHash(multibases.update),
417
+ await deriveNextKeyHash(multibases.staged)
418
+ ],
419
+ signer
420
+ });
421
+ await publishWebvhLog({
422
+ idStore,
423
+ log: created.log,
424
+ webDoc: created.webDoc
425
+ });
426
+ await writeKeysJson({
427
+ idStore,
428
+ didWebKeys,
429
+ webvh: { did: created.did }
430
+ });
431
+ return { did: created.did };
432
+ }
433
+ /**
434
+ * Rotates this client's did:webvh update key (the user-triggered ceremony on
435
+ * the wallet's settings screen). The staged key is revealed to sign its own
436
+ * activation and become the sole active update key, a freshly minted staged
437
+ * key is committed as the new `nextKeyHashes`, and the caller's persisted
438
+ * seeds roll forward. No KMS and no `keys.json` involvement: the update keys
439
+ * are client-held, and the published DID does not change.
440
+ *
441
+ * The persist-before-publish invariant is load-bearing: the new staged seed is
442
+ * handed to `persistUpdateKeys` (as `pendingStagedSeed`) and awaited BEFORE the
443
+ * log entry committing it is published, so no published log can ever depend on
444
+ * a seed that is not durable. A crash between publish and finalize is
445
+ * recovered on the next run: the log already sits at the staged (or pending)
446
+ * key, and the seeds are simply rolled forward locally without touching it.
447
+ *
448
+ * @param options {object}
449
+ * @param options.idStore {WebvhIdStore}
450
+ * @param options.updateKeys {ClientWebvhUpdateKeys} the current seeds
451
+ * @param options.persistUpdateKeys {Function} awaited before every publish
452
+ * that changes the log's authorized update keys
453
+ * @returns {Promise<{ did: string }>}
454
+ */
455
+ export async function rotateWebvhUpdateKey({ idStore, updateKeys, persistUpdateKeys }) {
456
+ const published = await readPublishedLog({ idStore });
457
+ if (!published) {
458
+ throw new Error('did:webvh: did.jsonl is missing; nothing to rotate.');
459
+ }
460
+ const multibases = await updateKeyMultibases({ updateKeys });
461
+ // Crash recovery: a prior ceremony published the extended log but never
462
+ // finalized the local seeds, so the log already sits at a key this client
463
+ // holds without having promoted it. Roll the seeds forward and stop --
464
+ // re-signing would fork the log.
465
+ const advanced = advancedSeeds({ published, multibases, updateKeys });
466
+ if (advanced) {
467
+ if (!advanced.stagedSeed) {
468
+ // The active key is recovered, but the next key that entry committed was
469
+ // never persisted here, so no future entry can reveal it.
470
+ throw new Error('did:webvh: the published log has advanced to a key whose committed ' +
471
+ 'next key this client does not hold; the log can no longer be ' +
472
+ 'rotated.');
473
+ }
474
+ await persistUpdateKeys({
475
+ updateSeed: advanced.updateSeed,
476
+ stagedSeed: advanced.stagedSeed
477
+ });
478
+ return { did: published.did };
479
+ }
480
+ // Diverged-state guard: the log must still be at this client's active update
481
+ // key, or another client has rotated it out from under us.
482
+ if (!published.updateKeys.includes(multibases.update)) {
483
+ throw new Error("did:webvh: the published log has diverged from this client's update " +
484
+ 'keys (it authorizes neither the active nor the staged key); ' +
485
+ 'refusing to rotate.');
486
+ }
487
+ // Mint the NEW staged seed and persist it BEFORE publishing anything, keeping
488
+ // the current roles intact until the ceremony finalizes.
489
+ const newStagedSeed = randomSeed();
490
+ await persistUpdateKeys({
491
+ updateSeed: updateKeys.updateSeed,
492
+ stagedSeed: updateKeys.stagedSeed,
493
+ pendingStagedSeed: newStagedSeed
494
+ });
495
+ // Extend the log. The revealed staged key signs its own activation and
496
+ // replaces THIS client's active update key; every other enrolled client's
497
+ // key is preserved (their hashes ride the carry-over commitments, so the
498
+ // re-stated set still resolves). The retired key's hash is dropped and the
499
+ // new staged key's committed. updateDID is sparse (no document directives)
500
+ // so the verification methods are preserved -- a key-only rotation.
501
+ const signer = await updateKeySigner({ seed: updateKeys.stagedSeed });
502
+ const retiredKeyHash = await deriveNextKeyHash(multibases.update);
503
+ const updated = await updateDID({
504
+ log: published.log,
505
+ signer,
506
+ updateKeys: [
507
+ ...published.updateKeys.filter(key => key !== multibases.update),
508
+ multibases.staged
509
+ ],
510
+ nextKeyHashes: [
511
+ ...new Set([
512
+ ...published.nextKeyHashes.filter(hash => hash !== retiredKeyHash),
513
+ await deriveNextKeyHash(multibases.staged),
514
+ await deriveNextKeyHash(await updateKeyMultibase({ seed: newStagedSeed }))
515
+ ])
516
+ ]
517
+ });
518
+ if (!updated.webDoc) {
519
+ throw new Error('did:webvh: updateDID returned no webDoc despite the did:web alsoKnownAs.');
520
+ }
521
+ // Publish the extended log and republish did.json (its did:web projection),
522
+ // then finalize the local seeds: the staged key is now active, the pending
523
+ // one is the new staged key.
524
+ await publishWebvhLog({
525
+ idStore,
526
+ log: updated.log,
527
+ webDoc: updated.webDoc
528
+ });
529
+ await persistUpdateKeys({
530
+ updateSeed: updateKeys.stagedSeed,
531
+ stagedSeed: newStagedSeed
532
+ });
533
+ return { did: updated.did };
534
+ }
535
+ /**
536
+ * The relationship references of a resolved document as verification-method
537
+ * ids, tolerating embedded objects beside string references.
538
+ *
539
+ * @param relation {Array} the relationship array, when present
540
+ * @returns {string[]}
541
+ */
542
+ function relationIds(relation) {
543
+ const ids = [];
544
+ for (const entry of relation ?? []) {
545
+ const id = typeof entry === 'string' ? entry : entry?.id;
546
+ if (id) {
547
+ ids.push(id);
548
+ }
549
+ }
550
+ return ids;
551
+ }
552
+ /**
553
+ * Enrolls a second wallet client into the published did:webvh document -- the
554
+ * log half of the enrollment ceremony (the PUK roster wrap happens first,
555
+ * outside this module). Two entries, forced by prerotation (a new update key
556
+ * must hash into the PREVIOUS entry's `nextKeyHashes`):
557
+ *
558
+ * 1. **Commit**: a sparse entry extending `nextKeyHashes` with the new
559
+ * client's update-key and staged-key hashes (document and `updateKeys`
560
+ * untouched).
561
+ * 2. **Add**: an entry adding the new client's verification methods (its
562
+ * Ed25519 key under the four signing relationships, its X25519 twin under
563
+ * `keyAgreement`) and its update key to `updateKeys`.
564
+ *
565
+ * Both entries are signed by THIS client's active update key (quorum-of-one:
566
+ * any single enrolled client can enroll). The ceremony is resumable from
567
+ * durable state alone: a tear after the commit is detected by its hashes
568
+ * already standing in `nextKeyHashes` (skip to the add entry), and a
569
+ * completed enrollment is detected by the update key already being authorized
570
+ * (no-op). Re-running with the same key set converges without forking the
571
+ * log.
572
+ *
573
+ * @param options {object}
574
+ * @param options.idStore {WebvhIdStore}
575
+ * @param options.updateKeys {ClientWebvhUpdateKeys} THIS client's seeds
576
+ * @param options.newClient {WebvhEnrollmentKeys} the enrollee's public halves
577
+ * @returns {Promise<{ did: string }>}
578
+ */
579
+ export async function enrollWebvhClient({ idStore, updateKeys, newClient }) {
580
+ let published = await readPublishedLog({ idStore });
581
+ if (!published) {
582
+ throw new Error('did:webvh: did.jsonl is missing; nothing to enroll into.');
583
+ }
584
+ const multibases = await updateKeyMultibases({ updateKeys });
585
+ // Already enrolled (a completed earlier run): the new client's update key is
586
+ // authorized, which only the add entry writes. Idempotent no-op.
587
+ if (published.updateKeys.includes(newClient.updateKeyMultibase)) {
588
+ return { did: published.did };
589
+ }
590
+ // Both entries are signed by this client's active update key; a log that
591
+ // does not authorize it (a rotation torn elsewhere) must heal first.
592
+ if (!published.updateKeys.includes(multibases.update)) {
593
+ throw new Error("did:webvh: the published log does not authorize this client's active " +
594
+ 'update key; finalize the pending rotation before enrolling.');
595
+ }
596
+ const newUpdateKeyHash = await deriveNextKeyHash(newClient.updateKeyMultibase);
597
+ const newStagedKeyHash = await deriveNextKeyHash(newClient.stagedUpdateKeyMultibase);
598
+ // The commit entry (skipped when a torn earlier run already published it).
599
+ const committed = published.nextKeyHashes.includes(newUpdateKeyHash) &&
600
+ published.nextKeyHashes.includes(newStagedKeyHash);
601
+ if (!committed) {
602
+ // A sparse entry re-states the authorized updateKeys, and the resolver
603
+ // checks each against the PREVIOUS entry's commitments -- so every
604
+ // currently authorized key's hash must already stand in nextKeyHashes
605
+ // (the carry-over convention). A log minted before the convention cannot
606
+ // take a non-rotating entry.
607
+ for (const key of published.updateKeys) {
608
+ if (!published.nextKeyHashes.includes(await deriveNextKeyHash(key))) {
609
+ throw new Error('did:webvh: the published log does not carry the active update ' +
610
+ "keys' own hashes in nextKeyHashes (it predates the carry-over " +
611
+ 'commitment convention); re-provision the account before ' +
612
+ 'enrolling.');
613
+ }
614
+ }
615
+ const signer = await updateKeySigner({ seed: updateKeys.updateSeed });
616
+ const updated = await updateDID({
617
+ log: published.log,
618
+ signer,
619
+ // Re-stated unchanged (the library requires them explicitly while
620
+ // prerotation is active); the carry-over commitments are what make the
621
+ // re-statement resolvable.
622
+ updateKeys: published.updateKeys,
623
+ nextKeyHashes: [
624
+ ...new Set([
625
+ ...published.nextKeyHashes,
626
+ newUpdateKeyHash,
627
+ newStagedKeyHash
628
+ ])
629
+ ]
630
+ });
631
+ if (!updated.webDoc) {
632
+ throw new Error('did:webvh: updateDID returned no webDoc despite the did:web alsoKnownAs.');
633
+ }
634
+ await publishWebvhLog({
635
+ idStore,
636
+ log: updated.log,
637
+ webDoc: updated.webDoc
638
+ });
639
+ // Re-read through the same verifying path the resume case uses, so the
640
+ // add entry below always builds on the published, resolved state.
641
+ published = await readPublishedLog({ idStore });
642
+ if (!published) {
643
+ throw new Error('did:webvh: did.jsonl vanished mid-enrollment.');
644
+ }
645
+ }
646
+ // The add entry: the new client's two verification methods and its update
647
+ // key, on top of the full existing document (updateDID replaces the
648
+ // verification-method set and relationship arrays wholesale).
649
+ const { did, doc, log, updateKeys: authorizedKeys, nextKeyHashes } = published;
650
+ const vmId = (publicKeyMultibase) => `${did}#${publicKeyMultibase}`;
651
+ const addedMethods = [
652
+ newClient.signingKeyMultibase,
653
+ newClient.keyAgreementKeyMultibase
654
+ ].map(publicKeyMultibase => ({
655
+ id: vmId(publicKeyMultibase),
656
+ type: MULTIKEY_VM_TYPE,
657
+ controller: did,
658
+ publicKeyMultibase
659
+ }));
660
+ const existingMethods = (doc.verificationMethod ?? []);
661
+ const verificationMethods = [
662
+ ...existingMethods.filter(method => !addedMethods.some(added => added.id === method.id)),
663
+ ...addedMethods
664
+ ];
665
+ const withReference = (relation, id) => [...new Set([...relationIds(relation), id])];
666
+ const signingVmId = vmId(newClient.signingKeyMultibase);
667
+ const signer = await updateKeySigner({ seed: updateKeys.updateSeed });
668
+ const updated = await updateDID({
669
+ log,
670
+ signer,
671
+ updateKeys: [...new Set([...authorizedKeys, newClient.updateKeyMultibase])],
672
+ nextKeyHashes,
673
+ verificationMethods,
674
+ authentication: withReference(doc.authentication, signingVmId),
675
+ assertionMethod: withReference(doc.assertionMethod, signingVmId),
676
+ keyAgreement: withReference(doc.keyAgreement, vmId(newClient.keyAgreementKeyMultibase)),
677
+ capabilityInvocation: withReference(doc.capabilityInvocation, signingVmId),
678
+ capabilityDelegation: withReference(doc.capabilityDelegation, signingVmId)
679
+ });
680
+ if (!updated.webDoc) {
681
+ throw new Error('did:webvh: updateDID returned no webDoc despite the did:web alsoKnownAs.');
682
+ }
683
+ await publishWebvhLog({
684
+ idStore,
685
+ log: updated.log,
686
+ webDoc: updated.webDoc
687
+ });
688
+ return { did: updated.did };
689
+ }
690
+ /**
691
+ * Rebuilds `keys.json` from the published artifacts plus a WebKMS key listing
692
+ * -- the recovery path for a lost or rolled-back `keys.json`. List Keys is
693
+ * authorized as `read` against the keystore controller, which only the
694
+ * root-controlled keystore agent can invoke.
695
+ *
696
+ * KMS key local ids are server-generated random and appear in no published
697
+ * artifact, so the bindings are rediscovered by public key material instead.
698
+ * List the keystore once -- each listed description carries `keyUrl`, the
699
+ * key's canonical invocation URL (the signable handle its alias-overridden
700
+ * `id` erases) -- then match `did.json`'s three relationship verification
701
+ * methods by `publicKeyMultibase` and rewrite `keys.json` from what matched.
702
+ * When `did.jsonl` is published, its resolved DID is recorded in the `webvh`
703
+ * block; there is nothing else to repair there, since the log's update keys are
704
+ * client-held seeds that no keystore listing could recover.
705
+ *
706
+ * An unmatchable binding is unrepairable and throws: a published artifact
707
+ * depends on a key the keystore no longer lists.
708
+ *
709
+ * @param options {object}
710
+ * @param options.keystoreAgent {KeystoreAgent}
711
+ * @param options.idStore {WebvhIdStore}
712
+ * @returns {Promise<DidWebKeyMapV2>} the rebuilt, persisted keys.json
713
+ */
714
+ export async function repairKeyBindings({ keystoreAgent, idStore }) {
715
+ const didDoc = (await idStore.getIdResource({
716
+ resourceId: DID_DOCUMENT_RESOURCE
717
+ }));
718
+ if (!didDoc) {
719
+ throw new Error('keys.json repair: did.json is not published; there is nothing to ' +
720
+ 'match key bindings against.');
721
+ }
722
+ // One listing, matched by public key material below. `keyUrl` is the
723
+ // list-only projection field (webkms-client >= 14.7.1 types it; an older
724
+ // server omits it, so entries without one are skipped and simply fail to
725
+ // match).
726
+ const listed = (await keystoreAgent.listKeys());
727
+ const keyUrlByMultibase = new Map();
728
+ for (const description of listed) {
729
+ if (description.publicKeyMultibase && description.keyUrl) {
730
+ keyUrlByMultibase.set(description.publicKeyMultibase, description.keyUrl);
731
+ }
732
+ }
733
+ // The three did:web relationships, matched from the published document. A
734
+ // relationship can name several verification methods now that enrolled
735
+ // clients publish their own keys beside the KMS ones, so every reference is
736
+ // tried and the first keystore-backed one wins; a client-held key simply
737
+ // fails to match and is skipped.
738
+ const bind = (relationship) => {
739
+ const references = didDoc[relationship] ?? [];
740
+ if (references.length === 0) {
741
+ throw new Error(`keys.json repair: did.json declares no ${relationship} verification method.`);
742
+ }
743
+ const tried = [];
744
+ for (const reference of references) {
745
+ const vmId = typeof reference === 'string' ? reference : reference?.id;
746
+ if (!vmId) {
747
+ continue;
748
+ }
749
+ const method = didDoc.verificationMethod?.find(entry => entry.id === vmId);
750
+ const publicKeyMultibase = method?.publicKeyMultibase ?? multibaseOf(vmId);
751
+ tried.push(publicKeyMultibase);
752
+ const kmsKeyId = keyUrlByMultibase.get(publicKeyMultibase);
753
+ if (kmsKeyId) {
754
+ return { vmId, kmsKeyId };
755
+ }
756
+ }
757
+ throw new Error(`keys.json repair: no keystore key matches the ${relationship} ` +
758
+ `verification method (${tried.join(', ')}).`);
759
+ };
760
+ const repaired = {
761
+ authentication: bind('authentication'),
762
+ assertionMethod: bind('assertionMethod'),
763
+ keyAgreement: bind('keyAgreement')
764
+ };
765
+ // The webvh block, recovered from the published log: the DID and nothing
766
+ // else, since the update keys never left the client that minted them.
767
+ const published = await readPublishedLog({ idStore });
768
+ if (published) {
769
+ repaired.webvh = { did: published.did };
770
+ }
771
+ // Persist the rebuilt anchor in one write.
772
+ await idStore.putKeyMap({ content: repaired });
773
+ return repaired;
774
+ }
775
+ //# sourceMappingURL=didWebvh.js.map