@kokuin/token 0.4.0 → 0.5.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.
@@ -0,0 +1,70 @@
1
+ import type { ResolveIssuerHeader } from './did.js';
2
+ import type { SignatureAlgorithm } from './schemas.js';
3
+ /** The signing key a DID method resolved for a given issuer and header. */
4
+ export type ResolvedSigningKey = {
5
+ alg: SignatureAlgorithm;
6
+ publicKey: Uint8Array;
7
+ };
8
+ export type AgreementAlgorithm = 'X25519';
9
+ /** A key agreement key a DID method resolved for a recipient. */
10
+ export type ResolvedAgreementKey = {
11
+ alg: AgreementAlgorithm;
12
+ publicKey: Uint8Array;
13
+ };
14
+ /**
15
+ * A DID method this package can resolve `iss` through without importing its implementation.
16
+ *
17
+ * Methods whose document is a projection of an event log — `did:kokuin:` — cannot be resolved
18
+ * from the identifier alone and cannot be linked from here without a dependency cycle:
19
+ * `@kokuin/controller` depends on this package for signing. Injecting the resolver keeps the
20
+ * dependency one-way.
21
+ */
22
+ export type DIDMethodResolver = {
23
+ /** The method segment, without `did:` and without the trailing colon. E.g. `kokuin`. */
24
+ method: string;
25
+ /**
26
+ * The key this subject signs with **now** — its head keys, nothing rotated away. The safe default:
27
+ * authenticating a live signer asks whether *this* party can sign today, so a retired key must not
28
+ * answer yes. {@link resolveHistoric} is the other question.
29
+ */
30
+ resolve(did: string, header: ResolveIssuerHeader): Promise<ResolvedSigningKey>;
31
+ /**
32
+ * The key this subject signed with **at some point in the past**, for verifying an artefact it has
33
+ * already issued — the explicit opt-in half of the split. It accepts a key the subject may have
34
+ * rotated away *because it was compromised*, so the answer is "did once hold", never "holds". Use it
35
+ * only for past-minted material that must survive routine key hygiene (a capability, a revocation
36
+ * record, an archived grant), never to authenticate a live signer.
37
+ *
38
+ * A method whose key set never changes may alias it to {@link resolve}. Optional, so a resolver
39
+ * written before this member typechecks, but its **absence fails closed**: `resolveIssuerWithDoc`
40
+ * refuses rather than falling back to {@link resolve}, which would answer a different question. A
41
+ * method that can retire keys must implement it — including a *wrapper* around one that does.
42
+ */
43
+ resolveHistoric?(did: string, header: ResolveIssuerHeader): Promise<ResolvedSigningKey>;
44
+ /**
45
+ * Resolve the recipient's key agreement key set, in the method's order. Every entry with its
46
+ * algorithm rather than one chosen key: the set is an OR set, and selection belongs to the
47
+ * encrypting package. Optional: a method with no key agreement omits it.
48
+ */
49
+ resolveAgreementKey?(did: string): Promise<Array<ResolvedAgreementKey>>;
50
+ /**
51
+ * What this subject has revoked: no capability whose `aud` is in the set is valid. Entries are DIDs,
52
+ * but a wider vocabulary may add others — `did:kokuin:` also denies its own signing keys, spelled
53
+ * `#<key>` — so a reader must **match, never enumerate**. A DID and a key fragment cannot collide.
54
+ *
55
+ * Answer for **now**, not any position a token names: a capability's claims are backdatable, so an
56
+ * anchored check would let the holder pick a moment before it was revoked. A subject that cannot be
57
+ * resolved must reject, never answer the empty set (which reads as "nobody revoked").
58
+ *
59
+ * Optional, but unlike `resolveAgreementKey` its absence **disables enforcement** for every subject
60
+ * of this method, silently and in the passing direction. So a method with no revocation concept
61
+ * omits it, and **a method that can revoke must implement it — including a *wrapper*** (one
62
+ * forwarding only `resolve` type-checks and turns every denial into a pass). It rides this registry
63
+ * so a caller who wired resolution cannot separately forget enforcement; requiring it here would
64
+ * break every implementation for a member most methods have no answer for.
65
+ */
66
+ resolveDenySet?(did: string): Promise<ReadonlySet<string>>;
67
+ };
68
+ export type MethodRegistry = ReadonlyArray<DIDMethodResolver>;
69
+ /** Total: a malformed DID yields `undefined` rather than throwing. */
70
+ export declare function findMethodResolver(registry: MethodRegistry, did: string): DIDMethodResolver | undefined;
package/lib/method.js ADDED
@@ -0,0 +1,8 @@
1
+ /** Total: a malformed DID yields `undefined` rather than throwing. */ export function findMethodResolver(registry, did) {
2
+ const parts = did.split(':');
3
+ if (parts.length < 3 || parts[0] !== 'did') {
4
+ return undefined;
5
+ }
6
+ const method = parts[1];
7
+ return registry.find((entry)=>entry.method === method);
8
+ }
package/lib/schemas.d.ts CHANGED
@@ -10,7 +10,13 @@ export declare const signatureAlgorithmSchema: {
10
10
  export type SignatureAlgorithm = FromSchema<typeof signatureAlgorithmSchema>;
11
11
  /** @internal */
12
12
  export declare const validateAlgorithm: import("@sozai/schema").Validator<"EdDSA" | "ES256">;
13
- /** @internal */
13
+ /**
14
+ * The header of a signed token.
15
+ *
16
+ * Supported rather than internal: `@enkaku/protocol` composes its own message schemas out of this,
17
+ * `unsignedHeaderSchema` and `signedPayloadSchema` — a protocol built on kokuin tokens has to
18
+ * describe them in its own wire schema, and it cannot do that from a type alone.
19
+ */
14
20
  export declare const signedHeaderSchema: {
15
21
  readonly type: "object";
16
22
  readonly properties: {
@@ -38,7 +44,9 @@ export declare const validateSignedHeader: import("@sozai/schema").Validator<{
38
44
  alg: "EdDSA" | "ES256";
39
45
  typ: "JWT";
40
46
  }>;
41
- /** @internal */
47
+ /**
48
+ * The header of an unsigned token. See {@link signedHeaderSchema} for why this is supported.
49
+ */
42
50
  export declare const unsignedHeaderSchema: {
43
51
  readonly type: "object";
44
52
  readonly properties: {
@@ -110,7 +118,12 @@ export declare const capabilitySchema: {
110
118
  };
111
119
  }];
112
120
  };
113
- /** @internal */
121
+ /**
122
+ * The registered claims a signed token carries. `additionalProperties` is open, so a consumer
123
+ * spreads `properties` into its own object schema and concatenates `required`.
124
+ *
125
+ * Supported rather than internal — see {@link signedHeaderSchema}.
126
+ */
114
127
  export declare const signedPayloadSchema: {
115
128
  readonly type: "object";
116
129
  readonly properties: {
@@ -146,7 +159,12 @@ export declare const signedPayloadSchema: {
146
159
  readonly required: readonly ["iss"];
147
160
  readonly additionalProperties: true;
148
161
  };
149
- /** @internal */
162
+ /**
163
+ * The registered claims of a signed token, inferred from {@link signedPayloadSchema}.
164
+ *
165
+ * Supported rather than internal, and the most widely used of these: it is what a consumer
166
+ * intersects its own payload type with to describe a token it issues or verifies.
167
+ */
150
168
  export type SignedPayload = FromSchema<typeof signedPayloadSchema>;
151
169
  /** @internal */
152
170
  export declare const validateSignedPayload: import("@sozai/schema").Validator<{
package/lib/schemas.js CHANGED
@@ -8,7 +8,13 @@ import { createValidator } from '@sozai/schema';
8
8
  enum: SUPPORTED_ALGORITHMS
9
9
  };
10
10
  /** @internal */ export const validateAlgorithm = createValidator(signatureAlgorithmSchema);
11
- /** @internal */ export const signedHeaderSchema = {
11
+ /**
12
+ * The header of a signed token.
13
+ *
14
+ * Supported rather than internal: `@enkaku/protocol` composes its own message schemas out of this,
15
+ * `unsignedHeaderSchema` and `signedPayloadSchema` — a protocol built on kokuin tokens has to
16
+ * describe them in its own wire schema, and it cannot do that from a type alone.
17
+ */ export const signedHeaderSchema = {
12
18
  type: 'object',
13
19
  properties: {
14
20
  typ: {
@@ -27,7 +33,9 @@ import { createValidator } from '@sozai/schema';
27
33
  additionalProperties: true
28
34
  };
29
35
  /** @internal */ export const validateSignedHeader = createValidator(signedHeaderSchema);
30
- /** @internal */ export const unsignedHeaderSchema = {
36
+ /**
37
+ * The header of an unsigned token. See {@link signedHeaderSchema} for why this is supported.
38
+ */ export const unsignedHeaderSchema = {
31
39
  type: 'object',
32
40
  properties: {
33
41
  typ: {
@@ -65,7 +73,12 @@ import { createValidator } from '@sozai/schema';
65
73
  }
66
74
  ]
67
75
  };
68
- /** @internal */ export const signedPayloadSchema = {
76
+ /**
77
+ * The registered claims a signed token carries. `additionalProperties` is open, so a consumer
78
+ * spreads `properties` into its own object schema and concatenates `required`.
79
+ *
80
+ * Supported rather than internal — see {@link signedHeaderSchema}.
81
+ */ export const signedPayloadSchema = {
69
82
  type: 'object',
70
83
  properties: {
71
84
  iss: {
package/lib/token.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { DIDCache, DIDResolver } from './cache.js';
2
2
  import type { SigningIdentity } from './identity.js';
3
+ import type { MethodRegistry } from './method.js';
3
4
  import { type SignedPayload } from './schemas.js';
4
5
  import { type TimeValidationOptions } from './time.js';
5
6
  import type { DIDString, SignedToken, Token, UnsignedToken, VerifiedToken } from './types.js';
@@ -8,6 +9,13 @@ export type VerifyTokenOptions = TimeValidationOptions & {
8
9
  verifiers?: Verifiers;
9
10
  resolver?: DIDResolver;
10
11
  cache?: DIDCache;
12
+ /**
13
+ * DID methods that need external resolution to verify an issuer. `did:key` and `did:peer:4` need no
14
+ * entry (they carry their key in the identifier or document); a method whose key set is a projection
15
+ * of state held elsewhere — `did:kokuin:` — cannot be verified without one, and fails with `Unknown
16
+ * DID` when no registry is passed.
17
+ */
18
+ methods?: MethodRegistry;
11
19
  /**
12
20
  * Expected audience(s) for the token. When set, verification rejects a token whose `aud`
13
21
  * claim is not among the given value(s), and rejects unsigned / `alg:none` tokens outright
@@ -17,6 +25,18 @@ export type VerifyTokenOptions = TimeValidationOptions & {
17
25
  * array accepts no audience (every token is rejected).
18
26
  */
19
27
  audience?: DIDString | Array<DIDString>;
28
+ /**
29
+ * Verify against a key the issuer has already rotated away, as well as its current ones. Defaults
30
+ * to `false` — the safe question: the signature must be from a key the issuer holds **now**, so a
31
+ * rotated-away (leaked) key mints nothing. Opting in asks whether the issuer held the key **at some
32
+ * point**, which a past-issued capability or revocation record needs to survive routine key hygiene.
33
+ *
34
+ * Set it only for a past-issued token that is not itself proof a live party holds a key. Not a
35
+ * compatibility switch: it is the difference between "a compromised key mints nothing" and "mints
36
+ * anything until the profile resets". A resolver with no `resolveHistoric` rejects with
37
+ * `UnresolvableIssuerError` rather than answering the current-key question.
38
+ */
39
+ historic?: boolean;
20
40
  /**
21
41
  * Accept unsigned (`alg:none`) tokens. Defaults to `false`.
22
42
  *
@@ -38,6 +58,9 @@ export type VerifySignedPayloadInput<Payload extends Record<string, unknown> = R
38
58
  verifiers?: Verifiers;
39
59
  resolver?: DIDResolver;
40
60
  cache?: DIDCache;
61
+ methods?: MethodRegistry;
62
+ /** See `VerifyTokenOptions.historic`. Defaults to `false` — the issuer's current keys only. */
63
+ historic?: boolean;
41
64
  };
42
65
  /**
43
66
  * Verify the signature of a signed payload and return the public key of the issuer.
package/lib/token.js CHANGED
@@ -17,6 +17,11 @@ const verifiedTokens = new WeakSet();
17
17
  // re-derive `data` from the mutation and stay self-consistent. This map is not reachable from the
18
18
  // token, so the value captured at verification time is the one the re-bind compares against.
19
19
  const verifiedTokenData = new WeakMap();
20
+ // Tokens verified with `historic: true` — against a key the issuer has since rotated away.
21
+ // Membership blocks the fast path: a later `verifyToken` without `historic` asks the stricter
22
+ // question, and answering it from a weaker check is fail-open, so it re-verifies (which may pass).
23
+ // The reverse needs nothing — a strict answer already implies the loose one.
24
+ const historicallyVerifiedTokens = new WeakSet();
20
25
  function assertAudienceValid(payload, audience) {
21
26
  if (audience == null) {
22
27
  return;
@@ -44,16 +49,22 @@ function assertUnsignedAllowed(allowUnsigned) {
44
49
  /**
45
50
  * Verify the signature of a signed payload and return the public key of the issuer.
46
51
  */ export async function verifySignedPayload(input) {
47
- const { signature, payload, header, data, verifiers, resolver, cache } = input;
52
+ const { signature, payload, header, data, verifiers, resolver, cache, methods, historic } = input;
48
53
  assertType(validateSignedPayload, payload);
49
54
  const effectiveResolver = cache == null ? resolver : async (did)=>{
50
55
  const cached = await cache.get(did);
51
56
  if (cached != null) return cached;
52
57
  return resolver != null ? resolver(did) : undefined;
53
58
  };
54
- const { alg, publicKey, peer4Doc } = await resolveIssuerWithDoc(payload.iss, {
55
- kid: header.kid
56
- }, effectiveResolver);
59
+ const { alg, publicKey, peer4Doc } = await resolveIssuerWithDoc({
60
+ iss: payload.iss,
61
+ header: {
62
+ kid: header.kid
63
+ },
64
+ resolver: effectiveResolver,
65
+ methods,
66
+ historic
67
+ });
57
68
  const verify = getVerifier(alg, verifiers);
58
69
  const message = typeof data === 'string' ? fromUTF(data) : data;
59
70
  const verified = await verify(signature, message, publicKey);
@@ -145,7 +156,7 @@ function assertVerifiedDataUnchanged(token) {
145
156
  }
146
157
  }
147
158
  async function verifyTokenInner(token, options = {}) {
148
- const { verifiers, resolver, cache, audience, allowUnsigned = false, ...timeOptions } = options;
159
+ const { verifiers, resolver, cache, methods, audience, allowUnsigned = false, historic, ...timeOptions } = options;
149
160
  if (typeof token !== 'string') {
150
161
  if (isUnsignedToken(token)) {
151
162
  assertSignedForAudience(audience);
@@ -153,7 +164,10 @@ async function verifyTokenInner(token, options = {}) {
153
164
  assertTimeClaimsValid(token.payload, timeOptions);
154
165
  return token;
155
166
  }
156
- if (isVerifiedToken(token)) {
167
+ // The `historic` arm is what keeps the fast path from weakening the check: a token verified
168
+ // against a rotated-away key must not satisfy a later caller who asked for the issuer's current
169
+ // keys. It falls through to the full verification below rather than being rejected.
170
+ if (isVerifiedToken(token) && !(historic !== true && historicallyVerifiedTokens.has(token))) {
157
171
  // The signature was checked when this object entered `verifiedTokens`, but its payload may
158
172
  // have been mutated in place since. Re-bind it to the signed bytes — cheap next to a
159
173
  // signature verification, and enough to reject tampering.
@@ -171,7 +185,9 @@ async function verifyTokenInner(token, options = {}) {
171
185
  data,
172
186
  verifiers,
173
187
  resolver,
174
- cache
188
+ cache,
189
+ methods,
190
+ historic
175
191
  });
176
192
  assertTimeClaimsValid(token.payload, timeOptions);
177
193
  assertAudienceValid(token.payload, audience);
@@ -182,6 +198,9 @@ async function verifyTokenInner(token, options = {}) {
182
198
  };
183
199
  verifiedTokens.add(result);
184
200
  verifiedTokenData.set(result, data);
201
+ if (historic === true) {
202
+ historicallyVerifiedTokens.add(result);
203
+ }
185
204
  return result;
186
205
  }
187
206
  throw new Error('Unsupported token');
@@ -218,7 +237,9 @@ async function verifyTokenInner(token, options = {}) {
218
237
  data,
219
238
  verifiers,
220
239
  resolver,
221
- cache
240
+ cache,
241
+ methods,
242
+ historic
222
243
  });
223
244
  assertTimeClaimsValid(payload, timeOptions);
224
245
  assertAudienceValid(payload, audience);
@@ -231,6 +252,9 @@ async function verifyTokenInner(token, options = {}) {
231
252
  };
232
253
  verifiedTokens.add(result);
233
254
  verifiedTokenData.set(result, data);
255
+ if (historic === true) {
256
+ historicallyVerifiedTokens.add(result);
257
+ }
234
258
  return result;
235
259
  }
236
260
  throw new Error('Unsupported signature algorithm');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kokuin/token",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "JWT signing and verification",
5
5
  "keywords": [
6
6
  "json",
@@ -26,10 +26,9 @@
26
26
  "lib/*"
27
27
  ],
28
28
  "dependencies": {
29
- "@noble/ciphers": "^2.1.1",
30
29
  "@noble/curves": "^2.3.0",
31
30
  "@noble/hashes": "^2.3.0",
32
- "@scure/base": "^2.2.0",
31
+ "@scure/base": "^2.3.0",
33
32
  "@sozai/codec": "^0.4.0",
34
33
  "@sozai/otel": "^0.3.2",
35
34
  "@sozai/schema": "^0.1.1",
package/lib/jwe.d.ts DELETED
@@ -1,106 +0,0 @@
1
- import type { DecryptingIdentity, SigningIdentity } from './identity.js';
2
- import type { Verifiers } from './verifier.js';
3
- export type ConcatKDFParams = {
4
- sharedSecret: Uint8Array;
5
- keyLength: number;
6
- algorithmID: string;
7
- partyUInfo: Uint8Array;
8
- partyVInfo: Uint8Array;
9
- };
10
- export type JWEHeader = {
11
- alg: string;
12
- enc: string;
13
- epk: {
14
- kty: string;
15
- crv: string;
16
- x: string;
17
- };
18
- apu?: string;
19
- apv?: string;
20
- };
21
- export type TokenEncrypter = {
22
- recipientID?: string;
23
- encrypt(plaintext: Uint8Array): Promise<string>;
24
- };
25
- export type EncryptOptions = {
26
- algorithm: 'X25519';
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
- };
40
- /**
41
- * Concat KDF per RFC 7518 Section 4.6.2.
42
- * Single SHA-256 iteration (sufficient for 256-bit keys).
43
- */
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;
72
- /**
73
- * Create a token encrypter for a recipient identified by X25519 public key or DID string.
74
- */
75
- export declare function createTokenEncrypter(recipient: Uint8Array, options: EncryptOptions): TokenEncrypter;
76
- export declare function createTokenEncrypter(recipient: string): TokenEncrypter;
77
- /**
78
- * Encrypt plaintext to JWE compact serialization using the given encrypter.
79
- */
80
- export declare function encryptToken(encrypter: TokenEncrypter, plaintext: Uint8Array): Promise<string>;
81
- /**
82
- * Decrypt a JWE compact serialization string.
83
- */
84
- export declare function decryptToken(decrypter: DecryptingIdentity, jwe: string): Promise<Uint8Array>;
85
- export type EnvelopeMode = 'plain' | 'jws' | 'jws-in-jwe' | 'jwe-in-jws';
86
- export type UnwrappedEnvelope = {
87
- payload: Record<string, unknown>;
88
- mode: EnvelopeMode;
89
- };
90
- export type WrapOptions = {
91
- signer?: SigningIdentity;
92
- encrypter?: TokenEncrypter;
93
- header?: Record<string, unknown>;
94
- };
95
- export type UnwrapOptions = {
96
- decrypter?: DecryptingIdentity;
97
- verifiers?: Verifiers;
98
- };
99
- /**
100
- * Wrap a payload into a token string according to the specified envelope mode.
101
- */
102
- export declare function wrapEnvelope(mode: EnvelopeMode, payload: Record<string, unknown>, options: WrapOptions): Promise<string>;
103
- /**
104
- * Unwrap a token string, auto-detecting the envelope mode from its structure.
105
- */
106
- export declare function unwrapEnvelope(message: string, options: UnwrapOptions): Promise<UnwrappedEnvelope>;