@kubun/credential 0.17.4 → 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 +15 -6
- package/lib/index.js +7 -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 -1073
- 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/package.json +16 -15
- package/lib/op.d.ts +0 -128
- package/lib/op.js +0 -304
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { isKeyAgreementIdentity, normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { HLC } from '@kubun/hlc';
|
|
3
|
+
import { createDeviceAuthority } from './authority.js';
|
|
4
|
+
import { createKeyCache } from './key-cache.js';
|
|
5
|
+
import { createPinThrottle } from './pin-throttle.js';
|
|
6
|
+
/** Kinds a user can be prompted for, as opposed to ones held as key material. */ export const SECRET_FACTOR_KINDS = [
|
|
7
|
+
'passphrase',
|
|
8
|
+
'pin'
|
|
9
|
+
];
|
|
10
|
+
export function wrappingNamesRemovedDevice(wrapping, removed) {
|
|
11
|
+
return [
|
|
12
|
+
wrapping.recipient_did,
|
|
13
|
+
...wrapping.factors.filter((factor)=>factor.kind === 'did').map((factor)=>factor.params.recipient)
|
|
14
|
+
].some((recipient)=>typeof recipient === 'string' && removed.has(normalizeDID(recipient)));
|
|
15
|
+
}
|
|
16
|
+
export function isSecretFactorKind(kind) {
|
|
17
|
+
return SECRET_FACTOR_KINDS.includes(kind);
|
|
18
|
+
}
|
|
19
|
+
export function createManagerContext(params) {
|
|
20
|
+
const { store, identity, runtime, hlc } = params;
|
|
21
|
+
const authority = params.authority ?? createDeviceAuthority(identity.id);
|
|
22
|
+
const decrypting = isKeyAgreementIdentity(identity) ? identity : null;
|
|
23
|
+
const cache = createKeyCache({
|
|
24
|
+
ttl: params.unlockTTL,
|
|
25
|
+
now: params.now
|
|
26
|
+
});
|
|
27
|
+
const throttle = createPinThrottle({
|
|
28
|
+
...params.pinThrottle,
|
|
29
|
+
now: params.now
|
|
30
|
+
});
|
|
31
|
+
// Concurrency guards for `openKey`:
|
|
32
|
+
// - `inFlight` collapses a thundering herd of input-free opens of one key into
|
|
33
|
+
// a single unwrap; each waiter then takes its own clone from the cache.
|
|
34
|
+
// - `keyGeneration` (bumped by `lock`) and `lockAllEpoch` (bumped by `lockAll`)
|
|
35
|
+
// let a lock landing mid-open suppress that open's `cache.set`, so an open
|
|
36
|
+
// begun before the lock cannot re-warm the cache the lock just cleared. The
|
|
37
|
+
// in-flight read still returns its key — it was already authorized; the lock
|
|
38
|
+
// only means "do not stay warm."
|
|
39
|
+
const state = {
|
|
40
|
+
inFlight: new Map(),
|
|
41
|
+
keyGeneration: new Map(),
|
|
42
|
+
lockAllEpoch: 0
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Floor the shared clock to the greatest stored entry HLC, once per manager.
|
|
46
|
+
*
|
|
47
|
+
* The engine floors from the graph mutation log, which credential writes never
|
|
48
|
+
* touch — they advance the same clock and leave no trace there, so this is the
|
|
49
|
+
* only read that can see them again after a restart where the OS clock stepped
|
|
50
|
+
* backward. Memoized so concurrent first-writes await one read.
|
|
51
|
+
*/ async function ensureFloored() {
|
|
52
|
+
state.floorPromise ??= (async ()=>{
|
|
53
|
+
const max = await store.getMaxEntryHLC();
|
|
54
|
+
// An applied grant stores the granter's HLC verbatim, so the maximum can be
|
|
55
|
+
// a remote peer's string — and `HLC.parse` answers a malformed one with NaN
|
|
56
|
+
// fields that `receive` would carry into the clock.
|
|
57
|
+
const parsed = max == null ? null : HLC.tryParse(max);
|
|
58
|
+
if (parsed != null) {
|
|
59
|
+
hlc.receive(parsed);
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
await state.floorPromise;
|
|
63
|
+
}
|
|
64
|
+
async function nextHLC() {
|
|
65
|
+
await ensureFloored();
|
|
66
|
+
return HLC.serialize(hlc.now());
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
params,
|
|
70
|
+
store,
|
|
71
|
+
identity,
|
|
72
|
+
runtime,
|
|
73
|
+
hlc,
|
|
74
|
+
authority,
|
|
75
|
+
decrypting,
|
|
76
|
+
cache,
|
|
77
|
+
throttle,
|
|
78
|
+
state,
|
|
79
|
+
ensureFloored,
|
|
80
|
+
nextHLC
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ManagerContext, ManagerInternals } from './manager-context.js';
|
|
2
|
+
import type { CredentialManager } from './manager-types.js';
|
|
3
|
+
export declare function createEntryFunctions(ctx: ManagerContext, internals: ManagerInternals): Pick<CredentialManager, 'putEntry' | 'updateEntry' | 'updateEntryIfPresent' | 'updateEntryIfUnchanged' | 'readEntry' | 'getEntryProvenance' | 'getEntryKeyOwnerDID' | 'listEntries' | 'reencryptGrantEntries' | 'deleteEntry' | 'reKeyEntry'>;
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { verifySignedRecord } from '@kubun/records';
|
|
3
|
+
import { buildCredentialEntry } from './builder.js';
|
|
4
|
+
import { CredentialEntryConflict, CredentialEntryOwnerMismatch, CredentialInvalidEntry, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialRepairPending, CredentialSupersededBranch, CredentialUnopenable, UnsupportedCredentialSuite } from './errors.js';
|
|
5
|
+
import { entryProvenanceFields } from './provenance.js';
|
|
6
|
+
import { credentialGrantEntryID } from './record-types.js';
|
|
7
|
+
import { getSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
|
|
8
|
+
export function createEntryFunctions(ctx, internals) {
|
|
9
|
+
const { store, identity, runtime, authority, nextHLC } = ctx;
|
|
10
|
+
const { entryRecord, winningBranch, putRecord, branchRecordID, putEntryRecord, entryGrant, writableGrant, branchProofs, suiteForVersion } = internals.records;
|
|
11
|
+
const { loadKey, openKey, openSpecificBranch } = internals.open;
|
|
12
|
+
const { checkRemovalRecovery } = internals.removalRecovery;
|
|
13
|
+
const requireEntryAddress = (address)=>{
|
|
14
|
+
if (typeof address?.entryID !== 'string' || typeof address.ownerDID !== 'string' || !address.ownerDID) {
|
|
15
|
+
throw new CredentialInvalidEntry('credential entry address requires an ownerDID and entryID');
|
|
16
|
+
}
|
|
17
|
+
return address;
|
|
18
|
+
};
|
|
19
|
+
// The shared body of updateEntryIfPresent and updateEntryIfUnchanged; a null
|
|
20
|
+
// `expectedHLC` is the unconditional presence-gated write.
|
|
21
|
+
async function updateEntryConditionally({ entryID, plaintext, expectedHLC, attempt = 1, ownerDID }) {
|
|
22
|
+
const existing = await store.getEntry(entryID, ownerDID);
|
|
23
|
+
if (existing == null) {
|
|
24
|
+
return 'absent';
|
|
25
|
+
}
|
|
26
|
+
// Already rewritten since the caller read it: skip the key open and the
|
|
27
|
+
// re-encrypt. The store repeats the check atomically with the write.
|
|
28
|
+
if (expectedHLC != null && existing.hlc !== expectedHLC) {
|
|
29
|
+
return 'superseded';
|
|
30
|
+
}
|
|
31
|
+
const grant = await writableGrant(entryID, existing.owner_did);
|
|
32
|
+
const { contentKey, version, branchID } = await openKey(existing.key_id);
|
|
33
|
+
// Same re-encrypt-under-current-version shape as `updateEntry`: the suite
|
|
34
|
+
// lookup's store read stays inside the `try` so a rejection cannot leak the
|
|
35
|
+
// key copy.
|
|
36
|
+
let encrypted;
|
|
37
|
+
try {
|
|
38
|
+
const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
|
|
39
|
+
encrypted = suite.encryptEntry({
|
|
40
|
+
runtime,
|
|
41
|
+
contentKey,
|
|
42
|
+
plaintext,
|
|
43
|
+
keyID: existing.key_id,
|
|
44
|
+
keyVersion: version,
|
|
45
|
+
entryID
|
|
46
|
+
});
|
|
47
|
+
} finally{
|
|
48
|
+
contentKey.fill(0);
|
|
49
|
+
}
|
|
50
|
+
const { iv, ciphertext } = encrypted;
|
|
51
|
+
const updated = {
|
|
52
|
+
entryID,
|
|
53
|
+
keyID: existing.key_id,
|
|
54
|
+
keyVersion: version,
|
|
55
|
+
iv,
|
|
56
|
+
ciphertext,
|
|
57
|
+
// A fresh stamp every time, not `existing.hlc` carried forward: the store
|
|
58
|
+
// primitive is op-first and gates the row write on this op winning LWW, so
|
|
59
|
+
// the writeback needs a current stamp to land at all.
|
|
60
|
+
hlc: await nextHLC()
|
|
61
|
+
};
|
|
62
|
+
const outcome = await store.transaction(async (tx)=>{
|
|
63
|
+
await tx.lockKeyRow(updated.keyID);
|
|
64
|
+
const current = await tx.getEntry(entryID, existing.owner_did);
|
|
65
|
+
if (current == null) return 'absent';
|
|
66
|
+
if (expectedHLC != null && current.hlc !== expectedHLC) return 'superseded';
|
|
67
|
+
const key = await tx.getKey(updated.keyID);
|
|
68
|
+
if (key == null) throw new CredentialNotFound('Credential key', updated.keyID);
|
|
69
|
+
if (key.version !== version || current.key_id !== updated.keyID) return 'retry';
|
|
70
|
+
await putEntryRecord({
|
|
71
|
+
entry: updated,
|
|
72
|
+
owner: key.owner_did,
|
|
73
|
+
branchID,
|
|
74
|
+
contentHLC: updated.hlc,
|
|
75
|
+
target: tx,
|
|
76
|
+
authority,
|
|
77
|
+
grant
|
|
78
|
+
});
|
|
79
|
+
return 'updated';
|
|
80
|
+
});
|
|
81
|
+
if (outcome !== 'retry') return outcome;
|
|
82
|
+
if (attempt === 5) throw new CredentialEntryConflict(entryID);
|
|
83
|
+
return await updateEntryConditionally({
|
|
84
|
+
entryID,
|
|
85
|
+
plaintext,
|
|
86
|
+
expectedHLC,
|
|
87
|
+
attempt: attempt + 1,
|
|
88
|
+
ownerDID
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async function reencryptGrantEntries(keyID) {
|
|
92
|
+
await checkRemovalRecovery();
|
|
93
|
+
for (const listed of (await store.listEntries(keyID))){
|
|
94
|
+
for(let attempt = 0; attempt < 5; attempt += 1){
|
|
95
|
+
const key = await loadKey(keyID);
|
|
96
|
+
const entry = await store.getEntry(listed.entry_id, key.owner_did);
|
|
97
|
+
if (entry == null || entry.key_id !== keyID || entry.key_version >= key.version) break;
|
|
98
|
+
const grant = await entryGrant(entry.entry_id, store, entry.owner_did);
|
|
99
|
+
if (grant === undefined || normalizeDID(grant.grantDeviceDID) !== normalizeDID(identity.id)) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const previousRecord = await entryRecord(entry.entry_id, store, entry.owner_did);
|
|
103
|
+
const previousOp = previousRecord === null ? null : await verifySignedRecord(previousRecord);
|
|
104
|
+
const contentHLC = previousOp?.claim.meta.contentHLC;
|
|
105
|
+
if (typeof contentHLC !== 'string') break;
|
|
106
|
+
const old = await openKey(keyID, undefined, entry.key_version);
|
|
107
|
+
let plaintext;
|
|
108
|
+
try {
|
|
109
|
+
plaintext = getSuite(await suiteForVersion(keyID, entry.key_version, key.suite)).decryptEntry({
|
|
110
|
+
contentKey: old.contentKey,
|
|
111
|
+
iv: new Uint8Array(entry.iv),
|
|
112
|
+
ciphertext: new Uint8Array(entry.ciphertext),
|
|
113
|
+
keyID,
|
|
114
|
+
keyVersion: entry.key_version,
|
|
115
|
+
entryID: entry.entry_id
|
|
116
|
+
});
|
|
117
|
+
} finally{
|
|
118
|
+
old.contentKey.fill(0);
|
|
119
|
+
}
|
|
120
|
+
let retry;
|
|
121
|
+
try {
|
|
122
|
+
const expectedKey = await store.getKey(keyID);
|
|
123
|
+
const current = await openKey(keyID);
|
|
124
|
+
try {
|
|
125
|
+
const stamp = await nextHLC();
|
|
126
|
+
retry = await store.transaction(async (tx)=>{
|
|
127
|
+
await tx.lockKeyRows([
|
|
128
|
+
keyID
|
|
129
|
+
]);
|
|
130
|
+
const latestKey = await tx.getKey(keyID);
|
|
131
|
+
const latestEntry = await tx.getEntry(entry.entry_id, entry.owner_did);
|
|
132
|
+
if (latestKey == null || expectedKey == null || latestKey.version !== expectedKey.version || latestKey.suite !== expectedKey.suite || latestKey.version !== current.version || latestEntry?.hlc !== entry.hlc || latestEntry.key_version !== entry.key_version) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
const encrypted = getSuite(latestKey.suite).encryptEntry({
|
|
136
|
+
runtime,
|
|
137
|
+
contentKey: current.contentKey,
|
|
138
|
+
plaintext,
|
|
139
|
+
keyID,
|
|
140
|
+
keyVersion: latestKey.version,
|
|
141
|
+
entryID: entry.entry_id
|
|
142
|
+
});
|
|
143
|
+
await putEntryRecord({
|
|
144
|
+
entry: {
|
|
145
|
+
entryID: entry.entry_id,
|
|
146
|
+
keyID,
|
|
147
|
+
keyVersion: latestKey.version,
|
|
148
|
+
...encrypted,
|
|
149
|
+
hlc: stamp
|
|
150
|
+
},
|
|
151
|
+
owner: latestKey.owner_did,
|
|
152
|
+
branchID: current.branchID,
|
|
153
|
+
contentHLC,
|
|
154
|
+
target: tx,
|
|
155
|
+
authority,
|
|
156
|
+
grant
|
|
157
|
+
});
|
|
158
|
+
return false;
|
|
159
|
+
});
|
|
160
|
+
} finally{
|
|
161
|
+
current.contentKey.fill(0);
|
|
162
|
+
}
|
|
163
|
+
} finally{
|
|
164
|
+
plaintext.fill(0);
|
|
165
|
+
}
|
|
166
|
+
if (!retry) break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
async putEntry (keyID, plaintext, grant) {
|
|
172
|
+
await checkRemovalRecovery();
|
|
173
|
+
if (grant !== undefined && grant.grantName === '') {
|
|
174
|
+
throw new CredentialInvalidEntry('Grant name is empty');
|
|
175
|
+
}
|
|
176
|
+
if (grant !== undefined && normalizeDID(grant.grantDeviceDID) !== normalizeDID(identity.id)) {
|
|
177
|
+
throw new CredentialNotAuthorized('administer', keyID);
|
|
178
|
+
}
|
|
179
|
+
for(;;){
|
|
180
|
+
const { contentKey, version, branchID } = await openKey(keyID);
|
|
181
|
+
let entry;
|
|
182
|
+
let candidateSuite;
|
|
183
|
+
try {
|
|
184
|
+
const key = await loadKey(keyID);
|
|
185
|
+
candidateSuite = key.suite;
|
|
186
|
+
if (grant === undefined) {
|
|
187
|
+
entry = buildCredentialEntry({
|
|
188
|
+
runtime,
|
|
189
|
+
key: {
|
|
190
|
+
keyID,
|
|
191
|
+
ownerDID: key.owner_did,
|
|
192
|
+
suite: key.suite,
|
|
193
|
+
version,
|
|
194
|
+
contentKey
|
|
195
|
+
},
|
|
196
|
+
plaintext
|
|
197
|
+
});
|
|
198
|
+
} else {
|
|
199
|
+
const entryID = credentialGrantEntryID(grant.grantOwnerDID ?? key.owner_did, grant.grantDeviceDID, grant.grantName);
|
|
200
|
+
const encrypted = getSuite(key.suite).encryptEntry({
|
|
201
|
+
runtime,
|
|
202
|
+
contentKey,
|
|
203
|
+
plaintext,
|
|
204
|
+
keyID,
|
|
205
|
+
keyVersion: version,
|
|
206
|
+
entryID
|
|
207
|
+
});
|
|
208
|
+
entry = {
|
|
209
|
+
entryID,
|
|
210
|
+
keyID,
|
|
211
|
+
keyVersion: version,
|
|
212
|
+
...encrypted
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
} finally{
|
|
216
|
+
contentKey.fill(0);
|
|
217
|
+
}
|
|
218
|
+
const entryParams = {
|
|
219
|
+
...entry,
|
|
220
|
+
hlc: await nextHLC()
|
|
221
|
+
};
|
|
222
|
+
const written = await store.transaction(async (tx)=>{
|
|
223
|
+
await tx.lockKeyRow(keyID);
|
|
224
|
+
const key = await tx.getKey(keyID);
|
|
225
|
+
if (key == null) throw new CredentialNotFound('Credential key', keyID);
|
|
226
|
+
if (key.version !== version || key.suite !== candidateSuite) return false;
|
|
227
|
+
await putEntryRecord({
|
|
228
|
+
entry: entryParams,
|
|
229
|
+
owner: key.owner_did,
|
|
230
|
+
branchID,
|
|
231
|
+
contentHLC: entryParams.hlc,
|
|
232
|
+
target: tx,
|
|
233
|
+
authority,
|
|
234
|
+
grant
|
|
235
|
+
});
|
|
236
|
+
return true;
|
|
237
|
+
});
|
|
238
|
+
if (written) return entry.entryID;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async updateEntry (address, plaintext) {
|
|
242
|
+
const { entryID, ownerDID } = requireEntryAddress(address);
|
|
243
|
+
await checkRemovalRecovery();
|
|
244
|
+
for(;;){
|
|
245
|
+
const existing = await store.getEntry(entryID, ownerDID);
|
|
246
|
+
if (existing == null) throw new CredentialNotFound('Credential entry', entryID);
|
|
247
|
+
const grant = await writableGrant(entryID, existing.owner_did);
|
|
248
|
+
const { contentKey, version, branchID } = await openKey(existing.key_id);
|
|
249
|
+
let encrypted;
|
|
250
|
+
let candidateSuite;
|
|
251
|
+
try {
|
|
252
|
+
candidateSuite = (await loadKey(existing.key_id)).suite;
|
|
253
|
+
encrypted = getSuite(candidateSuite).encryptEntry({
|
|
254
|
+
runtime,
|
|
255
|
+
contentKey,
|
|
256
|
+
plaintext,
|
|
257
|
+
keyID: existing.key_id,
|
|
258
|
+
keyVersion: version,
|
|
259
|
+
entryID
|
|
260
|
+
});
|
|
261
|
+
} finally{
|
|
262
|
+
contentKey.fill(0);
|
|
263
|
+
}
|
|
264
|
+
const updated = {
|
|
265
|
+
entryID,
|
|
266
|
+
keyID: existing.key_id,
|
|
267
|
+
keyVersion: version,
|
|
268
|
+
...encrypted,
|
|
269
|
+
hlc: await nextHLC()
|
|
270
|
+
};
|
|
271
|
+
const written = await store.transaction(async (tx)=>{
|
|
272
|
+
await tx.lockKeyRow(existing.key_id);
|
|
273
|
+
const key = await tx.getKey(existing.key_id);
|
|
274
|
+
if (key == null) throw new CredentialNotFound('Credential key', existing.key_id);
|
|
275
|
+
if (key.version !== version || key.suite !== candidateSuite) return false;
|
|
276
|
+
const current = await tx.getEntry(entryID, existing.owner_did);
|
|
277
|
+
if (current == null) throw new CredentialNotFound('Credential entry', entryID);
|
|
278
|
+
if (current.key_id !== existing.key_id) return false;
|
|
279
|
+
await putEntryRecord({
|
|
280
|
+
entry: updated,
|
|
281
|
+
owner: key.owner_did,
|
|
282
|
+
branchID,
|
|
283
|
+
contentHLC: updated.hlc,
|
|
284
|
+
target: tx,
|
|
285
|
+
authority,
|
|
286
|
+
grant
|
|
287
|
+
});
|
|
288
|
+
return true;
|
|
289
|
+
});
|
|
290
|
+
if (written) return;
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
async updateEntryIfPresent (address, plaintext) {
|
|
294
|
+
const { entryID, ownerDID } = requireEntryAddress(address);
|
|
295
|
+
await checkRemovalRecovery();
|
|
296
|
+
// Unconditional, so never superseded.
|
|
297
|
+
return await updateEntryConditionally({
|
|
298
|
+
entryID,
|
|
299
|
+
plaintext,
|
|
300
|
+
expectedHLC: null,
|
|
301
|
+
attempt: 1,
|
|
302
|
+
ownerDID
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
async updateEntryIfUnchanged (input) {
|
|
306
|
+
const { entryID, ownerDID } = requireEntryAddress(input);
|
|
307
|
+
const { plaintext, expectedHLC } = input;
|
|
308
|
+
await checkRemovalRecovery();
|
|
309
|
+
return await updateEntryConditionally({
|
|
310
|
+
entryID,
|
|
311
|
+
plaintext,
|
|
312
|
+
expectedHLC,
|
|
313
|
+
attempt: 1,
|
|
314
|
+
ownerDID
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
async readEntry (address) {
|
|
318
|
+
const { entryID, ownerDID } = requireEntryAddress(address);
|
|
319
|
+
const entry = await store.getEntry(entryID, ownerDID);
|
|
320
|
+
if (entry == null) {
|
|
321
|
+
throw new CredentialNotFound('Credential entry', entryID);
|
|
322
|
+
}
|
|
323
|
+
if (entryID.startsWith('grant:')) {
|
|
324
|
+
try {
|
|
325
|
+
await reencryptGrantEntries(entry.key_id);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (!(error instanceof UnsupportedCredentialSuite) && !(error instanceof CredentialUnopenable) && !(error instanceof CredentialKeyLocked)) {
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const currentEntry = await store.getEntry(entryID, entry.owner_did);
|
|
333
|
+
if (currentEntry == null) throw new CredentialNotFound('Credential entry', entryID);
|
|
334
|
+
const key = await store.getKey(currentEntry.key_id);
|
|
335
|
+
if (key == null) {
|
|
336
|
+
throw new CredentialNotFound('Credential key', entry.key_id);
|
|
337
|
+
}
|
|
338
|
+
if (currentEntry.key_version > key.version) {
|
|
339
|
+
throw new CredentialKeyVersionMismatch(entryID, currentEntry.key_version, key.version);
|
|
340
|
+
}
|
|
341
|
+
const { contentKey, branchID } = await openKey(currentEntry.key_id, undefined, currentEntry.key_version);
|
|
342
|
+
// Zero our private clone on every exit — the branch-mismatch throw
|
|
343
|
+
// and the decrypt alike.
|
|
344
|
+
try {
|
|
345
|
+
// The row's own label: a replicated entry record has no op row to read it from.
|
|
346
|
+
const entryBranchID = currentEntry.branch_id;
|
|
347
|
+
if (entryBranchID !== '' && branchID !== '' && entryBranchID !== branchID) {
|
|
348
|
+
const proofs = await branchProofs(currentEntry.key_id, currentEntry.key_version);
|
|
349
|
+
const entryProof = proofs.get(entryBranchID);
|
|
350
|
+
const winnerProof = proofs.get(branchID);
|
|
351
|
+
if (entryProof?.repairDigest === undefined || entryProof.repairDigest !== winnerProof?.repairDigest) {
|
|
352
|
+
throw new CredentialSupersededBranch(entryID, entryBranchID, branchID);
|
|
353
|
+
}
|
|
354
|
+
const ownBranchKey = await openSpecificBranch({
|
|
355
|
+
keyID: currentEntry.key_id,
|
|
356
|
+
version: currentEntry.key_version,
|
|
357
|
+
branchID: entryBranchID,
|
|
358
|
+
suiteID: entryProof.suite
|
|
359
|
+
});
|
|
360
|
+
if (ownBranchKey === null) throw new CredentialRepairPending(currentEntry.key_id);
|
|
361
|
+
try {
|
|
362
|
+
return getSuite(entryProof.suite).decryptEntry({
|
|
363
|
+
contentKey: ownBranchKey,
|
|
364
|
+
iv: new Uint8Array(currentEntry.iv),
|
|
365
|
+
ciphertext: new Uint8Array(currentEntry.ciphertext),
|
|
366
|
+
keyID: currentEntry.key_id,
|
|
367
|
+
keyVersion: currentEntry.key_version,
|
|
368
|
+
entryID
|
|
369
|
+
});
|
|
370
|
+
} finally{
|
|
371
|
+
ownBranchKey.fill(0);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return getSuite(await suiteForVersion(currentEntry.key_id, currentEntry.key_version, key.suite)).decryptEntry({
|
|
375
|
+
contentKey,
|
|
376
|
+
iv: new Uint8Array(currentEntry.iv),
|
|
377
|
+
ciphertext: new Uint8Array(currentEntry.ciphertext),
|
|
378
|
+
keyID: currentEntry.key_id,
|
|
379
|
+
keyVersion: currentEntry.key_version,
|
|
380
|
+
entryID
|
|
381
|
+
});
|
|
382
|
+
} finally{
|
|
383
|
+
contentKey.fill(0);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
async getEntryProvenance (address) {
|
|
387
|
+
const { entryID, ownerDID } = requireEntryAddress(address);
|
|
388
|
+
const entry = await store.getEntryWithProvenance(entryID, ownerDID);
|
|
389
|
+
if (entry == null) {
|
|
390
|
+
return {
|
|
391
|
+
updatedAt: null,
|
|
392
|
+
writerDID: null
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
const record = await entryRecord(entryID, store, entry.owner_did);
|
|
396
|
+
const op = record === null ? null : await verifySignedRecord(record);
|
|
397
|
+
return entryProvenanceFields({
|
|
398
|
+
hlc: entry.hlc,
|
|
399
|
+
author_did: op?.issuer ?? entry.author_did
|
|
400
|
+
});
|
|
401
|
+
},
|
|
402
|
+
async getEntryKeyOwnerDID (address) {
|
|
403
|
+
const { entryID, ownerDID } = requireEntryAddress(address);
|
|
404
|
+
const entry = await store.getEntry(entryID, ownerDID);
|
|
405
|
+
if (entry == null) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
const key = await store.getKey(entry.key_id, entry.owner_did);
|
|
409
|
+
return key == null ? null : key.owner_did;
|
|
410
|
+
},
|
|
411
|
+
async listEntries (keyID) {
|
|
412
|
+
await reencryptGrantEntries(keyID);
|
|
413
|
+
return (await store.listEntries(keyID)).map((entry)=>entry.entry_id);
|
|
414
|
+
},
|
|
415
|
+
reencryptGrantEntries,
|
|
416
|
+
async deleteEntry (address) {
|
|
417
|
+
const { entryID, ownerDID } = requireEntryAddress(address);
|
|
418
|
+
await checkRemovalRecovery();
|
|
419
|
+
for(;;){
|
|
420
|
+
const entry = await store.getEntry(entryID, ownerDID);
|
|
421
|
+
if (entry == null) return;
|
|
422
|
+
const key = await loadKey(entry.key_id);
|
|
423
|
+
if (!Number.isSafeInteger(entry.key_version) || entry.key_version < 0) {
|
|
424
|
+
throw new CredentialKeyVersionMismatch(entryID, entry.key_version, key.version);
|
|
425
|
+
}
|
|
426
|
+
const grant = await entryGrant(entryID, store, entry.owner_did);
|
|
427
|
+
const deletionHLC = await nextHLC();
|
|
428
|
+
const result = await store.transaction(async (tx)=>{
|
|
429
|
+
await tx.lockKeyRows([
|
|
430
|
+
entry.key_id
|
|
431
|
+
]);
|
|
432
|
+
const currentKey = await tx.getKey(entry.key_id);
|
|
433
|
+
if (currentKey == null) throw new CredentialNotFound('Credential key', entry.key_id);
|
|
434
|
+
const currentEntry = await tx.getEntry(entryID, entry.owner_did);
|
|
435
|
+
if (currentEntry == null) return 'done';
|
|
436
|
+
if (currentKey.version !== key.version || currentEntry.key_id !== entry.key_id || currentEntry.hlc !== entry.hlc) {
|
|
437
|
+
return 'retry';
|
|
438
|
+
}
|
|
439
|
+
const currentBranches = await tx.listKeyBranches(entry.key_id, key.version);
|
|
440
|
+
const deletionBranchID = currentEntry.key_version === key.version && currentEntry.branch_id !== '' ? currentEntry.branch_id : currentBranches.length > 0 ? await winningBranch(currentBranches) ?? '' : '';
|
|
441
|
+
await putRecord({
|
|
442
|
+
type: 'credential.entry',
|
|
443
|
+
id: entryID,
|
|
444
|
+
owner: currentKey.owner_did,
|
|
445
|
+
hlc: deletionHLC,
|
|
446
|
+
payload: null,
|
|
447
|
+
meta: {
|
|
448
|
+
keyID: entry.key_id,
|
|
449
|
+
keyVersion: key.version,
|
|
450
|
+
contentHLC: deletionHLC,
|
|
451
|
+
...deletionBranchID === '' ? {} : {
|
|
452
|
+
branchID: deletionBranchID,
|
|
453
|
+
branchRecordID: await branchRecordID({
|
|
454
|
+
keyID: entry.key_id,
|
|
455
|
+
version: key.version,
|
|
456
|
+
branchID: deletionBranchID,
|
|
457
|
+
target: tx
|
|
458
|
+
})
|
|
459
|
+
},
|
|
460
|
+
...grant === undefined ? {} : {
|
|
461
|
+
grantDeviceDID: normalizeDID(grant.grantDeviceDID),
|
|
462
|
+
grantName: grant.grantName,
|
|
463
|
+
grantOwnerDID: normalizeDID(grant.grantOwnerDID ?? currentKey.owner_did)
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
target: tx
|
|
467
|
+
});
|
|
468
|
+
return 'done';
|
|
469
|
+
});
|
|
470
|
+
if (result === 'done') return;
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
async reKeyEntry (input) {
|
|
474
|
+
const { entryID, ownerDID } = requireEntryAddress(input);
|
|
475
|
+
const { toKeyID } = input;
|
|
476
|
+
const params = input;
|
|
477
|
+
await checkRemovalRecovery();
|
|
478
|
+
const entry = await store.getEntry(entryID, ownerDID);
|
|
479
|
+
if (entry == null) {
|
|
480
|
+
throw new CredentialNotFound('Credential entry', entryID);
|
|
481
|
+
}
|
|
482
|
+
const grant = await writableGrant(entryID, entry.owner_did);
|
|
483
|
+
const fromKey = await loadKey(entry.key_id);
|
|
484
|
+
// Both keys resolved before either gate, so an absent target reports as
|
|
485
|
+
// missing rather than as something this identity may not administer.
|
|
486
|
+
const toKey = await loadKey(toKeyID);
|
|
487
|
+
for (const key of [
|
|
488
|
+
fromKey,
|
|
489
|
+
toKey
|
|
490
|
+
]){
|
|
491
|
+
if (!await (params?.authority ?? authority).authorize('administer', {
|
|
492
|
+
key,
|
|
493
|
+
store
|
|
494
|
+
})) {
|
|
495
|
+
throw new CredentialNotAuthorized('administer', key.key_id);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (normalizeDID(fromKey.owner_did) !== normalizeDID(toKey.owner_did)) {
|
|
499
|
+
throw new CredentialEntryOwnerMismatch(entryID, fromKey.owner_did, toKey.owner_did);
|
|
500
|
+
}
|
|
501
|
+
// The refusal `readEntry` makes, for the same reason: the content key this
|
|
502
|
+
// entry was written under is gone with the rotation that did not finish,
|
|
503
|
+
// so it cannot be carried anywhere, and decrypting it under the current
|
|
504
|
+
// one would fail at the tag and read as tampering.
|
|
505
|
+
if (entry.key_version !== fromKey.version) {
|
|
506
|
+
throw new CredentialKeyVersionMismatch(entryID, entry.key_version, fromKey.version);
|
|
507
|
+
}
|
|
508
|
+
// Both keys opened, and the plaintext recovered, before the single write.
|
|
509
|
+
// A target this device cannot open has to fail while the entry is still
|
|
510
|
+
// where it was.
|
|
511
|
+
const { contentKey: fromContentKey } = await openKey(entry.key_id, undefined, entry.key_version);
|
|
512
|
+
let plaintext;
|
|
513
|
+
try {
|
|
514
|
+
plaintext = getSuite(await suiteForVersion(entry.key_id, entry.key_version, fromKey.suite)).decryptEntry({
|
|
515
|
+
contentKey: fromContentKey,
|
|
516
|
+
iv: new Uint8Array(entry.iv),
|
|
517
|
+
ciphertext: new Uint8Array(entry.ciphertext),
|
|
518
|
+
keyID: entry.key_id,
|
|
519
|
+
keyVersion: entry.key_version,
|
|
520
|
+
entryID
|
|
521
|
+
});
|
|
522
|
+
} finally{
|
|
523
|
+
fromContentKey.fill(0);
|
|
524
|
+
}
|
|
525
|
+
let movedNow;
|
|
526
|
+
try {
|
|
527
|
+
const { contentKey: toContentKey, version, branchID } = await openKey(toKeyID);
|
|
528
|
+
// The target's CURRENT version, as an update is: landing on an older one
|
|
529
|
+
// would strand the entry the moment the key rotates.
|
|
530
|
+
let reEncrypted;
|
|
531
|
+
try {
|
|
532
|
+
reEncrypted = getSuite(toKey.suite).encryptEntry({
|
|
533
|
+
runtime,
|
|
534
|
+
contentKey: toContentKey,
|
|
535
|
+
plaintext,
|
|
536
|
+
keyID: toKeyID,
|
|
537
|
+
keyVersion: version,
|
|
538
|
+
entryID
|
|
539
|
+
});
|
|
540
|
+
} finally{
|
|
541
|
+
toContentKey.fill(0);
|
|
542
|
+
}
|
|
543
|
+
const { iv, ciphertext } = reEncrypted;
|
|
544
|
+
const moved = {
|
|
545
|
+
entryID,
|
|
546
|
+
keyID: toKeyID,
|
|
547
|
+
keyVersion: version,
|
|
548
|
+
iv,
|
|
549
|
+
ciphertext,
|
|
550
|
+
hlc: await nextHLC()
|
|
551
|
+
};
|
|
552
|
+
movedNow = await store.transaction(async (tx)=>{
|
|
553
|
+
// A move touches both key scopes; the store orders advisory IDs.
|
|
554
|
+
await tx.lockKeyRows([
|
|
555
|
+
entry.key_id,
|
|
556
|
+
toKeyID
|
|
557
|
+
]);
|
|
558
|
+
const currentFrom = await tx.getKey(entry.key_id);
|
|
559
|
+
const currentTo = await tx.getKey(toKeyID);
|
|
560
|
+
const currentEntry = await tx.getEntry(entryID, entry.owner_did);
|
|
561
|
+
if (currentFrom == null || currentTo == null || currentEntry == null) return false;
|
|
562
|
+
if (currentFrom.version !== fromKey.version || currentTo.version !== version || currentEntry.key_id !== entry.key_id || currentEntry.hlc !== entry.hlc) {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
await putEntryRecord({
|
|
566
|
+
entry: moved,
|
|
567
|
+
owner: currentTo.owner_did,
|
|
568
|
+
branchID,
|
|
569
|
+
contentHLC: moved.hlc,
|
|
570
|
+
target: tx,
|
|
571
|
+
authority: params?.authority ?? authority,
|
|
572
|
+
grant
|
|
573
|
+
});
|
|
574
|
+
return true;
|
|
575
|
+
});
|
|
576
|
+
} finally{
|
|
577
|
+
plaintext.fill(0);
|
|
578
|
+
}
|
|
579
|
+
if (!movedNow) await internals.manager.reKeyEntry({
|
|
580
|
+
...input,
|
|
581
|
+
ownerDID: entry.owner_did
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type ManagerContext, type ManagerInternals } from './manager-context.js';
|
|
2
|
+
import type { CredentialManager } from './manager-types.js';
|
|
3
|
+
export declare function createKeyMethods(ctx: ManagerContext, internals: ManagerInternals): Pick<CredentialManager, 'availableFactors' | 'unlock' | 'addFactor' | 'removeFactor' | 'lock' | 'lockAll' | 'status' | 'createKey' | 'rotate' | 'revoke' | 'grant' | 'listKeys' | 'countWrappings' | 'deleteKey'>;
|