@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/op.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type SigningIdentity } from '@kokuin/token';
|
|
2
|
+
import type { CredentialEntry, CredentialKey, CredentialOpInput, CredentialOpSubjectKind, CredentialTombstoneInput, CredentialWrapping, PutCredentialEntryParams, PutCredentialKeyParams, PutCredentialWrappingParams } from '@kubun/store-credential';
|
|
3
|
+
/**
|
|
4
|
+
* What the author of a credential row signs.
|
|
5
|
+
*
|
|
6
|
+
* Digests rather than bytes, as a grant token does: an op travels beside the row
|
|
7
|
+
* it speaks for, so repeating the ciphertext in the signature would double every
|
|
8
|
+
* vault on the wire.
|
|
9
|
+
*
|
|
10
|
+
* `contentDigest` is `null` exactly when `deleted` — a tombstone has no content,
|
|
11
|
+
* and a digest of nothing would be a constant every tombstone shared.
|
|
12
|
+
*/
|
|
13
|
+
export type CredentialOpClaim = {
|
|
14
|
+
subjectKind: CredentialOpSubjectKind;
|
|
15
|
+
subjectID: string;
|
|
16
|
+
keyID: string;
|
|
17
|
+
keyVersion: number;
|
|
18
|
+
hlc: string;
|
|
19
|
+
contentDigest: string | null;
|
|
20
|
+
deleted: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The content-key branch a wrapping or entry joins: its introducing key-op's
|
|
23
|
+
* `${hlc}:${opHash}` label, signed so a relay cannot move a row between
|
|
24
|
+
* branches. Absent on a key-op — a key-op's own label folds its own `opHash`,
|
|
25
|
+
* which is unknown until after signing, so it is recomputed on receipt rather
|
|
26
|
+
* than claimed. Absent too on an op minted outside a rotation/create, which
|
|
27
|
+
* does not yet resolve its branch.
|
|
28
|
+
*/
|
|
29
|
+
branchID?: string;
|
|
30
|
+
};
|
|
31
|
+
/** `issuer` is the normalized verified `iss` — the only author claim a receiver trusts. */
|
|
32
|
+
export type VerifiedCredentialOp = {
|
|
33
|
+
issuer: string;
|
|
34
|
+
claim: CredentialOpClaim;
|
|
35
|
+
};
|
|
36
|
+
/** Which row an op speaks for, for the ops that carry no row of their own. */
|
|
37
|
+
export type CredentialOpSubject = {
|
|
38
|
+
subjectKind: CredentialOpSubjectKind;
|
|
39
|
+
subjectID: string;
|
|
40
|
+
keyID: string;
|
|
41
|
+
keyVersion: number;
|
|
42
|
+
};
|
|
43
|
+
/** A stored row, tagged with which of the three tables it came from. */
|
|
44
|
+
export type CredentialRowSubject = {
|
|
45
|
+
kind: 'key';
|
|
46
|
+
row: CredentialKey;
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'wrapping';
|
|
49
|
+
row: CredentialWrapping;
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'entry';
|
|
52
|
+
row: CredentialEntry;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A row about to be written, in the shape the store takes it.
|
|
56
|
+
*
|
|
57
|
+
* The counterpart to {@link CredentialRowSubject} for a caller that holds no row
|
|
58
|
+
* yet — a receiver checking an op against bytes it has not stored. It is also
|
|
59
|
+
* the shape the signer signs, so the two sides compare the same fields.
|
|
60
|
+
*/
|
|
61
|
+
export type CredentialPutSubject = {
|
|
62
|
+
kind: 'key';
|
|
63
|
+
params: PutCredentialKeyParams;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'wrapping';
|
|
66
|
+
params: PutCredentialWrappingParams;
|
|
67
|
+
} | {
|
|
68
|
+
kind: 'entry';
|
|
69
|
+
params: PutCredentialEntryParams;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Mints the signed op for every credential row this device writes.
|
|
73
|
+
*
|
|
74
|
+
* Separate from the store because the store holds no identity and does no
|
|
75
|
+
* crypto: it takes the token as opaque text, exactly as it takes a ciphertext.
|
|
76
|
+
*/
|
|
77
|
+
export type CredentialOpSigner = {
|
|
78
|
+
key(params: PutCredentialKeyParams, hlc: string): Promise<CredentialOpInput>;
|
|
79
|
+
/**
|
|
80
|
+
* `branchID` is the introducing key-op's label — passed by a create/rotate
|
|
81
|
+
* that just minted that key-op. Omitted elsewhere: an op made outside a
|
|
82
|
+
* rotation does not yet resolve its branch, and gets an empty label until a
|
|
83
|
+
* later slice threads the current one out of the read path.
|
|
84
|
+
*/
|
|
85
|
+
wrapping(params: PutCredentialWrappingParams, hlc: string, branchID?: string): Promise<CredentialOpInput>;
|
|
86
|
+
/**
|
|
87
|
+
* Stamped from the entry's own `hlc`, never a second reading of the clock. The
|
|
88
|
+
* entry row keeps that column and the op repeats it, so the two would
|
|
89
|
+
* otherwise disagree the moment the clock moved between them. `branchID` as on
|
|
90
|
+
* {@link CredentialOpSigner.wrapping}.
|
|
91
|
+
*/
|
|
92
|
+
entry(params: PutCredentialEntryParams, branchID?: string): Promise<CredentialOpInput>;
|
|
93
|
+
tombstone(subject: CredentialOpSubject, hlc: string): Promise<CredentialOpInput>;
|
|
94
|
+
/** A tombstone naming its own subject, for a delete that removes several rows. */
|
|
95
|
+
wrappingTombstone(subject: CredentialOpSubject, hlc: string): Promise<CredentialTombstoneInput>;
|
|
96
|
+
};
|
|
97
|
+
/** The digest of the bytes a row holds, per subject kind. */
|
|
98
|
+
export declare function credentialRowDigest(subject: CredentialRowSubject): string;
|
|
99
|
+
/** The digest of the bytes a row is about to be written with, per subject kind. */
|
|
100
|
+
export declare function credentialPutDigest(subject: CredentialPutSubject): string;
|
|
101
|
+
/** Whether a verified claim speaks for a stored row. */
|
|
102
|
+
export declare function credentialOpMatchesRow(claim: CredentialOpClaim, subject: CredentialRowSubject): boolean;
|
|
103
|
+
/** Whether a verified claim speaks for a row about to be written. */
|
|
104
|
+
export declare function credentialOpMatchesPut(claim: CredentialOpClaim, subject: CredentialPutSubject): boolean;
|
|
105
|
+
export declare function createCredentialOpSigner(identity: SigningIdentity): CredentialOpSigner;
|
|
106
|
+
/**
|
|
107
|
+
* Order two branch labels, keyless and total.
|
|
108
|
+
*
|
|
109
|
+
* The `hlc` halves are compared as strings: the serialized HLC form is
|
|
110
|
+
* lexically total-ordered by construction (fixed-width ISO wall time, then a
|
|
111
|
+
* zero-padded counter, then the nodeID), the same property the store's
|
|
112
|
+
* tombstone check leans on. Two concurrent rotations neither dominate, so the
|
|
113
|
+
* `opHash` breaks the tie — also lexical, and distinct per op, which is what
|
|
114
|
+
* makes the order total and identical on every device.
|
|
115
|
+
*/
|
|
116
|
+
export declare function compareBranchIDs(a: string, b: string): number;
|
|
117
|
+
/**
|
|
118
|
+
* The single branch a keyless party names as the winner: the one whose
|
|
119
|
+
* introducing key-op dominates by HLC, `opHash` breaking genuine concurrency.
|
|
120
|
+
* Order-independent — every device folds the same set to the same label.
|
|
121
|
+
*/
|
|
122
|
+
export declare function selectWinningBranch(branchIDs: ReadonlyArray<string>): string;
|
|
123
|
+
/**
|
|
124
|
+
* Verify an op and extract its claim. Returns `null` (never throws) when the
|
|
125
|
+
* token is unparseable, unsigned (`alg: 'none'`), or structurally malformed — an
|
|
126
|
+
* attacker cannot forge an `iss` this way.
|
|
127
|
+
*/
|
|
128
|
+
export declare function verifyCredentialOp(token: string): Promise<VerifiedCredentialOp | null>;
|
package/lib/op.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
|
|
2
|
+
import { toB64U } from '@sozai/codec';
|
|
3
|
+
import { credentialEntryDigest, credentialKeyDigest, credentialWrappingDigest, digestParts } from './digest.js';
|
|
4
|
+
/** The digest of the bytes a row holds, per subject kind. */ export function credentialRowDigest(subject) {
|
|
5
|
+
switch(subject.kind){
|
|
6
|
+
case 'key':
|
|
7
|
+
return credentialKeyDigest({
|
|
8
|
+
ownerDID: subject.row.owner_did,
|
|
9
|
+
suite: subject.row.suite,
|
|
10
|
+
version: subject.row.version,
|
|
11
|
+
state: subject.row.state
|
|
12
|
+
});
|
|
13
|
+
case 'wrapping':
|
|
14
|
+
return credentialWrappingDigest({
|
|
15
|
+
wrappingID: subject.row.wrapping_id,
|
|
16
|
+
recipientDID: subject.row.recipient_did,
|
|
17
|
+
iv: toB64U(new Uint8Array(subject.row.iv)),
|
|
18
|
+
wrappedKey: toB64U(new Uint8Array(subject.row.wrapped_key)),
|
|
19
|
+
factors: subject.row.factors
|
|
20
|
+
});
|
|
21
|
+
case 'entry':
|
|
22
|
+
return credentialEntryDigest({
|
|
23
|
+
entryID: subject.row.entry_id,
|
|
24
|
+
iv: toB64U(new Uint8Array(subject.row.iv)),
|
|
25
|
+
ciphertext: toB64U(new Uint8Array(subject.row.ciphertext)),
|
|
26
|
+
hlc: subject.row.hlc
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The digest of the bytes a row is about to be written with, per subject kind. */ export function credentialPutDigest(subject) {
|
|
31
|
+
switch(subject.kind){
|
|
32
|
+
case 'key':
|
|
33
|
+
return credentialKeyDigest({
|
|
34
|
+
ownerDID: subject.params.ownerDID,
|
|
35
|
+
suite: subject.params.suite,
|
|
36
|
+
version: subject.params.version,
|
|
37
|
+
state: subject.params.state
|
|
38
|
+
});
|
|
39
|
+
case 'wrapping':
|
|
40
|
+
return credentialWrappingDigest({
|
|
41
|
+
wrappingID: subject.params.wrappingID,
|
|
42
|
+
// An omitted recipient and an explicit null are the same wrapping, as
|
|
43
|
+
// the signer folds them.
|
|
44
|
+
recipientDID: subject.params.recipientDID ?? null,
|
|
45
|
+
iv: toB64U(subject.params.iv),
|
|
46
|
+
wrappedKey: toB64U(subject.params.wrappedKey),
|
|
47
|
+
factors: subject.params.factors
|
|
48
|
+
});
|
|
49
|
+
case 'entry':
|
|
50
|
+
return credentialEntryDigest({
|
|
51
|
+
entryID: subject.params.entryID,
|
|
52
|
+
iv: toB64U(subject.params.iv),
|
|
53
|
+
ciphertext: toB64U(subject.params.ciphertext),
|
|
54
|
+
hlc: subject.params.hlc
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether a verified claim speaks for exactly these bytes under exactly this
|
|
60
|
+
* identity.
|
|
61
|
+
*
|
|
62
|
+
* The identifiers are checked along with the digest: a claim over another row's
|
|
63
|
+
* content would otherwise authenticate this one as soon as the two held the same
|
|
64
|
+
* bytes, which two wrappings of the same key routinely do not but two
|
|
65
|
+
* empty-state keys would. `keyID` matters beyond that — it is the column the
|
|
66
|
+
* sync lane buckets a vault by, so an op filed under another key's id would
|
|
67
|
+
* travel with the wrong set.
|
|
68
|
+
*
|
|
69
|
+
* `stamp` is the row's own `hlc` where the row has one, which is entries only.
|
|
70
|
+
* It has to equal the claim's: the op row keeps the claim's stamp and the entry
|
|
71
|
+
* row keeps its own, and the two disagreeing would order a row differently from
|
|
72
|
+
* the op that speaks for it.
|
|
73
|
+
*/ function matchesClaim(claim, subject, contentDigest) {
|
|
74
|
+
return claim.subjectKind === subject.kind && claim.subjectID === subject.subjectID && claim.keyID === subject.keyID && claim.keyVersion === subject.keyVersion && (subject.stamp == null || claim.hlc === subject.stamp) && !claim.deleted && claim.contentDigest === contentDigest;
|
|
75
|
+
}
|
|
76
|
+
/** Whether a verified claim speaks for a stored row. */ export function credentialOpMatchesRow(claim, subject) {
|
|
77
|
+
const identity = subject.kind === 'key' ? {
|
|
78
|
+
kind: 'key',
|
|
79
|
+
subjectID: subject.row.key_id,
|
|
80
|
+
keyID: subject.row.key_id,
|
|
81
|
+
keyVersion: subject.row.version
|
|
82
|
+
} : subject.kind === 'wrapping' ? {
|
|
83
|
+
kind: 'wrapping',
|
|
84
|
+
subjectID: subject.row.wrapping_id,
|
|
85
|
+
keyID: subject.row.key_id,
|
|
86
|
+
keyVersion: subject.row.key_version
|
|
87
|
+
} : {
|
|
88
|
+
kind: 'entry',
|
|
89
|
+
subjectID: subject.row.entry_id,
|
|
90
|
+
keyID: subject.row.key_id,
|
|
91
|
+
keyVersion: subject.row.key_version,
|
|
92
|
+
stamp: subject.row.hlc
|
|
93
|
+
};
|
|
94
|
+
return matchesClaim(claim, identity, credentialRowDigest(subject));
|
|
95
|
+
}
|
|
96
|
+
/** Whether a verified claim speaks for a row about to be written. */ export function credentialOpMatchesPut(claim, subject) {
|
|
97
|
+
const identity = subject.kind === 'key' ? {
|
|
98
|
+
kind: 'key',
|
|
99
|
+
subjectID: subject.params.keyID,
|
|
100
|
+
keyID: subject.params.keyID,
|
|
101
|
+
keyVersion: subject.params.version
|
|
102
|
+
} : subject.kind === 'wrapping' ? {
|
|
103
|
+
kind: 'wrapping',
|
|
104
|
+
subjectID: subject.params.wrappingID,
|
|
105
|
+
keyID: subject.params.keyID,
|
|
106
|
+
keyVersion: subject.params.keyVersion
|
|
107
|
+
} : {
|
|
108
|
+
kind: 'entry',
|
|
109
|
+
subjectID: subject.params.entryID,
|
|
110
|
+
keyID: subject.params.keyID,
|
|
111
|
+
keyVersion: subject.params.keyVersion,
|
|
112
|
+
stamp: subject.params.hlc
|
|
113
|
+
};
|
|
114
|
+
return matchesClaim(claim, identity, credentialPutDigest(subject));
|
|
115
|
+
}
|
|
116
|
+
async function signOp(identity, claim) {
|
|
117
|
+
const signed = await identity.signToken({
|
|
118
|
+
...claim
|
|
119
|
+
}, {
|
|
120
|
+
embedLongForm: true
|
|
121
|
+
});
|
|
122
|
+
const opJWT = stringifyToken(signed);
|
|
123
|
+
return {
|
|
124
|
+
keyID: claim.keyID,
|
|
125
|
+
// Normalized, unlike a wrapping's recipient: this one is compared against a
|
|
126
|
+
// key's owner and against a verified issuer, never resolved to a document.
|
|
127
|
+
authorDID: normalizeDID(identity.id),
|
|
128
|
+
hlc: claim.hlc,
|
|
129
|
+
opJWT,
|
|
130
|
+
// The token addresses itself, the way the graph mutation log addresses a
|
|
131
|
+
// mutation by its hash.
|
|
132
|
+
opHash: digestParts([
|
|
133
|
+
opJWT
|
|
134
|
+
]),
|
|
135
|
+
// A wrapping or entry mirrors the branch it signed; anything without one
|
|
136
|
+
// gets the empty label. A key-op overrides this from its own `opHash`.
|
|
137
|
+
branchID: claim.branchID ?? ''
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function createCredentialOpSigner(identity) {
|
|
141
|
+
async function tombstone(subject, hlc) {
|
|
142
|
+
return await signOp(identity, {
|
|
143
|
+
subjectKind: subject.subjectKind,
|
|
144
|
+
subjectID: subject.subjectID,
|
|
145
|
+
keyID: subject.keyID,
|
|
146
|
+
keyVersion: subject.keyVersion,
|
|
147
|
+
hlc,
|
|
148
|
+
contentDigest: null,
|
|
149
|
+
deleted: true
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
async key (params, hlc) {
|
|
154
|
+
const input = await signOp(identity, {
|
|
155
|
+
subjectKind: 'key',
|
|
156
|
+
subjectID: params.keyID,
|
|
157
|
+
keyID: params.keyID,
|
|
158
|
+
keyVersion: params.version,
|
|
159
|
+
hlc,
|
|
160
|
+
contentDigest: credentialKeyDigest({
|
|
161
|
+
ownerDID: params.ownerDID,
|
|
162
|
+
suite: params.suite,
|
|
163
|
+
version: params.version,
|
|
164
|
+
state: params.state
|
|
165
|
+
}),
|
|
166
|
+
deleted: false
|
|
167
|
+
});
|
|
168
|
+
// The key-op labels its own branch. It cannot claim this in the signed
|
|
169
|
+
// token — the label folds `opHash`, which the signature produces — so it
|
|
170
|
+
// rides on the input and a receiver recomputes it from the op's `hlc` and
|
|
171
|
+
// `opHash`, which is what authenticates it.
|
|
172
|
+
return {
|
|
173
|
+
...input,
|
|
174
|
+
branchID: `${hlc}:${input.opHash}`
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
async wrapping (params, hlc, branchID) {
|
|
178
|
+
return await signOp(identity, {
|
|
179
|
+
subjectKind: 'wrapping',
|
|
180
|
+
subjectID: params.wrappingID,
|
|
181
|
+
keyID: params.keyID,
|
|
182
|
+
keyVersion: params.keyVersion,
|
|
183
|
+
hlc,
|
|
184
|
+
contentDigest: credentialWrappingDigest({
|
|
185
|
+
wrappingID: params.wrappingID,
|
|
186
|
+
// An omitted recipient and an explicit null are the same wrapping, and
|
|
187
|
+
// the stored row keeps only one of the two spellings.
|
|
188
|
+
recipientDID: params.recipientDID ?? null,
|
|
189
|
+
iv: toB64U(params.iv),
|
|
190
|
+
wrappedKey: toB64U(params.wrappedKey),
|
|
191
|
+
factors: params.factors
|
|
192
|
+
}),
|
|
193
|
+
deleted: false,
|
|
194
|
+
branchID
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
async entry (params, branchID) {
|
|
198
|
+
return await signOp(identity, {
|
|
199
|
+
subjectKind: 'entry',
|
|
200
|
+
subjectID: params.entryID,
|
|
201
|
+
keyID: params.keyID,
|
|
202
|
+
keyVersion: params.keyVersion,
|
|
203
|
+
hlc: params.hlc,
|
|
204
|
+
contentDigest: credentialEntryDigest({
|
|
205
|
+
entryID: params.entryID,
|
|
206
|
+
iv: toB64U(params.iv),
|
|
207
|
+
ciphertext: toB64U(params.ciphertext),
|
|
208
|
+
hlc: params.hlc
|
|
209
|
+
}),
|
|
210
|
+
deleted: false,
|
|
211
|
+
branchID
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
tombstone,
|
|
215
|
+
async wrappingTombstone (subject, hlc) {
|
|
216
|
+
return {
|
|
217
|
+
...await tombstone(subject, hlc),
|
|
218
|
+
subjectID: subject.subjectID
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function isSubjectKind(value) {
|
|
224
|
+
return value === 'key' || value === 'wrapping' || value === 'entry';
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Split a `branchID` into its `hlc` and `opHash` halves.
|
|
228
|
+
*
|
|
229
|
+
* The label is `${hlc}:${opHash}`, and both halves — an HLC and a DID nodeID —
|
|
230
|
+
* carry their own colons, but `opHash` is base64url and carries none, so the
|
|
231
|
+
* LAST colon is the boundary however many precede it.
|
|
232
|
+
*/ function splitBranchID(branchID) {
|
|
233
|
+
const boundary = branchID.lastIndexOf(':');
|
|
234
|
+
return boundary < 0 ? {
|
|
235
|
+
hlc: branchID,
|
|
236
|
+
opHash: ''
|
|
237
|
+
} : {
|
|
238
|
+
hlc: branchID.slice(0, boundary),
|
|
239
|
+
opHash: branchID.slice(boundary + 1)
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Order two branch labels, keyless and total.
|
|
244
|
+
*
|
|
245
|
+
* The `hlc` halves are compared as strings: the serialized HLC form is
|
|
246
|
+
* lexically total-ordered by construction (fixed-width ISO wall time, then a
|
|
247
|
+
* zero-padded counter, then the nodeID), the same property the store's
|
|
248
|
+
* tombstone check leans on. Two concurrent rotations neither dominate, so the
|
|
249
|
+
* `opHash` breaks the tie — also lexical, and distinct per op, which is what
|
|
250
|
+
* makes the order total and identical on every device.
|
|
251
|
+
*/ export function compareBranchIDs(a, b) {
|
|
252
|
+
const left = splitBranchID(a);
|
|
253
|
+
const right = splitBranchID(b);
|
|
254
|
+
if (left.hlc !== right.hlc) {
|
|
255
|
+
return left.hlc < right.hlc ? -1 : 1;
|
|
256
|
+
}
|
|
257
|
+
return left.opHash < right.opHash ? -1 : left.opHash > right.opHash ? 1 : 0;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The single branch a keyless party names as the winner: the one whose
|
|
261
|
+
* introducing key-op dominates by HLC, `opHash` breaking genuine concurrency.
|
|
262
|
+
* Order-independent — every device folds the same set to the same label.
|
|
263
|
+
*/ export function selectWinningBranch(branchIDs) {
|
|
264
|
+
if (branchIDs.length === 0) {
|
|
265
|
+
throw new RangeError('selectWinningBranch requires at least one branchID');
|
|
266
|
+
}
|
|
267
|
+
return branchIDs.reduce((winner, candidate)=>compareBranchIDs(candidate, winner) > 0 ? candidate : winner);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Verify an op and extract its claim. Returns `null` (never throws) when the
|
|
271
|
+
* token is unparseable, unsigned (`alg: 'none'`), or structurally malformed — an
|
|
272
|
+
* attacker cannot forge an `iss` this way.
|
|
273
|
+
*/ export async function verifyCredentialOp(token) {
|
|
274
|
+
let verified;
|
|
275
|
+
try {
|
|
276
|
+
verified = await verifyToken(token);
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
if (!isVerifiedToken(verified)) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const { iss, subjectKind, subjectID, keyID, keyVersion, hlc, contentDigest, deleted, branchID } = verified.payload;
|
|
284
|
+
if (!isSubjectKind(subjectKind) || typeof subjectID !== 'string' || typeof keyID !== 'string' || typeof keyVersion !== 'number' || typeof hlc !== 'string' || typeof deleted !== 'boolean' || // A live op with no digest would authenticate any bytes at all, so the two
|
|
285
|
+
// fields are checked against each other rather than one at a time.
|
|
286
|
+
(deleted ? contentDigest !== null : typeof contentDigest !== 'string') || // Optional — a key-op omits it — but a present one must be the signed string
|
|
287
|
+
// a receiver reads as the row's branch label.
|
|
288
|
+
branchID != null && typeof branchID !== 'string') {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
issuer: normalizeDID(iss),
|
|
293
|
+
claim: {
|
|
294
|
+
subjectKind,
|
|
295
|
+
subjectID,
|
|
296
|
+
keyID,
|
|
297
|
+
keyVersion,
|
|
298
|
+
hlc,
|
|
299
|
+
contentDigest: contentDigest,
|
|
300
|
+
deleted,
|
|
301
|
+
branchID: branchID
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attempts allowed before any delay. A mistyped PIN is the common case and
|
|
3
|
+
* should not cost the user a wait.
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_FREE_ATTEMPTS = 3;
|
|
6
|
+
/** First delay after the free attempts, doubling from here. */
|
|
7
|
+
export declare const DEFAULT_BASE_DELAY = 1000;
|
|
8
|
+
/** Ceiling on the doubling. Past this the wait is punitive rather than useful. */
|
|
9
|
+
export declare const DEFAULT_MAX_DELAY: number;
|
|
10
|
+
export type PinThrottleParams = {
|
|
11
|
+
freeAttempts?: number;
|
|
12
|
+
baseDelay?: number;
|
|
13
|
+
maxDelay?: number;
|
|
14
|
+
/** Injectable so a backoff test does not sleep. */
|
|
15
|
+
now?: () => number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Per-key failed-PIN counter with exponential backoff.
|
|
19
|
+
*
|
|
20
|
+
* In memory only. A restart resets it, which is the honest position rather than
|
|
21
|
+
* a gap: anyone able to restart the process to dodge the counter can also read
|
|
22
|
+
* the database, so persisting it would buy nothing while adding a write on every
|
|
23
|
+
* failed attempt — a denial-of-service surface reachable by anyone who can guess
|
|
24
|
+
* wrong repeatedly.
|
|
25
|
+
*
|
|
26
|
+
* The value it does carry is against the borrowed unlocked device, where the
|
|
27
|
+
* `did` factor is non-extractable and the attacker cannot take the ciphertext
|
|
28
|
+
* elsewhere to grind it. There, 10^4 combinations against a doubling delay is
|
|
29
|
+
* not a search anyone completes by hand.
|
|
30
|
+
*/
|
|
31
|
+
export type PinThrottle = {
|
|
32
|
+
/**
|
|
33
|
+
* Milliseconds until this key accepts another attempt, or 0 if it accepts one
|
|
34
|
+
* now. Called BEFORE the KDF runs: a refusal that still derives costs the
|
|
35
|
+
* defender exactly what it costs the attacker.
|
|
36
|
+
*/
|
|
37
|
+
retryAfter(keyID: string): number;
|
|
38
|
+
/** How many attempts remain before a delay begins. */
|
|
39
|
+
attemptsRemaining(keyID: string): number;
|
|
40
|
+
recordFailure(keyID: string): void;
|
|
41
|
+
/** Clear the count. A correct PIN ends the sequence. */
|
|
42
|
+
recordSuccess(keyID: string): void;
|
|
43
|
+
};
|
|
44
|
+
export declare function createPinThrottle(params?: PinThrottleParams): PinThrottle;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attempts allowed before any delay. A mistyped PIN is the common case and
|
|
3
|
+
* should not cost the user a wait.
|
|
4
|
+
*/ export const DEFAULT_FREE_ATTEMPTS = 3;
|
|
5
|
+
/** First delay after the free attempts, doubling from here. */ export const DEFAULT_BASE_DELAY = 1000;
|
|
6
|
+
/** Ceiling on the doubling. Past this the wait is punitive rather than useful. */ export const DEFAULT_MAX_DELAY = 15 * 60 * 1000;
|
|
7
|
+
export function createPinThrottle(params = {}) {
|
|
8
|
+
const freeAttempts = params.freeAttempts ?? DEFAULT_FREE_ATTEMPTS;
|
|
9
|
+
const baseDelay = params.baseDelay ?? DEFAULT_BASE_DELAY;
|
|
10
|
+
const maxDelay = params.maxDelay ?? DEFAULT_MAX_DELAY;
|
|
11
|
+
const now = params.now ?? Date.now;
|
|
12
|
+
const attempts = new Map();
|
|
13
|
+
function delayAfter(failures) {
|
|
14
|
+
// `<`, not `<=`: with the allowance spent, `attemptsRemaining` reads 0 and
|
|
15
|
+
// the next attempt must actually be refused. The two have to agree, or the
|
|
16
|
+
// caller shows "0 attempts left" while another one still goes through.
|
|
17
|
+
if (failures < freeAttempts) {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
// 1s, 2s, 4s … so the first failure past the allowance is barely felt and
|
|
21
|
+
// the tenth is not worth waiting out.
|
|
22
|
+
return Math.min(baseDelay * 2 ** (failures - freeAttempts), maxDelay);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
retryAfter (keyID) {
|
|
26
|
+
const record = attempts.get(keyID);
|
|
27
|
+
if (record == null) {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const elapsed = now() - record.lastFailureAt;
|
|
31
|
+
return Math.max(0, delayAfter(record.failures) - elapsed);
|
|
32
|
+
},
|
|
33
|
+
attemptsRemaining (keyID) {
|
|
34
|
+
const record = attempts.get(keyID);
|
|
35
|
+
return Math.max(0, freeAttempts - (record?.failures ?? 0));
|
|
36
|
+
},
|
|
37
|
+
recordFailure (keyID) {
|
|
38
|
+
const record = attempts.get(keyID);
|
|
39
|
+
attempts.set(keyID, {
|
|
40
|
+
failures: (record?.failures ?? 0) + 1,
|
|
41
|
+
lastFailureAt: now()
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
recordSuccess (keyID) {
|
|
45
|
+
attempts.delete(keyID);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CredentialEntryWithProvenance } from '@kubun/store-credential';
|
|
2
|
+
export type CredentialEntryProvenance = {
|
|
3
|
+
updatedAt: string | null;
|
|
4
|
+
writerDID: string | null;
|
|
5
|
+
};
|
|
6
|
+
export declare function entryProvenanceFields(entry: Pick<CredentialEntryWithProvenance, 'hlc' | 'author_did'>): CredentialEntryProvenance;
|
package/lib/suite.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { KeyAgreementIdentity, MethodRegistry } from '@kokuin/token';
|
|
2
|
+
import type { CredentialFactorDescriptor } from '@kubun/store-credential';
|
|
3
|
+
import type { Runtime } from '@sozai/runtime';
|
|
4
|
+
/** X25519 / HKDF-SHA256 / Argon2id / A256GCM. */
|
|
5
|
+
export declare const SUITE_KUBUN_CRED_1 = 1;
|
|
6
|
+
/** Argon2id cost, in the RFC 9106 parameter names: memory KiB, passes, lanes. */
|
|
7
|
+
export type Argon2Cost = {
|
|
8
|
+
m: number;
|
|
9
|
+
t: number;
|
|
10
|
+
p: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Per-wrapping, not per-suite: the suite fixes the algorithm and the wrapping
|
|
14
|
+
* fixes the cost, so cost rises with hardware without minting a suite.
|
|
15
|
+
*
|
|
16
|
+
* OWASP's first Argon2id configuration. Measured at 340ms on an M2 Max; the
|
|
17
|
+
* 46MiB/t=1 alternative costs the same time for 2.4x the memory, which is the
|
|
18
|
+
* scarcer resource on the Expo and browser targets.
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEFAULT_ARGON2_COST: Argon2Cost;
|
|
21
|
+
/** A kind whose secret comes from the user rather than from key material. */
|
|
22
|
+
export type SecretFactorKind = 'passphrase' | 'pin';
|
|
23
|
+
export type FactorInput = {
|
|
24
|
+
kind: 'did';
|
|
25
|
+
recipientDID: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: SecretFactorKind;
|
|
28
|
+
input: Uint8Array;
|
|
29
|
+
cost?: Argon2Cost;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* User-supplied factor inputs for an unwrap, keyed by kind.
|
|
33
|
+
*
|
|
34
|
+
* Keyed by kind rather than by descriptor index because a caller supplies "my
|
|
35
|
+
* PIN", not "the secret for descriptor 3". That makes two factors of the same
|
|
36
|
+
* kind in one wrapping ambiguous, so {@link validateFactorList} refuses them at
|
|
37
|
+
* creation.
|
|
38
|
+
*/
|
|
39
|
+
export type FactorInputs = Partial<Record<SecretFactorKind, Uint8Array>>;
|
|
40
|
+
export type CredentialSuite = {
|
|
41
|
+
readonly id: number;
|
|
42
|
+
readonly name: string;
|
|
43
|
+
/** Fresh content key for a new key version. */
|
|
44
|
+
generateContentKey(runtime: Runtime): Uint8Array;
|
|
45
|
+
wrap(params: WrapParams): Promise<WrapResult>;
|
|
46
|
+
unwrap(params: UnwrapParams): Promise<Uint8Array>;
|
|
47
|
+
encryptEntry(params: EncryptEntryParams): {
|
|
48
|
+
iv: Uint8Array;
|
|
49
|
+
ciphertext: Uint8Array;
|
|
50
|
+
};
|
|
51
|
+
decryptEntry(params: DecryptEntryParams): Uint8Array;
|
|
52
|
+
};
|
|
53
|
+
export type WrapParams = {
|
|
54
|
+
runtime: Runtime;
|
|
55
|
+
contentKey: Uint8Array;
|
|
56
|
+
factors: Array<FactorInput>;
|
|
57
|
+
wrappingID: string;
|
|
58
|
+
keyID: string;
|
|
59
|
+
keyVersion: number;
|
|
60
|
+
/**
|
|
61
|
+
* Resolvers for recipient DIDs that carry no key material of their own
|
|
62
|
+
* (`did:kokuin:`). Absent leaves self-contained recipients (`did:key`,
|
|
63
|
+
* `did:peer:4`) resolving as before and any other DID failing closed.
|
|
64
|
+
*/
|
|
65
|
+
methods?: MethodRegistry;
|
|
66
|
+
};
|
|
67
|
+
export type WrapResult = {
|
|
68
|
+
descriptors: Array<CredentialFactorDescriptor>;
|
|
69
|
+
iv: Uint8Array;
|
|
70
|
+
/** `ciphertext ‖ tag`. The IV travels beside it, as it does for an entry. */
|
|
71
|
+
wrappedKey: Uint8Array;
|
|
72
|
+
};
|
|
73
|
+
export type UnwrapParams = {
|
|
74
|
+
identity: KeyAgreementIdentity | null;
|
|
75
|
+
descriptors: Array<CredentialFactorDescriptor>;
|
|
76
|
+
/** Raw user input per kind. Absent for a `did`-only wrapping. */
|
|
77
|
+
inputs?: FactorInputs;
|
|
78
|
+
iv: Uint8Array;
|
|
79
|
+
wrappedKey: Uint8Array;
|
|
80
|
+
wrappingID: string;
|
|
81
|
+
keyID: string;
|
|
82
|
+
keyVersion: number;
|
|
83
|
+
};
|
|
84
|
+
export type EncryptEntryParams = {
|
|
85
|
+
runtime: Runtime;
|
|
86
|
+
contentKey: Uint8Array;
|
|
87
|
+
plaintext: Uint8Array;
|
|
88
|
+
keyID: string;
|
|
89
|
+
keyVersion: number;
|
|
90
|
+
entryID: string;
|
|
91
|
+
};
|
|
92
|
+
export type DecryptEntryParams = {
|
|
93
|
+
contentKey: Uint8Array;
|
|
94
|
+
iv: Uint8Array;
|
|
95
|
+
ciphertext: Uint8Array;
|
|
96
|
+
keyID: string;
|
|
97
|
+
keyVersion: number;
|
|
98
|
+
entryID: string;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Factor-list rules that hold regardless of suite.
|
|
102
|
+
*
|
|
103
|
+
* A second `passphrase` or `pin` is refused because unwrap inputs are keyed by
|
|
104
|
+
* kind: it could only ever be handed the same secret as the first, making an AND
|
|
105
|
+
* that reads as two factors and is one. Repeated `did` factors are fine and
|
|
106
|
+
* meaningful — each descriptor carries its own recipient, so "Alice AND Bob" is
|
|
107
|
+
* a list of two.
|
|
108
|
+
*/
|
|
109
|
+
export declare function validateFactorList(kinds: Array<string>): void;
|
|
110
|
+
/** The suite a key names, or `UnsupportedCredentialSuite` — never a silent skip. */
|
|
111
|
+
export declare function getSuite(id: number): CredentialSuite;
|
|
112
|
+
export declare function isSupportedSuite(id: number): boolean;
|