@kokuin/token 0.2.0 → 0.3.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/did.d.ts CHANGED
@@ -37,3 +37,12 @@ export declare function resolveIssuer(iss: string, header?: ResolveIssuerHeader,
37
37
  * All other DID methods pass through unchanged.
38
38
  */
39
39
  export declare function normalizeDID(did: string): string;
40
+ /**
41
+ * The X25519 public key a DID document publishes for key agreement, or `null` when it
42
+ * publishes none.
43
+ *
44
+ * Unlike a `did:key` EdDSA identity — whose agreement key is *derived* from its signing key
45
+ * via the birational map — a peer:4 identity carries an independent agreement key in its doc.
46
+ * A sender MUST use the published key: the derived one is a different key and will not decrypt.
47
+ */
48
+ export declare function getAgreementKey(doc: DIDDoc): Uint8Array | null;
package/lib/did.js CHANGED
@@ -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.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');
@@ -268,11 +287,10 @@ function buildIdentity(id, longForm, doc, keys) {
268
287
  };
269
288
  }
270
289
  async function agreeKey(ephemeralPublicKey, kid) {
271
- const key = pickKemKey(keys, kid);
272
- return x25519.getSharedSecret(key.privateKey, ephemeralPublicKey);
290
+ return x25519.getSharedSecret(pickAgreementSecret(keys, isPeer, kid), ephemeralPublicKey);
273
291
  }
274
292
  async function decrypt(jwe) {
275
- pickKemKey(keys);
293
+ pickAgreementSecret(keys, isPeer); // fail fast when this identity cannot agree at all
276
294
  return decryptToken({
277
295
  id,
278
296
  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
15
  export type { ConcatKDFParams, EncryptOptions, EnvelopeMode, JWEHeader, TokenEncrypter, UnwrapOptions, UnwrappedEnvelope, WrapOptions, } from './jwe.js';
16
16
  export { concatKDF, createTokenEncrypter, decryptToken, encryptToken, unwrapEnvelope, wrapEnvelope, } from './jwe.js';
17
- export type { KeyEntry, KeyStore } from './keystore.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';
package/lib/index.js CHANGED
@@ -9,7 +9,7 @@
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
14
  export { concatKDF, createTokenEncrypter, decryptToken, encryptToken, unwrapEnvelope, wrapEnvelope } from './jwe.js';
15
15
  export { decodeMultibase, encodeMultibase, multihashSHA256, verifyMultihash } from './multibase.js';
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) {
@@ -104,6 +105,23 @@ function resolveX25519Key(recipient) {
104
105
  key: recipient
105
106
  };
106
107
  }
108
+ if (isPeer4(recipient)) {
109
+ const shortForm = getPeer4ShortForm(recipient);
110
+ if (recipient === shortForm) {
111
+ // The doc lives in the long form. Resolving a short form needs a DIDResolver, which
112
+ // this sync constructor cannot await — so say so, rather than failing as a bad DID.
113
+ throw new Error(`Cannot encrypt to a did:peer:4 short form: ${shortForm}. Pass the long form, which carries the document.`);
114
+ }
115
+ const { doc } = decodePeer4(recipient);
116
+ const key = getAgreementKey(doc);
117
+ if (key == null) {
118
+ throw new Error(`Recipient publishes no X25519 keyAgreement key: ${shortForm}`);
119
+ }
120
+ return {
121
+ key,
122
+ id: shortForm
123
+ };
124
+ }
107
125
  const [algorithm, publicKey] = getSignatureInfo(recipient);
108
126
  if (algorithm === 'EdDSA') {
109
127
  return {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kokuin/token",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "JWT signing and verification",
6
6
  "keywords": [
@@ -31,9 +31,9 @@
31
31
  "@noble/hashes": "^2.0.0",
32
32
  "@scure/base": "^2.2.0",
33
33
  "@sozai/codec": "^0.2.0",
34
- "@sozai/otel": "^0.2.0",
34
+ "@sozai/otel": "^0.3.0",
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
39
  "@opentelemetry/sdk-trace-base": "^2.9.0",