@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 +8 -1
- package/lib/did.d.ts +2 -1
- package/lib/did.js +1 -1
- package/lib/identity.d.ts +9 -6
- package/lib/identity.js +3 -1
- package/lib/index.d.ts +3 -3
- package/lib/index.js +2 -2
- package/lib/jwe.d.ts +39 -0
- package/lib/jwe.js +39 -5
- package/lib/peer4.d.ts +7 -2
- package/lib/peer4.js +12 -0
- package/lib/rotation.d.ts +6 -5
- package/lib/rotation.js +2 -1
- package/lib/token.d.ts +2 -2
- package/lib/token.js +44 -16
- package/lib/types.d.ts +1 -0
- package/package.json +17 -14
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):
|
|
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
|
|
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:
|
|
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
|
|
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):
|
|
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:
|
|
87
|
-
longForm:
|
|
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
|
-
|
|
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
|
|
57
|
-
// Generate ephemeral X25519 key pair
|
|
56
|
+
function agreeWithKey(recipientPublicKey) {
|
|
58
57
|
const ephemeralPrivateKey = x25519.utils.randomSecretKey();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
115
|
-
shortForm:
|
|
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?:
|
|
6
|
-
to:
|
|
7
|
-
toLongForm:
|
|
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
|
|
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
|
|
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?:
|
|
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
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
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
|
|
31
|
-
"@noble/hashes": "^2.
|
|
30
|
+
"@noble/curves": "^2.3.0",
|
|
31
|
+
"@noble/hashes": "^2.3.0",
|
|
32
32
|
"@scure/base": "^2.2.0",
|
|
33
|
-
"@sozai/codec": "^0.
|
|
34
|
-
"@sozai/otel": "^0.3.
|
|
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.
|
|
40
|
-
"@opentelemetry/sdk-trace-node": "^2.
|
|
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
|
-
"
|
|
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
|
}
|