@kokuin/token 0.3.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 = {
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)) {
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
@@ -256,7 +256,9 @@ function buildIdentity(id, longForm, doc, keys) {
256
256
  if (embedLongForm === true) return longForm;
257
257
  if (embedLongForm === false) return id;
258
258
  const aud = payload.aud;
259
- 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;
260
262
  const normalizedAud = normalizeDID(aud);
261
263
  if (sentTo.has(normalizedAud)) return id;
262
264
  // Concurrent sign() calls with the same new aud may both emit long-form; recipient cache writes are idempotent so this is acceptable.
package/lib/index.d.ts CHANGED
@@ -12,8 +12,8 @@
12
12
  export { type CreateInMemoryDIDCacheOptions, createInMemoryDIDCache, type DIDCache, type DIDResolver, } from './cache.js';
13
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';
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
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';
@@ -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
@@ -11,7 +11,7 @@
11
11
  */ export { createInMemoryDIDCache } from './cache.js';
12
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
@@ -53,12 +53,15 @@ function lengthPrefixed(data) {
53
53
  }
54
54
  return sha256(hashInput).slice(0, keyLength / 8);
55
55
  }
56
- function encryptWithX25519(recipientPublicKey, plaintext) {
57
- // Generate ephemeral X25519 key pair
56
+ function agreeWithKey(recipientPublicKey) {
58
57
  const ephemeralPrivateKey = x25519.utils.randomSecretKey();
59
- const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey);
60
- // Compute shared secret via ECDH
61
- 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);
62
65
  // Derive content encryption key via Concat KDF
63
66
  const cek = concatKDF({
64
67
  sharedSecret,
@@ -131,6 +134,37 @@ function resolveX25519Key(recipient) {
131
134
  }
132
135
  throw new Error(`Unsupported DID algorithm for encryption: ${algorithm}`);
133
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
+ }
134
168
  export function createTokenEncrypter(recipient, options) {
135
169
  if (typeof recipient !== 'string' && options?.algorithm !== 'X25519') {
136
170
  throw new Error(`Unsupported algorithm: ${options?.algorithm}`);
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.3.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.3.0",
33
+ "@sozai/codec": "^0.4.0",
34
+ "@sozai/otel": "^0.3.2",
35
35
  "@sozai/schema": "^0.1.1",
36
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
  }