@kubun/credential 0.13.0 → 0.14.1
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 +69 -0
- package/lib/api.js +124 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +2 -0
- package/lib/key-cache.d.ts +6 -0
- package/lib/key-cache.js +32 -11
- package/lib/manager.js +338 -188
- package/lib/provider.d.ts +24 -0
- package/lib/provider.js +129 -0
- package/package.json +11 -9
package/lib/api.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type MethodRegistry } from '@kokuin/token';
|
|
2
|
+
import type { StoreProvider } from '@kubun/db';
|
|
3
|
+
import type { Logger } from '@kubun/logger';
|
|
4
|
+
import type { CredentialManager } from './manager.js';
|
|
5
|
+
/**
|
|
6
|
+
* The credential seam: resolves its `(providerName, ownerDID) → entryID` pointer
|
|
7
|
+
* through the alias layer of the credential store, and its entries through a
|
|
8
|
+
* `CredentialManager`. Every `ownerDID` is normalized before it reaches the
|
|
9
|
+
* alias, so an un-normalized equality key cannot split one account across two
|
|
10
|
+
* rows: a credential invisible under one spelling and un-revokable under the
|
|
11
|
+
* other.
|
|
12
|
+
*/
|
|
13
|
+
export type CredentialAPI = {
|
|
14
|
+
getCredential: (providerName: string, ownerDID: string) => Promise<string | null>;
|
|
15
|
+
getCredentialProvenance: (providerName: string, ownerDID: string) => Promise<{
|
|
16
|
+
updatedAt: string | null;
|
|
17
|
+
writerDID: string | null;
|
|
18
|
+
} | null>;
|
|
19
|
+
/**
|
|
20
|
+
* Re-encrypt an existing credential. Refuses when none exists, because
|
|
21
|
+
* creating one is a different decision — see `createCredential`.
|
|
22
|
+
*/
|
|
23
|
+
setCredential: (providerName: string, ownerDID: string, credential: string) => Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Mint the key, its two wrappings and the entry, and record the pointer.
|
|
26
|
+
*
|
|
27
|
+
* Split from `setCredential` so the headless refresh path cannot mint a key.
|
|
28
|
+
* A minted key fixes who will ever be able to read it, and a refresh running
|
|
29
|
+
* with no owner in hand would mint one wrapped to the server alone — locking
|
|
30
|
+
* the account owner out of their own credential with nothing failing.
|
|
31
|
+
*/
|
|
32
|
+
createCredential: (params: {
|
|
33
|
+
providerName: string;
|
|
34
|
+
ownerDID: string;
|
|
35
|
+
ownerWrappableDID: string;
|
|
36
|
+
controllerWrappableDID?: string;
|
|
37
|
+
credential: string;
|
|
38
|
+
}) => Promise<void>;
|
|
39
|
+
deleteCredential: (providerName: string, ownerDID: string) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* The controller resolvers for this API's (request-scoped) transaction
|
|
42
|
+
* provider — the exact `methods` the credential mint passes to `createKey`.
|
|
43
|
+
* Exposed so the OAuth pre-flight can ask `canWrapTo` with the same resolver
|
|
44
|
+
* the mint uses, rather than failing closed on a `did:kokuin:` owner whose
|
|
45
|
+
* agreement key is resolvable only through the controller resolver.
|
|
46
|
+
*/
|
|
47
|
+
getControllerMethods: () => MethodRegistry;
|
|
48
|
+
};
|
|
49
|
+
export type CreateCredentialAPIParams = {
|
|
50
|
+
stores: StoreProvider;
|
|
51
|
+
logger: Logger;
|
|
52
|
+
/**
|
|
53
|
+
* Takes the `StoreProvider` rather than a manager, because during a mutation
|
|
54
|
+
* that provider is the transaction and a manager built over the base one
|
|
55
|
+
* would read outside it.
|
|
56
|
+
*/
|
|
57
|
+
getCredentialManager: (stores: StoreProvider) => Promise<CredentialManager>;
|
|
58
|
+
/** The engine's own DID, in a form that can be encrypted to. */
|
|
59
|
+
serverWrappableDID: string;
|
|
60
|
+
/**
|
|
61
|
+
* Resolvers for wrapping recipients that carry no key material of their own
|
|
62
|
+
* (`did:kokuin:`), scoped to the request's transaction provider so a resolve
|
|
63
|
+
* reads the mutation's tx, never a second connection. A self-contained
|
|
64
|
+
* recipient resolves with an empty registry, so callers with no controller
|
|
65
|
+
* recipient in play still pass `() => []`.
|
|
66
|
+
*/
|
|
67
|
+
getControllerMethods: (stores: StoreProvider) => MethodRegistry;
|
|
68
|
+
};
|
|
69
|
+
export declare function createCredentialAPI(params: CreateCredentialAPIParams): CredentialAPI;
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { getCredentialStore } from '@kubun/store-credential';
|
|
3
|
+
import { fromUTF, toUTF } from '@sozai/codec';
|
|
4
|
+
import { CredentialSupersededBranch } from './errors.js';
|
|
5
|
+
export function createCredentialAPI(params) {
|
|
6
|
+
const getAliasStore = ()=>getCredentialStore(params.stores);
|
|
7
|
+
const getCredentials = ()=>params.getCredentialManager(params.stores);
|
|
8
|
+
return {
|
|
9
|
+
async getCredential (providerName, ownerDID) {
|
|
10
|
+
const store = await getAliasStore();
|
|
11
|
+
const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
|
|
12
|
+
if (entryID == null) return null;
|
|
13
|
+
try {
|
|
14
|
+
return toUTF(await (await getCredentials()).readEntry(entryID));
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error instanceof CredentialSupersededBranch) {
|
|
17
|
+
params.logger.warn('Credential entry {entryID} for provider {providerName} belongs to a superseded branch', {
|
|
18
|
+
providerName,
|
|
19
|
+
entryID
|
|
20
|
+
});
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
async getCredentialProvenance (providerName, ownerDID) {
|
|
27
|
+
// Reflects the current pointer, not the winning branch — no superseded-branch guard
|
|
28
|
+
// (it never decrypts). Callers must gate on getCredential() != null so a loser-branch
|
|
29
|
+
// pointer's provenance is never surfaced.
|
|
30
|
+
const store = await getAliasStore();
|
|
31
|
+
const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
|
|
32
|
+
if (entryID == null) return null;
|
|
33
|
+
return (await getCredentials()).getEntryProvenance(entryID);
|
|
34
|
+
},
|
|
35
|
+
async setCredential (providerName, ownerDID, credential) {
|
|
36
|
+
const store = await getAliasStore();
|
|
37
|
+
const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
|
|
38
|
+
if (entryID == null) {
|
|
39
|
+
throw new Error(`No connector credential to update for provider "${providerName}"; create it through the authorization flow`);
|
|
40
|
+
}
|
|
41
|
+
// Opens the key through the server's own wrapping and rewrites the entry
|
|
42
|
+
// under it. No key is minted and no wrapping is touched, so a refresh
|
|
43
|
+
// cannot change who can read the credential.
|
|
44
|
+
await (await getCredentials()).updateEntry(entryID, fromUTF(credential));
|
|
45
|
+
},
|
|
46
|
+
async createCredential (input) {
|
|
47
|
+
const { providerName, ownerDID, ownerWrappableDID, credential } = input;
|
|
48
|
+
const store = await getAliasStore();
|
|
49
|
+
const credentials = await getCredentials();
|
|
50
|
+
// Wrapping to a DID needs only that DID's published key, so an engine that
|
|
51
|
+
// cannot decrypt would mint this key perfectly and then be unable to read
|
|
52
|
+
// it — a failure that surfaces at the first headless sync, far from here.
|
|
53
|
+
// This is where `cipher == null` used to fail closed, and it still does.
|
|
54
|
+
if (!credentials.availableFactors().includes('did')) {
|
|
55
|
+
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`);
|
|
56
|
+
}
|
|
57
|
+
// Two wrappings from the first write, not one plus a later grant: the
|
|
58
|
+
// server has to read this to run sync, and the owner has to be able to
|
|
59
|
+
// rotate and revoke it. Owner is authority, a different question from who
|
|
60
|
+
// can decrypt: when a controller is supplied it is the owner, so a device
|
|
61
|
+
// revoke goes through the controller's capability chain rather than the
|
|
62
|
+
// device self-authorizing; absent one the device owns its own key.
|
|
63
|
+
//
|
|
64
|
+
// The two spellings below are deliberately different questions about the
|
|
65
|
+
// same identity, and NOT interchangeable: `owner_did` is an equality key
|
|
66
|
+
// and so is normalized, while a recipient is encrypted to and must keep
|
|
67
|
+
// its wrappable form. `ownerWrappableDID` is therefore NOT normalized.
|
|
68
|
+
// Normalizing a `did:peer:4` recipient hands the short form to
|
|
69
|
+
// `deriveSharedSecret`, which resolves no document and no agreement key —
|
|
70
|
+
// do not fold these together.
|
|
71
|
+
const wrappings = [
|
|
72
|
+
[
|
|
73
|
+
{
|
|
74
|
+
kind: 'did',
|
|
75
|
+
recipientDID: ownerWrappableDID
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
{
|
|
80
|
+
kind: 'did',
|
|
81
|
+
recipientDID: params.serverWrappableDID
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
];
|
|
85
|
+
// A recovery recipient (e.g. a controller / seed-holder), so the credential
|
|
86
|
+
// survives device loss. Opt-in: absent when the caller supplied none. Kept
|
|
87
|
+
// in its wrappable form for the same reason as the two above — it is
|
|
88
|
+
// encrypted to, not compared against.
|
|
89
|
+
if (input.controllerWrappableDID != null) {
|
|
90
|
+
wrappings.push([
|
|
91
|
+
{
|
|
92
|
+
kind: 'did',
|
|
93
|
+
recipientDID: input.controllerWrappableDID
|
|
94
|
+
}
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
// The controller owns the key when supplied (its canonical DID equals the
|
|
98
|
+
// normalized wrappable form); the account pointer below stays keyed on the
|
|
99
|
+
// device — ownership and account scoping are separate concerns.
|
|
100
|
+
const keyOwnerDID = input.controllerWrappableDID != null ? normalizeDID(input.controllerWrappableDID) : normalizeDID(ownerDID);
|
|
101
|
+
const keyID = await credentials.createKey({
|
|
102
|
+
ownerDID: keyOwnerDID,
|
|
103
|
+
wrappings,
|
|
104
|
+
methods: params.getControllerMethods(params.stores)
|
|
105
|
+
});
|
|
106
|
+
const entryID = await credentials.putEntry(keyID, fromUTF(credential));
|
|
107
|
+
await store.setAliasEntryID(providerName, normalizeDID(ownerDID), entryID);
|
|
108
|
+
},
|
|
109
|
+
async deleteCredential (providerName, ownerDID) {
|
|
110
|
+
const store = await getAliasStore();
|
|
111
|
+
const owner = normalizeDID(ownerDID);
|
|
112
|
+
const entryID = await store.getAliasEntryID(providerName, owner);
|
|
113
|
+
await store.deleteAliasEntryID(providerName, owner);
|
|
114
|
+
if (entryID != null) {
|
|
115
|
+
// The pointer and the entry go together. Leaving the entry would keep
|
|
116
|
+
// the token readable by anyone who kept its id after a disconnect.
|
|
117
|
+
await (await getCredentials()).deleteEntry(entryID);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
getControllerMethods () {
|
|
121
|
+
return params.getControllerMethods(params.stores);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { CreateCredentialAPIParams, CredentialAPI } from './api.js';
|
|
2
|
+
export { createCredentialAPI } from './api.js';
|
|
1
3
|
export type { CredentialAuthority, CredentialAuthorityParams, CredentialAuthorization, } from './authority.js';
|
|
2
4
|
export { createDeviceAuthority } from './authority.js';
|
|
3
5
|
export type { BuildEntryParams, BuildKeyParams, BuiltEntry, BuiltKey, BuiltWrapping, CredentialKeyMaterial, } from './builder.js';
|
|
@@ -14,6 +16,8 @@ export type { PinThrottle, PinThrottleParams } from './pin-throttle.js';
|
|
|
14
16
|
export { createPinThrottle, DEFAULT_BASE_DELAY, DEFAULT_FREE_ATTEMPTS, DEFAULT_MAX_DELAY, } from './pin-throttle.js';
|
|
15
17
|
export type { CredentialEntryProvenance } from './provenance.js';
|
|
16
18
|
export { entryProvenanceFields } from './provenance.js';
|
|
19
|
+
export type { DBCredentialProviderParams } from './provider.js';
|
|
20
|
+
export { DBCredentialProvider, deserializeCredential, serializeCredential } from './provider.js';
|
|
17
21
|
export type { Argon2Cost, CredentialSuite, FactorInput, FactorInputs, SecretFactorKind, } from './suite.js';
|
|
18
22
|
export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
|
|
19
23
|
export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { createCredentialAPI } from './api.js';
|
|
1
2
|
export { createDeviceAuthority } from './authority.js';
|
|
2
3
|
export { buildCredentialEntry, buildCredentialKey } from './builder.js';
|
|
3
4
|
export { credentialEntriesDigest, credentialEntryDigest, credentialKeyDigest, credentialWrappingDigest, digestParts } from './digest.js';
|
|
@@ -6,5 +7,6 @@ export { createCredentialManager } from './manager.js';
|
|
|
6
7
|
export { compareBranchIDs, createCredentialOpSigner, credentialOpMatchesPut, credentialOpMatchesRow, credentialPutDigest, credentialRowDigest, selectWinningBranch, verifyCredentialOp } from './op.js';
|
|
7
8
|
export { createPinThrottle, DEFAULT_BASE_DELAY, DEFAULT_FREE_ATTEMPTS, DEFAULT_MAX_DELAY } from './pin-throttle.js';
|
|
8
9
|
export { entryProvenanceFields } from './provenance.js';
|
|
10
|
+
export { DBCredentialProvider, deserializeCredential, serializeCredential } from './provider.js';
|
|
9
11
|
export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
|
|
10
12
|
export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
|
package/lib/key-cache.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export type KeyCache = {
|
|
|
27
27
|
contentKey: Uint8Array;
|
|
28
28
|
branchID: string;
|
|
29
29
|
} | null;
|
|
30
|
+
/**
|
|
31
|
+
* Whether an unexpired, current-version key is cached, without materialising a
|
|
32
|
+
* copy of it. Presence-only callers (`status`) use this so a probe never mints
|
|
33
|
+
* a secret buffer it immediately discards.
|
|
34
|
+
*/
|
|
35
|
+
has(keyID: string, keyVersion: number): boolean;
|
|
30
36
|
set(keyID: string, contentKey: Uint8Array, keyVersion: number, branchID: string): void;
|
|
31
37
|
evict(keyID: string): void;
|
|
32
38
|
evictAll(): void;
|
package/lib/key-cache.js
CHANGED
|
@@ -8,8 +8,8 @@ export function createKeyCache(params = {}) {
|
|
|
8
8
|
*
|
|
9
9
|
* Dropping the entry alone leaves the bytes wherever they are until a
|
|
10
10
|
* collector reaches them. Zeroing is the part that can be guaranteed;
|
|
11
|
-
* JavaScript cannot promise no interpreter-level copy survives,
|
|
12
|
-
*
|
|
11
|
+
* JavaScript cannot promise no interpreter-level copy survives, so this is
|
|
12
|
+
* best-effort hygiene, not a guarantee that no copy of the key remains.
|
|
13
13
|
*/ function evict(keyID) {
|
|
14
14
|
const cached = entries.get(keyID);
|
|
15
15
|
if (cached != null) {
|
|
@@ -17,27 +17,48 @@ export function createKeyCache(params = {}) {
|
|
|
17
17
|
entries.delete(keyID);
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The still-valid cached entry for this key/version, or null — evicting a
|
|
22
|
+
* stale one on the way. A rotation invalidates the cache as surely as time
|
|
23
|
+
* does: serving the pre-rotation key would decrypt entries the rotation was
|
|
24
|
+
* retiring. Shared by `get` and `has` so both apply identical validity and
|
|
25
|
+
* lazy-eviction rules; neither exposes the buffer.
|
|
26
|
+
*/ function live(keyID, keyVersion) {
|
|
27
|
+
const cached = entries.get(keyID);
|
|
28
|
+
if (cached == null) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (now() - cached.unlockedAt >= ttl || cached.keyVersion !== keyVersion) {
|
|
32
|
+
evict(keyID);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
20
37
|
return {
|
|
21
38
|
get (keyID, keyVersion) {
|
|
22
|
-
const cached =
|
|
39
|
+
const cached = live(keyID, keyVersion);
|
|
23
40
|
if (cached == null) {
|
|
24
41
|
return null;
|
|
25
42
|
}
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
43
|
+
// The caller receives a private copy, never the cache's own buffer: the
|
|
44
|
+
// cache reserves the right to zero what it holds (on evict, replacement,
|
|
45
|
+
// expiry or lock), and a returned alias would let that zeroing corrupt a
|
|
46
|
+
// consumer still holding the key across an await.
|
|
32
47
|
return {
|
|
33
|
-
contentKey: cached.contentKey,
|
|
48
|
+
contentKey: cached.contentKey.slice(),
|
|
34
49
|
branchID: cached.branchID
|
|
35
50
|
};
|
|
36
51
|
},
|
|
52
|
+
has (keyID, keyVersion) {
|
|
53
|
+
return live(keyID, keyVersion) != null;
|
|
54
|
+
},
|
|
37
55
|
set (keyID, contentKey, keyVersion, branchID) {
|
|
38
56
|
evict(keyID);
|
|
57
|
+
// Store a private clone so the caller's buffer is never the cached buffer:
|
|
58
|
+
// the caller may zero its own copy after use without disturbing the cache,
|
|
59
|
+
// and the cache may zero its clone on evict without disturbing the caller.
|
|
39
60
|
entries.set(keyID, {
|
|
40
|
-
contentKey,
|
|
61
|
+
contentKey: contentKey.slice(),
|
|
41
62
|
keyVersion,
|
|
42
63
|
branchID,
|
|
43
64
|
unlockedAt: now()
|
package/lib/manager.js
CHANGED
|
@@ -28,6 +28,21 @@ export function createCredentialManager(params) {
|
|
|
28
28
|
...params.pinThrottle,
|
|
29
29
|
now: params.now
|
|
30
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 inFlight = new Map();
|
|
40
|
+
const keyGeneration = new Map();
|
|
41
|
+
let lockAllEpoch = 0;
|
|
42
|
+
// Input-bearing opens (a supplied pin/passphrase) never dedup: their outcome
|
|
43
|
+
// and the PIN throttle are caller-specific, so one caller's attempt must not
|
|
44
|
+
// stand in for another's.
|
|
45
|
+
const hasSuppliedInputs = (inputs)=>inputs != null && Object.values(inputs).some((value)=>value != null);
|
|
31
46
|
let floorPromise;
|
|
32
47
|
/**
|
|
33
48
|
* Floor the shared clock to the greatest stored entry HLC, once per manager.
|
|
@@ -151,18 +166,27 @@ export function createCredentialManager(params) {
|
|
|
151
166
|
const key = await loadKey(keyID);
|
|
152
167
|
const existing = await ownWrapping(keyID, key.version);
|
|
153
168
|
const { contentKey, branchID } = await openKey(keyID, inputs);
|
|
154
|
-
const factors = change(factorsFromDescriptors(existing.factors, inputs));
|
|
155
169
|
const suite = getSuite(key.suite);
|
|
156
170
|
const wrappingID = runtime.getRandomID();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
// `contentKey` is our own private clone from the cache; zero it once its use — this
|
|
172
|
+
// rewrap — is done, on success or throw. The `try` opens right after the
|
|
173
|
+
// acquisition so the caller-supplied `change` throwing also cannot leak it.
|
|
174
|
+
let wrapped;
|
|
175
|
+
try {
|
|
176
|
+
const factors = change(factorsFromDescriptors(existing.factors, inputs));
|
|
177
|
+
wrapped = await suite.wrap({
|
|
178
|
+
runtime,
|
|
179
|
+
contentKey,
|
|
180
|
+
factors,
|
|
181
|
+
wrappingID,
|
|
182
|
+
keyID,
|
|
183
|
+
keyVersion: key.version,
|
|
184
|
+
methods
|
|
185
|
+
});
|
|
186
|
+
} finally{
|
|
187
|
+
contentKey.fill(0);
|
|
188
|
+
}
|
|
189
|
+
const { descriptors, iv, wrappedKey } = wrapped;
|
|
166
190
|
const replacement = {
|
|
167
191
|
wrappingID,
|
|
168
192
|
keyID,
|
|
@@ -197,7 +221,17 @@ export function createCredentialManager(params) {
|
|
|
197
221
|
* failure this is depends on whether any of them COULD have opened with input
|
|
198
222
|
* this device is able to ask for — locked and unopenable are the same event
|
|
199
223
|
* with opposite remedies.
|
|
200
|
-
*/
|
|
224
|
+
*/ /**
|
|
225
|
+
* Recover a key's content key. Wrapped by `openKey`, which adds the input-free
|
|
226
|
+
* dedup; this is the actual open. Captures the lock generation and epoch at
|
|
227
|
+
* entry so a `lock`/`lockAll` that lands before the terminal `cache.set` can
|
|
228
|
+
* suppress it — the key is still returned to this caller, but the cache is not
|
|
229
|
+
* re-warmed behind a lock.
|
|
230
|
+
*/ async function doOpenKey(keyID, inputs, genAtStart, epochAtStart) {
|
|
231
|
+
// `genAtStart`/`epochAtStart` are captured by `openKey` at the moment the
|
|
232
|
+
// caller entered — before any dedup wait — so a lock that lands while a
|
|
233
|
+
// waiter is queued still suppresses that waiter's eventual re-warm.
|
|
234
|
+
const lockedSince = ()=>(keyGeneration.get(keyID) ?? 0) !== genAtStart || lockAllEpoch !== epochAtStart;
|
|
201
235
|
const key = await loadKey(keyID);
|
|
202
236
|
const proven = new Set((await store.listKeyBranches(keyID, key.version)).map((branch)=>branch.branch_id));
|
|
203
237
|
const winner = proven.size >= 2 ? selectWinningBranch([
|
|
@@ -212,6 +246,9 @@ export function createCredentialManager(params) {
|
|
|
212
246
|
branchID: cached.branchID
|
|
213
247
|
};
|
|
214
248
|
}
|
|
249
|
+
// A fork-losing hit: `cached.contentKey` is our private clone that
|
|
250
|
+
// we are about to discard for a re-unwrap, so zero it rather than orphan it.
|
|
251
|
+
cached.contentKey.fill(0);
|
|
215
252
|
cache.evict(keyID);
|
|
216
253
|
}
|
|
217
254
|
const suite = getSuite(key.suite);
|
|
@@ -258,7 +295,11 @@ export function createCredentialManager(params) {
|
|
|
258
295
|
// winner as the resolved branch converges post-fork writes even when an
|
|
259
296
|
// empty-label fallback wrapping is what actually opened.
|
|
260
297
|
const branchID = winner ?? wrapping.branch_id;
|
|
261
|
-
|
|
298
|
+
// Suppress the re-warm if a lock landed while we were unwrapping: the
|
|
299
|
+
// caller still gets its key, but the cache stays cleared.
|
|
300
|
+
if (!lockedSince()) {
|
|
301
|
+
cache.set(keyID, contentKey, key.version, branchID);
|
|
302
|
+
}
|
|
262
303
|
return {
|
|
263
304
|
contentKey,
|
|
264
305
|
version: key.version,
|
|
@@ -289,6 +330,55 @@ export function createCredentialManager(params) {
|
|
|
289
330
|
}
|
|
290
331
|
throw new CredentialUnopenable(keyID, reasons.join('; '));
|
|
291
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Open a key, collapsing concurrent input-free opens of the same key into one
|
|
335
|
+
* unwrap. A waiter shares only the fact that an open ran, then takes its own
|
|
336
|
+
* clone from the cache; on a miss — the leader hit a lock and skipped its set,
|
|
337
|
+
* or failed — it falls through to its own open. Input-bearing opens never join
|
|
338
|
+
* the herd (see `hasSuppliedInputs`).
|
|
339
|
+
*/ async function openKey(keyID, inputs) {
|
|
340
|
+
// Captured once, at entry, so a lock landing while this call is queued behind
|
|
341
|
+
// another open still counts against it (see `doOpenKey`).
|
|
342
|
+
const genAtStart = keyGeneration.get(keyID) ?? 0;
|
|
343
|
+
const epochAtStart = lockAllEpoch;
|
|
344
|
+
if (hasSuppliedInputs(inputs)) {
|
|
345
|
+
return doOpenKey(keyID, inputs, genAtStart, epochAtStart);
|
|
346
|
+
}
|
|
347
|
+
// Join any open already in flight for this key; on a miss, re-contend for
|
|
348
|
+
// leadership rather than opening in parallel — so post-miss opens (a leader
|
|
349
|
+
// that failed or was lock-suppressed) serialize into one leader at a time
|
|
350
|
+
// instead of re-forming the herd.
|
|
351
|
+
while(true){
|
|
352
|
+
const pending = inFlight.get(keyID);
|
|
353
|
+
if (pending == null) {
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
await pending;
|
|
357
|
+
const key = await loadKey(keyID);
|
|
358
|
+
const hit = cache.get(keyID, key.version);
|
|
359
|
+
if (hit != null) {
|
|
360
|
+
return {
|
|
361
|
+
contentKey: hit.contentKey,
|
|
362
|
+
version: key.version,
|
|
363
|
+
branchID: hit.branchID
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const run = doOpenKey(keyID, inputs, genAtStart, epochAtStart);
|
|
368
|
+
// A gate that never rejects, so a waiter's `await pending` cannot throw the
|
|
369
|
+
// leader's error into the wrong caller; the waiter re-derives its own result
|
|
370
|
+
// from the cache (or its own open) after the gate settles.
|
|
371
|
+
const gate = run.then(()=>undefined, ()=>undefined);
|
|
372
|
+
inFlight.set(keyID, gate);
|
|
373
|
+
try {
|
|
374
|
+
return await run;
|
|
375
|
+
} finally{
|
|
376
|
+
// Only clear our own entry — a later leader may already have replaced it.
|
|
377
|
+
if (inFlight.get(keyID) === gate) {
|
|
378
|
+
inFlight.delete(keyID);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
292
382
|
return {
|
|
293
383
|
availableFactors () {
|
|
294
384
|
// A passphrase or pin needs no key material, only someone to ask. They are
|
|
@@ -302,7 +392,9 @@ export function createCredentialManager(params) {
|
|
|
302
392
|
},
|
|
303
393
|
async unlock (keyID, inputs) {
|
|
304
394
|
if (inputs?.pin == null) {
|
|
305
|
-
|
|
395
|
+
// Unlock only warms the cache; the returned clone is ours to zero.
|
|
396
|
+
;
|
|
397
|
+
(await openKey(keyID, inputs)).contentKey.fill(0);
|
|
306
398
|
return;
|
|
307
399
|
}
|
|
308
400
|
// Refused BEFORE `openKey`, so a throttled attempt never reaches Argon2.
|
|
@@ -316,7 +408,8 @@ export function createCredentialManager(params) {
|
|
|
316
408
|
});
|
|
317
409
|
}
|
|
318
410
|
try {
|
|
319
|
-
|
|
411
|
+
;
|
|
412
|
+
(await openKey(keyID, inputs)).contentKey.fill(0);
|
|
320
413
|
} catch (error) {
|
|
321
414
|
// Only a rejected secret advances the counter. A key that is simply not
|
|
322
415
|
// addressed to this device, or whose suite is unknown, is not someone
|
|
@@ -378,9 +471,13 @@ export function createCredentialManager(params) {
|
|
|
378
471
|
}, params?.methods);
|
|
379
472
|
},
|
|
380
473
|
lock (keyID) {
|
|
474
|
+
// Bump before evicting so an `openKey` already past its generation capture
|
|
475
|
+
// sees the change and skips its `cache.set`.
|
|
476
|
+
keyGeneration.set(keyID, (keyGeneration.get(keyID) ?? 0) + 1);
|
|
381
477
|
cache.evict(keyID);
|
|
382
478
|
},
|
|
383
479
|
lockAll () {
|
|
480
|
+
lockAllEpoch += 1;
|
|
384
481
|
cache.evictAll();
|
|
385
482
|
},
|
|
386
483
|
async status (keyID) {
|
|
@@ -397,7 +494,7 @@ export function createCredentialManager(params) {
|
|
|
397
494
|
reason: 'unsupported-suite'
|
|
398
495
|
};
|
|
399
496
|
}
|
|
400
|
-
if (cache.
|
|
497
|
+
if (cache.has(keyID, key.version)) {
|
|
401
498
|
return {
|
|
402
499
|
state: 'unlocked'
|
|
403
500
|
};
|
|
@@ -461,6 +558,10 @@ export function createCredentialManager(params) {
|
|
|
461
558
|
wrappings,
|
|
462
559
|
methods: createParams.methods
|
|
463
560
|
});
|
|
561
|
+
// The wrappings already encapsulate the content key; createKey never reads
|
|
562
|
+
// `built.contentKey` again, so zero it rather than leave the generated
|
|
563
|
+
// secret live until collection.
|
|
564
|
+
built.contentKey.fill(0);
|
|
464
565
|
const keyParams = {
|
|
465
566
|
keyID: built.keyID,
|
|
466
567
|
ownerDID: built.ownerDID,
|
|
@@ -508,121 +609,134 @@ export function createCredentialManager(params) {
|
|
|
508
609
|
// read, has to fail while the store is still untouched — past the first
|
|
509
610
|
// write there is nothing here that could put it back.
|
|
510
611
|
const previousKey = (await openKey(keyID, rotateParams?.inputs)).contentKey;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
entryID: entry.entry_id
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const rotatedKey = {
|
|
541
|
-
keyID,
|
|
542
|
-
ownerDID: key.owner_did,
|
|
543
|
-
suite: suiteID,
|
|
544
|
-
version: newVersion,
|
|
545
|
-
state: key.state === 'retired' ? 'retired' : 'active'
|
|
546
|
-
};
|
|
547
|
-
const rotatedKeyOp = await ops.key(rotatedKey, await nextHLC());
|
|
548
|
-
const branchID = rotatedKeyOp.branchID;
|
|
549
|
-
await store.recordKeyBranch({
|
|
550
|
-
keyID,
|
|
551
|
-
keyVersion: newVersion,
|
|
552
|
-
authorDID: rotatedKeyOp.authorDID,
|
|
553
|
-
hlc: rotatedKeyOp.hlc,
|
|
554
|
-
opJWT: rotatedKeyOp.opJWT,
|
|
555
|
-
opHash: rotatedKeyOp.opHash,
|
|
556
|
-
branchID: rotatedKeyOp.branchID
|
|
557
|
-
});
|
|
558
|
-
for (const { entryID, plaintext } of plaintexts){
|
|
559
|
-
const { iv, ciphertext } = suite.encryptEntry({
|
|
560
|
-
runtime,
|
|
561
|
-
contentKey,
|
|
562
|
-
plaintext,
|
|
563
|
-
keyID,
|
|
564
|
-
keyVersion: newVersion,
|
|
565
|
-
entryID
|
|
612
|
+
// Both key buffers are our private clones. One try spans from here to the end so
|
|
613
|
+
// a throw anywhere in the rotation zeros them, not only the success path.
|
|
614
|
+
// `previousKey` is read only in the plaintext-recovery loop; the generated
|
|
615
|
+
// key through the last write. It is declared here so the `finally` can zero
|
|
616
|
+
// it whether or not generation was reached.
|
|
617
|
+
let generatedKey;
|
|
618
|
+
try {
|
|
619
|
+
const retained = (await store.listWrappings(keyID, key.version)).map((wrapping)=>({
|
|
620
|
+
recipientDID: wrapping.recipient_did,
|
|
621
|
+
factors: factorsFromDescriptors(wrapping.factors, rotateParams?.inputs)
|
|
622
|
+
}));
|
|
623
|
+
const plaintexts = (await store.listEntries(keyID)).map((entry)=>{
|
|
624
|
+
// An entry left behind by a rotation that did not finish. Carrying it
|
|
625
|
+
// forward is impossible — its ciphertext is under a content key nothing
|
|
626
|
+
// holds — and skipping it would quietly strand it one version further.
|
|
627
|
+
if (entry.key_version !== key.version) {
|
|
628
|
+
throw new CredentialKeyVersionMismatch(entry.entry_id, entry.key_version, key.version);
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
entryID: entry.entry_id,
|
|
632
|
+
plaintext: previousSuite.decryptEntry({
|
|
633
|
+
contentKey: previousKey,
|
|
634
|
+
iv: new Uint8Array(entry.iv),
|
|
635
|
+
ciphertext: new Uint8Array(entry.ciphertext),
|
|
636
|
+
keyID,
|
|
637
|
+
keyVersion: entry.key_version,
|
|
638
|
+
entryID: entry.entry_id
|
|
639
|
+
})
|
|
640
|
+
};
|
|
566
641
|
});
|
|
567
|
-
const
|
|
568
|
-
|
|
642
|
+
const contentKey = suite.generateContentKey(runtime);
|
|
643
|
+
generatedKey = contentKey;
|
|
644
|
+
// Signed before the entries and wrappings it labels — its branch is what
|
|
645
|
+
// they join — but persisted last, so the version bump still lands after
|
|
646
|
+
// the rows a reader needs at it. Signing touches no store, so this only
|
|
647
|
+
// moves the key-op's stamp ahead of the batch, which is where the branch
|
|
648
|
+
// it introduces belongs.
|
|
649
|
+
const rotatedKey = {
|
|
569
650
|
keyID,
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
651
|
+
ownerDID: key.owner_did,
|
|
652
|
+
suite: suiteID,
|
|
653
|
+
version: newVersion,
|
|
654
|
+
state: key.state === 'retired' ? 'retired' : 'active'
|
|
574
655
|
};
|
|
575
|
-
await
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const wrappingID = runtime.getRandomID();
|
|
579
|
-
const { descriptors, iv, wrappedKey } = await suite.wrap({
|
|
580
|
-
runtime,
|
|
581
|
-
contentKey,
|
|
582
|
-
factors,
|
|
583
|
-
wrappingID,
|
|
656
|
+
const rotatedKeyOp = await ops.key(rotatedKey, await nextHLC());
|
|
657
|
+
const branchID = rotatedKeyOp.branchID;
|
|
658
|
+
await store.recordKeyBranch({
|
|
584
659
|
keyID,
|
|
585
660
|
keyVersion: newVersion,
|
|
586
|
-
|
|
661
|
+
authorDID: rotatedKeyOp.authorDID,
|
|
662
|
+
hlc: rotatedKeyOp.hlc,
|
|
663
|
+
opJWT: rotatedKeyOp.opJWT,
|
|
664
|
+
opHash: rotatedKeyOp.opHash,
|
|
665
|
+
branchID: rotatedKeyOp.branchID
|
|
587
666
|
});
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
667
|
+
for (const { entryID, plaintext } of plaintexts){
|
|
668
|
+
const { iv, ciphertext } = suite.encryptEntry({
|
|
669
|
+
runtime,
|
|
670
|
+
contentKey,
|
|
671
|
+
plaintext,
|
|
672
|
+
keyID,
|
|
673
|
+
keyVersion: newVersion,
|
|
674
|
+
entryID
|
|
675
|
+
});
|
|
676
|
+
const entryParams = {
|
|
677
|
+
entryID,
|
|
678
|
+
keyID,
|
|
679
|
+
keyVersion: newVersion,
|
|
680
|
+
iv,
|
|
681
|
+
ciphertext,
|
|
682
|
+
hlc: await nextHLC()
|
|
683
|
+
};
|
|
684
|
+
await store.putEntry(entryParams, await ops.entry(entryParams, branchID));
|
|
685
|
+
}
|
|
686
|
+
for (const { recipientDID, factors } of retained){
|
|
687
|
+
const wrappingID = runtime.getRandomID();
|
|
688
|
+
const { descriptors, iv, wrappedKey } = await suite.wrap({
|
|
689
|
+
runtime,
|
|
690
|
+
contentKey,
|
|
691
|
+
factors,
|
|
692
|
+
wrappingID,
|
|
693
|
+
keyID,
|
|
694
|
+
keyVersion: newVersion,
|
|
695
|
+
methods: rotateParams?.methods
|
|
696
|
+
});
|
|
697
|
+
const wrappingParams = {
|
|
698
|
+
wrappingID,
|
|
699
|
+
keyID,
|
|
700
|
+
keyVersion: newVersion,
|
|
701
|
+
factors: descriptors,
|
|
702
|
+
iv,
|
|
703
|
+
wrappedKey,
|
|
704
|
+
recipientDID
|
|
705
|
+
};
|
|
706
|
+
await store.putWrapping(wrappingParams, await ops.wrapping(wrappingParams, await nextHLC(), branchID));
|
|
707
|
+
}
|
|
708
|
+
// The version and the suite have to land together — an entry encrypted
|
|
709
|
+
// under one and read under the other fails its tag — and the op speaks
|
|
710
|
+
// for the whole row either way, so both branches sign the same shape. The
|
|
711
|
+
// op was signed above so its label could tag the rows; it persists here.
|
|
712
|
+
if (suiteID === key.suite) {
|
|
713
|
+
await store.setKeyVersion(keyID, newVersion, rotatedKeyOp);
|
|
714
|
+
} else {
|
|
715
|
+
await store.putKey(rotatedKey, rotatedKeyOp);
|
|
716
|
+
}
|
|
717
|
+
// Listed before the sweep so every removed row gets a tombstone: the
|
|
718
|
+
// delete predicate spans versions, and a row it takes with no tombstone
|
|
719
|
+
// comes back the first time a peer that still holds it ships it over.
|
|
720
|
+
const superseded = (await store.listWrappings(keyID)).filter((wrapping)=>wrapping.key_version < newVersion);
|
|
721
|
+
const tombstones = [];
|
|
722
|
+
for (const wrapping of superseded){
|
|
723
|
+
tombstones.push(await ops.wrappingTombstone({
|
|
724
|
+
subjectKind: 'wrapping',
|
|
725
|
+
subjectID: wrapping.wrapping_id,
|
|
726
|
+
keyID,
|
|
727
|
+
keyVersion: wrapping.key_version
|
|
728
|
+
}, await nextHLC()));
|
|
729
|
+
}
|
|
730
|
+
await store.deleteWrappingsBelowVersion(keyID, newVersion, tombstones);
|
|
731
|
+
// The rotation's own key-op labels the branch it just made. `cache.set`
|
|
732
|
+
// stores its own clone, so zeroing the local generated key in the
|
|
733
|
+
// `finally` does not disturb the cached one.
|
|
734
|
+
cache.set(keyID, contentKey, newVersion, branchID);
|
|
735
|
+
return newVersion;
|
|
736
|
+
} finally{
|
|
737
|
+
previousKey.fill(0);
|
|
738
|
+
generatedKey?.fill(0);
|
|
620
739
|
}
|
|
621
|
-
await store.deleteWrappingsBelowVersion(keyID, newVersion, tombstones);
|
|
622
|
-
// Zeroes `previousKey` on the way in, which is why nothing above may read
|
|
623
|
-
// it afterwards. The rotation's own key-op labels the branch it just made.
|
|
624
|
-
cache.set(keyID, contentKey, newVersion, branchID);
|
|
625
|
-
return newVersion;
|
|
626
740
|
},
|
|
627
741
|
async grant (keyID, recipientWrappableDID, params) {
|
|
628
742
|
const key = await loadKey(keyID);
|
|
@@ -654,20 +768,27 @@ export function createCredentialManager(params) {
|
|
|
654
768
|
// leave a wrapping nobody can open.
|
|
655
769
|
const { contentKey, branchID } = await openKey(keyID);
|
|
656
770
|
const wrappingID = runtime.getRandomID();
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
771
|
+
const suite = getSuite(key.suite);
|
|
772
|
+
let wrapped;
|
|
773
|
+
try {
|
|
774
|
+
wrapped = await suite.wrap({
|
|
775
|
+
runtime,
|
|
776
|
+
contentKey,
|
|
777
|
+
factors: [
|
|
778
|
+
{
|
|
779
|
+
kind: 'did',
|
|
780
|
+
recipientDID: recipientWrappableDID
|
|
781
|
+
}
|
|
782
|
+
],
|
|
783
|
+
wrappingID,
|
|
784
|
+
keyID,
|
|
785
|
+
keyVersion: key.version,
|
|
786
|
+
methods: params?.methods
|
|
787
|
+
});
|
|
788
|
+
} finally{
|
|
789
|
+
contentKey.fill(0);
|
|
790
|
+
}
|
|
791
|
+
const { descriptors, iv, wrappedKey } = wrapped;
|
|
671
792
|
const granted = {
|
|
672
793
|
wrappingID,
|
|
673
794
|
keyID,
|
|
@@ -712,20 +833,25 @@ export function createCredentialManager(params) {
|
|
|
712
833
|
},
|
|
713
834
|
async putEntry (keyID, plaintext) {
|
|
714
835
|
const { contentKey, version, branchID } = await openKey(keyID);
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
836
|
+
let entry;
|
|
837
|
+
try {
|
|
838
|
+
const key = await store.getKey(keyID);
|
|
839
|
+
entry = buildCredentialEntry({
|
|
840
|
+
runtime,
|
|
841
|
+
key: {
|
|
842
|
+
keyID,
|
|
843
|
+
// The key's owner, not this device: an entry under a viewer's key
|
|
844
|
+
// belongs to that viewer even when a server wrote it.
|
|
845
|
+
ownerDID: key?.owner_did ?? identity.id,
|
|
846
|
+
suite: key?.suite ?? SUITE_KUBUN_CRED_1,
|
|
847
|
+
version,
|
|
848
|
+
contentKey
|
|
849
|
+
},
|
|
850
|
+
plaintext
|
|
851
|
+
});
|
|
852
|
+
} finally{
|
|
853
|
+
contentKey.fill(0);
|
|
854
|
+
}
|
|
729
855
|
const entryParams = {
|
|
730
856
|
...entry,
|
|
731
857
|
hlc: await nextHLC()
|
|
@@ -739,18 +865,25 @@ export function createCredentialManager(params) {
|
|
|
739
865
|
throw new CredentialNotFound('Credential entry', entryID);
|
|
740
866
|
}
|
|
741
867
|
const { contentKey, version, branchID } = await openKey(existing.key_id);
|
|
742
|
-
const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
|
|
743
868
|
// Re-encrypted under the key's CURRENT version, not the version the entry
|
|
744
869
|
// was written at: an update is a fresh write, and leaving it behind would
|
|
745
|
-
// keep a rotated-away version alive indefinitely.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
870
|
+
// keep a rotated-away version alive indefinitely. The suite lookup's store
|
|
871
|
+
// read is inside the `try` so a rejection cannot leak the key copy.
|
|
872
|
+
let encrypted;
|
|
873
|
+
try {
|
|
874
|
+
const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
|
|
875
|
+
encrypted = suite.encryptEntry({
|
|
876
|
+
runtime,
|
|
877
|
+
contentKey,
|
|
878
|
+
plaintext,
|
|
879
|
+
keyID: existing.key_id,
|
|
880
|
+
keyVersion: version,
|
|
881
|
+
entryID
|
|
882
|
+
});
|
|
883
|
+
} finally{
|
|
884
|
+
contentKey.fill(0);
|
|
885
|
+
}
|
|
886
|
+
const { iv, ciphertext } = encrypted;
|
|
754
887
|
const updated = {
|
|
755
888
|
entryID,
|
|
756
889
|
keyID: existing.key_id,
|
|
@@ -777,18 +910,24 @@ export function createCredentialManager(params) {
|
|
|
777
910
|
throw new CredentialKeyVersionMismatch(entryID, entry.key_version, key.version);
|
|
778
911
|
}
|
|
779
912
|
const { contentKey, branchID } = await openKey(entry.key_id);
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
913
|
+
// Zero our private clone on every exit — the branch-mismatch throw
|
|
914
|
+
// and the decrypt alike.
|
|
915
|
+
try {
|
|
916
|
+
const entryBranchID = (await store.getOp('entry', entryID))?.branch_id ?? '';
|
|
917
|
+
if (entryBranchID !== '' && branchID !== '' && entryBranchID !== branchID) {
|
|
918
|
+
throw new CredentialSupersededBranch(entryID, entryBranchID, branchID);
|
|
919
|
+
}
|
|
920
|
+
return getSuite(key.suite).decryptEntry({
|
|
921
|
+
contentKey,
|
|
922
|
+
iv: new Uint8Array(entry.iv),
|
|
923
|
+
ciphertext: new Uint8Array(entry.ciphertext),
|
|
924
|
+
keyID: entry.key_id,
|
|
925
|
+
keyVersion: entry.key_version,
|
|
926
|
+
entryID
|
|
927
|
+
});
|
|
928
|
+
} finally{
|
|
929
|
+
contentKey.fill(0);
|
|
783
930
|
}
|
|
784
|
-
return getSuite(key.suite).decryptEntry({
|
|
785
|
-
contentKey,
|
|
786
|
-
iv: new Uint8Array(entry.iv),
|
|
787
|
-
ciphertext: new Uint8Array(entry.ciphertext),
|
|
788
|
-
keyID: entry.key_id,
|
|
789
|
-
keyVersion: entry.key_version,
|
|
790
|
-
entryID
|
|
791
|
-
});
|
|
792
931
|
},
|
|
793
932
|
async getEntryProvenance (entryID) {
|
|
794
933
|
const entry = await store.getEntryWithProvenance(entryID);
|
|
@@ -848,25 +987,36 @@ export function createCredentialManager(params) {
|
|
|
848
987
|
// A target this device cannot open has to fail while the entry is still
|
|
849
988
|
// where it was.
|
|
850
989
|
const { contentKey: fromContentKey } = await openKey(entry.key_id);
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
990
|
+
let plaintext;
|
|
991
|
+
try {
|
|
992
|
+
plaintext = getSuite(fromKey.suite).decryptEntry({
|
|
993
|
+
contentKey: fromContentKey,
|
|
994
|
+
iv: new Uint8Array(entry.iv),
|
|
995
|
+
ciphertext: new Uint8Array(entry.ciphertext),
|
|
996
|
+
keyID: entry.key_id,
|
|
997
|
+
keyVersion: entry.key_version,
|
|
998
|
+
entryID
|
|
999
|
+
});
|
|
1000
|
+
} finally{
|
|
1001
|
+
fromContentKey.fill(0);
|
|
1002
|
+
}
|
|
859
1003
|
const { contentKey: toContentKey, version, branchID } = await openKey(toKeyID);
|
|
860
1004
|
// The target's CURRENT version, as an update is: landing on an older one
|
|
861
1005
|
// would strand the entry the moment the key rotates.
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1006
|
+
let reEncrypted;
|
|
1007
|
+
try {
|
|
1008
|
+
reEncrypted = getSuite(toKey.suite).encryptEntry({
|
|
1009
|
+
runtime,
|
|
1010
|
+
contentKey: toContentKey,
|
|
1011
|
+
plaintext,
|
|
1012
|
+
keyID: toKeyID,
|
|
1013
|
+
keyVersion: version,
|
|
1014
|
+
entryID
|
|
1015
|
+
});
|
|
1016
|
+
} finally{
|
|
1017
|
+
toContentKey.fill(0);
|
|
1018
|
+
}
|
|
1019
|
+
const { iv, ciphertext } = reEncrypted;
|
|
870
1020
|
const moved = {
|
|
871
1021
|
entryID,
|
|
872
1022
|
keyID: toKeyID,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Credential, CredentialProvider, OAuthProviderDefinition } from '@kubun/credential-types';
|
|
2
|
+
import type { Runtime } from '@sozai/runtime';
|
|
3
|
+
import type { CredentialAPI } from './api.js';
|
|
4
|
+
export declare function serializeCredential(credential: Credential): string;
|
|
5
|
+
export declare function deserializeCredential(raw: string | Record<string, unknown>): Credential;
|
|
6
|
+
export type DBCredentialProviderParams = {
|
|
7
|
+
api: CredentialAPI;
|
|
8
|
+
runtime: Runtime;
|
|
9
|
+
providers: Array<OAuthProviderDefinition>;
|
|
10
|
+
bufferSeconds?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare class DBCredentialProvider implements CredentialProvider {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(params: DBCredentialProviderParams);
|
|
15
|
+
get(providerName: string, ownerDID: string): Promise<Credential | null>;
|
|
16
|
+
set(params: {
|
|
17
|
+
providerName: string;
|
|
18
|
+
ownerDID: string;
|
|
19
|
+
credential: Credential;
|
|
20
|
+
ownerWrappableDID?: string;
|
|
21
|
+
controllerWrappableDID?: string;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
delete(providerName: string, ownerDID: string): Promise<void>;
|
|
24
|
+
}
|
package/lib/provider.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const DEFAULT_BUFFER_SECONDS = 300;
|
|
2
|
+
export function serializeCredential(credential) {
|
|
3
|
+
const serialized = {
|
|
4
|
+
accessToken: credential.accessToken,
|
|
5
|
+
scopes: credential.scopes
|
|
6
|
+
};
|
|
7
|
+
if (credential.refreshToken != null) {
|
|
8
|
+
serialized.refreshToken = credential.refreshToken;
|
|
9
|
+
}
|
|
10
|
+
if (credential.expiresAt != null) {
|
|
11
|
+
serialized.expiresAt = credential.expiresAt.toISOString();
|
|
12
|
+
}
|
|
13
|
+
if (credential.accountLabel != null) {
|
|
14
|
+
serialized.accountLabel = credential.accountLabel;
|
|
15
|
+
}
|
|
16
|
+
if (credential.metadata != null) {
|
|
17
|
+
serialized.metadata = credential.metadata;
|
|
18
|
+
}
|
|
19
|
+
return JSON.stringify(serialized);
|
|
20
|
+
}
|
|
21
|
+
export function deserializeCredential(raw) {
|
|
22
|
+
// ParseJSONResultsPlugin may have already parsed the JSON string
|
|
23
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
24
|
+
const credential = {
|
|
25
|
+
accessToken: parsed.accessToken,
|
|
26
|
+
scopes: parsed.scopes
|
|
27
|
+
};
|
|
28
|
+
if (parsed.refreshToken != null) {
|
|
29
|
+
credential.refreshToken = parsed.refreshToken;
|
|
30
|
+
}
|
|
31
|
+
if (parsed.expiresAt != null) {
|
|
32
|
+
credential.expiresAt = new Date(parsed.expiresAt);
|
|
33
|
+
}
|
|
34
|
+
if (parsed.accountLabel != null) {
|
|
35
|
+
credential.accountLabel = parsed.accountLabel;
|
|
36
|
+
}
|
|
37
|
+
if (parsed.metadata != null) {
|
|
38
|
+
credential.metadata = parsed.metadata;
|
|
39
|
+
}
|
|
40
|
+
return credential;
|
|
41
|
+
}
|
|
42
|
+
export class DBCredentialProvider {
|
|
43
|
+
#api;
|
|
44
|
+
#runtime;
|
|
45
|
+
#providers;
|
|
46
|
+
#bufferMs;
|
|
47
|
+
constructor(params){
|
|
48
|
+
this.#api = params.api;
|
|
49
|
+
this.#runtime = params.runtime;
|
|
50
|
+
this.#providers = params.providers;
|
|
51
|
+
this.#bufferMs = (params.bufferSeconds ?? DEFAULT_BUFFER_SECONDS) * 1000;
|
|
52
|
+
}
|
|
53
|
+
async get(providerName, ownerDID) {
|
|
54
|
+
const raw = await this.#api.getCredential(providerName, ownerDID);
|
|
55
|
+
if (raw == null) return null;
|
|
56
|
+
const credential = deserializeCredential(raw);
|
|
57
|
+
if (this.#isExpiringSoon(credential) && credential.refreshToken != null) {
|
|
58
|
+
const refreshed = await this.#refresh(providerName, ownerDID, credential, credential.refreshToken);
|
|
59
|
+
return refreshed ?? credential;
|
|
60
|
+
}
|
|
61
|
+
return credential;
|
|
62
|
+
}
|
|
63
|
+
async set(params) {
|
|
64
|
+
const { providerName, ownerDID, credential, ownerWrappableDID, controllerWrappableDID } = params;
|
|
65
|
+
const serialized = serializeCredential(credential);
|
|
66
|
+
// Update when one exists, mint only when it does not. Dispatching on
|
|
67
|
+
// presence rather than on the caller keeps the refresh path unable to mint
|
|
68
|
+
// even when it does hold a wrappable DID.
|
|
69
|
+
if (await this.#api.getCredential(providerName, ownerDID) != null) {
|
|
70
|
+
await this.#api.setCredential(providerName, ownerDID, serialized);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (ownerWrappableDID == null) {
|
|
74
|
+
throw new Error(`Cannot create a connector credential for "${providerName}" without the owner's wrappable DID`);
|
|
75
|
+
}
|
|
76
|
+
await this.#api.createCredential({
|
|
77
|
+
providerName,
|
|
78
|
+
ownerDID,
|
|
79
|
+
ownerWrappableDID,
|
|
80
|
+
controllerWrappableDID,
|
|
81
|
+
credential: serialized
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async delete(providerName, ownerDID) {
|
|
85
|
+
await this.#api.deleteCredential(providerName, ownerDID);
|
|
86
|
+
}
|
|
87
|
+
#isExpiringSoon(credential) {
|
|
88
|
+
if (credential.expiresAt == null) return false;
|
|
89
|
+
return credential.expiresAt.getTime() - Date.now() < this.#bufferMs;
|
|
90
|
+
}
|
|
91
|
+
async #refresh(providerName, ownerDID, credential, refreshToken) {
|
|
92
|
+
const provider = this.#providers.find((p)=>p.name === providerName);
|
|
93
|
+
if (provider == null || provider.clientID == null || provider.clientSecret == null) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const response = await this.#runtime.fetch(provider.tokenEndpoint, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
101
|
+
},
|
|
102
|
+
body: new URLSearchParams({
|
|
103
|
+
grant_type: 'refresh_token',
|
|
104
|
+
refresh_token: refreshToken,
|
|
105
|
+
client_id: provider.clientID,
|
|
106
|
+
client_secret: provider.clientSecret
|
|
107
|
+
})
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) return null;
|
|
110
|
+
const tokenData = await response.json();
|
|
111
|
+
const refreshed = {
|
|
112
|
+
accessToken: tokenData.access_token,
|
|
113
|
+
refreshToken: tokenData.refresh_token ?? credential.refreshToken,
|
|
114
|
+
expiresAt: tokenData.expires_in != null ? new Date(Date.now() + tokenData.expires_in * 1000) : undefined,
|
|
115
|
+
scopes: tokenData.scope?.split(' ') ?? credential.scopes,
|
|
116
|
+
accountLabel: credential.accountLabel,
|
|
117
|
+
metadata: credential.metadata
|
|
118
|
+
};
|
|
119
|
+
await this.set({
|
|
120
|
+
providerName,
|
|
121
|
+
ownerDID,
|
|
122
|
+
credential: refreshed
|
|
123
|
+
});
|
|
124
|
+
return refreshed;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/credential",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -17,17 +17,19 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@kokuin/jwe": "^0.1.0",
|
|
19
19
|
"@kokuin/token": "^0.5.0",
|
|
20
|
-
"@
|
|
21
|
-
"@
|
|
20
|
+
"@kubun/credential-types": "^0.14.0",
|
|
21
|
+
"@kubun/db": "^0.14.0",
|
|
22
|
+
"@kubun/hlc": "^0.14.0",
|
|
23
|
+
"@kubun/id": "^0.14.0",
|
|
24
|
+
"@kubun/logger": "^0.14.0",
|
|
25
|
+
"@kubun/store-credential": "^0.14.0",
|
|
26
|
+
"@noble/ciphers": "^2.4.0",
|
|
27
|
+
"@noble/hashes": "^2.4.0",
|
|
22
28
|
"@sozai/codec": "^0.4.0",
|
|
23
|
-
"@sozai/runtime": "^0.1.0"
|
|
24
|
-
"@kubun/hlc": "^0.13.0",
|
|
25
|
-
"@kubun/store-credential": "^0.13.0",
|
|
26
|
-
"@kubun/id": "^0.13.0"
|
|
29
|
+
"@sozai/runtime": "^0.1.0"
|
|
27
30
|
},
|
|
28
31
|
"devDependencies": {
|
|
29
|
-
"@kubun/db-better-sqlite": "^0.
|
|
30
|
-
"@kubun/db": "^0.13.0"
|
|
32
|
+
"@kubun/db-better-sqlite": "^0.14.0"
|
|
31
33
|
},
|
|
32
34
|
"publishConfig": {
|
|
33
35
|
"access": "public"
|