@kubun/credential 0.17.3 → 0.18.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 (53) hide show
  1. package/lib/api.d.ts +61 -30
  2. package/lib/api.js +455 -111
  3. package/lib/authority.d.ts +4 -0
  4. package/lib/authority.js +3 -0
  5. package/lib/branch.d.ts +4 -0
  6. package/lib/branch.js +24 -0
  7. package/lib/builder.d.ts +5 -0
  8. package/lib/builder.js +8 -6
  9. package/lib/canonical-token.d.ts +2 -0
  10. package/lib/canonical-token.js +10 -0
  11. package/lib/errors.d.ts +39 -1
  12. package/lib/errors.js +77 -0
  13. package/lib/fork-repair.d.ts +19 -0
  14. package/lib/fork-repair.js +3 -0
  15. package/lib/grantor-authority.d.ts +26 -0
  16. package/lib/grantor-authority.js +43 -0
  17. package/lib/index.d.ts +16 -6
  18. package/lib/index.js +8 -2
  19. package/lib/key-cache.d.ts +4 -3
  20. package/lib/key-cache.js +22 -15
  21. package/lib/manager-context.d.ts +50 -0
  22. package/lib/manager-context.js +82 -0
  23. package/lib/manager-entries.d.ts +3 -0
  24. package/lib/manager-entries.js +585 -0
  25. package/lib/manager-keys.d.ts +3 -0
  26. package/lib/manager-keys.js +413 -0
  27. package/lib/manager-open.d.ts +45 -0
  28. package/lib/manager-open.js +421 -0
  29. package/lib/manager-records.d.ts +94 -0
  30. package/lib/manager-records.js +291 -0
  31. package/lib/manager-removal-recovery.d.ts +7 -0
  32. package/lib/manager-removal-recovery.js +211 -0
  33. package/lib/manager-rotate.d.ts +15 -0
  34. package/lib/manager-rotate.js +536 -0
  35. package/lib/manager-types.d.ts +337 -0
  36. package/lib/manager-types.js +1 -0
  37. package/lib/manager.d.ts +2 -293
  38. package/lib/manager.js +38 -1072
  39. package/lib/manifest-token.d.ts +31 -0
  40. package/lib/manifest-token.js +51 -0
  41. package/lib/provider.d.ts +15 -2
  42. package/lib/provider.js +39 -0
  43. package/lib/record-types.d.ts +65 -0
  44. package/lib/record-types.js +865 -0
  45. package/lib/refreshing-credential-provider.d.ts +6 -2
  46. package/lib/refreshing-credential-provider.js +76 -13
  47. package/lib/suite.d.ts +5 -4
  48. package/lib/suite.js +2 -2
  49. package/lib/wrappable.d.ts +14 -0
  50. package/lib/wrappable.js +15 -0
  51. package/package.json +16 -15
  52. package/lib/op.d.ts +0 -128
  53. package/lib/op.js +0 -304
package/lib/api.js CHANGED
@@ -1,26 +1,222 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
- import { getCredentialStore } from '@kubun/store-credential';
2
+ import { verifySignedRecord } from '@kubun/records';
3
+ import { credentialRecordAdapter, getCredentialStore } from '@kubun/store-credential';
3
4
  import { fromUTF, toUTF } from '@sozai/codec';
4
- import { CredentialAliasAlreadyBound, CredentialBindNotAuthorized, CredentialEntryAlreadyAliased, CredentialInvalidEntry, CredentialNotFound, CredentialSupersededBranch } from './errors.js';
5
+ import { CredentialAliasAlreadyBound, CredentialBindNotAuthorized, CredentialGrantAccountMismatch, CredentialGrantCopyUnavailable, CredentialGrantOwnerAmbiguous, CredentialGrantRehomeNotAuthorized, CredentialInvalidEntry, CredentialManagerStoreMismatch, CredentialNotFound, CredentialSupersededBranch } from './errors.js';
6
+ import { credentialGrantEntryID } from './record-types.js';
5
7
  import { assertValidCredentialShape } from './validate.js';
