@kokuin/token 0.2.0 → 0.4.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/cache.js CHANGED
@@ -1,4 +1,4 @@
1
- import { encodePeer4, isPeer4 } from './peer4.js';
1
+ import { assertDocWithinMaxSize, encodePeer4, isPeer4 } from './peer4.js';
2
2
  const DEFAULT_MAX_ENTRIES = 10_000;
3
3
  /**
4
4
  * Build an in-memory DID cache. The returned cache verifies short-form/doc binding on every set
@@ -25,6 +25,13 @@ const DEFAULT_MAX_ENTRIES = 10_000;
25
25
  if (!isPeer4(shortForm)) {
26
26
  return Promise.reject(new Error('DIDCache: short form must be a did:peer:4 identifier'));
27
27
  }
28
+ // The doc originated from a resolver. Bound it before the encode below, which is linear in
29
+ // its size — the same guard the resolver path applies.
30
+ try {
31
+ assertDocWithinMaxSize(doc);
32
+ } catch (err) {
33
+ return Promise.reject(err);
34
+ }
28
35
  const expected = encodePeer4(doc).shortForm;
29
36
  if (expected !== shortForm) {
30
37
  return Promise.reject(new Error('DIDCache: short form/doc hash mismatch'));
package/lib/did.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { DIDResolver } from './cache.js';
2
2
  import type { DIDDoc } from './peer4.js';
3
3
  import type { SignatureAlgorithm } from './schemas.js';
4
+ import type { DIDString } from './types.js';
4
5
  /** @internal */
5
6
  export declare const CODECS: Record<SignatureAlgorithm, Uint8Array>;
6
7
  /** @internal */
7
8
  export declare function getAlgorithmAndPublicKey(bytes: Uint8Array): [SignatureAlgorithm, Uint8Array] | null;
8
9
  /** @internal */
9
- export declare function getDID(codec: Uint8Array, publicKey: Uint8Array): string;
10
+ export declare function getDID(codec: Uint8Array, publicKey: Uint8Array): DIDString;
10
11
  /** @internal */
11
12
  export declare function getSignatureInfo(did: string): [SignatureAlgorithm, Uint8Array];
12
13
  export type ResolveIssuerHeader = {
@@ -37,3 +38,12 @@ export declare function resolveIssuer(iss: string, header?: ResolveIssuerHeader,
37
38
  * All other DID methods pass through unchanged.
38
39
  */
39
40
  export declare function normalizeDID(did: string): string;
41
+ /**
42
+ * The X25519 public key a DID document publishes for key agreement, or `null` when it
43
+ * publishes none.
44
+ *
45
+ * Unlike a `did:key` EdDSA identity — whose agreement key is *derived* from its signing key
46
+ * via the birational map — a peer:4 identity carries an independent agreement key in its doc.
47
+ * A sender MUST use the published key: the derived one is a different key and will not decrypt.
48
+ */
49
+ export declare function getAgreementKey(doc: DIDDoc): Uint8Array | null;
package/lib/did.js CHANGED
@@ -45,7 +45,7 @@ function isCodecMatch(codec, bytes) {
45
45
  bytes[i] = v;
46
46
  });
47
47
  bytes.set(publicKey, codec.length);
48
- return PREFIX + base58.encode(bytes);
48
+ return `${PREFIX}${base58.encode(bytes)}`;
49
49
  }
