@kubun/credential 0.13.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/LICENSE.md +57 -0
- package/lib/authority.d.ts +29 -0
- package/lib/authority.js +26 -0
- package/lib/builder.d.ts +82 -0
- package/lib/builder.js +76 -0
- package/lib/digest.d.ts +53 -0
- package/lib/digest.js +82 -0
- package/lib/errors.d.ts +169 -0
- package/lib/errors.js +248 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.js +10 -0
- package/lib/key-cache.d.ts +34 -0
- package/lib/key-cache.js +55 -0
- package/lib/manager.d.ts +280 -0
- package/lib/manager.js +881 -0
- package/lib/op.d.ts +128 -0
- package/lib/op.js +304 -0
- package/lib/pin-throttle.d.ts +44 -0
- package/lib/pin-throttle.js +48 -0
- package/lib/provenance.d.ts +6 -0
- package/lib/provenance.js +8 -0
- package/lib/suite.d.ts +112 -0
- package/lib/suite.js +317 -0
- package/lib/vocabulary.d.ts +16 -0
- package/lib/vocabulary.js +17 -0
- package/package.json +45 -0
package/lib/errors.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures a caller acts on differently.
|
|
3
|
+
*
|
|
4
|
+
* The distinctions are not cosmetic. "This device cannot supply the factor" is a
|
|
5
|
+
* prompt-the-user or use-another-device outcome; "the descriptors were tampered
|
|
6
|
+
* with" is a security event; "wrong secret" is a retry. Collapsing them into one
|
|
7
|
+
* decrypt error is what makes a tampered wrapping indistinguishable from a
|
|
8
|
+
* mistyped PIN.
|
|
9
|
+
*
|
|
10
|
+
* `code` is required rather than defaulted: a subclass that inherited a generic
|
|
11
|
+
* code would compile, ship, and reach a client as an untyped failure, which is
|
|
12
|
+
* the one outcome this hierarchy exists to prevent.
|
|
13
|
+
*/ export class CredentialError extends Error {
|
|
14
|
+
#code;
|
|
15
|
+
constructor(message, options){
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = 'CredentialError';
|
|
18
|
+
this.#code = options.code;
|
|
19
|
+
}
|
|
20
|
+
get code() {
|
|
21
|
+
return this.#code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A wrapping needs a factor kind this device cannot supply. */ export class NoAvailableFactor extends CredentialError {
|
|
25
|
+
#kind;
|
|
26
|
+
constructor(kind){
|
|
27
|
+
super(`No available credential factor of kind "${kind}" on this device`, {
|
|
28
|
+
code: 'CREDENTIAL_FACTOR_UNAVAILABLE'
|
|
29
|
+
});
|
|
30
|
+
this.name = 'NoAvailableFactor';
|
|
31
|
+
this.#kind = kind;
|
|
32
|
+
}
|
|
33
|
+
get kind() {
|
|
34
|
+
return this.#kind;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A factor list a wrapping may not be built from.
|
|
39
|
+
*
|
|
40
|
+
* Raised at creation rather than at first unwrap: an unopenable or ambiguous
|
|
41
|
+
* wrapping that only fails on read is a key nobody notices is broken until they
|
|
42
|
+
* need it.
|
|
43
|
+
*/ export class InvalidCredentialFactors extends CredentialError {
|
|
44
|
+
constructor(reason){
|
|
45
|
+
super(`Invalid credential factor list: ${reason}`, {
|
|
46
|
+
code: 'CREDENTIAL_FACTORS_INVALID'
|
|
47
|
+
});
|
|
48
|
+
this.name = 'InvalidCredentialFactors';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** A key names a ciphersuite this build does not implement. */ export class UnsupportedCredentialSuite extends CredentialError {
|
|
52
|
+
#suite;
|
|
53
|
+
constructor(suite){
|
|
54
|
+
super(`Unsupported credential suite ${suite}`, {
|
|
55
|
+
code: 'CREDENTIAL_SUITE_UNSUPPORTED'
|
|
56
|
+
});
|
|
57
|
+
this.name = 'UnsupportedCredentialSuite';
|
|
58
|
+
this.#suite = suite;
|
|
59
|
+
}
|
|
60
|
+
get suite() {
|
|
61
|
+
return this.#suite;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A wrapping did not authenticate, and no user-supplied secret was involved.
|
|
66
|
+
*
|
|
67
|
+
* Raised for a wrapping whose factors are all `did`, where nothing the user
|
|
68
|
+
* typed could have been wrong. The causes left are an edited descriptor list,
|
|
69
|
+
* corrupted ciphertext, a wrapping copied onto another row — **and a wrapping
|
|
70
|
+
* addressed to someone else**. X25519 agreement never fails: a foreign recipient
|
|
71
|
+
* recovers different bytes rather than erroring, so the tag fails identically. A
|
|
72
|
+
* wrapping this device is not addressed to normally never reaches an unwrap at
|
|
73
|
+
* all, because `satisfiableFactors` filters it out first.
|
|
74
|
+
*/ export class CredentialTampered extends CredentialError {
|
|
75
|
+
constructor(message, options){
|
|
76
|
+
super(message, {
|
|
77
|
+
...options,
|
|
78
|
+
code: 'CREDENTIAL_TAMPERED'
|
|
79
|
+
});
|
|
80
|
+
this.name = 'CredentialTampered';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** An entry belongs to a branch superseded by the key's resolved winner. */ export class CredentialSupersededBranch extends CredentialError {
|
|
84
|
+
#entryID;
|
|
85
|
+
#entryBranchID;
|
|
86
|
+
#winnerBranchID;
|
|
87
|
+
constructor(entryID, entryBranchID, winnerBranchID){
|
|
88
|
+
super(`Credential entry ${entryID} belongs to superseded branch ${entryBranchID}; winner is ${winnerBranchID}`, {
|
|
89
|
+
code: 'CREDENTIAL_SUPERSEDED_BRANCH'
|
|
90
|
+
});
|
|
91
|
+
this.name = 'CredentialSupersededBranch';
|
|
92
|
+
this.#entryID = entryID;
|
|
93
|
+
this.#entryBranchID = entryBranchID;
|
|
94
|
+
this.#winnerBranchID = winnerBranchID;
|
|
95
|
+
}
|
|
96
|
+
get entryID() {
|
|
97
|
+
return this.#entryID;
|
|
98
|
+
}
|
|
99
|
+
get entryBranchID() {
|
|
100
|
+
return this.#entryBranchID;
|
|
101
|
+
}
|
|
102
|
+
get winnerBranchID() {
|
|
103
|
+
return this.#winnerBranchID;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A wrapping carrying a user-supplied factor did not authenticate.
|
|
108
|
+
*
|
|
109
|
+
* Attribution to a wrong secret is policy, not a cryptographic fact: one tag
|
|
110
|
+
* failure is all that is observable, and a tampered descriptor produces the same
|
|
111
|
+
* one. This is the retry path; the `pin` throttle bounds repeated attempts.
|
|
112
|
+
*/ export class CredentialSecretRejected extends CredentialError {
|
|
113
|
+
#kinds;
|
|
114
|
+
constructor(kinds, options){
|
|
115
|
+
const keyID = options?.keyID;
|
|
116
|
+
super(`Credential ${kinds.join(' and ')} rejected${keyID == null ? '' : ` for key ${keyID}`}. If the secret is known to be correct, the wrapping's factor descriptors may have been altered — the two are indistinguishable at this point.`, {
|
|
117
|
+
...options,
|
|
118
|
+
code: options?.code ?? 'CREDENTIAL_SECRET_REJECTED'
|
|
119
|
+
});
|
|
120
|
+
this.name = 'CredentialSecretRejected';
|
|
121
|
+
this.#kinds = kinds;
|
|
122
|
+
}
|
|
123
|
+
/** The user-supplied factor kinds in the wrapping, any of which could be wrong. */ get kinds() {
|
|
124
|
+
return this.#kinds;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A PIN attempt was wrong, or refused because too many already were.
|
|
129
|
+
*
|
|
130
|
+
* Raised by the manager rather than the suite: the counter is per-key and the
|
|
131
|
+
* suite holds none. A refusal is issued before the KDF runs, so a throttled
|
|
132
|
+
* attempt costs the attacker time and the defender nothing.
|
|
133
|
+
*/ export class CredentialPinRejected extends CredentialSecretRejected {
|
|
134
|
+
#attemptsRemaining;
|
|
135
|
+
#retryAfter;
|
|
136
|
+
constructor(keyID, options){
|
|
137
|
+
super([
|
|
138
|
+
'pin'
|
|
139
|
+
], {
|
|
140
|
+
cause: options.cause,
|
|
141
|
+
keyID,
|
|
142
|
+
code: 'CREDENTIAL_PIN_REJECTED'
|
|
143
|
+
});
|
|
144
|
+
this.name = 'CredentialPinRejected';
|
|
145
|
+
this.#attemptsRemaining = options.attemptsRemaining;
|
|
146
|
+
this.#retryAfter = options.retryAfter;
|
|
147
|
+
}
|
|
148
|
+
/** Attempts left before a delay begins. */ get attemptsRemaining() {
|
|
149
|
+
return this.#attemptsRemaining;
|
|
150
|
+
}
|
|
151
|
+
/** Milliseconds until another attempt is accepted. Zero means now. */ get retryAfter() {
|
|
152
|
+
return this.#retryAfter;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* This identity holds no authority to do that to that key.
|
|
157
|
+
*
|
|
158
|
+
* Named rather than collapsed into {@link CredentialNotFound}, which is what a
|
|
159
|
+
* verb with no ownership check reports once it goes looking for a wrapping it
|
|
160
|
+
* was never entitled to touch. "There is no such key here" and "that key is not
|
|
161
|
+
* yours to administer" want opposite things from a caller, and only one of them
|
|
162
|
+
* is worth retrying on another device.
|
|
163
|
+
*/ export class CredentialNotAuthorized extends CredentialError {
|
|
164
|
+
#authorization;
|
|
165
|
+
constructor(authorization, keyID){
|
|
166
|
+
super(`Not authorized to ${authorization} credential key ${keyID}`, {
|
|
167
|
+
code: 'CREDENTIAL_NOT_AUTHORIZED'
|
|
168
|
+
});
|
|
169
|
+
this.name = 'CredentialNotAuthorized';
|
|
170
|
+
this.#authorization = authorization;
|
|
171
|
+
}
|
|
172
|
+
get authorization() {
|
|
173
|
+
return this.#authorization;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** No key, wrapping or entry under that id. */ export class CredentialNotFound extends CredentialError {
|
|
177
|
+
constructor(what, id){
|
|
178
|
+
super(`${what} not found: ${id}`, {
|
|
179
|
+
code: 'CREDENTIAL_NOT_FOUND'
|
|
180
|
+
});
|
|
181
|
+
this.name = 'CredentialNotFound';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* More than one wrapping on a key is addressed to this device.
|
|
186
|
+
*
|
|
187
|
+
* Thrown rather than resolved by picking: re-wrapping one of them would leave
|
|
188
|
+
* the other un-gated and look like success.
|
|
189
|
+
*/ export class AmbiguousCredentialWrapping extends CredentialError {
|
|
190
|
+
constructor(keyID, count){
|
|
191
|
+
super(`Credential key ${keyID} has ${count} wrappings addressed to this device; which one to re-wrap is ambiguous`, {
|
|
192
|
+
code: 'CREDENTIAL_WRAPPING_AMBIGUOUS'
|
|
193
|
+
});
|
|
194
|
+
this.name = 'AmbiguousCredentialWrapping';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The key is closed, and a factor this device can supply would open it.
|
|
199
|
+
*
|
|
200
|
+
* Distinct from {@link CredentialUnopenable} because the two need opposite
|
|
201
|
+
* responses: this one is a prompt, that one is "use another device". Collapsing
|
|
202
|
+
* them makes the UI ask for a PIN that will never work.
|
|
203
|
+
*/ export class CredentialKeyLocked extends CredentialError {
|
|
204
|
+
#keyID;
|
|
205
|
+
constructor(keyID, kinds){
|
|
206
|
+
super(`Credential key ${keyID} is locked; unlock it with: ${kinds.join(', ')}`, {
|
|
207
|
+
code: 'CREDENTIAL_KEY_LOCKED'
|
|
208
|
+
});
|
|
209
|
+
this.name = 'CredentialKeyLocked';
|
|
210
|
+
this.#keyID = keyID;
|
|
211
|
+
}
|
|
212
|
+
get keyID() {
|
|
213
|
+
return this.#keyID;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* An entry was written under a key version the key has moved past.
|
|
218
|
+
*
|
|
219
|
+
* Rotation re-encrypts every entry, so this is a rotation that crashed partway.
|
|
220
|
+
* Loud by design: falling back to the older version would read a value the
|
|
221
|
+
* rotation was meant to retire, and silently.
|
|
222
|
+
*/ export class CredentialKeyVersionMismatch extends CredentialError {
|
|
223
|
+
constructor(entryID, entryVersion, keyVersion){
|
|
224
|
+
super(`Credential entry ${entryID} is at key version ${entryVersion} but its key is at ${keyVersion}: a rotation did not complete`, {
|
|
225
|
+
code: 'CREDENTIAL_KEY_VERSION_MISMATCH'
|
|
226
|
+
});
|
|
227
|
+
this.name = 'CredentialKeyVersionMismatch';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Every wrapping for the key was tried and none opened.
|
|
232
|
+
*
|
|
233
|
+
* Reported rather than skipped: a key whose only wrapping uses an unimplemented
|
|
234
|
+
* suite, or whose factors this device lacks, is a state the caller must be able
|
|
235
|
+
* to show. Silently returning "no entries" would read as an empty vault.
|
|
236
|
+
*/ export class CredentialUnopenable extends CredentialError {
|
|
237
|
+
#keyID;
|
|
238
|
+
constructor(keyID, reason){
|
|
239
|
+
super(`Credential key ${keyID} cannot be opened on this device: ${reason}`, {
|
|
240
|
+
code: 'CREDENTIAL_UNOPENABLE'
|
|
241
|
+
});
|
|
242
|
+
this.name = 'CredentialUnopenable';
|
|
243
|
+
this.#keyID = keyID;
|
|
244
|
+
}
|
|
245
|
+
get keyID() {
|
|
246
|
+
return this.#keyID;
|
|
247
|
+
}
|
|
248
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type { CredentialAuthority, CredentialAuthorityParams, CredentialAuthorization, } from './authority.js';
|
|
2
|
+
export { createDeviceAuthority } from './authority.js';
|
|
3
|
+
export type { BuildEntryParams, BuildKeyParams, BuiltEntry, BuiltKey, BuiltWrapping, CredentialKeyMaterial, } from './builder.js';
|
|
4
|
+
export { buildCredentialEntry, buildCredentialKey } from './builder.js';
|
|
5
|
+
export type { CredentialEntryDigestInput, CredentialKeyDigestInput, CredentialWrappingDigestInput, } from './digest.js';
|
|
6
|
+
export { credentialEntriesDigest, credentialEntryDigest, credentialKeyDigest, credentialWrappingDigest, digestParts, } from './digest.js';
|
|
7
|
+
export type { CredentialErrorCode, CredentialErrorOptions } from './errors.js';
|
|
8
|
+
export { AmbiguousCredentialWrapping, CredentialError, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialSecretRejected, CredentialSupersededBranch, CredentialTampered, CredentialUnopenable, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite, } from './errors.js';
|
|
9
|
+
export type { CreateCredentialManagerParams, CreateKeyParams, CredentialGrantResult, CredentialKeyStatus, CredentialManager, CredentialUnopenableReason, FactorChangeParams, GrantKeyParams, ReKeyEntryParams, RotateKeyParams, } from './manager.js';
|
|
10
|
+
export { createCredentialManager } from './manager.js';
|
|
11
|
+
export type { CredentialOpClaim, CredentialOpSigner, CredentialOpSubject, CredentialPutSubject, CredentialRowSubject, VerifiedCredentialOp, } from './op.js';
|
|
12
|
+
export { compareBranchIDs, createCredentialOpSigner, credentialOpMatchesPut, credentialOpMatchesRow, credentialPutDigest, credentialRowDigest, selectWinningBranch, verifyCredentialOp, } from './op.js';
|
|
13
|
+
export type { PinThrottle, PinThrottleParams } from './pin-throttle.js';
|
|
14
|
+
export { createPinThrottle, DEFAULT_BASE_DELAY, DEFAULT_FREE_ATTEMPTS, DEFAULT_MAX_DELAY, } from './pin-throttle.js';
|
|
15
|
+
export type { CredentialEntryProvenance } from './provenance.js';
|
|
16
|
+
export { entryProvenanceFields } from './provenance.js';
|
|
17
|
+
export type { Argon2Cost, CredentialSuite, FactorInput, FactorInputs, SecretFactorKind, } from './suite.js';
|
|
18
|
+
export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
|
|
19
|
+
export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createDeviceAuthority } from './authority.js';
|
|
2
|
+
export { buildCredentialEntry, buildCredentialKey } from './builder.js';
|
|
3
|
+
export { credentialEntriesDigest, credentialEntryDigest, credentialKeyDigest, credentialWrappingDigest, digestParts } from './digest.js';
|
|
4
|
+
export { AmbiguousCredentialWrapping, CredentialError, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialSecretRejected, CredentialSupersededBranch, CredentialTampered, CredentialUnopenable, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite } from './errors.js';
|
|
5
|
+
export { createCredentialManager } from './manager.js';
|
|
6
|
+
export { compareBranchIDs, createCredentialOpSigner, credentialOpMatchesPut, credentialOpMatchesRow, credentialPutDigest, credentialRowDigest, selectWinningBranch, verifyCredentialOp } from './op.js';
|
|
7
|
+
export { createPinThrottle, DEFAULT_BASE_DELAY, DEFAULT_FREE_ATTEMPTS, DEFAULT_MAX_DELAY } from './pin-throttle.js';
|
|
8
|
+
export { entryProvenanceFields } from './provenance.js';
|
|
9
|
+
export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
|
|
10
|
+
export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** 15 minutes. Long enough for a working session, short enough to matter. */
|
|
2
|
+
export declare const DEFAULT_UNLOCK_TTL: number;
|
|
3
|
+
export type KeyCacheParams = {
|
|
4
|
+
/**
|
|
5
|
+
* How long an unlocked key stays in memory. Eviction is lazy — checked on
|
|
6
|
+
* read — because a timer would keep the host process alive and make every
|
|
7
|
+
* test wait on a clock it does not control.
|
|
8
|
+
*/
|
|
9
|
+
ttl?: number;
|
|
10
|
+
/** Injectable so a TTL test does not sleep. */
|
|
11
|
+
now?: () => number;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The unlocked content keys, and the only place lock state exists.
|
|
15
|
+
*
|
|
16
|
+
* It is deliberately not a column. Locked, unlocked and unopenable are derived
|
|
17
|
+
* from what is here plus which wrappings exist; a persisted `unlocked` would be
|
|
18
|
+
* a belief that survives a restart it should not.
|
|
19
|
+
*/
|
|
20
|
+
export type KeyCache = {
|
|
21
|
+
/**
|
|
22
|
+
* The cached key and the branch it resolved to, or null if absent, expired,
|
|
23
|
+
* or from a superseded version. A cache hit still surfaces the branch so a
|
|
24
|
+
* post-fork write joins the same branch the first open resolved.
|
|
25
|
+
*/
|
|
26
|
+
get(keyID: string, keyVersion: number): {
|
|
27
|
+
contentKey: Uint8Array;
|
|
28
|
+
branchID: string;
|
|
29
|
+
} | null;
|
|
30
|
+
set(keyID: string, contentKey: Uint8Array, keyVersion: number, branchID: string): void;
|
|
31
|
+
evict(keyID: string): void;
|
|
32
|
+
evictAll(): void;
|
|
33
|
+
};
|
|
34
|
+
export declare function createKeyCache(params?: KeyCacheParams): KeyCache;
|
package/lib/key-cache.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** 15 minutes. Long enough for a working session, short enough to matter. */ export const DEFAULT_UNLOCK_TTL = 15 * 60 * 1000;
|
|
2
|
+
export function createKeyCache(params = {}) {
|
|
3
|
+
const ttl = params.ttl ?? DEFAULT_UNLOCK_TTL;
|
|
4
|
+
const now = params.now ?? Date.now;
|
|
5
|
+
const entries = new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Overwrite the key material before dropping the entry.
|
|
8
|
+
*
|
|
9
|
+
* Dropping the entry alone leaves the bytes wherever they are until a
|
|
10
|
+
* collector reaches them. Zeroing is the part that can be guaranteed;
|
|
11
|
+
* JavaScript cannot promise no interpreter-level copy survives, and the design
|
|
12
|
+
* spec records that limit rather than implying hygiene it cannot deliver.
|
|
13
|
+
*/ function evict(keyID) {
|
|
14
|
+
const cached = entries.get(keyID);
|
|
15
|
+
if (cached != null) {
|
|
16
|
+
cached.contentKey.fill(0);
|
|
17
|
+
entries.delete(keyID);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
get (keyID, keyVersion) {
|
|
22
|
+
const cached = entries.get(keyID);
|
|
23
|
+
if (cached == null) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
// A rotation invalidates the cache as surely as time does: serving the
|
|
27
|
+
// pre-rotation key would decrypt entries the rotation was retiring.
|
|
28
|
+
if (now() - cached.unlockedAt >= ttl || cached.keyVersion !== keyVersion) {
|
|
29
|
+
evict(keyID);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
contentKey: cached.contentKey,
|
|
34
|
+
branchID: cached.branchID
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
set (keyID, contentKey, keyVersion, branchID) {
|
|
38
|
+
evict(keyID);
|
|
39
|
+
entries.set(keyID, {
|
|
40
|
+
contentKey,
|
|
41
|
+
keyVersion,
|
|
42
|
+
branchID,
|
|
43
|
+
unlockedAt: now()
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
evict,
|
|
47
|
+
evictAll () {
|
|
48
|
+
for (const keyID of [
|
|
49
|
+
...entries.keys()
|
|
50
|
+
]){
|
|
51
|
+
evict(keyID);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
package/lib/manager.d.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { type MethodRegistry, type SigningIdentity } from '@kokuin/token';
|
|
2
|
+
import { HLC } from '@kubun/hlc';
|
|
3
|
+
import type { CredentialStoreAPI } from '@kubun/store-credential';
|
|
4
|
+
import type { Runtime } from '@sozai/runtime';
|
|
5
|
+
import { type CredentialAuthority } from './authority.js';
|
|
6
|
+
import { type PinThrottleParams } from './pin-throttle.js';
|
|
7
|
+
import { type CredentialEntryProvenance } from './provenance.js';
|
|
8
|
+
import { type FactorInput, type FactorInputs, type SecretFactorKind } from './suite.js';
|
|
9
|
+
export type CreateCredentialManagerParams = {
|
|
10
|
+
store: CredentialStoreAPI;
|
|
11
|
+
/**
|
|
12
|
+
* This device's identity. NOT required to be a `KeyAgreementIdentity`: a device
|
|
13
|
+
* that cannot decrypt can still read metadata, and will be able to unwrap by
|
|
14
|
+
* passphrase once that factor exists. What it cannot do is take the `did`
|
|
15
|
+
* default in {@link CredentialManager.createKey}.
|
|
16
|
+
*
|
|
17
|
+
* It MUST be able to sign, which a reading identity need not: every row this
|
|
18
|
+
* manager writes carries a signed op, and a device that could write rows no
|
|
19
|
+
* peer can authenticate would be writing into a lane that has to drop them.
|
|
20
|
+
*/
|
|
21
|
+
identity: SigningIdentity;
|
|
22
|
+
/**
|
|
23
|
+
* Host runtime, propagated rather than constructed here. This package runs on
|
|
24
|
+
* React Native and in browsers as well as on node, and content keys, IVs and
|
|
25
|
+
* factor secrets are the values that most need the platform's own CSPRNG
|
|
26
|
+
* rather than whatever `crypto` a bundler resolved.
|
|
27
|
+
*/
|
|
28
|
+
runtime: Runtime;
|
|
29
|
+
/**
|
|
30
|
+
* The device's one clock, passed in rather than built here.
|
|
31
|
+
*
|
|
32
|
+
* Required, and deliberately not defaulted: a connector builds one manager per
|
|
33
|
+
* transaction, so a manager that constructed its own would start from
|
|
34
|
+
* `lastWallTime = 0` every time and two of them writing in the same
|
|
35
|
+
* millisecond would emit an identical `(wallTime, counter, nodeID)` — a
|
|
36
|
+
* colliding merkle leaf and an order tie with no tiebreak.
|
|
37
|
+
*/
|
|
38
|
+
hlc: HLC;
|
|
39
|
+
/**
|
|
40
|
+
* Who this identity is relative to a key. Defaults to the device answer:
|
|
41
|
+
* it administers what it owns and nothing else.
|
|
42
|
+
*
|
|
43
|
+
* The same port the plugin's query context takes, deliberately — a device
|
|
44
|
+
* authority refuses a grant recipient gating someone else's key, and a
|
|
45
|
+
* delegation authority will let an agent gate its controller's, without
|
|
46
|
+
* either layer growing a predicate of its own.
|
|
47
|
+
*/
|
|
48
|
+
authority?: CredentialAuthority;
|
|
49
|
+
/**
|
|
50
|
+
* How long an unlocked key stays in memory. Eviction is lazy — checked when
|
|
51
|
+
* the cache is read — because a timer would keep the host process alive and
|
|
52
|
+
* make every test wait on a clock it does not control.
|
|
53
|
+
*/
|
|
54
|
+
unlockTTL?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Failed-PIN backoff. Its clock comes from `now`, so a caller cannot give the
|
|
57
|
+
* throttle a different notion of time from the cache's.
|
|
58
|
+
*/
|
|
59
|
+
pinThrottle?: Omit<PinThrottleParams, 'now'>;
|
|
60
|
+
/** Injectable so a TTL or backoff test does not sleep. */
|
|
61
|
+
now?: () => number;
|
|
62
|
+
};
|
|
63
|
+
export type CreateKeyParams = {
|
|
64
|
+
suite?: number;
|
|
65
|
+
/** One wrapping, whose factors are an AND. Shorthand for a single-entry `wrappings`. */
|
|
66
|
+
factors?: Array<FactorInput>;
|
|
67
|
+
/**
|
|
68
|
+
* Several wrappings — an OR of ANDs, one per way of opening the key.
|
|
69
|
+
*
|
|
70
|
+
* A key addressed to two recipients is not two keys and is not a grant: both
|
|
71
|
+
* wrappings exist from the first write, which is what a connector needs when
|
|
72
|
+
* the account owner and the server that runs sync are different DIDs.
|
|
73
|
+
*/
|
|
74
|
+
wrappings?: Array<Array<FactorInput>>;
|
|
75
|
+
/**
|
|
76
|
+
* Who may later grant, rotate and revoke. Defaults to this device, which is
|
|
77
|
+
* the peer case. A server creating a key on a viewer's behalf passes the
|
|
78
|
+
* viewer, and stays a recipient rather than the owner.
|
|
79
|
+
*/
|
|
80
|
+
ownerDID?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Resolvers for recipient DIDs that carry no key material of their own
|
|
83
|
+
* (`did:kokuin:`). Passed per call rather than held on the manager, so a
|
|
84
|
+
* request-scoped resolver never outlives its request in the cached manager.
|
|
85
|
+
*/
|
|
86
|
+
methods?: MethodRegistry;
|
|
87
|
+
};
|
|
88
|
+
export type RotateKeyParams = {
|
|
89
|
+
/**
|
|
90
|
+
* Migrate to a different ciphersuite. Rotation is the only path between
|
|
91
|
+
* suites: it already re-encrypts every entry and re-makes every wrapping,
|
|
92
|
+
* which is exactly what a suite change needs.
|
|
93
|
+
*/
|
|
94
|
+
suite?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Secrets for the surviving factors, keyed by kind — the same prompt
|
|
97
|
+
* `addFactor` needs, for the same reason: a descriptor holds a salt and a
|
|
98
|
+
* cost, never the secret, so a wrapping carrying one cannot be re-made at the
|
|
99
|
+
* new version without it.
|
|
100
|
+
*
|
|
101
|
+
* Only the rotating identity's own wrappings can carry a secret factor, since
|
|
102
|
+
* `addFactor` refuses one on a key it does not administer — so the prompt is
|
|
103
|
+
* always one the caller is present for.
|
|
104
|
+
*/
|
|
105
|
+
inputs?: FactorInputs;
|
|
106
|
+
/** Resolvers for recipient DIDs that need one (`did:kokuin:`), for re-made `did` wrappings. */
|
|
107
|
+
methods?: MethodRegistry;
|
|
108
|
+
/**
|
|
109
|
+
* Overrides the constructed authority for this one call, so a provider-cached
|
|
110
|
+
* manager holds no request state. No production caller feeds one yet — the
|
|
111
|
+
* group-grant wiring that will is a follow-up.
|
|
112
|
+
*/
|
|
113
|
+
authority?: CredentialAuthority;
|
|
114
|
+
};
|
|
115
|
+
/** Optionals shared by the factor-editing verbs `addFactor` and `removeFactor`. */
|
|
116
|
+
export type FactorChangeParams = {
|
|
117
|
+
/**
|
|
118
|
+
* Secrets for the factors that SURVIVE the change, keyed by kind — a
|
|
119
|
+
* descriptor holds a salt, never a secret, so the surviving wrapping's KEK
|
|
120
|
+
* cannot be re-derived without them.
|
|
121
|
+
*/
|
|
122
|
+
inputs?: FactorInputs;
|
|
123
|
+
/** Resolvers for recipient DIDs that need one (`did:kokuin:`), for re-made `did` wrappings. */
|
|
124
|
+
methods?: MethodRegistry;
|
|
125
|
+
/** Per-call authority overriding the constructed default. See `RotateKeyParams.authority`. */
|
|
126
|
+
authority?: CredentialAuthority;
|
|
127
|
+
};
|
|
128
|
+
/** See `grant`. */
|
|
129
|
+
export type GrantKeyParams = {
|
|
130
|
+
/** Resolvers for recipient DIDs that carry no key material of their own (`did:kokuin:`). */
|
|
131
|
+
methods?: MethodRegistry;
|
|
132
|
+
/** Per-call authority overriding the constructed default. See `RotateKeyParams.authority`. */
|
|
133
|
+
authority?: CredentialAuthority;
|
|
134
|
+
};
|
|
135
|
+
/** See `reKeyEntry`. */
|
|
136
|
+
export type ReKeyEntryParams = {
|
|
137
|
+
/** Per-call authority overriding the constructed default. See `RotateKeyParams.authority`. */
|
|
138
|
+
authority?: CredentialAuthority;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Why a key cannot be opened here, as opposed to not being open yet.
|
|
142
|
+
*
|
|
143
|
+
* Carried so that `status` can answer rather than throw: the one call whose job
|
|
144
|
+
* is to report a key as unreadable must not fail on the key that is unreadable.
|
|
145
|
+
*/
|
|
146
|
+
export type CredentialUnopenableReason =
|
|
147
|
+
/** A newer peer's ciphersuite. A different build could open it; this one cannot. */
|
|
148
|
+
'unsupported-suite'
|
|
149
|
+
/** No wrapping at the key's current version — a rotation that did not finish. */
|
|
150
|
+
| 'no-wrapping'
|
|
151
|
+
/** Every wrapping belongs to another recipient. Another device can open it. */
|
|
152
|
+
| 'not-addressed';
|
|
153
|
+
/**
|
|
154
|
+
* Derived, never stored. A persisted `unlocked` column would be a belief that
|
|
155
|
+
* survives a restart it should not.
|
|
156
|
+
*
|
|
157
|
+
* `available` is separated from `unlocked` because they differ in what the
|
|
158
|
+
* caller must do, which is nothing, and in what the machine must do, which is
|
|
159
|
+
* one unwrap. A `did`-only key is `available` until first read, and a TTL
|
|
160
|
+
* eviction returns it there rather than to `locked` — no prompt ever becomes
|
|
161
|
+
* necessary.
|
|
162
|
+
*/
|
|
163
|
+
export type CredentialKeyStatus = {
|
|
164
|
+
state: 'unlocked';
|
|
165
|
+
} | {
|
|
166
|
+
state: 'available';
|
|
167
|
+
} | {
|
|
168
|
+
state: 'locked';
|
|
169
|
+
/**
|
|
170
|
+
* One entry per wrapping that could be opened here: an OR of ANDs. Nested
|
|
171
|
+
* because flattening loses the difference between a key openable by a pin
|
|
172
|
+
* OR a passphrase and one demanding both, and those are different prompts.
|
|
173
|
+
* `did` factors are absent — this device supplies them without asking.
|
|
174
|
+
*/
|
|
175
|
+
unlockOptions: Array<Array<SecretFactorKind>>;
|
|
176
|
+
} | {
|
|
177
|
+
state: 'unopenable';
|
|
178
|
+
reason: CredentialUnopenableReason;
|
|
179
|
+
};
|
|
180
|
+
export type CredentialGrantResult = {
|
|
181
|
+
/** The key version the recipient's wrapping sits at. */
|
|
182
|
+
version: number;
|
|
183
|
+
/**
|
|
184
|
+
* False when the recipient already held a wrapping and nothing was written.
|
|
185
|
+
* A caller broadcasting the grant emits its frame either way — that is what
|
|
186
|
+
* makes a repeat grant the retry path for a frame that never arrived.
|
|
187
|
+
*/
|
|
188
|
+
created: boolean;
|
|
189
|
+
};
|
|
190
|
+
export type CredentialManager = {
|
|
191
|
+
/** Factor kinds this device can supply. Empty means it can hold no credentials. */
|
|
192
|
+
availableFactors(): Array<string>;
|
|
193
|
+
/**
|
|
194
|
+
* Open a key and hold its content key in memory.
|
|
195
|
+
*
|
|
196
|
+
* A `did`-only key needs no call: it opens on demand. This is for the ones
|
|
197
|
+
* carrying a factor only the user can supply.
|
|
198
|
+
*/
|
|
199
|
+
unlock(keyID: string, inputs?: FactorInputs): Promise<void>;
|
|
200
|
+
/** Zero the cached content key. TTL eviction does the same thing. */
|
|
201
|
+
lock(keyID: string): void;
|
|
202
|
+
lockAll(): void;
|
|
203
|
+
status(keyID: string): Promise<CredentialKeyStatus>;
|
|
204
|
+
/**
|
|
205
|
+
* Add a factor to THIS device's wrapping, leaving every other recipient's
|
|
206
|
+
* alone. Adding a `pin` therefore gates this device and no other, which is the
|
|
207
|
+
* intended granularity.
|
|
208
|
+
*
|
|
209
|
+
* `params.inputs` must carry the secret for every factor that SURVIVES the
|
|
210
|
+
* change, not only the one being added: the new wrapping's key is derived from
|
|
211
|
+
* all of them, and a descriptor holds a salt, never a secret.
|
|
212
|
+
*/
|
|
213
|
+
addFactor(keyID: string, factor: FactorInput, params?: FactorChangeParams): Promise<void>;
|
|
214
|
+
/** Remove a factor kind from this device's wrapping. See `addFactor` on `params.inputs`. */
|
|
215
|
+
removeFactor(keyID: string, kind: string, params?: FactorChangeParams): Promise<void>;
|
|
216
|
+
createKey(params?: CreateKeyParams): Promise<string>;
|
|
217
|
+
/**
|
|
218
|
+
* Retire this key's content key: mint a fresh one at `version + 1`,
|
|
219
|
+
* re-encrypt every entry under it and re-make every wrapping. Returns the new
|
|
220
|
+
* version, which a caller has to carry in the `credential:key-rotate` frame.
|
|
221
|
+
*
|
|
222
|
+
* `keyID` and every `entryID` survive, so documents referencing them do not
|
|
223
|
+
* move. A wrapping survives only if this device can re-make it: `did`-only
|
|
224
|
+
* ones re-make unattended, and one carrying a secret factor needs that secret
|
|
225
|
+
* back in `params.inputs`, because re-making a wrapping derives its KEK from
|
|
226
|
+
* every surviving factor and a descriptor holds a salt, never a secret.
|
|
227
|
+
*
|
|
228
|
+
* NOT atomic. This manager holds a resolved store, not a `StoreProvider`, so
|
|
229
|
+
* it has no transaction to open — a caller wanting all-or-nothing has to build
|
|
230
|
+
* the manager inside one.
|
|
231
|
+
*/
|
|
232
|
+
rotate(keyID: string, params?: RotateKeyParams): Promise<number>;
|
|
233
|
+
/**
|
|
234
|
+
* Address this key to one more recipient: a `did` wrapping over the SAME
|
|
235
|
+
* content key at the key's current version. Every entry already written stays
|
|
236
|
+
* as it is — the recipient opens the key, not a copy of it — and no entry
|
|
237
|
+
* plaintext is touched. Returns the version the wrapping was made at, which a
|
|
238
|
+
* caller has to carry in the `credential:key-grant` frame.
|
|
239
|
+
*
|
|
240
|
+
* A recipient that already holds a wrapping is a no-op, so the version alone
|
|
241
|
+
* cannot tell a fresh grant from a re-grant — `created` is what distinguishes
|
|
242
|
+
* them.
|
|
243
|
+
*
|
|
244
|
+
* `recipientWrappableDID` must be RESOLVABLE, not merely a name: a
|
|
245
|
+
* `did:peer:4` short form carries no document and so no agreement key. Working
|
|
246
|
+
* out which string that is for a given party is a group question, and this
|
|
247
|
+
* package does not know what a group is — so it takes the answer rather than
|
|
248
|
+
* asking.
|
|
249
|
+
*
|
|
250
|
+
* `administer`, and the device must be able to open the key: a locked one
|
|
251
|
+
* fails before the first write rather than persisting half a grant.
|
|
252
|
+
*/
|
|
253
|
+
grant(keyID: string, recipientWrappableDID: string, params?: GrantKeyParams): Promise<CredentialGrantResult>;
|
|
254
|
+
listKeys(): Promise<Array<string>>;
|
|
255
|
+
/** How many wrappings a key carries — one per way of opening it. */
|
|
256
|
+
countWrappings(keyID: string): Promise<number>;
|
|
257
|
+
deleteKey(keyID: string): Promise<void>;
|
|
258
|
+
putEntry(keyID: string, plaintext: Uint8Array): Promise<string>;
|
|
259
|
+
updateEntry(entryID: string, plaintext: Uint8Array): Promise<void>;
|
|
260
|
+
readEntry(entryID: string): Promise<Uint8Array>;
|
|
261
|
+
getEntryProvenance(entryID: string): Promise<CredentialEntryProvenance>;
|
|
262
|
+
listEntries(keyID: string): Promise<Array<string>>;
|
|
263
|
+
deleteEntry(entryID: string): Promise<void>;
|
|
264
|
+
/**
|
|
265
|
+
* Move an entry onto another key: decrypt under the one it sits on, re-encrypt
|
|
266
|
+
* under the target's current version, keep the entry id.
|
|
267
|
+
*
|
|
268
|
+
* The key is the sharing unit, so sharing one secret means moving it onto a
|
|
269
|
+
* shared key rather than sharing the key it already sits under, which would
|
|
270
|
+
* share every other entry beneath that one too. The entry id survives because
|
|
271
|
+
* it never embedded a key id — every document referencing it stays valid. The
|
|
272
|
+
* ciphertext does not survive: it is bound to its key, so this is a genuine
|
|
273
|
+
* re-encryption and the target must be open, not merely named.
|
|
274
|
+
*
|
|
275
|
+
* `administer` on BOTH keys. Writing under the target changes what the target
|
|
276
|
+
* holds, and taking an entry out of the source changes what the source holds.
|
|
277
|
+
*/
|
|
278
|
+
reKeyEntry(entryID: string, toKeyID: string, params?: ReKeyEntryParams): Promise<void>;
|
|
279
|
+
};
|
|
280
|
+
export declare function createCredentialManager(params: CreateCredentialManagerParams): CredentialManager;
|