6
8
  export function createCredentialAPI(params) {
7
9
  const getAliasStore = ()=>getCredentialStore(params.stores);
8
10
  const getCredentials = ()=>params.getCredentialManager(params.stores);
9
- return {
11
+ const deviceDID = normalizeDID(params.serverWrappableDID);
12
+ const signedEntry = async (store, entryID, keyOwnerDID)=>{
13
+ const entry = await store.getEntry(entryID, keyOwnerDID);
14
+ const key = entry === undefined ? undefined : await store.getKey(entry.key_id, entry.owner_did);
15
+ if (key === undefined) return null;
16
+ const decision = await credentialRecordAdapter(store).get({
17
+ type: 'credential.entry',
18
+ owner: key.owner_did,
19
+ id: entryID
20
+ });
21
+ return decision?.copies[0] ?? null;
22
+ };
23
+ const grantKeyOwner = async ({ store, entryID, accountOwnerDID, providerName, grantDeviceDID = deviceDID, stores = params.stores, explicitSourceOwnerDID })=>{
24
+ const owner = normalizeDID(accountOwnerDID);
25
+ const alias = await store.getDeviceAliasEntry({
26
+ providerName,
27
+ ownerDID: owner,
28
+ deviceDID: grantDeviceDID
29
+ });
30
+ if (alias?.entryID === entryID) {
31
+ if (explicitSourceOwnerDID !== undefined && alias.entryOwnerDID !== normalizeDID(explicitSourceOwnerDID)) {
32
+ throw new CredentialGrantAccountMismatch(providerName, owner);
33
+ }
34
+ return alias.entryOwnerDID;
35
+ }
36
+ const controller = await params.resolveControllerOfOwner?.(owner, stores);
37
+ const candidates = controller == null ? [
38
+ owner
39
+ ] : [
40
+ normalizeDID(controller),
41
+ owner
42
+ ];
43
+ const heldOwners = await store.listEntryOwners(entryID);
44
+ if (explicitSourceOwnerDID !== undefined) {
45
+ const selected = normalizeDID(explicitSourceOwnerDID);
46
+ if (!candidates.includes(selected)) {
47
+ throw new CredentialGrantAccountMismatch(providerName, owner);
48
+ }
49
+ return selected;
50
+ }
51
+ if (heldOwners.length > 1) throw new CredentialGrantOwnerAmbiguous(entryID, heldOwners);
52
+ for (const candidate of new Set(candidates)){
53
+ if (heldOwners.includes(candidate)) return candidate;
54
+ }
55
+ return owner;
56
+ };
57
+ const aliasEntry = async (providerName, ownerDID)=>{
58
+ const store = await getAliasStore();
59
+ const entryID = await store.getDeviceAliasEntryID({
60
+ providerName,
61
+ ownerDID: normalizeDID(ownerDID),
62
+ deviceDID
63
+ });
64
+ if (entryID == null) return null;
65
+ const keyOwner = await grantKeyOwner({
66
+ store,
67
+ entryID,
68
+ accountOwnerDID: ownerDID,
69
+ providerName
70
+ });
71
+ const record = await signedEntry(store, entryID, keyOwner);
72
+ if (record == null) return null;
73
+ const op = await verifySignedRecord(record);
74
+ return op?.claim.meta.grantDeviceDID === deviceDID && op.claim.meta.grantName === providerName && op.claim.meta.grantOwnerDID === normalizeDID(ownerDID) ? entryID : null;
75
+ };
76
+ const localGrantRecord = async (entryID, providerName, ownerDID)=>{
77
+ const store = await getAliasStore();
78
+ const keyOwner = await grantKeyOwner({
79
+ store,
80
+ entryID,
81
+ accountOwnerDID: ownerDID,
82
+ providerName
83
+ });
84
+ const record = await signedEntry(store, entryID, keyOwner);
85
+ const op = record == null ? null : await verifySignedRecord(record);
86
+ return op != null && !op.claim.deleted && op.claim.meta.grantDeviceDID === deviceDID && op.claim.meta.grantName === providerName && op.claim.meta.grantOwnerDID === ownerDID;
87
+ };
88
+ const adoptionSource = async ({ stores, store, input })=>{
89
+ const owner = normalizeDID(input.ownerDID);
90
+ const from = normalizeDID(input.fromDeviceDID);
91
+ const aliasID = await store.getDeviceAliasEntryID({
92
+ providerName: input.providerName,
93
+ ownerDID: owner,
94
+ deviceDID: from
95
+ });
96
+ // The grant ID names the account, not the key owner. Backups need no local alias.
97
+ const sourceID = aliasID ?? credentialGrantEntryID(owner, from, input.providerName);
98
+ const sourceOwner = await grantKeyOwner({
99
+ store,
100
+ entryID: sourceID,
101
+ accountOwnerDID: owner,
102
+ providerName: input.providerName,
103
+ grantDeviceDID: from,
104
+ stores,
105
+ explicitSourceOwnerDID: input.sourceKeyOwnerDID
106
+ });
107
+ const source = await store.getEntry(sourceID, sourceOwner);
108
+ if (source == null) {
109
+ throw new CredentialGrantCopyUnavailable(input.providerName, owner, from);
110
+ }
111
+ const record = await signedEntry(store, sourceID, sourceOwner);
112
+ const op = record == null ? null : await verifySignedRecord(record);
113
+ if (op == null || op.claim.deleted || op.claim.meta.grantDeviceDID !== from || op.claim.meta.grantName !== input.providerName) {
114
+ throw new CredentialGrantCopyUnavailable(input.providerName, owner, from);
115
+ }
116
+ const key = await store.getKey(source.key_id, source.owner_did);
117
+ if (key == null || normalizeDID(op.claim.owner) !== normalizeDID(key.owner_did)) {
118
+ throw new CredentialGrantCopyUnavailable(input.providerName, owner, from);
119
+ }
120
+ const keyOwnerDID = normalizeDID(key.owner_did);
121
+ const controller = keyOwnerDID === owner ? null : await params.resolveControllerOfOwner?.(owner, stores);
122
+ if (op.claim.meta.grantOwnerDID !== owner || sourceID !== credentialGrantEntryID(owner, from, input.providerName) || keyOwnerDID !== owner && (controller == null || keyOwnerDID !== normalizeDID(controller))) {
123
+ throw new CredentialGrantAccountMismatch(input.providerName, owner);
124
+ }
125
+ return {
126
+ sourceID,
127
+ source,
128
+ keyOwnerDID
129
+ };
130
+ };
131
+ const api = {
132
+ getLocalDeviceDID: ()=>deviceDID,
133
+ async getGrantKeyOwnerDID (input) {
134
+ const store = await getAliasStore();
135
+ return (await adoptionSource({
136
+ stores: params.stores,
137
+ store,
138
+ input
139
+ })).keyOwnerDID;
140
+ },
141
+ async adoptCredentialGrant (input, authorizedKeyOwnerDID) {
142
+ const owner = normalizeDID(input.ownerDID);
143
+ const from = normalizeDID(input.fromDeviceDID);
144
+ const to = normalizeDID(input.toDeviceDID);
145
+ if (to !== deviceDID || from === to) {
146
+ throw new CredentialBindNotAuthorized(input.providerName, input.ownerDID);
147
+ }
148
+ if (input.sourceKeyOwnerDID !== undefined && authorizedKeyOwnerDID === undefined) {
149
+ throw new CredentialBindNotAuthorized(input.providerName, input.ownerDID);
150
+ }
151
+ await (await params.getCredentialManager(params.stores)).ensureRemovalRecovery();
152
+ return params.stores.withTransaction(async (tx)=>{
153
+ const store = await getCredentialStore(tx);
154
+ const credentials = await params.getCredentialManager(tx, true);
155
+ const { sourceID, source, keyOwnerDID } = await adoptionSource({
156
+ stores: tx,
157
+ store,
158
+ input
159
+ });
160
+ if (authorizedKeyOwnerDID != null && keyOwnerDID !== normalizeDID(authorizedKeyOwnerDID)) {
161
+ throw new CredentialBindNotAuthorized(input.providerName, input.ownerDID);
162
+ }
163
+ const plaintext = await credentials.readEntry({
164
+ entryID: sourceID,
165
+ ownerDID: keyOwnerDID
166
+ });
167
+ const targetID = await credentials.putEntry(source.key_id, plaintext, {
168
+ grantDeviceDID: to,
169
+ grantName: input.providerName,
170
+ grantOwnerDID: owner
171
+ });
172
+ await store.setDeviceAliasEntryID({
173
+ providerName: input.providerName,
174
+ ownerDID: owner,
175
+ deviceDID: to,
176
+ entryID: targetID,
177
+ entryOwnerDID: keyOwnerDID
178
+ });
179
+ await credentials.deleteEntry({
180
+ entryID: sourceID,
181
+ ownerDID: keyOwnerDID
182
+ });
183
+ await store.deleteDeviceAliasIfEntry({
184
+ providerName: input.providerName,
185
+ ownerDID: owner,
186
+ deviceDID: from,
187
+ entryID: sourceID
188
+ });
189
+ return {
190
+ adopted: true
191
+ };
192
+ });
193
+ },
10
194
  async listSubjects (providerName) {
11
- return (await getAliasStore()).listAliasKeys(providerName);
195
+ return (await getAliasStore()).listDeviceAliasOwnersForProvider({
196
+ providerName,
197
+ deviceDID
198
+ });
12
199
  },
13
200
  async getCredential (providerName, ownerDID) {
14
201
  const store = await getAliasStore();
15
- const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
202
+ const entryID = await aliasEntry(providerName, ownerDID);
16
203
  if (entryID == null) return null;
17
204
  // A dangling alias (its entry was deleted out from under it) resolves to null,
18
205
  // not an error: a missing ENTRY is checked structurally here so a missing KEY
19
206
  // inside readEntry still surfaces as CredentialNotFound below.
20
- if (await store.getEntry(entryID) == null) return null;
207
+ const keyOwner = await grantKeyOwner({
208
+ store,
209
+ entryID,
210
+ accountOwnerDID: ownerDID,
211
+ providerName
212
+ });
213
+ if (await store.getEntry(entryID, keyOwner) == null) return null;
21
214
  try {
22
215
  const credentials = await getCredentials();
23
- return toUTF(await credentials.readEntry(entryID));
216
+ return toUTF(await credentials.readEntry({
217
+ entryID,
218
+ ownerDID: keyOwner
219
+ }));
24
220
  } catch (error) {
25
221
  if (error instanceof CredentialSupersededBranch) {
26
222
  params.logger.warn('Credential entry {entryID} for provider {providerName} belongs to a superseded branch', {
@@ -36,14 +232,21 @@ export function createCredentialAPI(params) {
36
232
  // Reflects the current pointer, not the winning branch — no superseded-branch guard
37
233
  // (it never decrypts). Callers must gate on getCredential() != null so a loser-branch
38
234
  // pointer's provenance is never surfaced.
39
- const store = await getAliasStore();
40
- const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
235
+ const entryID = await aliasEntry(providerName, ownerDID);
41
236
  if (entryID == null) return null;
42
- return (await getCredentials()).getEntryProvenance(entryID);
237
+ const store = await getAliasStore();
238
+ return (await getCredentials()).getEntryProvenance({
239
+ entryID,
240
+ ownerDID: await grantKeyOwner({
241
+ store,
242
+ entryID,
243
+ accountOwnerDID: ownerDID,
244
+ providerName
245
+ })
246
+ });
43
247
  },
44
248
  async setCredential (providerName, ownerDID, credential) {
45
- const store = await getAliasStore();
46
- const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
249
+ const entryID = await aliasEntry(providerName, ownerDID);
47
250
  if (entryID == null) {
48
251
  throw new Error(`No connector credential to update for provider "${providerName}"; create it through the authorization flow`);
49
252
  }
@@ -51,117 +254,211 @@ export function createCredentialAPI(params) {
51
254
  // under it. No key is minted and no wrapping is touched, so a refresh
52
255
  // cannot change who can read the credential.
53
256
  const credentials = await getCredentials();
54
- await credentials.updateEntry(entryID, fromUTF(credential));
257
+ const store = await getAliasStore();
258
+ await credentials.updateEntry({
259
+ entryID,
260
+ ownerDID: await grantKeyOwner({
261
+ store,
262
+ entryID,
263
+ accountOwnerDID: ownerDID,
264
+ providerName
265
+ })
266
+ }, fromUTF(credential));
55
267
  },
56
268
  async createCredential (input) {
57
- const { providerName, ownerDID, ownerWrappableDID, credential } = input;
58
- const store = await getAliasStore();
59
- const credentials = await getCredentials();
60
- // Wrapping to a DID needs only that DID's published key, so an engine that
61
- // cannot decrypt would mint this key perfectly and then be unable to read
62
- // it — a failure that surfaces at the first headless sync, far from here.
63
- // This is where `cipher == null` used to fail closed, and it still does.
64
- if (!credentials.availableFactors().includes('did')) {
65
- throw new Error(`Cannot create a connector credential for "${providerName}": this engine's identity cannot decrypt, so it could never read back what it stored`);
66
- }
67
- // Two wrappings from the first write, not one plus a later grant: the
68
- // server has to read this to run sync, and the owner has to be able to
69
- // rotate and revoke it. Owner is authority, a different question from who
70
- // can decrypt: when a controller is supplied it is the owner, so a device
71
- // revoke goes through the controller's capability chain rather than the
72
- // device self-authorizing; absent one the device owns its own key.
73
- //
74
- // The two spellings below are deliberately different questions about the
75
- // same identity, and NOT interchangeable: `owner_did` is an equality key
76
- // and so is normalized, while a recipient is encrypted to and must keep
77
- // its wrappable form. `ownerWrappableDID` is therefore NOT normalized.
78
- // Normalizing a `did:peer:4` recipient hands the short form to
79
- // `deriveSharedSecret`, which resolves no document and no agreement key —
80
- // do not fold these together.
81
- const wrappings = [
82
- [
83
- {
84
- kind: 'did',
85
- recipientDID: ownerWrappableDID
269
+ // A controller-log pull may consult remote providers, so resolve before the grant lock.
270
+ if (!params.wrappingRecipientsPrewarmed) {
271
+ const methods = params.getControllerMethods(params.stores);
272
+ const recipients = new Set([
273
+ input.ownerWrappableDID,
274
+ params.serverWrappableDID,
275
+ input.controllerWrappableDID
276
+ ]);
277
+ for (const recipient of recipients){
278
+ if (recipient == null) continue;
279
+ for (const method of methods){
280
+ if (recipient.startsWith(`did:${method.method}:`)) {
281
+ await method.resolve(recipient, {});
282
+ }
86
283
  }
87
- ],
88
- [
89
- {
90
- kind: 'did',
91
- recipientDID: params.serverWrappableDID
284
+ }
285
+ }
286
+ await params.stores.withTransaction(async (tx)=>{
287
+ const { providerName, ownerDID, ownerWrappableDID, credential } = input;
288
+ const store = await getCredentialStore(tx);
289
+ const credentials = await params.getCredentialManager(tx);
290
+ if (credentials.store !== store) throw new CredentialManagerStoreMismatch();
291
+ // Wrapping to a DID needs only that DID's published key, so an engine that
292
+ // cannot decrypt would mint this key perfectly and then be unable to read
293
+ // it — a failure that surfaces at the first headless sync, far from here.
294
+ // This is where `cipher == null` used to fail closed, and it still does.
295
+ if (!credentials.availableFactors().includes('did')) {
296
+ throw new Error(`Cannot create a connector credential for "${providerName}": this engine's identity cannot decrypt, so it could never read back what it stored`);
297
+ }
298
+ // Two wrappings from the first write, not one plus a later grant: the
299
+ // server has to read this to run sync, and the owner has to be able to
300
+ // rotate and revoke it. Owner is authority, a different question from who
301
+ // can decrypt: when a controller is supplied it is the owner, so a device
302
+ // revoke goes through the controller's capability chain rather than the
303
+ // device self-authorizing; absent one the device owns its own key.
304
+ //
305
+ // The two spellings below are deliberately different questions about the
306
+ // same identity, and NOT interchangeable: `owner_did` is an equality key
307
+ // and so is normalized, while a recipient is encrypted to and must keep
308
+ // its wrappable form. `ownerWrappableDID` is therefore NOT normalized.
309
+ // Normalizing a `did:peer:4` recipient hands the short form to
310
+ // `deriveSharedSecret`, which resolves no document and no agreement key —
311
+ // do not fold these together.
312
+ const wrappings = [
313
+ [
314
+ {
315
+ kind: 'did',
316
+ recipientDID: ownerWrappableDID
317
+ }
318
+ ],
319
+ [
320
+ {
321
+ kind: 'did',
322
+ recipientDID: params.serverWrappableDID
323
+ }
324
+ ]
325
+ ];
326
+ // A recovery recipient (e.g. a controller / seed-holder), so the credential
327
+ // survives device loss. Opt-in: absent when the caller supplied none. Kept
328
+ // in its wrappable form for the same reason as the two above — it is
329
+ // encrypted to, not compared against.
330
+ if (input.controllerWrappableDID != null) {
331
+ wrappings.push([
332
+ {
333
+ kind: 'did',
334
+ recipientDID: input.controllerWrappableDID
335
+ }
336
+ ]);
337
+ }
338
+ // The controller owns the key when supplied (its canonical DID equals the
339
+ // normalized wrappable form); the account pointer below stays keyed on the
340
+ // device — ownership and account scoping are separate concerns.
341
+ const keyOwnerDID = input.controllerWrappableDID != null ? normalizeDID(input.controllerWrappableDID) : normalizeDID(ownerDID);
342
+ const grantOwnerDID = normalizeDID(ownerDID);
343
+ const entryID = credentialGrantEntryID(grantOwnerDID, deviceDID, providerName);
344
+ await store.lockKeyRow(`grant-entry:${entryID}`);
345
+ const priorOwners = [];
346
+ for (const priorOwner of (await store.listEntryOwners(entryID))){
347
+ const record = await signedEntry(store, entryID, priorOwner);
348
+ const op = record == null ? null : await verifySignedRecord(record);
349
+ if (op != null && !op.claim.deleted && op.claim.meta.grantDeviceDID === deviceDID && op.claim.meta.grantName === providerName && op.claim.meta.grantOwnerDID === grantOwnerDID) {
350
+ priorOwners.push(priorOwner);
92
351
  }
93
- ]
94
- ];
95
- // A recovery recipient (e.g. a controller / seed-holder), so the credential
96
- // survives device loss. Opt-in: absent when the caller supplied none. Kept
97
- // in its wrappable form for the same reason as the two above — it is
98
- // encrypted to, not compared against.
99
- if (input.controllerWrappableDID != null) {
100
- wrappings.push([
101
- {
102
- kind: 'did',
103
- recipientDID: input.controllerWrappableDID
352
+ }
353
+ if (priorOwners.length > 0) {
354
+ for (const owner of new Set([
355
+ ...priorOwners,
356
+ keyOwnerDID
357
+ ])){
358
+ const authorized = input.authorizeKeyOwnerDID ? await input.authorizeKeyOwnerDID(owner) : await params.authorizeKeyOwnerDID?.(owner, tx);
359
+ if (authorized !== true) throw new CredentialGrantRehomeNotAuthorized(entryID);
104
360
  }
105
- ]);
106
- }
107
- // The controller owns the key when supplied (its canonical DID equals the
108
- // normalized wrappable form); the account pointer below stays keyed on the
109
- // device — ownership and account scoping are separate concerns.
110
- const keyOwnerDID = input.controllerWrappableDID != null ? normalizeDID(input.controllerWrappableDID) : normalizeDID(ownerDID);
111
- const keyID = await credentials.createKey({
112
- ownerDID: keyOwnerDID,
113
- wrappings,
114
- methods: params.getControllerMethods(params.stores)
361
+ }
362
+ for (const priorOwner of priorOwners){
363
+ await credentials.deleteEntry({
364
+ entryID,
365
+ ownerDID: priorOwner
366
+ });
367
+ }
368
+ const keyID = await credentials.createKey({
369
+ ownerDID: keyOwnerDID,
370
+ wrappings,
371
+ methods: params.getControllerMethods(tx, {
372
+ localOnly: true
373
+ })
374
+ });
375
+ const writtenEntryID = await credentials.putEntry(keyID, fromUTF(credential), {
376
+ grantDeviceDID: deviceDID,
377
+ grantName: providerName,
378
+ grantOwnerDID
379
+ });
380
+ await store.setDeviceAliasEntryID({
381
+ providerName,
382
+ ownerDID: normalizeDID(ownerDID),
383
+ deviceDID,
384
+ entryID: writtenEntryID,
385
+ entryOwnerDID: keyOwnerDID
386
+ });
115
387
  });
116
- const entryID = await credentials.putEntry(keyID, fromUTF(credential));
117
- await store.setAliasEntryID(providerName, normalizeDID(ownerDID), entryID);
118
388
  },
119
389
  async deleteCredential (providerName, ownerDID) {
120
390
  const store = await getAliasStore();
121
391
  const owner = normalizeDID(ownerDID);
122
- const entryID = await store.getAliasEntryID(providerName, owner);
123
- // Entry first, THEN the pointer. This ordering keeps a concurrent bindAlias
124
- // from leaving a dangling alias: once the entry is gone, bindAliasIfAbsent's
125
- // entry-existence-conditioned insert refuses (there is no row to point at),
126
- // so no new alias can be installed against the entry being removed. The brief
127
- // window where the old pointer outlives its entry is covered by getCredential/
128
- // getCredentialRef, which treat a missing entry as null. The pointer and the
129
- // entry still go together — leaving the entry would keep the token readable by
130
- // anyone who kept its id after a disconnect.
392
+ const entryID = await aliasEntry(providerName, owner);
393
+ // Delete the entry before clearing its local pointer; readers treat the
394
+ // briefly dangling pointer as absent.
131
395
  // No pointer to delete: there is nothing of ours to remove. An unconditional
132
396
  // clear here would only drop an alias a concurrent bindAlias installed between
133
397
  // the read above and now, so leave the slot untouched.
134
398
  if (entryID == null) return;
135
399
  const credentials = await getCredentials();
136
- await credentials.deleteEntry(entryID);
400
+ await credentials.deleteEntry({
401
+ entryID,
402
+ ownerDID: await grantKeyOwner({
403
+ store,
404
+ entryID,
405
+ accountOwnerDID: owner,
406
+ providerName
407
+ })
408
+ });
137
409
  // Clear the pointer ONLY while it still names the entry we removed. A concurrent
138
410
  // bindAlias can repair a briefly-dangling pointer onto a fresh entry in the gap
139
411
  // after deleteEntry; an unconditional delete would drop that valid new binding.
140
412
  // If the pointer already moved, leave it.
141
- await store.deleteAliasIfEntry(providerName, owner, entryID);
413
+ await store.deleteDeviceAliasIfEntry({
414
+ providerName,
415
+ ownerDID: owner,
416
+ deviceDID,
417
+ entryID
418
+ });
142
419
  },
143
420
  async bindAlias (providerName, ownerDID, entryID) {
144
421
  const owner = normalizeDID(ownerDID);
145
422
  const credentials = await getCredentials();
146
- // Gate B: the acting subject must own the entry's key. The vault can
147
- // decrypt any entry it holds a server wrapping for, so decryptability is
148
- // not authority.
149
- const keyOwner = await credentials.getEntryKeyOwnerDID(entryID);
423
+ if (entryID !== credentialGrantEntryID(owner, deviceDID, providerName)) {
424
+ if (entryID.startsWith('grant:')) {
425
+ throw new CredentialBindNotAuthorized(providerName, ownerDID);
426
+ }
427
+ throw new CredentialNotFound('Credential entry', entryID);
428
+ }
429
+ // Gate B: the entry's key must belong to this account. The signed grant
430
+ // binds the account independently.
431
+ const store = await getAliasStore();
432
+ const expectedKeyOwner = await grantKeyOwner({
433
+ store,
434
+ entryID,
435
+ accountOwnerDID: owner,
436
+ providerName
437
+ });
438
+ const keyOwner = await credentials.getEntryKeyOwnerDID({
439
+ entryID,
440
+ ownerDID: expectedKeyOwner
441
+ });
150
442
  if (keyOwner == null) {
151
443
  throw new CredentialNotFound('Credential entry', entryID);
152
444
  }
153
445
  if (normalizeDID(keyOwner) !== owner) {
154
446
  throw new CredentialBindNotAuthorized(providerName, ownerDID);
155
447
  }
156
- const store = await getAliasStore();
448
+ if (!await localGrantRecord(entryID, providerName, owner)) {
449
+ throw new CredentialBindNotAuthorized(providerName, ownerDID);
450
+ }
157
451
  // Idempotent short-circuit BEFORE decrypt, so a safe retry cannot fail on
158
452
  // a branch/version check inside readEntry.
159
- if (await store.getAliasEntryID(providerName, owner) === entryID) {
453
+ if (await aliasEntry(providerName, owner) === entryID) {
160
454
  return;
161
455
  }
162
456
  // Validate the decrypted plaintext is actually a credential before it can
163
457
  // ever be resolved back out through getCredential.
164
- const bytes = await credentials.readEntry(entryID);
458
+ const bytes = await credentials.readEntry({
459
+ entryID,
460
+ ownerDID: keyOwner
461
+ });
165
462
  let parsed;
166
463
  try {
167
464
  parsed = JSON.parse(toUTF(bytes));
@@ -171,39 +468,59 @@ export function createCredentialAPI(params) {
171
468
  });
172
469
  }
173
470
  assertValidCredentialShape(parsed);
174
- const result = await store.bindAliasIfAbsent({
175
- namespace: providerName,
176
- aliasKey: owner,
177
- entryID
178
- });
179
- if (result === 'inserted') return;
180
- if (result === 'entry-missing') {
181
- throw new CredentialNotFound('Credential entry', entryID);
182
- }
183
- if (result.conflict === 'alias') {
184
- if (result.existing === entryID) return; // concurrent idempotent retry
471
+ const existing = await aliasEntry(providerName, owner);
472
+ if (existing != null && existing !== entryID) {
185
473
  throw new CredentialAliasAlreadyBound(providerName, ownerDID);
186
474
  }
187
- throw new CredentialEntryAlreadyAliased(providerName, entryID) // conflict: 'entry'
188
- ;
475
+ await store.setDeviceAliasEntryID({
476
+ providerName,
477
+ ownerDID: owner,
478
+ deviceDID,
479
+ entryID,
480
+ entryOwnerDID: keyOwner
481
+ });
189
482
  },
190
483
  getControllerMethods () {
191
484
  return params.getControllerMethods(params.stores);
192
485
  },
193
486
  async getCredentialRef (providerName, ownerDID) {
487
+ // Projected without the version: this shape is also a service wire result.
488
+ const ref = await api.getVersionedCredentialRef(providerName, ownerDID);
489
+ return ref == null ? null : {
490
+ credential: ref.credential,
491
+ entryID: ref.entryID
492
+ };
493
+ },
494
+ async getVersionedCredentialRef (providerName, ownerDID) {
194
495
  const store = await getAliasStore();
195
- const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
496
+ const entryID = await aliasEntry(providerName, ownerDID);
196
497
  if (entryID == null) return null;
197
- // Dangling alias → null, checked structurally (see getCredential): a missing
198
- // entry is not an error, a missing key inside readEntry still throws.
199
- if (await store.getEntry(entryID) == null) return null;
200
498
  try {
201
499
  const credentials = await getCredentials();
202
- const bytes = await credentials.readEntry(entryID);
203
- return {
204
- credential: toUTF(bytes),
205
- entryID
206
- };
500
+ const keyOwner = await grantKeyOwner({
501
+ store,
502
+ entryID,
503
+ accountOwnerDID: ownerDID,
504
+ providerName
505
+ });
506
+ for(let attempt = 0; attempt < 5; attempt += 1){
507
+ const before = await store.getEntry(entryID, keyOwner);
508
+ if (before == null) return null;
509
+ const bytes = await credentials.readEntry({
510
+ entryID,
511
+ ownerDID: keyOwner
512
+ });
513
+ const after = await store.getEntry(entryID, keyOwner);
514
+ if (after == null) return null;
515
+ if (before.hlc === after.hlc) {
516
+ return {
517
+ credential: toUTF(bytes),
518
+ entryID,
519
+ version: before.hlc
520
+ };
521
+ }
522
+ }
523
+ throw new CredentialInvalidEntry('credential entry changed during versioned read');
207
524
  } catch (error) {
208
525
  if (error instanceof CredentialSupersededBranch) {
209
526
  params.logger.warn('Credential entry {entryID} for provider {providerName} belongs to a superseded branch', {
@@ -216,8 +533,35 @@ export function createCredentialAPI(params) {
216
533
  }
217
534
  },
218
535
  async setCredentialForEntry (params) {
536
+ if (await aliasEntry(params.providerName, params.ownerDID) !== params.entryID) return 'absent';
219
537
  const credentials = await getCredentials();
220
- return await credentials.updateEntryIfPresent(params.entryID, fromUTF(params.credential));
538
+ const store = await getAliasStore();
539
+ return await credentials.updateEntryIfPresent({
540
+ entryID: params.entryID,
541
+ ownerDID: await grantKeyOwner({
542
+ store,
543
+ entryID: params.entryID,
544
+ accountOwnerDID: params.ownerDID,
545
+ providerName: params.providerName
546
+ })
547
+ }, fromUTF(params.credential));
548
+ },
549
+ async setCredentialForEntryIfUnchanged (params) {
550
+ if (await aliasEntry(params.providerName, params.ownerDID) !== params.entryID) return 'absent';
551
+ const credentials = await getCredentials();
552
+ const store = await getAliasStore();
553
+ return await credentials.updateEntryIfUnchanged({
554
+ entryID: params.entryID,
555
+ plaintext: fromUTF(params.credential),
556
+ expectedHLC: params.expectedVersion,
557
+ ownerDID: await grantKeyOwner({
558
+ store,
559
+ entryID: params.entryID,
560
+ accountOwnerDID: params.ownerDID,
561
+ providerName: params.providerName
562
+ })
563
+ });
221
564
  }
222
565
  };
566
+ return api;
223
567
  }