@kokuin/token 0.1.1 → 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 +9 -0
- package/lib/did.js +66 -2
- package/lib/identity.js +27 -9
- package/lib/index.d.ts +2 -2
- package/lib/index.js +1 -1
- package/lib/jwe.js +21 -2
- package/lib/keystore.d.ts +62 -1
- package/lib/keystore.js +31 -1
- package/lib/peer4.d.ts +6 -1
- package/lib/peer4.js +21 -3
- package/lib/token.d.ts +26 -2
- package/lib/token.js +43 -6
- package/package.json +7 -7
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { base58 } from '@scure/base';
|
|
2
2
|
import { decodeMultibase } from './multibase.js';
|
|
3
|
-
import { decodePeer4, encodePeer4, getPeer4ShortForm, isPeer4 } from './peer4.js';
|
|
3
|
+
import { assertDocWithinMaxSize, decodePeer4, encodePeer4, getPeer4ShortForm, isPeer4 } from './peer4.js';
|
|
4
4
|
/** @internal */ export const CODECS = {
|
|
5
5
|
ES256: new Uint8Array([
|
|
6
6
|
128,
|
|
@@ -16,6 +16,9 @@ const EXPECTED_KEY_SIZES = {
|
|
|
16
16
|
ES256: 33
|
|
17
17
|
};
|
|
18
18
|
const PREFIX = 'did:key:z';
|
|
19
|
+
// ES256 is the largest supported did:key payload: a 2-byte codec plus a 33-byte key
|
|
20
|
+
// encodes to 48 base58 characters. Bound before decoding — base58.decode is O(n^2).
|
|
21
|
+
const MAX_DID_KEY_ENCODED = 64;
|
|
19
22
|
function isCodecMatch(codec, bytes) {
|
|
20
23
|
if (bytes.length < codec.length) return false;
|
|
21
24
|
for(let i = 0; i < codec.length; i++){
|
|
@@ -48,7 +51,11 @@ function isCodecMatch(codec, bytes) {
|
|
|
48
51
|
if (!did.startsWith(PREFIX)) {
|
|
49
52
|
throw new Error('Invalid DID format');
|
|
50
53
|
}
|
|
51
|
-
const
|
|
54
|
+
const encoded = did.slice(PREFIX.length);
|
|
55
|
+
if (encoded.length > MAX_DID_KEY_ENCODED) {
|
|
56
|
+
throw new Error('Invalid DID format: key too large');
|
|
57
|
+
}
|
|
58
|
+
const bytes = base58.decode(encoded);
|
|
52
59
|
const info = getAlgorithmAndPublicKey(bytes);
|
|
53
60
|
if (info == null) {
|
|
54
61
|
throw new Error('Unsupported DID signature codec');
|
|
@@ -86,6 +93,7 @@ function isCodecMatch(codec, bytes) {
|
|
|
86
93
|
if (doc == null) {
|
|
87
94
|
throw new Error(`Unknown DID: ${shortForm}`);
|
|
88
95
|
}
|
|
96
|
+
assertDocWithinMaxSize(doc);
|
|
89
97
|
const expected = encodePeer4(doc).shortForm;
|
|
90
98
|
if (expected !== shortForm) {
|
|
91
99
|
throw new Error('DIDResolver: short form/doc hash mismatch');
|
|
@@ -123,6 +131,17 @@ function resolveKidOrAuth(doc, kid) {
|
|
|
123
131
|
}
|
|
124
132
|
return resolveKidFromDoc(doc, auth[0]);
|
|
125
133
|
}
|
|
134
|
+
// A kid must reference a method the subject authorized for `authentication`. Without this
|
|
135
|
+
// check a key listed only under `assertionMethod` (or any other relationship) could sign
|
|
136
|
+
// tokens. `KidNotFound` still takes precedence so a genuinely absent kid is reported as such.
|
|
137
|
+
const method = doc.verificationMethod.find((m)=>m.id === kid);
|
|
138
|
+
if (method == null) {
|
|
139
|
+
throw new Error(`KidNotFound: ${kid}`);
|
|
140
|
+
}
|
|
141
|
+
const auth = doc.authentication;
|
|
142
|
+
if (auth == null || !auth.includes(kid)) {
|
|
143
|
+
throw new Error(`resolveIssuer: kid ${kid} is not an authentication method`);
|
|
144
|
+
}
|
|
126
145
|
return resolveKidFromDoc(doc, kid);
|
|
127
146
|
}
|
|
128
147
|
function resolveKidFromDoc(doc, kid) {
|
|
@@ -130,6 +149,9 @@ function resolveKidFromDoc(doc, kid) {
|
|
|
130
149
|
if (method == null) {
|
|
131
150
|
throw new Error(`KidNotFound: ${kid}`);
|
|
132
151
|
}
|
|
152
|
+
if (method.publicKeyMultibase.length > MAX_DID_KEY_ENCODED) {
|
|
153
|
+
throw new Error('Invalid verification method: key too large');
|
|
154
|
+
}
|
|
133
155
|
const bytes = decodeMultibase(method.publicKeyMultibase);
|
|
134
156
|
const info = getAlgorithmAndPublicKey(bytes);
|
|
135
157
|
if (info == null) {
|
|
@@ -144,3 +166,45 @@ function resolveKidFromDoc(doc, kid) {
|
|
|
144
166
|
*/ export function normalizeDID(did) {
|
|
145
167
|
return isPeer4(did) ? getPeer4ShortForm(did) : did;
|
|
146
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
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
if (
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -235,7 +253,8 @@ export function createTokenEncrypter(recipient, options) {
|
|
|
235
253
|
if (parts.length === 3) {
|
|
236
254
|
// JWT: could be plain, jws, or jwe-in-jws
|
|
237
255
|
const token = await verifyToken(message, {
|
|
238
|
-
verifiers: options.verifiers
|
|
256
|
+
verifiers: options.verifiers,
|
|
257
|
+
allowUnsigned: true
|
|
239
258
|
});
|
|
240
259
|
if (isUnsignedToken(token)) {
|
|
241
260
|
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
|
-
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A single key slot in a {@link KeyStore}, identified by `keyID`.
|
|
3
|
+
*
|
|
4
|
+
* This is the **read/provide** facet — the floor every backend can honor, including ones
|
|
5
|
+
* that derive keys rather than store them (HD) or never expose key material at all.
|
|
6
|
+
* A backend that can also write and delete implements {@link MutableKeyEntry}.
|
|
7
|
+
*
|
|
8
|
+
* ## Invariants
|
|
9
|
+
*
|
|
10
|
+
* Enforced by `keyStoreConformanceCases`. A backend that cannot satisfy these does not
|
|
11
|
+
* implement this type — it exposes a different surface instead.
|
|
12
|
+
*
|
|
13
|
+
* 1. **keyID round-trips.** `store.entry(x).keyID === x`, always.
|
|
14
|
+
* 2. **Absent means `null`.** {@link getAsync} resolves `null` when no key exists. Never
|
|
15
|
+
* `undefined`, never a throw. A *derived* backend may never return `null` at all —
|
|
16
|
+
* it can always produce a key — and that is contract-legal.
|
|
17
|
+
* 3. **{@link provideAsync} is idempotent under concurrency.** Two concurrent calls on one
|
|
18
|
+
* keyID resolve to the *same* key. Never two keys; never a key that is generated,
|
|
19
|
+
* returned to a caller, and then overwritten by a racer.
|
|
20
|
+
*/ /**
|
|
21
|
+
* A keyed collection of {@link KeyEntry}s.
|
|
22
|
+
*
|
|
23
|
+
* Parameterized by entry type, so a writable backend is `KeyStore<T, MutableKeyEntry<T>>`
|
|
24
|
+
* and a derived one is `KeyStore<T, KeyEntry<T>>`. No separate mutable store type is needed.
|
|
25
|
+
*
|
|
26
|
+
* Most consumers should NOT be generic over this — they should be generic over
|
|
27
|
+
* `IdentityProvider`, which is what "give me an identity for this keyID" actually needs.
|
|
28
|
+
* `KeyStore` is the *storage* contract, useful to backend authors and to the conformance
|
|
29
|
+
* suite. A backend whose key never leaves the device (ledger) implements `IdentityProvider`
|
|
30
|
+
* and neither storage type; that is the contract working, not a gap.
|
|
31
|
+
*/ export { };
|
package/lib/peer4.d.ts
CHANGED
|
@@ -81,9 +81,14 @@ export type DIDDoc = FromSchema<typeof didDocSchema>;
|
|
|
81
81
|
*/
|
|
82
82
|
export declare function validateDIDDoc(value: unknown): value is DIDDoc;
|
|
83
83
|
export type DecodePeer4Options = {
|
|
84
|
-
/** Maximum allowed size of the canonical doc bytes. Default
|
|
84
|
+
/** Maximum allowed size of the canonical doc bytes. Default 4 KiB. */
|
|
85
85
|
maxDocSize?: number;
|
|
86
86
|
};
|
|
87
|
+
/**
|
|
88
|
+
* Throw if a DID document's canonical serialization exceeds `maxSize` bytes.
|
|
89
|
+
* Used to bound a resolver-returned doc before it reaches the O(n^2) base58 encode.
|
|
90
|
+
*/
|
|
91
|
+
export declare function assertDocWithinMaxSize(doc: DIDDoc, maxSize?: number): void;
|
|
87
92
|
/**
|
|
88
93
|
* Check whether a value is a did:peer:4 identifier (either form).
|
|
89
94
|
*/
|
package/lib/peer4.js
CHANGED
|
@@ -78,7 +78,20 @@ const JSON_MULTICODEC = new Uint8Array([
|
|
|
78
78
|
0x80,
|
|
79
79
|
0x04
|
|
80
80
|
]);
|
|
81
|
-
const DEFAULT_MAX_DOC_SIZE =
|
|
81
|
+
const DEFAULT_MAX_DOC_SIZE = 4 * 1024;
|
|
82
|
+
// base58 expands bytes to characters by log(256) / log(58).
|
|
83
|
+
const BASE58_EXPANSION = 1.3658;
|
|
84
|
+
// A SHA-256 multihash is 34 bytes: 47 base58 characters plus the multibase 'z'.
|
|
85
|
+
const MAX_HASH_ENCODED = 64;
|
|
86
|
+
/**
|
|
87
|
+
* Throw if a DID document's canonical serialization exceeds `maxSize` bytes.
|
|
88
|
+
* Used to bound a resolver-returned doc before it reaches the O(n^2) base58 encode.
|
|
89
|
+
*/ export function assertDocWithinMaxSize(doc, maxSize = DEFAULT_MAX_DOC_SIZE) {
|
|
90
|
+
const size = fromUTF(canonicalStringify(doc)).length;
|
|
91
|
+
if (size > maxSize) {
|
|
92
|
+
throw new Error(`did:peer:4 resolver doc too large: ${size} > ${maxSize}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
82
95
|
/**
|
|
83
96
|
* Check whether a value is a did:peer:4 identifier (either form).
|
|
84
97
|
*/ export function isPeer4(value) {
|
|
@@ -109,9 +122,14 @@ function startsWithJsonCodec(bytes) {
|
|
|
109
122
|
}
|
|
110
123
|
const hashEncoded = longForm.slice(PEER4_PREFIX.length, sep);
|
|
111
124
|
const encodedDoc = longForm.slice(sep + 1);
|
|
125
|
+
// Both segments feed an O(n^2) base58 decode, so bound them before decoding.
|
|
126
|
+
if (hashEncoded.length > MAX_HASH_ENCODED) {
|
|
127
|
+
throw new Error('did:peer:4 hash too large');
|
|
128
|
+
}
|
|
112
129
|
const maxSize = options.maxDocSize ?? DEFAULT_MAX_DOC_SIZE;
|
|
113
|
-
|
|
114
|
-
|
|
130
|
+
const maxEncoded = Math.ceil((maxSize + JSON_MULTICODEC.length) * BASE58_EXPANSION) + 8;
|
|
131
|
+
if (encodedDoc.length > maxEncoded) {
|
|
132
|
+
throw new Error(`did:peer:4 encoded doc too large: ${encodedDoc.length} > ${maxEncoded}`);
|
|
115
133
|
}
|
|
116
134
|
const hashBytes = decodeMultibase(hashEncoded);
|
|
117
135
|
if (!verifyMultihash(hashBytes, fromUTF(encodedDoc))) {
|
package/lib/token.d.ts
CHANGED
|
@@ -8,6 +8,24 @@ export type VerifyTokenOptions = TimeValidationOptions & {
|
|
|
8
8
|
verifiers?: Verifiers;
|
|
9
9
|
resolver?: DIDResolver;
|
|
10
10
|
cache?: DIDCache;
|
|
11
|
+
/**
|
|
12
|
+
* Expected audience(s) for the token. When set, verification rejects a token whose `aud`
|
|
13
|
+
* claim is not among the given value(s), and rejects unsigned / `alg:none` tokens outright
|
|
14
|
+
* (they carry no proof of their claims). Intended for the invocation/leaf token directed at
|
|
15
|
+
* a service — it must NOT be forwarded into capability-chain verification, where a
|
|
16
|
+
* capability's `aud` is the delegation next-hop rather than a service audience. An empty
|
|
17
|
+
* array accepts no audience (every token is rejected).
|
|
18
|
+
*/
|
|
19
|
+
audience?: string | Array<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Accept unsigned (`alg:none`) tokens. Defaults to `false`.
|
|
22
|
+
*
|
|
23
|
+
* An unsigned token carries no proof of its claims: its payload is entirely
|
|
24
|
+
* attacker-chosen. With the default, `verifyToken` rejects them and its return type is
|
|
25
|
+
* `VerifiedToken`, so a caller cannot reach an unverified payload by accident. Only opt
|
|
26
|
+
* in when the payload is not used for authorization.
|
|
27
|
+
*/
|
|
28
|
+
allowUnsigned?: boolean;
|
|
11
29
|
};
|
|
12
30
|
export type VerifySignedPayloadInput<Payload extends Record<string, unknown> = Record<string, unknown>> = {
|
|
13
31
|
signature: Uint8Array;
|
|
@@ -32,7 +50,7 @@ export declare function isSignedToken<Payload extends SignedPayload = SignedPayl
|
|
|
32
50
|
/**
|
|
33
51
|
* Check if a token is unsigned.
|
|
34
52
|
*/
|
|
35
|
-
export declare function isUnsignedToken<Payload extends Record<string, unknown>>(token:
|
|
53
|
+
export declare function isUnsignedToken<Payload extends Record<string, unknown>>(token: unknown): token is UnsignedToken<Payload>;
|
|
36
54
|
/**
|
|
37
55
|
* Check if a token was verified by `verifyToken` in this process.
|
|
38
56
|
* A `verifiedPublicKey` property on a deserialized token is never trusted.
|
|
@@ -50,4 +68,10 @@ export declare function signToken<Payload extends Record<string, unknown>, Heade
|
|
|
50
68
|
* Verify a token is either unsigned or signed with a valid signature.
|
|
51
69
|
* Also validates time-based claims (exp, nbf) if present.
|
|
52
70
|
*/
|
|
53
|
-
export declare function verifyToken<Payload extends Record<string, unknown> = Record<string, unknown>>(token: Token<Payload> | string, options?: VerifyTokenOptions
|
|
71
|
+
export declare function verifyToken<Payload extends Record<string, unknown> = Record<string, unknown>>(token: Token<Payload> | string, options?: VerifyTokenOptions & {
|
|
72
|
+
allowUnsigned?: false;
|
|
73
|
+
}): Promise<VerifiedToken<Payload>>;
|
|
74
|
+
export declare function verifyToken<Payload extends Record<string, unknown> = Record<string, unknown>>(token: Token<Payload> | string, options: VerifyTokenOptions & {
|
|
75
|
+
allowUnsigned: true;
|
|
76
|
+
}): Promise<Token<Payload>>;
|
|
77
|
+
export declare function verifyToken<Payload extends Record<string, unknown> = Record<string, unknown>>(token: Token<Payload> | string, options: VerifyTokenOptions): Promise<Token<Payload>>;
|
package/lib/token.js
CHANGED
|
@@ -11,6 +11,30 @@ 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
|
+
function assertAudienceValid(payload, audience) {
|
|
15
|
+
if (audience == null) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const aud = payload.aud;
|
|
19
|
+
const allowed = Array.isArray(audience) ? audience : [
|
|
20
|
+
audience
|
|
21
|
+
];
|
|
22
|
+
if (typeof aud !== 'string' || !allowed.includes(aud)) {
|
|
23
|
+
throw new Error(`Invalid token: audience ${typeof aud === 'string' ? `"${aud}"` : 'missing'} not accepted`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// An unsigned / `alg:none` token carries no proof of its claims, so it cannot satisfy an
|
|
27
|
+
// audience requirement. Reject it rather than silently skipping the check.
|
|
28
|
+
function assertSignedForAudience(audience) {
|
|
29
|
+
if (audience != null) {
|
|
30
|
+
throw new Error('Invalid token: audience validation requires a signed token');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function assertUnsignedAllowed(allowUnsigned) {
|
|
34
|
+
if (!allowUnsigned) {
|
|
35
|
+
throw new Error('Invalid token: unsigned tokens rejected, pass allowUnsigned to accept');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
14
38
|
/**
|
|
15
39
|
* Verify the signature of a signed payload and return the public key of the issuer.
|
|
16
40
|
*/ export async function verifySignedPayload(input) {
|
|
@@ -38,12 +62,18 @@ const verifiedTokens = new WeakSet();
|
|
|
38
62
|
/**
|
|
39
63
|
* Check if a token is signed.
|
|
40
64
|
*/ export function isSignedToken(token) {
|
|
65
|
+
if (typeof token !== 'object' || token === null) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
41
68
|
const t = token;
|
|
42
69
|
return isType(validateSignedHeader, t.header) && isType(validateSignedPayload, t.payload) && t.signature != null;
|
|
43
70
|
}
|
|
44
71
|
/**
|
|
45
72
|
* Check if a token is unsigned.
|
|
46
73
|
*/ export function isUnsignedToken(token) {
|
|
74
|
+
if (typeof token !== 'object' || token === null) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
47
77
|
return isType(validateUnsignedHeader, token.header);
|
|
48
78
|
}
|
|
49
79
|
/**
|
|
@@ -93,13 +123,17 @@ function getVerifiableData(token) {
|
|
|
93
123
|
throw new Error('Invalid token: data does not match header and payload');
|
|
94
124
|
}
|
|
95
125
|
async function verifyTokenInner(token, options = {}) {
|
|
96
|
-
const { verifiers, resolver, cache, ...timeOptions } = options;
|
|
126
|
+
const { verifiers, resolver, cache, audience, allowUnsigned = false, ...timeOptions } = options;
|
|
97
127
|
if (typeof token !== 'string') {
|
|
98
128
|
if (isUnsignedToken(token)) {
|
|
129
|
+
assertSignedForAudience(audience);
|
|
130
|
+
assertUnsignedAllowed(allowUnsigned);
|
|
131
|
+
assertTimeClaimsValid(token.payload, timeOptions);
|
|
99
132
|
return token;
|
|
100
133
|
}
|
|
101
134
|
if (isVerifiedToken(token)) {
|
|
102
135
|
assertTimeClaimsValid(token.payload, timeOptions);
|
|
136
|
+
assertAudienceValid(token.payload, audience);
|
|
103
137
|
return token;
|
|
104
138
|
}
|
|
105
139
|
if (isSignedToken(token)) {
|
|
@@ -114,6 +148,7 @@ async function verifyTokenInner(token, options = {}) {
|
|
|
114
148
|
cache
|
|
115
149
|
});
|
|
116
150
|
assertTimeClaimsValid(token.payload, timeOptions);
|
|
151
|
+
assertAudienceValid(token.payload, audience);
|
|
117
152
|
const result = {
|
|
118
153
|
...token,
|
|
119
154
|
data,
|
|
@@ -134,9 +169,13 @@ async function verifyTokenInner(token, options = {}) {
|
|
|
134
169
|
throw new Error('Invalid token header type');
|
|
135
170
|
}
|
|
136
171
|
if (header.alg === 'none') {
|
|
172
|
+
assertSignedForAudience(audience);
|
|
173
|
+
assertUnsignedAllowed(allowUnsigned);
|
|
174
|
+
const payload = b64uToJSON(encodedPayload);
|
|
175
|
+
assertTimeClaimsValid(payload, timeOptions);
|
|
137
176
|
return {
|
|
138
177
|
header,
|
|
139
|
-
payload
|
|
178
|
+
payload
|
|
140
179
|
};
|
|
141
180
|
}
|
|
142
181
|
if (isType(validateAlgorithm, header.alg)) {
|
|
@@ -155,6 +194,7 @@ async function verifyTokenInner(token, options = {}) {
|
|
|
155
194
|
cache
|
|
156
195
|
});
|
|
157
196
|
assertTimeClaimsValid(payload, timeOptions);
|
|
197
|
+
assertAudienceValid(payload, audience);
|
|
158
198
|
const result = {
|
|
159
199
|
data,
|
|
160
200
|
header,
|
|
@@ -167,10 +207,7 @@ async function verifyTokenInner(token, options = {}) {
|
|
|
167
207
|
}
|
|
168
208
|
throw new Error('Unsupported signature algorithm');
|
|
169
209
|
}
|
|
170
|
-
|
|
171
|
-
* Verify a token is either unsigned or signed with a valid signature.
|
|
172
|
-
* Also validates time-based claims (exp, nbf) if present.
|
|
173
|
-
*/ export async function verifyToken(token, options = {}) {
|
|
210
|
+
export async function verifyToken(token, options = {}) {
|
|
174
211
|
return withSpan(tokenTracer, KokuinSpanNames.TOKEN_VERIFY, {}, async (span)=>{
|
|
175
212
|
const result = await verifyTokenInner(token, options);
|
|
176
213
|
if (isSignedToken(result)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kokuin/token",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "JWT signing and verification",
|
|
6
6
|
"keywords": [
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
"@noble/curves": "^2.0.1",
|
|
31
31
|
"@noble/hashes": "^2.0.0",
|
|
32
32
|
"@scure/base": "^2.2.0",
|
|
33
|
-
"@sozai/codec": "^0.
|
|
34
|
-
"@sozai/otel": "^0.
|
|
35
|
-
"@sozai/schema": "^0.1.
|
|
36
|
-
"@kokuin/otel": "^0.
|
|
33
|
+
"@sozai/codec": "^0.2.0",
|
|
34
|
+
"@sozai/otel": "^0.3.0",
|
|
35
|
+
"@sozai/schema": "^0.1.1",
|
|
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.9.0",
|
|
40
|
+
"@opentelemetry/sdk-trace-node": "^2.9.0",
|
|
41
41
|
"uint8arrays": "^6.1.1"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|