@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.
- package/lib/api.d.ts +61 -30
- package/lib/api.js +455 -111
- package/lib/authority.d.ts +4 -0
- package/lib/authority.js +3 -0
- package/lib/branch.d.ts +4 -0
- package/lib/branch.js +24 -0
- package/lib/builder.d.ts +5 -0
- package/lib/builder.js +8 -6
- package/lib/canonical-token.d.ts +2 -0
- package/lib/canonical-token.js +10 -0
- package/lib/errors.d.ts +39 -1
- package/lib/errors.js +77 -0
- package/lib/fork-repair.d.ts +19 -0
- package/lib/fork-repair.js +3 -0
- package/lib/grantor-authority.d.ts +26 -0
- package/lib/grantor-authority.js +43 -0
- package/lib/index.d.ts +16 -6
- package/lib/index.js +8 -2
- package/lib/key-cache.d.ts +4 -3
- package/lib/key-cache.js +22 -15
- package/lib/manager-context.d.ts +50 -0
- package/lib/manager-context.js +82 -0
- package/lib/manager-entries.d.ts +3 -0
- package/lib/manager-entries.js +585 -0
- package/lib/manager-keys.d.ts +3 -0
- package/lib/manager-keys.js +413 -0
- package/lib/manager-open.d.ts +45 -0
- package/lib/manager-open.js +421 -0
- package/lib/manager-records.d.ts +94 -0
- package/lib/manager-records.js +291 -0
- package/lib/manager-removal-recovery.d.ts +7 -0
- package/lib/manager-removal-recovery.js +211 -0
- package/lib/manager-rotate.d.ts +15 -0
- package/lib/manager-rotate.js +536 -0
- package/lib/manager-types.d.ts +337 -0
- package/lib/manager-types.js +1 -0
- package/lib/manager.d.ts +2 -293
- package/lib/manager.js +38 -1072
- package/lib/manifest-token.d.ts +31 -0
- package/lib/manifest-token.js +51 -0
- package/lib/provider.d.ts +15 -2
- package/lib/provider.js +39 -0
- package/lib/record-types.d.ts +65 -0
- package/lib/record-types.js +865 -0
- package/lib/refreshing-credential-provider.d.ts +6 -2
- package/lib/refreshing-credential-provider.js +76 -13
- package/lib/suite.d.ts +5 -4
- package/lib/suite.js +2 -2
- package/lib/wrappable.d.ts +14 -0
- package/lib/wrappable.js +15 -0
- package/package.json +16 -15
- package/lib/op.d.ts +0 -128
- package/lib/op.js +0 -304
package/lib/api.js
CHANGED
|
@@ -1,26 +1,222 @@
|
|
|
1
1
|
import { normalizeDID } from '@kokuin/token';
|
|
2
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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()).
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
40
|
-
const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
|
|
235
|
+
const entryID = await aliasEntry(providerName, ownerDID);
|
|
41
236
|
if (entryID == null) return null;
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
123
|
-
//
|
|
124
|
-
//
|
|
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(
|
|
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.
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
}
|