@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/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;
@@ -0,0 +1,8 @@
1
+ import { HLC } from '@kubun/hlc';
2
+ export function entryProvenanceFields(entry) {
3
+ const timestamp = HLC.tryParse(entry.hlc);
4
+ return {
5
+ updatedAt: timestamp == null ? null : new Date(timestamp.wallTime).toISOString(),
6
+ writerDID: entry.author_did
7
+ };
8
+ }
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;