50
50
  /** @internal */ export function getSignatureInfo(did) {
51
51
  if (!did.startsWith(PREFIX)) {
@@ -166,3 +166,45 @@ function resolveKidFromDoc(doc, kid) {
166
166
  */ export function normalizeDID(did) {
167
167
  return isPeer4(did) ? getPeer4ShortForm(did) : did;
168
168
  }
169
+ /** Multicodec prefix for an X25519 public key, as published in a peer:4 doc. */ const CODEC_X25519_PUB = new Uint8Array([
170
+ 0xec,
171
+ 0x01
172
+ ]);
173
+ /**
174
+ * The X25519 public key a DID document publishes for key agreement, or `null` when it
175
+ * publishes none.
176
+ *
177
+ * Unlike a `did:key` EdDSA identity — whose agreement key is *derived* from its signing key
178
+ * via the birational map — a peer:4 identity carries an independent agreement key in its doc.
179
+ * A sender MUST use the published key: the derived one is a different key and will not decrypt.
180
+ */ export function getAgreementKey(doc) {
181
+ const fragments = doc.keyAgreement;
182
+ if (fragments == null) {
183
+ return null;
184
+ }
185
+ for (const fragment of fragments){
186
+ const method = doc.verificationMethod.find((verificationMethod)=>verificationMethod.id === fragment);
187
+ if (method == null) {
188
+ continue;
189
+ }
190
+ // Bound before decoding — base58.decode is O(n^2) — same as resolveKidFromDoc().
191
+ if (method.publicKeyMultibase.length > MAX_DID_KEY_ENCODED) {
192
+ continue;
193
+ }
194
+ let bytes;
195
+ try {
196
+ bytes = decodeMultibase(method.publicKeyMultibase);
197
+ } catch {
198
+ continue;
199
+ }
200
+ if (!isCodecMatch(CODEC_X25519_PUB, bytes)) {
201
+ continue;
202
+ }
203
+ const publicKey = bytes.slice(CODEC_X25519_PUB.length);
204
+ if (publicKey.length !== 32) {
205
+ continue;
206
+ }
207
+ return publicKey;
208
+ }
209
+ return null;
210
+ }
package/lib/identity.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type DIDDoc } from './peer4.js';
2
- import type { SignedToken } from './types.js';
2
+ import type { DIDString, SignedToken } from './types.js';
3
3
  export type Identity = {
4
- readonly id: string;
4
+ readonly id: DIDString;
5
5
  };
6
6
  export type SignTokenOptions = {
7
7
  /** Extra header fields merged into the signed JWS header. */
@@ -9,10 +9,13 @@ export type SignTokenOptions = {
9
9
  /** Pick a non-primary signing key by fragment (e.g. "#key-1"). */
10
10
  kid?: string;
11
11
  /**
12
- * Override the first-per-aud long-form policy for did:peer:4 identities.
12
+ * Override the long-form policy for did:peer:4 identities.
13
13
  * - true: always use long form (no-op for did:key, where longForm === id).
14
14
  * - false: always use short form.
15
- * - undefined (default): use long form on first token to a given payload.aud, short form thereafter.
15
+ * - undefined (default): long form on the first token to a given `payload.aud`, short form
16
+ * thereafter; and always long form when the payload names no single string audience, since
17
+ * there is then no audience to key first contact on and the recipient may hold no cached
18
+ * document for this DID.
16
19
  */
17
20
  embedLongForm?: boolean;
18
21
  };
@@ -83,8 +86,8 @@ export type ResolvedKey = {
83
86
  publicKey: Uint8Array;
84
87
  };
85
88
  export type MultiKeyIdentity = {
86
- id: string;
87
- longForm: string;
89
+ id: DIDString;
90
+ longForm: DIDString;
88
91
  doc: DIDDoc;
89
92
  keys: Array<ResolvedKey>;
90
93
  publicKey: Uint8Array;
package/lib/identity.js CHANGED
@@ -212,18 +212,37 @@ function signWith(key, data) {
212
212
  throw new Error('X25519 cannot sign');
213
213
  }
214
214
  }
215
- function pickKemKey(keys, kid) {
215
+ /**
216
+ * The X25519 private scalar this identity agrees with, for `kid` or by default.
217
+ *
218
+ * A peer:4 identity uses its published `keyAgreement` key. A `did:key` EdDSA identity has no
219
+ * published agreement key — a sender derives one from its signing key via the birational map —
220
+ * so it must derive the matching secret the same way, exactly as `createDecryptingIdentity` does.
221
+ */ function pickAgreementSecret(keys, isPeer, kid) {
216
222
  if (kid != null) {
217
- const found = keys.find((k)=>k.fragment === kid);
223
+ // No birational fallback here, unlike the no-kid branch below: a kid names one specific
224
+ // key, so falling back to a *different*, derived key would silently agree with the wrong
225
+ // secret. This means a did:key identity's agreeKey(epk, '#key-0') throws where
226
+ // agreeKey(epk) succeeds — a known asymmetry, currently unreachable: decryptToken (jwe.ts)
227
+ // never passes a kid, and DecryptingIdentity#agreeKey has no kid parameter at all.
228
+ const found = keys.find((key)=>key.fragment === kid);
218
229
  if (found == null) throw new Error(`KidNotFound: ${kid}`);
219
230
  if (found.purpose !== 'kem' || found.alg !== 'X25519') {
220
231
  throw new Error(`Kid is not a KEM X25519 key: ${kid}`);
221
232
  }
222
- return found;
233
+ return found.privateKey;
223
234
  }
224
- const first = keys.find((k)=>k.purpose === 'kem' && k.alg === 'X25519');
225
- if (first == null) throw new Error('No KEM key in identity');
226
- return first;
235
+ const kem = keys.find((key)=>key.purpose === 'kem' && key.alg === 'X25519');
236
+ if (kem != null) {
237
+ return kem.privateKey;
238
+ }
239
+ if (!isPeer) {
240
+ const sig = keys.find((key)=>key.purpose === 'sig' && key.alg === 'EdDSA');
241
+ if (sig != null) {
242
+ return ed25519.utils.toMontgomerySecret(sig.privateKey);
243
+ }
244
+ }
245
+ throw new Error('No KEM key in identity');
227
246
  }
228
247
  function buildIdentity(id, longForm, doc, keys) {
229
248
  const primarySig = keys.find((k)=>k.purpose === 'sig');
@@ -237,7 +256,9 @@ function buildIdentity(id, longForm, doc, keys) {
237
256
  if (embedLongForm === true) return longForm;
238
257
  if (embedLongForm === false) return id;
239
258
  const aud = payload.aud;
240
- if (typeof aud !== 'string') return id;
259
+ // No single named audience: there is nothing to key first-contact on, and the recipient may
260
+ // never have seen this doc. Embed the long form so the token resolves standalone.
261
+ if (typeof aud !== 'string') return longForm;
241
262
  const normalizedAud = normalizeDID(aud);
242
263
  if (sentTo.has(normalizedAud)) return id;
243
264
  // Concurrent sign() calls with the same new aud may both emit long-form; recipient cache writes are idempotent so this is acceptable.
@@ -268,11 +289,10 @@ function buildIdentity(id, longForm, doc, keys) {
268
289
  };
269
290
  }
270
291
  async function agreeKey(ephemeralPublicKey, kid) {
271
- const key = pickKemKey(keys, kid);
272
- return x25519.getSharedSecret(key.privateKey, ephemeralPublicKey);
292
+ return x25519.getSharedSecret(pickAgreementSecret(keys, isPeer, kid), ephemeralPublicKey);
273
293
  }
274
294
  async function decrypt(jwe) {
275
- pickKemKey(keys);
295
+ pickAgreementSecret(keys, isPeer); // fail fast when this identity cannot agree at all
276
296
  return decryptToken({
277
297
  id,
278
298
  decrypt,
package/lib/index.d.ts CHANGED
@@ -10,11 +10,11 @@
10
10
  * @module token
11
11
  */
12
12
  export { type CreateInMemoryDIDCacheOptions, createInMemoryDIDCache, type DIDCache, type DIDResolver, } from './cache.js';
13
- export { CODECS, getAlgorithmAndPublicKey, getDID, getSignatureInfo, normalizeDID, type ResolveIssuerHeader, type ResolveIssuerWithDocResult, resolveIssuer, resolveIssuerWithDoc, } from './did.js';
13
+ export { CODECS, getAgreementKey, getAlgorithmAndPublicKey, getDID, getSignatureInfo, normalizeDID, type ResolveIssuerHeader, type ResolveIssuerWithDocResult, resolveIssuer, resolveIssuerWithDoc, } from './did.js';
14
14
  export { type CreateIdentityInput, createDecryptingIdentity, createFullIdentity, createIdentity, createSigningIdentity, type DecryptingIdentity, type FullIdentity, type Identity, type IdentityKeySpec, type IdentityProvider, isDecryptingIdentity, isFullIdentity, isOwnIdentity, isSigningIdentity, type KeyAlg, type KeyPurpose, type MultiKeyIdentity, type OwnIdentity, type ResolvedKey, randomIdentity, type SigningIdentity, type SignTokenOptions, } from './identity.js';
15
- export type { ConcatKDFParams, EncryptOptions, EnvelopeMode, JWEHeader, TokenEncrypter, UnwrapOptions, UnwrappedEnvelope, WrapOptions, } from './jwe.js';
16
- export { concatKDF, createTokenEncrypter, decryptToken, encryptToken, unwrapEnvelope, wrapEnvelope, } from './jwe.js';
17
- export type { KeyEntry, KeyStore } from './keystore.js';
15
+ export type { ConcatKDFParams, EncryptOptions, EnvelopeMode, JWEHeader, SharedSecretResult, TokenEncrypter, UnwrapOptions, UnwrappedEnvelope, WrapOptions, } from './jwe.js';
16
+ export { concatKDF, createTokenEncrypter, decryptToken, deriveSharedSecret, encryptToken, unwrapEnvelope, wrapEnvelope, } from './jwe.js';
17
+ export type { KeyEntry, KeyStore, MutableKeyEntry } from './keystore.js';
18
18
  export { decodeMultibase, encodeMultibase, multihashSHA256, verifyMultihash, } from './multibase.js';
19
19
  export { type DecodePeer4Options, type DIDDoc, decodePeer4, encodePeer4, getPeer4ShortForm, isPeer4, type VerificationMethod, validateDIDDoc, } from './peer4.js';
20
20
  export { createRotationAssertion, type RotationPayload, } from './rotation.js';
@@ -23,5 +23,5 @@ export { decodePrivateKey, encodePrivateKey, randomPrivateKey, } from './signer.
23
23
  export { assertTimeClaimsValid, now, type TimeClaimsPayload, type TimeValidationOptions, } from './time.js';
24
24
  export { createUnsignedToken, isSignedToken, isUnsignedToken, isVerifiedToken, signToken, type VerifyTokenOptions, verifyToken, } from './token.js';
25
25
  export type * from './types.js';
26
- export { stringifyToken } from './utils.js';
26
+ export { concatBytes, stringifyToken } from './utils.js';
27
27
  export { getVerifier, type Verifier, type Verifiers, } from './verifier.js';
package/lib/index.js CHANGED
@@ -9,9 +9,9 @@
9
9
  *
10
10
  * @module token
11
11
  */ export { createInMemoryDIDCache } from './cache.js';
12
- export { CODECS, getAlgorithmAndPublicKey, getDID, getSignatureInfo, normalizeDID, resolveIssuer, resolveIssuerWithDoc } from './did.js';
12
+ export { CODECS, getAgreementKey, getAlgorithmAndPublicKey, getDID, getSignatureInfo, normalizeDID, resolveIssuer, resolveIssuerWithDoc } from './did.js';
13
13
  export { createDecryptingIdentity, createFullIdentity, createIdentity, createSigningIdentity, isDecryptingIdentity, isFullIdentity, isOwnIdentity, isSigningIdentity, randomIdentity } from './identity.js';
14
- export { concatKDF, createTokenEncrypter, decryptToken, encryptToken, unwrapEnvelope, wrapEnvelope } from './jwe.js';
14
+ export { concatKDF, createTokenEncrypter, decryptToken, deriveSharedSecret, encryptToken, unwrapEnvelope, wrapEnvelope } from './jwe.js';
15
15
  export { decodeMultibase, encodeMultibase, multihashSHA256, verifyMultihash } from './multibase.js';
16
16
  export { decodePeer4, encodePeer4, getPeer4ShortForm, isPeer4, validateDIDDoc } from './peer4.js';
17
17
  export { createRotationAssertion } from './rotation.js';
@@ -19,5 +19,5 @@ export { capabilitySchema, signedHeaderSchema, signedPayloadSchema, supportedHea
19
19
  export { decodePrivateKey, encodePrivateKey, randomPrivateKey } from './signer.js';
20
20
  export { assertTimeClaimsValid, now } from './time.js';
21
21
  export { createUnsignedToken, isSignedToken, isUnsignedToken, isVerifiedToken, signToken, verifyToken } from './token.js';
22
- export { stringifyToken } from './utils.js';
22
+ export { concatBytes, stringifyToken } from './utils.js';
23
23
  export { getVerifier } from './verifier.js';
package/lib/jwe.d.ts CHANGED
@@ -25,11 +25,50 @@ export type TokenEncrypter = {
25
25
  export type EncryptOptions = {
26
26
  algorithm: 'X25519';
27
27
  };
28
+ export type SharedSecretResult = {
29
+ /**
30
+ * The raw X25519 ECDH output. **Not** uniformly random — run it through a KDF with your own
31
+ * domain separation before using it as a key.
32
+ */
33
+ sharedSecret: Uint8Array;
34
+ /**
35
+ * Send this to the recipient. They recover the same `sharedSecret` bytes with
36
+ * `identity.agreeKey(ephemeralPublicKey)`.
37
+ */
38
+ ephemeralPublicKey: Uint8Array;
39
+ };
28
40
  /**
29
41
  * Concat KDF per RFC 7518 Section 4.6.2.
30
42
  * Single SHA-256 iteration (sufficient for 256-bit keys).
31
43
  */
32
44
  export declare function concatKDF(params: ConcatKDFParams): Uint8Array;
45
+ /**
46
+ * Perform X25519 key agreement with a recipient DID, without building a JWE.
47
+ *
48
+ * Resolves the recipient's agreement key by the same rule `createTokenEncrypter` uses — a
49
+ * did:peer:4 identity's published `keyAgreement` key, or an EdDSA `did:key`'s birationally
50
+ * derived one — generates a single-use ephemeral key pair, and returns the ECDH output.
51
+ * The ephemeral private key never leaves this function.
52
+ *
53
+ * The recipient recovers the identical bytes with `identity.agreeKey(ephemeralPublicKey)` —
54
+ * called with no `kid`; the sender always resolves the first published `keyAgreement` key, and
55
+ * on a multi-KEM identity a different `kid` silently recovers different bytes.
56
+ *
57
+ * The agreement is anonymous — it authenticates nothing about the sender, only that the secret
58
+ * is recoverable by the recipient. Bind `ephemeralPublicKey` and the recipient DID into your
59
+ * KDF's info/context.
60
+ *
61
+ * ```ts
62
+ * const { sharedSecret, ephemeralPublicKey } = deriveSharedSecret(recipientDID)
63
+ * // ship ephemeralPublicKey alongside whatever the secret protects
64
+ * ```
65
+ *
66
+ * The result is a raw ECDH output, not a key: run it through a KDF before use.
67
+ *
68
+ * @param recipient a `did:key` EdDSA DID, or a `did:peer:4` **long form**. A peer:4 short form
69
+ * throws — the document that carries the agreement key lives only in the long form.
70
+ */
71
+ export declare function deriveSharedSecret(recipient: string): SharedSecretResult;
33
72
  /**
34
73
  * Create a token encrypter for a recipient identified by X25519 public key or DID string.
35
74
  */
package/lib/jwe.js CHANGED
@@ -3,7 +3,8 @@ import { randomBytes } from '@noble/ciphers/utils.js';
3
3
  import { ed25519, x25519 } from '@noble/curves/ed25519.js';
4
4
  import { sha256 } from '@noble/hashes/sha2.js';
5
5
  import { b64uFromJSON, b64uToJSON, fromB64U, toB64U } from '@sozai/codec';
6
- import { getSignatureInfo } from './did.js';
6
+ import { getAgreementKey, getSignatureInfo } from './did.js';
7
+ import { decodePeer4, getPeer4ShortForm, isPeer4 } from './peer4.js';
7
8
  import { createUnsignedToken, isUnsignedToken, verifyToken } from './token.js';
8
9
  import { stringifyToken } from './utils.js';
9
10
  function uint32BE(value) {
@@ -52,12 +53,15 @@ function lengthPrefixed(data) {
52
53
  }
53
54
  return sha256(hashInput).slice(0, keyLength / 8);
54
55
  }
55
- function encryptWithX25519(recipientPublicKey, plaintext) {
56
- // Generate ephemeral X25519 key pair
56
+ function agreeWithKey(recipientPublicKey) {
57
57
  const ephemeralPrivateKey = x25519.utils.randomSecretKey();
58
- const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey);
59
- // Compute shared secret via ECDH
60
- const sharedSecret = x25519.getSharedSecret(ephemeralPrivateKey, recipientPublicKey);
58
+ return {
59
+ ephemeralPublicKey: x25519.getPublicKey(ephemeralPrivateKey),
60
+ sharedSecret: x25519.getSharedSecret(ephemeralPrivateKey, recipientPublicKey)
61
+ };
62
+ }
63
+ function encryptWithX25519(recipientPublicKey, plaintext) {
64
+ const { sharedSecret, ephemeralPublicKey } = agreeWithKey(recipientPublicKey);
61
65
  // Derive content encryption key via Concat KDF
62
66
  const cek = concatKDF({
63
67
  sharedSecret,
@@ -104,6 +108,23 @@ function resolveX25519Key(recipient) {
104
108
  key: recipient
105
109
  };
106
110
  }
111
+ if (isPeer4(recipient)) {
112
+ const shortForm = getPeer4ShortForm(recipient);
113
+ if (recipient === shortForm) {
114
+ // The doc lives in the long form. Resolving a short form needs a DIDResolver, which
115
+ // this sync constructor cannot await — so say so, rather than failing as a bad DID.
116
+ throw new Error(`Cannot encrypt to a did:peer:4 short form: ${shortForm}. Pass the long form, which carries the document.`);
117
+ }
118
+ const { doc } = decodePeer4(recipient);
119
+ const key = getAgreementKey(doc);
120
+ if (key == null) {
121
+ throw new Error(`Recipient publishes no X25519 keyAgreement key: ${shortForm}`);
122
+ }
123
+ return {
124
+ key,
125
+ id: shortForm
126
+ };
127
+ }
107
128
  const [algorithm, publicKey] = getSignatureInfo(recipient);
108
129
  if (algorithm === 'EdDSA') {
109
130
  return {
@@ -113,6 +134,37 @@ function resolveX25519Key(recipient) {
113
134
  }
114
135
  throw new Error(`Unsupported DID algorithm for encryption: ${algorithm}`);
115
136
  }
137
+ /**
138
+ * Perform X25519 key agreement with a recipient DID, without building a JWE.
139
+ *
140
+ * Resolves the recipient's agreement key by the same rule `createTokenEncrypter` uses — a
141
+ * did:peer:4 identity's published `keyAgreement` key, or an EdDSA `did:key`'s birationally
142
+ * derived one — generates a single-use ephemeral key pair, and returns the ECDH output.
143
+ * The ephemeral private key never leaves this function.
144
+ *
145
+ * The recipient recovers the identical bytes with `identity.agreeKey(ephemeralPublicKey)` —
146
+ * called with no `kid`; the sender always resolves the first published `keyAgreement` key, and
147
+ * on a multi-KEM identity a different `kid` silently recovers different bytes.
148
+ *
149
+ * The agreement is anonymous — it authenticates nothing about the sender, only that the secret
150
+ * is recoverable by the recipient. Bind `ephemeralPublicKey` and the recipient DID into your
151
+ * KDF's info/context.
152
+ *
153
+ * ```ts
154
+ * const { sharedSecret, ephemeralPublicKey } = deriveSharedSecret(recipientDID)
155
+ * // ship ephemeralPublicKey alongside whatever the secret protects
156
+ * ```
157
+ *
158
+ * The result is a raw ECDH output, not a key: run it through a KDF before use.
159
+ *
160
+ * @param recipient a `did:key` EdDSA DID, or a `did:peer:4` **long form**. A peer:4 short form
161
+ * throws — the document that carries the agreement key lives only in the long form.
162
+ */ export function deriveSharedSecret(recipient) {
163
+ if (typeof recipient !== 'string') {
164
+ throw new Error('deriveSharedSecret requires a DID string');
165
+ }
166
+ return agreeWithKey(resolveX25519Key(recipient).key);
167
+ }
116
168
  export function createTokenEncrypter(recipient, options) {
117
169
  if (typeof recipient !== 'string' && options?.algorithm !== 'X25519') {
118
170
  throw new Error(`Unsupported algorithm: ${options?.algorithm}`);
package/lib/keystore.d.ts CHANGED
@@ -1,10 +1,71 @@
1
+ /**
2
+ * A single key slot in a {@link KeyStore}, identified by `keyID`.
3
+ *
4
+ * This is the **read/provide** facet — the floor every backend can honor, including ones
5
+ * that derive keys rather than store them (HD) or never expose key material at all.
6
+ * A backend that can also write and delete implements {@link MutableKeyEntry}.
7
+ *
8
+ * ## Invariants
9
+ *
10
+ * Enforced by `keyStoreConformanceCases`. A backend that cannot satisfy these does not
11
+ * implement this type — it exposes a different surface instead.
12
+ *
13
+ * 1. **keyID round-trips.** `store.entry(x).keyID === x`, always.
14
+ * 2. **Absent means `null`.** {@link getAsync} resolves `null` when no key exists. Never
15
+ * `undefined`, never a throw. A *derived* backend may never return `null` at all —
16
+ * it can always produce a key — and that is contract-legal.
17
+ * 3. **{@link provideAsync} is idempotent under concurrency.** Two concurrent calls on one
18
+ * keyID resolve to the *same* key. Never two keys; never a key that is generated,
19
+ * returned to a caller, and then overwritten by a racer.
20
+ */
1
21
  export type KeyEntry<PrivateKeyType> = {
22
+ /** The keyID this entry was created for. Identical to the string passed to `entry()`. */
2
23
  readonly keyID: string;
24
+ /** The stored key, or `null` when none exists. Never `undefined`; never throws for absence. */
3
25
  getAsync(): Promise<PrivateKeyType | null>;
4
- setAsync(privateKey: PrivateKeyType): Promise<void>;
26
+ /**
27
+ * The stored key, generating and persisting one if absent.
28
+ *
29
+ * Idempotent under concurrency: racing callers converge on a single key. Backends that
30
+ * share storage across processes need a cross-process lock to hold this (see `lockPath`
31
+ * on `NodeKeyStore` / `ElectronKeyStore`); an in-process promise chain is not enough.
32
+ */
5
33
  provideAsync(): Promise<PrivateKeyType>;
34
+ };
35
+ /**
36
+ * A {@link KeyEntry} on a backend that can also **write and delete** key material.
37
+ *
38
+ * HD and ledger backends deliberately do NOT implement this: an HD key is derived (there is
39
+ * nothing to set, and removing it does not stop it being derivable), and a ledger key never
40
+ * leaves the device. Previously they "conformed" by throwing from `setAsync` and silently
41
+ * no-op'ing `removeAsync` — the type now says what the substrate can do, so there is nothing
42
+ * to throw from.
43
+ */
44
+ export type MutableKeyEntry<PrivateKeyType> = KeyEntry<PrivateKeyType> & {
45
+ /** Persist `privateKey` under this entry's keyID, replacing any existing key. */
46
+ setAsync(privateKey: PrivateKeyType): Promise<void>;
47
+ /** Delete this entry's key. Idempotent — removing an absent key resolves, it does not throw. */
6
48
  removeAsync(): Promise<void>;
7
49
  };
50
+ /**
51
+ * A keyed collection of {@link KeyEntry}s.
52
+ *
53
+ * Parameterized by entry type, so a writable backend is `KeyStore<T, MutableKeyEntry<T>>`
54
+ * and a derived one is `KeyStore<T, KeyEntry<T>>`. No separate mutable store type is needed.
55
+ *
56
+ * Most consumers should NOT be generic over this — they should be generic over
57
+ * `IdentityProvider`, which is what "give me an identity for this keyID" actually needs.
58
+ * `KeyStore` is the *storage* contract, useful to backend authors and to the conformance
59
+ * suite. A backend whose key never leaves the device (ledger) implements `IdentityProvider`
60
+ * and neither storage type; that is the contract working, not a gap.
61
+ */
8
62
  export type KeyStore<PrivateKeyType, EntryType extends KeyEntry<PrivateKeyType> = KeyEntry<PrivateKeyType>> = {
63
+ /**
64
+ * The entry for `keyID`.
65
+ *
66
+ * Must be **cached**: `store.entry(x) === store.entry(x)`. Entries carry per-entry
67
+ * concurrency state (the `provideAsync` lock), so handing out a fresh entry per call
68
+ * silently defeats it.
69
+ */
9
70
  entry(keyID: string): EntryType;
10
71
  };
package/lib/keystore.js CHANGED
@@ -1 +1,31 @@
1
- export { };
1
+ /**
2
+ * A single key slot in a {@link KeyStore}, identified by `keyID`.
3
+ *
4
+ * This is the **read/provide** facet — the floor every backend can honor, including ones
5
+ * that derive keys rather than store them (HD) or never expose key material at all.
6
+ * A backend that can also write and delete implements {@link MutableKeyEntry}.
7
+ *
8
+ * ## Invariants
9
+ *
10
+ * Enforced by `keyStoreConformanceCases`. A backend that cannot satisfy these does not
11
+ * implement this type — it exposes a different surface instead.
12
+ *
13
+ * 1. **keyID round-trips.** `store.entry(x).keyID === x`, always.
14
+ * 2. **Absent means `null`.** {@link getAsync} resolves `null` when no key exists. Never
15
+ * `undefined`, never a throw. A *derived* backend may never return `null` at all —
16
+ * it can always produce a key — and that is contract-legal.
17
+ * 3. **{@link provideAsync} is idempotent under concurrency.** Two concurrent calls on one
18
+ * keyID resolve to the *same* key. Never two keys; never a key that is generated,
19
+ * returned to a caller, and then overwritten by a racer.
20
+ */ /**
21
+ * A keyed collection of {@link KeyEntry}s.
22
+ *
23
+ * Parameterized by entry type, so a writable backend is `KeyStore<T, MutableKeyEntry<T>>`
24
+ * and a derived one is `KeyStore<T, KeyEntry<T>>`. No separate mutable store type is needed.
25
+ *
26
+ * Most consumers should NOT be generic over this — they should be generic over
27
+ * `IdentityProvider`, which is what "give me an identity for this keyID" actually needs.
28
+ * `KeyStore` is the *storage* contract, useful to backend authors and to the conformance
29
+ * suite. A backend whose key never leaves the device (ledger) implements `IdentityProvider`
30
+ * and neither storage type; that is the contract working, not a gap.
31
+ */ export { };
package/lib/peer4.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type FromSchema } from '@sozai/schema';
2
+ import type { DIDString } from './types.js';
2
3
  /** @internal */
3
4
  export declare const verificationMethodSchema: {
4
5
  readonly type: "object";
@@ -87,6 +88,10 @@ export type DecodePeer4Options = {
87
88
  /**
88
89
  * Throw if a DID document's canonical serialization exceeds `maxSize` bytes.
89
90
  * Used to bound a resolver-returned doc before it reaches the O(n^2) base58 encode.
91
+ *
92
+ * The entry-count check runs first and is O(1): `canonicalStringify` below is linear in the
93
+ * document, so an attacker-supplied `verificationMethod` array with millions of entries would
94
+ * otherwise cost linear time before it could be measured and rejected.
90
95
  */
91
96
  export declare function assertDocWithinMaxSize(doc: DIDDoc, maxSize?: number): void;
92
97
  /**
@@ -111,7 +116,7 @@ export declare function decodePeer4(longForm: string, options?: DecodePeer4Optio
111
116
  * Returns both the long form (self-contained, doc embedded) and short form (hash only).
112
117
  */
113
118
  export declare function encodePeer4(doc: DIDDoc): {
114
- longForm: string;
115
- shortForm: string;
119
+ longForm: DIDString;
120
+ shortForm: DIDString;
116
121
  doc: DIDDoc;
117
122
  };
package/lib/peer4.js CHANGED
@@ -79,6 +79,10 @@ const JSON_MULTICODEC = new Uint8Array([
79
79
  0x04
80
80
  ]);
81
81
  const DEFAULT_MAX_DOC_SIZE = 4 * 1024;
82
+ // The smallest legal verificationMethod entry — {"id":"","type":"","publicKeyMultibase":""} at 43
83
+ // bytes plus an array separator — serializes to 44. Undercounting here only widens the cap, so the
84
+ // O(1) guard below can never reject a doc the full byte measure would have accepted.
85
+ const MIN_VERIFICATION_METHOD_BYTES = 40;
82
86
  // base58 expands bytes to characters by log(256) / log(58).
83
87
  const BASE58_EXPANSION = 1.3658;
84
88
  // A SHA-256 multihash is 34 bytes: 47 base58 characters plus the multibase 'z'.
@@ -86,7 +90,15 @@ const MAX_HASH_ENCODED = 64;
86
90
  /**
87
91
  * Throw if a DID document's canonical serialization exceeds `maxSize` bytes.
88
92
  * Used to bound a resolver-returned doc before it reaches the O(n^2) base58 encode.
93
+ *
94
+ * The entry-count check runs first and is O(1): `canonicalStringify` below is linear in the
95
+ * document, so an attacker-supplied `verificationMethod` array with millions of entries would
96
+ * otherwise cost linear time before it could be measured and rejected.
89
97
  */ export function assertDocWithinMaxSize(doc, maxSize = DEFAULT_MAX_DOC_SIZE) {
98
+ const maxEntries = Math.ceil(maxSize / MIN_VERIFICATION_METHOD_BYTES);
99
+ if (Array.isArray(doc.verificationMethod) && doc.verificationMethod.length > maxEntries) {
100
+ throw new Error(`did:peer:4 resolver doc has too many verification methods: ${doc.verificationMethod.length} > ${maxEntries}`);
101
+ }
90
102
  const size = fromUTF(canonicalStringify(doc)).length;
91
103
  if (size > maxSize) {
92
104
  throw new Error(`did:peer:4 resolver doc too large: ${size} > ${maxSize}`);
package/lib/rotation.d.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  import type { MultiKeyIdentity } from './identity.js';
2
- import type { SignedToken } from './types.js';
2
+ import type { DIDString, SignedToken } from './types.js';
3
3
  export type RotationPayload = {
4
4
  type: 'did-rotation';
5
- iss?: string;
6
- to: string;
7
- toLongForm: string;
5
+ iss?: DIDString;
6
+ to: DIDString;
7
+ toLongForm: DIDString;
8
8
  issuedAt: number;
9
9
  };
10
10
  /**
11
11
  * Sign a rotation assertion linking an old identity to a new one.
12
- * The assertion is a regular signed token whose `iss` is the old DID.
12
+ * The assertion is a regular signed token issued by the old identity: its `iss` is that identity's
13
+ * DID, in long form for a `did:peer:4` signer since the payload names no audience.
13
14
  * Verifiers walking a rotation chain can use this to link related identities.
14
15
  */
15
16
  export declare function createRotationAssertion(oldIdentity: MultiKeyIdentity, newIdentity: MultiKeyIdentity, issuedAt?: number): Promise<SignedToken<RotationPayload>>;
package/lib/rotation.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Sign a rotation assertion linking an old identity to a new one.
3
- * The assertion is a regular signed token whose `iss` is the old DID.
3
+ * The assertion is a regular signed token issued by the old identity: its `iss` is that identity's
4
+ * DID, in long form for a `did:peer:4` signer since the payload names no audience.
4
5
  * Verifiers walking a rotation chain can use this to link related identities.
5
6
  */ export async function createRotationAssertion(oldIdentity, newIdentity, issuedAt = Math.floor(Date.now() / 1000)) {
6
7
  return oldIdentity.signToken({
package/lib/token.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { DIDCache, DIDResolver } from './cache.js';
2
2
  import type { SigningIdentity } from './identity.js';
3
3
  import { type SignedPayload } from './schemas.js';
4
4
  import { type TimeValidationOptions } from './time.js';
5
- import type { SignedToken, Token, UnsignedToken, VerifiedToken } from './types.js';
5
+ import type { DIDString, SignedToken, Token, UnsignedToken, VerifiedToken } from './types.js';
6
6
  import { type Verifiers } from './verifier.js';
7
7
  export type VerifyTokenOptions = TimeValidationOptions & {
8
8
  verifiers?: Verifiers;
@@ -16,7 +16,7 @@ export type VerifyTokenOptions = TimeValidationOptions & {
16
16
  * capability's `aud` is the delegation next-hop rather than a service audience. An empty
17
17
  * array accepts no audience (every token is rejected).
18
18
  */
19
- audience?: string | Array<string>;
19
+ audience?: DIDString | Array<DIDString>;
20
20
  /**
21
21
  * Accept unsigned (`alg:none`) tokens. Defaults to `false`.
22
22
  *
package/lib/token.js CHANGED
@@ -11,6 +11,12 @@ const tokenTracer = createTracer('token');
11
11
  // `verifiedPublicKey` property but can never be a member of this set, so verification
12
12
  // is only ever skipped for objects produced by `verifyToken` itself.
13
13
  const verifiedTokens = new WeakSet();
14
+ // The `data` string each verified token's signature was checked against, keyed off the object
15
+ // rather than read back from it. `token.data` is a mutable property of that same object, so
16
+ // comparing a recomputed header/payload against it is vacuous: code that mutates the payload can
17
+ // re-derive `data` from the mutation and stay self-consistent. This map is not reachable from the
18
+ // token, so the value captured at verification time is the one the re-bind compares against.
19
+ const verifiedTokenData = new WeakMap();
14
20
  function assertAudienceValid(payload, audience) {
15
21
  if (audience == null) {
16
22
  return;
@@ -101,27 +107,43 @@ function assertUnsignedAllowed(allowUnsigned) {
101
107
  header: token.header
102
108
  });
103
109
  }
110
+ // Whether `data` is a serialization of the token's current header and payload. It may use a
111
+ // different JSON serialization of the same values; accept it only if it decodes to exactly those,
112
+ // so the signed bytes can never be decoupled from the payload used for authorization.
113
+ function matchesVerifiableData(token, data) {
114
+ if (data === `${b64uFromJSON(token.header)}.${b64uFromJSON(token.payload)}`) {
115
+ return true;
116
+ }
117
+ const parts = data.split('.');
118
+ if (parts.length !== 2) {
119
+ return false;
120
+ }
121
+ try {
122
+ return canonicalStringify(b64uToJSON(parts[0])) === canonicalStringify(token.header) && canonicalStringify(b64uToJSON(parts[1])) === canonicalStringify(token.payload);
123
+ } catch {
124
+ // invalid base64url or JSON in data
125
+ return false;
126
+ }
127
+ }
104
128
  function getVerifiableData(token) {
105
- const recomputed = `${b64uFromJSON(token.header)}.${b64uFromJSON(token.payload)}`;
106
129
  const data = token.data;
107
- if (data == null || data === recomputed) {
108
- return recomputed;
109
- }
110
- // `data` may use a different JSON serialization of the same header and payload.
111
- // Accept it only if it decodes to exactly the same values, so the signed bytes
112
- // can never be decoupled from the payload used for authorization.
113
- const parts = typeof data === 'string' ? data.split('.') : [];
114
- if (parts.length === 2) {
115
- try {
116
- if (canonicalStringify(b64uToJSON(parts[0])) === canonicalStringify(token.header) && canonicalStringify(b64uToJSON(parts[1])) === canonicalStringify(token.payload)) {
117
- return data;
118
- }
119
- } catch {
120
- // invalid base64url or JSON in data: fall through to the error below
121
- }
130
+ if (data == null) {
131
+ return `${b64uFromJSON(token.header)}.${b64uFromJSON(token.payload)}`;
132
+ }
133
+ if (typeof data === 'string' && matchesVerifiableData(token, data)) {
134
+ return data;
122
135
  }
123
136
  throw new Error('Invalid token: data does not match header and payload');
124
137
  }
138
+ // Re-bind an already-verified token to the bytes its signature was checked against. The `data`
139
+ // comes from `verifiedTokenData`, never from the token, so a caller that mutates the payload
140
+ // cannot make the check pass by re-deriving `token.data` alongside it.
141
+ function assertVerifiedDataUnchanged(token) {
142
+ const data = verifiedTokenData.get(token);
143
+ if (data == null || !matchesVerifiableData(token, data)) {
144
+ throw new Error('Invalid token: data does not match header and payload');
145
+ }
146
+ }
125
147
  async function verifyTokenInner(token, options = {}) {
126
148
  const { verifiers, resolver, cache, audience, allowUnsigned = false, ...timeOptions } = options;
127
149
  if (typeof token !== 'string') {
@@ -132,6 +154,10 @@ async function verifyTokenInner(token, options = {}) {
132
154
  return token;
133
155
  }
134
156
  if (isVerifiedToken(token)) {
157
+ // The signature was checked when this object entered `verifiedTokens`, but its payload may
158
+ // have been mutated in place since. Re-bind it to the signed bytes — cheap next to a
159
+ // signature verification, and enough to reject tampering.
160
+ assertVerifiedDataUnchanged(token);
135
161
  assertTimeClaimsValid(token.payload, timeOptions);
136
162
  assertAudienceValid(token.payload, audience);
137
163
  return token;
@@ -155,6 +181,7 @@ async function verifyTokenInner(token, options = {}) {
155
181
  verifiedPublicKey
156
182
  };
157
183
  verifiedTokens.add(result);
184
+ verifiedTokenData.set(result, data);
158
185
  return result;
159
186
  }
160
187
  throw new Error('Unsupported token');
@@ -203,6 +230,7 @@ async function verifyTokenInner(token, options = {}) {
203
230
  verifiedPublicKey
204
231
  };
205
232
  verifiedTokens.add(result);
233
+ verifiedTokenData.set(result, data);
206
234
  return result;
207
235
  }
208
236
  throw new Error('Unsupported signature algorithm');
package/lib/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SignedHeader, SignedPayload, UnsignedHeader } from './schemas.js';
2
+ export type DIDString = `did:${string}:${string}`;
2
3
  export type SignedToken<Payload extends Record<string, unknown> = Record<string, unknown>, Header extends Record<string, unknown> = Record<string, unknown>> = {
3
4
  data: string;
4
5
  header: SignedHeader & Header;
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@kokuin/token",
3
- "version": "0.2.0",
4
- "license": "MIT",
3
+ "version": "0.4.0",
5
4
  "description": "JWT signing and verification",
6
5
  "keywords": [
7
6
  "json",
@@ -15,38 +14,42 @@
15
14
  "url": "https://github.com/TairuFramework/kokuin",
16
15
  "directory": "packages/token"
17
16
  },
17
+ "license": "MIT",
18
+ "sideEffects": false,
18
19
  "type": "module",
19
- "main": "lib/index.js",
20
- "types": "lib/index.d.ts",
21
20
  "exports": {
22
21
  ".": "./lib/index.js"
23
22
  },
23
+ "main": "lib/index.js",
24
+ "types": "lib/index.d.ts",
24
25
  "files": [
25
26
  "lib/*"
26
27
  ],
27
- "sideEffects": false,
28
28
  "dependencies": {
29
29
  "@noble/ciphers": "^2.1.1",
30
- "@noble/curves": "^2.0.1",
31
- "@noble/hashes": "^2.0.0",
30
+ "@noble/curves": "^2.3.0",
31
+ "@noble/hashes": "^2.3.0",
32
32
  "@scure/base": "^2.2.0",
33
- "@sozai/codec": "^0.2.0",
34
- "@sozai/otel": "^0.2.0",
33
+ "@sozai/codec": "^0.4.0",
34
+ "@sozai/otel": "^0.3.2",
35
35
  "@sozai/schema": "^0.1.1",
36
- "@kokuin/otel": "^0.1.0"
36
+ "@kokuin/otel": "^0.3.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@opentelemetry/sdk-trace-base": "^2.9.0",
40
- "@opentelemetry/sdk-trace-node": "^2.9.0",
39
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
40
+ "@opentelemetry/sdk-trace-node": "^2.10.0",
41
41
  "uint8arrays": "^6.1.1"
42
42
  },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
43
46
  "scripts": {
47
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
44
48
  "build:clean": "del lib",
45
49
  "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
46
50
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
47
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
51
+ "test": "pnpm run test:types && pnpm run test:unit",
48
52
  "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
49
- "test:unit": "vitest run",
50
- "test": "pnpm run test:types && pnpm run test:unit"
53
+ "test:unit": "vitest run"
51
54
  }
52
55
  }