@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/suite.js ADDED
@@ -0,0 +1,317 @@
1
+ import { deriveSharedSecretAsync } from '@kokuin/jwe';
2
+ import { gcm } from '@noble/ciphers/aes.js';
3
+ import { argon2idAsync } from '@noble/hashes/argon2.js';
4
+ import { hkdf } from '@noble/hashes/hkdf.js';
5
+ import { sha256 } from '@noble/hashes/sha2.js';
6
+ import { concatBytes } from '@noble/hashes/utils.js';
7
+ import { canonicalStringify, fromB64, fromUTF, toB64 } from '@sozai/codec';
8
+ import { CredentialSecretRejected, CredentialTampered, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite } from './errors.js';
9
+ /** X25519 / HKDF-SHA256 / Argon2id / A256GCM. */ export const SUITE_KUBUN_CRED_1 = 1;
10
+ const IV_LENGTH = 12;
11
+ const CONTENT_KEY_LENGTH = 32;
12
+ const FACTOR_SECRET_LENGTH = 32;
13
+ const SALT_LENGTH = 16;
14
+ const WRAP_INFO_PREFIX = 'kubun/credential-wrap/v1';
15
+ const ARGON2_COST_LIMITS = {
16
+ m: {
17
+ min: 8,
18
+ max: 262_144
19
+ },
20
+ t: {
21
+ min: 1,
22
+ max: 10
23
+ },
24
+ p: {
25
+ min: 1,
26
+ max: 16
27
+ }
28
+ };
29
+ /**
30
+ * Per-wrapping, not per-suite: the suite fixes the algorithm and the wrapping
31
+ * fixes the cost, so cost rises with hardware without minting a suite.
32
+ *
33
+ * OWASP's first Argon2id configuration. Measured at 340ms on an M2 Max; the
34
+ * 46MiB/t=1 alternative costs the same time for 2.4x the memory, which is the
35
+ * scarcer resource on the Expo and browser targets.
36
+ */ export const DEFAULT_ARGON2_COST = {
37
+ m: 19456,
38
+ t: 2,
39
+ p: 1
40
+ };
41
+ function invalidArgon2Cost(cost) {
42
+ for (const name of [
43
+ 'm',
44
+ 't',
45
+ 'p'
46
+ ]){
47
+ const value = cost[name];
48
+ const limits = ARGON2_COST_LIMITS[name];
49
+ if (!Number.isSafeInteger(value) || value < limits.min || value > limits.max) {
50
+ return `${name} must be an integer from ${limits.min} through ${limits.max}`;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ /**
56
+ * The wrapping's key-encryption key.
57
+ *
58
+ * `salt` is the wrapping id, so two wrappings of the same content key under the
59
+ * same secrets still derive different KEKs. `info` binds the suite, key and
60
+ * version, which is what stops a wrapping at version N opening ciphertext at
61
+ * N+1 — a rotation's whole point.
62
+ */ function deriveKEK(secrets, wrappingID, suiteID, keyID, keyVersion) {
63
+ const info = fromUTF(`${WRAP_INFO_PREFIX}:${suiteID}:${keyID}:${keyVersion}`);
64
+ return hkdf(sha256, concatBytes(...secrets), fromUTF(wrappingID), info, CONTENT_KEY_LENGTH);
65
+ }
66
+ /**
67
+ * The wrapping's additional authenticated data: the factor descriptors, in
68
+ * order, canonically serialized.
69
+ *
70
+ * Order is load-bearing twice over — the KEK concatenates the secrets in this
71
+ * order, and the AAD covers it — so a reordered list fails the tag rather than
72
+ * deriving a different key and failing indistinguishably from a wrong secret.
73
+ */ function wrappingAAD(descriptors) {
74
+ return fromUTF(canonicalStringify(descriptors));
75
+ }
76
+ /** Binds an entry's ciphertext to the exact key, version and entry it was written for. */ function entryAAD(suiteID, keyID, keyVersion, entryID) {
77
+ return fromUTF(`${suiteID}:${keyID}:${keyVersion}:${entryID}`);
78
+ }
79
+ /**
80
+ * The ECDH output is raw, not uniformly random. It is safe here only because
81
+ * `deriveKEK` runs every factor secret through HKDF with its own domain
82
+ * separation — a caller consuming this as a key directly would not be.
83
+ */ async function didFactorSecretForSender(recipientDID, methods) {
84
+ const { sharedSecret, ephemeralPublicKey } = await deriveSharedSecretAsync(recipientDID, {
85
+ methods: methods ?? []
86
+ });
87
+ return {
88
+ secret: sharedSecret,
89
+ params: {
90
+ recipient: recipientDID,
91
+ epk: toB64(ephemeralPublicKey)
92
+ }
93
+ };
94
+ }
95
+ /**
96
+ * The KDF runs here rather than in the manager.
97
+ *
98
+ * A caller deriving the secret itself would have to read the descriptor's salt
99
+ * and cost, which makes the suite's `passphraseKDF` a property nothing enforces:
100
+ * a later scrypt suite would silently be fed Argon2 output.
101
+ */ async function secretFactorForSender(runtime, input, cost) {
102
+ const invalid = invalidArgon2Cost(cost);
103
+ if (invalid != null) {
104
+ throw new InvalidCredentialFactors(`invalid Argon2 cost: ${invalid}`);
105
+ }
106
+ const salt = runtime.getRandomValues(new Uint8Array(SALT_LENGTH));
107
+ const secret = await argon2idAsync(input, salt, {
108
+ ...cost,
109
+ dkLen: FACTOR_SECRET_LENGTH
110
+ });
111
+ return {
112
+ secret,
113
+ params: {
114
+ salt: toB64(salt),
115
+ ...cost
116
+ }
117
+ };
118
+ }
119
+ async function secretFactorForRecipient(kind, input, params) {
120
+ if (input == null) {
121
+ throw new NoAvailableFactor(kind);
122
+ }
123
+ const { salt, m, t, p } = params;
124
+ if (typeof salt !== 'string' || typeof m !== 'number' || typeof t !== 'number' || typeof p !== 'number') {
125
+ throw new CredentialTampered(`${kind} factor descriptor carries no usable Argon2 parameters`);
126
+ }
127
+ const invalid = invalidArgon2Cost({
128
+ m,
129
+ t,
130
+ p
131
+ });
132
+ if (invalid != null) {
133
+ throw new CredentialTampered(`${kind} factor descriptor has invalid Argon2 cost: ${invalid}`);
134
+ }
135
+ let decodedSalt;
136
+ try {
137
+ decodedSalt = fromB64(salt);
138
+ } catch (cause) {
139
+ throw new CredentialTampered(`${kind} factor descriptor carries an invalid Argon2 salt`, {
140
+ cause
141
+ });
142
+ }
143
+ if (decodedSalt.length !== SALT_LENGTH) {
144
+ throw new CredentialTampered(`${kind} factor descriptor Argon2 salt must be ${SALT_LENGTH} bytes`);
145
+ }
146
+ return await argon2idAsync(input, decodedSalt, {
147
+ m,
148
+ t,
149
+ p,
150
+ dkLen: FACTOR_SECRET_LENGTH
151
+ });
152
+ }
153
+ /**
154
+ * Factor-list rules that hold regardless of suite.
155
+ *
156
+ * A second `passphrase` or `pin` is refused because unwrap inputs are keyed by
157
+ * kind: it could only ever be handed the same secret as the first, making an AND
158
+ * that reads as two factors and is one. Repeated `did` factors are fine and
159
+ * meaningful — each descriptor carries its own recipient, so "Alice AND Bob" is
160
+ * a list of two.
161
+ */ export function validateFactorList(kinds) {
162
+ // Zero factors is not a weaker AND, it is no AND: `deriveKEK` runs over no
163
+ // secrets at all, so the wrapping opens for anyone holding the row.
164
+ if (kinds.length === 0) {
165
+ throw new InvalidCredentialFactors('a wrapping needs at least one factor');
166
+ }
167
+ const seen = new Set();
168
+ for (const kind of kinds){
169
+ if (kind === 'did') {
170
+ continue;
171
+ }
172
+ if (seen.has(kind)) {
173
+ throw new InvalidCredentialFactors(`duplicate "${kind}" factor`);
174
+ }
175
+ seen.add(kind);
176
+ }
177
+ // A pin is confirmation-bearing, not entropy-bearing: four digits are
178
+ // grindable by anyone holding the ciphertext. It is only worth anything
179
+ // chained to a key that device possession supplies, and a passphrase does not
180
+ // substitute — the threat model is the borrowed unlocked device, not the
181
+ // attacker with the database.
182
+ if (seen.has('pin') && !kinds.includes('did')) {
183
+ throw new InvalidCredentialFactors('a "pin" factor requires a "did" factor in the same wrapping');
184
+ }
185
+ }
186
+ async function didFactorSecretForRecipient(identity, params) {
187
+ if (identity == null) {
188
+ throw new NoAvailableFactor('did');
189
+ }
190
+ const epk = params.epk;
191
+ if (typeof epk !== 'string') {
192
+ throw new CredentialTampered('did factor descriptor carries no epk');
193
+ }
194
+ // No `kid`: the sender always resolves the first published keyAgreement key, and
195
+ // on a multi-KEM identity another one silently recovers different bytes.
196
+ return await identity.agreeKey(fromB64(epk));
197
+ }
198
+ const suite1 = {
199
+ id: SUITE_KUBUN_CRED_1,
200
+ name: 'KUBUN_CRED_1',
201
+ generateContentKey (runtime) {
202
+ return runtime.getRandomValues(new Uint8Array(CONTENT_KEY_LENGTH));
203
+ },
204
+ async wrap (params) {
205
+ validateFactorList(params.factors.map((factor)=>factor.kind));
206
+ const secrets = [];
207
+ const descriptors = [];
208
+ for (const factor of params.factors){
209
+ switch(factor.kind){
210
+ case 'did':
211
+ {
212
+ const { secret, params: factorParams } = await didFactorSecretForSender(factor.recipientDID, params.methods);
213
+ secrets.push(secret);
214
+ descriptors.push({
215
+ kind: 'did',
216
+ params: factorParams
217
+ });
218
+ break;
219
+ }
220
+ case 'passphrase':
221
+ case 'pin':
222
+ {
223
+ const { secret, params: factorParams } = await secretFactorForSender(params.runtime, factor.input, factor.cost ?? DEFAULT_ARGON2_COST);
224
+ secrets.push(secret);
225
+ descriptors.push({
226
+ kind: factor.kind,
227
+ params: factorParams
228
+ });
229
+ break;
230
+ }
231
+ }
232
+ }
233
+ const kek = deriveKEK(secrets, params.wrappingID, SUITE_KUBUN_CRED_1, params.keyID, params.keyVersion);
234
+ const iv = params.runtime.getRandomValues(new Uint8Array(IV_LENGTH));
235
+ return {
236
+ descriptors,
237
+ iv,
238
+ wrappedKey: gcm(kek, iv, wrappingAAD(descriptors)).encrypt(params.contentKey)
239
+ };
240
+ },
241
+ async unwrap (params) {
242
+ // Validated on open too, not only on create: wrappings arrive off the wire
243
+ // (`credential:key-grant`), and a pin-only wrapping is openable by anyone who
244
+ // guesses four digits, a factorless one by anyone at all. A wrapping this
245
+ // build refuses to make is not one it agrees to open.
246
+ validateFactorList(params.descriptors.map((descriptor)=>descriptor.kind));
247
+ const secrets = [];
248
+ const userSuppliedKinds = [];
249
+ for (const descriptor of params.descriptors){
250
+ switch(descriptor.kind){
251
+ case 'did':
252
+ secrets.push(await didFactorSecretForRecipient(params.identity, descriptor.params));
253
+ break;
254
+ case 'passphrase':
255
+ case 'pin':
256
+ secrets.push(await secretFactorForRecipient(descriptor.kind, params.inputs?.[descriptor.kind], descriptor.params));
257
+ userSuppliedKinds.push(descriptor.kind);
258
+ break;
259
+ default:
260
+ throw new NoAvailableFactor(descriptor.kind);
261
+ }
262
+ }
263
+ const kek = deriveKEK(secrets, params.wrappingID, SUITE_KUBUN_CRED_1, params.keyID, params.keyVersion);
264
+ try {
265
+ return gcm(kek, params.iv, wrappingAAD(params.descriptors)).decrypt(params.wrappedKey);
266
+ } catch (cause) {
267
+ // One tag failure is everything that is observable here, so which error
268
+ // this is amounts to a policy about who could have been wrong. With a
269
+ // user-supplied factor in the list the secret is the likely cause and the
270
+ // caller needs a retry path; with none, nothing the user typed could have
271
+ // been wrong and the remaining causes are all tampering.
272
+ if (userSuppliedKinds.length > 0) {
273
+ throw new CredentialSecretRejected(userSuppliedKinds, {
274
+ cause,
275
+ keyID: params.keyID
276
+ });
277
+ }
278
+ throw new CredentialTampered('credential wrapping failed authentication: its descriptors or ciphertext were altered, or it belongs to another key', {
279
+ cause
280
+ });
281
+ }
282
+ },
283
+ encryptEntry (params) {
284
+ const iv = params.runtime.getRandomValues(new Uint8Array(IV_LENGTH));
285
+ const aad = entryAAD(SUITE_KUBUN_CRED_1, params.keyID, params.keyVersion, params.entryID);
286
+ return {
287
+ iv,
288
+ ciphertext: gcm(params.contentKey, iv, aad).encrypt(params.plaintext)
289
+ };
290
+ },
291
+ decryptEntry (params) {
292
+ const aad = entryAAD(SUITE_KUBUN_CRED_1, params.keyID, params.keyVersion, params.entryID);
293
+ try {
294
+ return gcm(params.contentKey, params.iv, aad).decrypt(params.ciphertext);
295
+ } catch (cause) {
296
+ throw new CredentialTampered('credential entry failed authentication: it was altered, or is being read under the wrong key version', {
297
+ cause
298
+ });
299
+ }
300
+ }
301
+ };
302
+ const SUITES = new Map([
303
+ [
304
+ SUITE_KUBUN_CRED_1,
305
+ suite1
306
+ ]
307
+ ]);
308
+ /** The suite a key names, or `UnsupportedCredentialSuite` — never a silent skip. */ export function getSuite(id) {
309
+ const suite = SUITES.get(id);
310
+ if (suite == null) {
311
+ throw new UnsupportedCredentialSuite(id);
312
+ }
313
+ return suite;
314
+ }
315
+ export function isSupportedSuite(id) {
316
+ return SUITES.has(id);
317
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The capability action a device presents to administer (grant/rotate/revoke/
3
+ * factor-change) a controller-owned credential on its owner's behalf. It rides
4
+ * the same `checkCapability` matcher documents use — an action is matched by
5
+ * segment, so a `credential/*` grant covers it, exactly as `document/*` covers
6
+ * `document/write`.
7
+ */
8
+ export declare const CREDENTIAL_ADMINISTER_ACTION = "credential/administer";
9
+ /**
10
+ * The user-wide capability resource for a controller's credentials, mirroring
11
+ * `urn:kubun:user:<owner>` for documents: one delegation covers all of a
12
+ * controller's credentials rather than naming each key. The DID is normalized so
13
+ * the resource is an equality key — the same reason `owner_did` is normalized and
14
+ * a wrapping's recipient is not.
15
+ */
16
+ export declare function credentialUserResource(controllerDID: string): string;
@@ -0,0 +1,17 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ /**
3
+ * The capability action a device presents to administer (grant/rotate/revoke/
4
+ * factor-change) a controller-owned credential on its owner's behalf. It rides
5
+ * the same `checkCapability` matcher documents use — an action is matched by
6
+ * segment, so a `credential/*` grant covers it, exactly as `document/*` covers
7
+ * `document/write`.
8
+ */ export const CREDENTIAL_ADMINISTER_ACTION = 'credential/administer';
9
+ /**
10
+ * The user-wide capability resource for a controller's credentials, mirroring
11
+ * `urn:kubun:user:<owner>` for documents: one delegation covers all of a
12
+ * controller's credentials rather than naming each key. The DID is normalized so
13
+ * the resource is an equality key — the same reason `owner_did` is normalized and
14
+ * a wrapping's recipient is not.
15
+ */ export function credentialUserResource(controllerDID) {
16
+ return `urn:kubun:credential:user:${normalizeDID(controllerDID)}`;
17
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@kubun/credential",
3
+ "version": "0.13.0",
4
+ "keywords": [],
5
+ "license": "see LICENSE.md",
6
+ "sideEffects": false,
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./lib/index.js"
10
+ },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
13
+ "files": [
14
+ "lib/*",
15
+ "LICENSE.md"
16
+ ],
17
+ "dependencies": {
18
+ "@kokuin/jwe": "^0.1.0",
19
+ "@kokuin/token": "^0.5.0",
20
+ "@noble/ciphers": "^2.3.0",
21
+ "@noble/hashes": "^2.3.0",
22
+ "@sozai/codec": "^0.4.0",
23
+ "@sozai/runtime": "^0.1.0",
24
+ "@kubun/hlc": "^0.13.0",
25
+ "@kubun/store-credential": "^0.13.0",
26
+ "@kubun/id": "^0.13.0"
27
+ },
28
+ "devDependencies": {
29
+ "@kubun/db-better-sqlite": "^0.13.0",
30
+ "@kubun/db": "^0.13.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
37
+ "build:clean": "del lib",
38
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
39
+ "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
40
+ "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
41
+ "test": "pnpm run test:types && pnpm run test:unit",
42
+ "test:types": "tsc --noEmit -p tsconfig.test.json",
43
+ "test:unit": "vitest run"
44
+ }
45
+ }