@kokuin/token 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cache.js +8 -1
- package/lib/did.d.ts +102 -6
- package/lib/did.js +169 -12
- package/lib/identity.d.ts +35 -14
- package/lib/identity.js +51 -34
- package/lib/index.d.ts +4 -6
- package/lib/index.js +4 -5
- package/lib/method.d.ts +70 -0
- package/lib/method.js +8 -0
- package/lib/peer4.d.ts +7 -2
- package/lib/peer4.js +12 -0
- package/lib/schemas.d.ts +22 -4
- package/lib/schemas.js +16 -3
- package/lib/token.d.ts +25 -2
- package/lib/token.js +76 -24
- package/lib/types.d.ts +1 -0
- package/package.json +18 -16
- package/lib/jwe.d.ts +0 -67
- package/lib/jwe.js +0 -281
- package/lib/rotation.d.ts +0 -15
- package/lib/rotation.js +0 -12
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,14 +1,86 @@
|
|
|
1
1
|
import type { DIDResolver } from './cache.js';
|
|
2
|
+
import { type MethodRegistry } from './method.js';
|
|
2
3
|
import type { DIDDoc } from './peer4.js';
|
|
3
4
|
import type { SignatureAlgorithm } from './schemas.js';
|
|
4
|
-
|
|
5
|
+
import type { DIDString } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Multicodec prefixes per signature algorithm, following the `did:key` convention. Supported, not
|
|
8
|
+
* internal: `@kokuin/capability` builds the `cnf.kid` confirmation claim from this and
|
|
9
|
+
* {@link getAlgorithmAndPublicKey}, and `cnf.kid` is a published wire format third parties produce
|
|
10
|
+
* and read.
|
|
11
|
+
*/
|
|
5
12
|
export declare const CODECS: Record<SignatureAlgorithm, Uint8Array>;
|
|
6
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Split multicodec-prefixed key bytes into their algorithm and the raw public key, or `null` when
|
|
15
|
+
* the prefix names no algorithm this package knows.
|
|
16
|
+
*
|
|
17
|
+
* The decoder half of the `cnf.kid` wire format — see {@link CODECS}.
|
|
18
|
+
*/
|
|
7
19
|
export declare function getAlgorithmAndPublicKey(bytes: Uint8Array): [SignatureAlgorithm, Uint8Array] | null;
|
|
8
20
|
/** @internal */
|
|
9
|
-
export declare function getDID(codec: Uint8Array, publicKey: Uint8Array):
|
|
10
|
-
/**
|
|
21
|
+
export declare function getDID(codec: Uint8Array, publicKey: Uint8Array): DIDString;
|
|
22
|
+
/**
|
|
23
|
+
* The signature algorithm and raw public key a `did:key` identifier carries. Throws when the string
|
|
24
|
+
* is not a `did:key`, its encoded form is implausibly long, or the codec names no supported
|
|
25
|
+
* algorithm.
|
|
26
|
+
*
|
|
27
|
+
* Supported, not internal: `@kumiai/mls` checks an MLS credential's key against the key its DID
|
|
28
|
+
* names, a comparison only this function makes correctly — the alternative is a second decoder in
|
|
29
|
+
* another repo whose failure mode is accepting a credential for the wrong key. Any other method
|
|
30
|
+
* resolves through a `DIDMethodResolver`; this is the self-contained case.
|
|
31
|
+
*/
|
|
11
32
|
export declare function getSignatureInfo(did: string): [SignatureAlgorithm, Uint8Array];
|
|
33
|
+
/**
|
|
34
|
+
* The issuer could not be resolved to a usable signing key: no method, resolver, or registry entry
|
|
35
|
+
* turned `iss` into one — including a resolver that has no answer, throws, or answers with something
|
|
36
|
+
* unusable (an oversized document, or one not hashing to the DID asked for).
|
|
37
|
+
*
|
|
38
|
+
* Distinct from every other verification failure on purpose. An invalid signature or a bad `kid`
|
|
39
|
+
* means the issuer *was* resolved and the token is bad (positive evidence); this means nothing was
|
|
40
|
+
* learned either way, and a caller treating "could not check" as "checked and fine" fails open.
|
|
41
|
+
* `@kokuin/capability`'s revocation checker turns on this distinction, so it must be a type, not a
|
|
42
|
+
* message — text matching is how such a check regresses silently.
|
|
43
|
+
*/
|
|
44
|
+
export declare class UnresolvableIssuerError extends Error {
|
|
45
|
+
/**
|
|
46
|
+
* Identifies the error by value, not identity: within this package `instanceof` is exact, but a
|
|
47
|
+
* duplicated `@kokuin/token` in a consumer's tree would make a cross-copy `instanceof` false (which
|
|
48
|
+
* fails closed, so this is hardening). Consumers should use `isUnresolvableIssuerError`.
|
|
49
|
+
*/
|
|
50
|
+
static get brand(): string;
|
|
51
|
+
/** The brand, readable from an instance — what `isUnresolvableIssuerError` matches on. */
|
|
52
|
+
get brand(): string;
|
|
53
|
+
constructor(message: string, options?: ErrorOptions);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether a thrown value means the issuer could not be resolved.
|
|
57
|
+
*
|
|
58
|
+
* Prefer this to `instanceof UnresolvableIssuerError` across a package boundary: it matches on the
|
|
59
|
+
* brand, so it holds even if the thrower and the checker resolved different copies of this package.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isUnresolvableIssuerError(value: unknown): value is UnresolvableIssuerError;
|
|
62
|
+
/**
|
|
63
|
+
* A DID method resolved the issuer, and the token then named a key that issuer does not have. The
|
|
64
|
+
* counterpart of {@link UnresolvableIssuerError}: a `DIDMethodResolver` needs a way to say "resolved,
|
|
65
|
+
* and the token is bad" too, else `resolveIssuerWithDoc` wraps everything a method throws as
|
|
66
|
+
* unresolvable and an unauthenticated `kid` naming a real DID and an invented key reads as "could not
|
|
67
|
+
* check" rather than "checked, and bad". Throw it only for that; not knowing the DID at all is
|
|
68
|
+
* {@link UnresolvableIssuerError}.
|
|
69
|
+
*/
|
|
70
|
+
export declare class IssuerKeyNotFoundError extends Error {
|
|
71
|
+
/** @see UnresolvableIssuerError.brand — same reasoning, across the same package boundary. */
|
|
72
|
+
static get brand(): string;
|
|
73
|
+
/** The brand, readable from an instance — what `isIssuerKeyNotFoundError` matches on. */
|
|
74
|
+
get brand(): string;
|
|
75
|
+
constructor(message: string, options?: ErrorOptions);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether a thrown value means the issuer resolved but does not have the key the token named.
|
|
79
|
+
*
|
|
80
|
+
* Prefer this to `instanceof IssuerKeyNotFoundError` across a package boundary, for the same
|
|
81
|
+
* reason as {@link isUnresolvableIssuerError}.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isIssuerKeyNotFoundError(value: unknown): value is IssuerKeyNotFoundError;
|
|
12
84
|
export type ResolveIssuerHeader = {
|
|
13
85
|
kid?: string;
|
|
14
86
|
};
|
|
@@ -21,16 +93,40 @@ export type ResolveIssuerWithDocResult = {
|
|
|
21
93
|
doc: DIDDoc;
|
|
22
94
|
};
|
|
23
95
|
};
|
|
96
|
+
/**
|
|
97
|
+
* Whether to resolve the issuer's *current* signing key or one it signed with in the past. `false`
|
|
98
|
+
* (default) asks {@link DIDMethodResolver.resolve} — the safe question, right for a live signer.
|
|
99
|
+
* `true` asks {@link DIDMethodResolver.resolveHistoric}, an explicit statement that the artefact was
|
|
100
|
+
* issued in the past and must survive key rotation. A resolver with no `resolveHistoric` **refuses**
|
|
101
|
+
* rather than falling back to `resolve`, which would be the permissive scan by another name.
|
|
102
|
+
*/
|
|
103
|
+
export type ResolveIssuerMode = {
|
|
104
|
+
historic?: boolean;
|
|
105
|
+
};
|
|
106
|
+
/** Params for {@link resolveIssuer} / {@link resolveIssuerWithDoc}. */
|
|
107
|
+
export type ResolveIssuerParams = {
|
|
108
|
+
iss: string;
|
|
109
|
+
header?: ResolveIssuerHeader;
|
|
110
|
+
resolver?: DIDResolver;
|
|
111
|
+
methods?: MethodRegistry;
|
|
112
|
+
/** See {@link ResolveIssuerMode.historic}. */
|
|
113
|
+
historic?: boolean;
|
|
114
|
+
};
|
|
24
115
|
/**
|
|
25
116
|
* Resolve a token issuer (did:key or did:peer:4) and return alg + public key,
|
|
26
117
|
* plus the decoded peer:4 doc when one was obtained inline or via the resolver.
|
|
27
118
|
* Callers writing to a DID cache should write `peer4Doc` only after signature verification.
|
|
119
|
+
*
|
|
120
|
+
* The `historic` param selects which question is asked of a `DIDMethodResolver` — see
|
|
121
|
+
* {@link ResolveIssuerMode}. It reaches only the method-registry branch: `did:key` carries its key
|
|
122
|
+
* in the identifier and a `did:peer:4` document is fixed by its own hash, so neither has a past key
|
|
123
|
+
* set distinct from its present one.
|
|
28
124
|
*/
|
|
29
|
-
export declare function resolveIssuerWithDoc(iss
|
|
125
|
+
export declare function resolveIssuerWithDoc({ iss, header, resolver, methods, historic, }: ResolveIssuerParams): Promise<ResolveIssuerWithDocResult>;
|
|
30
126
|
/**
|
|
31
127
|
* Resolve a token issuer to [alg, publicKey]. Backward-compatible wrapper around resolveIssuerWithDoc.
|
|
32
128
|
*/
|
|
33
|
-
export declare function resolveIssuer(iss
|
|
129
|
+
export declare function resolveIssuer({ iss, header, resolver, methods, historic, }: ResolveIssuerParams): Promise<[SignatureAlgorithm, Uint8Array]>;
|
|
34
130
|
/**
|
|
35
131
|
* Fold a DID to its canonical form for equality comparison.
|
|
36
132
|
* For did:peer:4, returns the short form regardless of whether input is long or short.
|
package/lib/did.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { base58 } from '@scure/base';
|
|
2
|
+
import { findMethodResolver } from './method.js';
|
|
2
3
|
import { decodeMultibase } from './multibase.js';
|
|
3
4
|
import { assertDocWithinMaxSize, decodePeer4, encodePeer4, getPeer4ShortForm, isPeer4 } from './peer4.js';
|
|
4
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Multicodec prefixes per signature algorithm, following the `did:key` convention. Supported, not
|
|
7
|
+
* internal: `@kokuin/capability` builds the `cnf.kid` confirmation claim from this and
|
|
8
|
+
* {@link getAlgorithmAndPublicKey}, and `cnf.kid` is a published wire format third parties produce
|
|
9
|
+
* and read.
|
|
10
|
+
*/ export const CODECS = {
|
|
5
11
|
ES256: new Uint8Array([
|
|
6
12
|
128,
|
|
7
13
|
36
|
|
@@ -28,7 +34,12 @@ function isCodecMatch(codec, bytes) {
|
|
|
28
34
|
}
|
|
29
35
|
return true;
|
|
30
36
|
}
|
|
31
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Split multicodec-prefixed key bytes into their algorithm and the raw public key, or `null` when
|
|
39
|
+
* the prefix names no algorithm this package knows.
|
|
40
|
+
*
|
|
41
|
+
* The decoder half of the `cnf.kid` wire format — see {@link CODECS}.
|
|
42
|
+
*/ export function getAlgorithmAndPublicKey(bytes) {
|
|
32
43
|
for (const [alg, codec] of Object.entries(CODECS)){
|
|
33
44
|
if (isCodecMatch(codec, bytes)) {
|
|
34
45
|
return [
|
|
@@ -45,9 +56,18 @@ function isCodecMatch(codec, bytes) {
|
|
|
45
56
|
bytes[i] = v;
|
|
46
57
|
});
|
|
47
58
|
bytes.set(publicKey, codec.length);
|
|
48
|
-
return PREFIX
|
|
59
|
+
return `${PREFIX}${base58.encode(bytes)}`;
|
|
49
60
|
}
|
|
50
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* The signature algorithm and raw public key a `did:key` identifier carries. Throws when the string
|
|
63
|
+
* is not a `did:key`, its encoded form is implausibly long, or the codec names no supported
|
|
64
|
+
* algorithm.
|
|
65
|
+
*
|
|
66
|
+
* Supported, not internal: `@kumiai/mls` checks an MLS credential's key against the key its DID
|
|
67
|
+
* names, a comparison only this function makes correctly — the alternative is a second decoder in
|
|
68
|
+
* another repo whose failure mode is accepting a credential for the wrong key. Any other method
|
|
69
|
+
* resolves through a `DIDMethodResolver`; this is the self-contained case.
|
|
70
|
+
*/ export function getSignatureInfo(did) {
|
|
51
71
|
if (!did.startsWith(PREFIX)) {
|
|
52
72
|
throw new Error('Invalid DID format');
|
|
53
73
|
}
|
|
@@ -67,11 +87,114 @@ function isCodecMatch(codec, bytes) {
|
|
|
67
87
|
}
|
|
68
88
|
return info;
|
|
69
89
|
}
|
|
90
|
+
const UNRESOLVABLE_ISSUER_BRAND = '@kokuin/token/UnresolvableIssuerError';
|
|
91
|
+
/**
|
|
92
|
+
* The issuer could not be resolved to a usable signing key: no method, resolver, or registry entry
|
|
93
|
+
* turned `iss` into one — including a resolver that has no answer, throws, or answers with something
|
|
94
|
+
* unusable (an oversized document, or one not hashing to the DID asked for).
|
|
95
|
+
*
|
|
96
|
+
* Distinct from every other verification failure on purpose. An invalid signature or a bad `kid`
|
|
97
|
+
* means the issuer *was* resolved and the token is bad (positive evidence); this means nothing was
|
|
98
|
+
* learned either way, and a caller treating "could not check" as "checked and fine" fails open.
|
|
99
|
+
* `@kokuin/capability`'s revocation checker turns on this distinction, so it must be a type, not a
|
|
100
|
+
* message — text matching is how such a check regresses silently.
|
|
101
|
+
*/ export class UnresolvableIssuerError extends Error {
|
|
102
|
+
/**
|
|
103
|
+
* Identifies the error by value, not identity: within this package `instanceof` is exact, but a
|
|
104
|
+
* duplicated `@kokuin/token` in a consumer's tree would make a cross-copy `instanceof` false (which
|
|
105
|
+
* fails closed, so this is hardening). Consumers should use `isUnresolvableIssuerError`.
|
|
106
|
+
*/ static get brand() {
|
|
107
|
+
return UNRESOLVABLE_ISSUER_BRAND;
|
|
108
|
+
}
|
|
109
|
+
/** The brand, readable from an instance — what `isUnresolvableIssuerError` matches on. */ get brand() {
|
|
110
|
+
return UNRESOLVABLE_ISSUER_BRAND;
|
|
111
|
+
}
|
|
112
|
+
constructor(message, options){
|
|
113
|
+
super(message, options);
|
|
114
|
+
this.name = 'UnresolvableIssuerError';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Whether a thrown value means the issuer could not be resolved.
|
|
119
|
+
*
|
|
120
|
+
* Prefer this to `instanceof UnresolvableIssuerError` across a package boundary: it matches on the
|
|
121
|
+
* brand, so it holds even if the thrower and the checker resolved different copies of this package.
|
|
122
|
+
*/ export function isUnresolvableIssuerError(value) {
|
|
123
|
+
return value instanceof Error && value.brand === UNRESOLVABLE_ISSUER_BRAND;
|
|
124
|
+
}
|
|
125
|
+
const ISSUER_KEY_NOT_FOUND_BRAND = '@kokuin/token/IssuerKeyNotFoundError';
|
|
126
|
+
/**
|
|
127
|
+
* A DID method resolved the issuer, and the token then named a key that issuer does not have. The
|
|
128
|
+
* counterpart of {@link UnresolvableIssuerError}: a `DIDMethodResolver` needs a way to say "resolved,
|
|
129
|
+
* and the token is bad" too, else `resolveIssuerWithDoc` wraps everything a method throws as
|
|
130
|
+
* unresolvable and an unauthenticated `kid` naming a real DID and an invented key reads as "could not
|
|
131
|
+
* check" rather than "checked, and bad". Throw it only for that; not knowing the DID at all is
|
|
132
|
+
* {@link UnresolvableIssuerError}.
|
|
133
|
+
*/ export class IssuerKeyNotFoundError extends Error {
|
|
134
|
+
/** @see UnresolvableIssuerError.brand — same reasoning, across the same package boundary. */ static get brand() {
|
|
135
|
+
return ISSUER_KEY_NOT_FOUND_BRAND;
|
|
136
|
+
}
|
|
137
|
+
/** The brand, readable from an instance — what `isIssuerKeyNotFoundError` matches on. */ get brand() {
|
|
138
|
+
return ISSUER_KEY_NOT_FOUND_BRAND;
|
|
139
|
+
}
|
|
140
|
+
constructor(message, options){
|
|
141
|
+
super(message, options);
|
|
142
|
+
this.name = 'IssuerKeyNotFoundError';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Whether a thrown value means the issuer resolved but does not have the key the token named.
|
|
147
|
+
*
|
|
148
|
+
* Prefer this to `instanceof IssuerKeyNotFoundError` across a package boundary, for the same
|
|
149
|
+
* reason as {@link isUnresolvableIssuerError}.
|
|
150
|
+
*/ export function isIssuerKeyNotFoundError(value) {
|
|
151
|
+
return value instanceof Error && value.brand === ISSUER_KEY_NOT_FOUND_BRAND;
|
|
152
|
+
}
|
|
70
153
|
/**
|
|
71
154
|
* Resolve a token issuer (did:key or did:peer:4) and return alg + public key,
|
|
72
155
|
* plus the decoded peer:4 doc when one was obtained inline or via the resolver.
|
|
73
156
|
* Callers writing to a DID cache should write `peer4Doc` only after signature verification.
|
|
74
|
-
|
|
157
|
+
*
|
|
158
|
+
* The `historic` param selects which question is asked of a `DIDMethodResolver` — see
|
|
159
|
+
* {@link ResolveIssuerMode}. It reaches only the method-registry branch: `did:key` carries its key
|
|
160
|
+
* in the identifier and a `did:peer:4` document is fixed by its own hash, so neither has a past key
|
|
161
|
+
* set distinct from its present one.
|
|
162
|
+
*/ export async function resolveIssuerWithDoc({ iss, header = {}, resolver, methods, historic = false }) {
|
|
163
|
+
if (methods != null) {
|
|
164
|
+
const methodResolver = findMethodResolver(methods, iss);
|
|
165
|
+
if (methodResolver != null) {
|
|
166
|
+
// The historic question is answered only by `resolveHistoric`; a resolver without one is not
|
|
167
|
+
// asked `resolve` instead, which would substitute a different question. `UnresolvableIssuerError`
|
|
168
|
+
// is right — nothing was learned either way, which a fail-closed caller treats as a denial.
|
|
169
|
+
if (historic && methodResolver.resolveHistoric == null) {
|
|
170
|
+
throw new UnresolvableIssuerError(`DID method ${methodResolver.method} cannot resolve historic keys: ${iss}`);
|
|
171
|
+
}
|
|
172
|
+
// A method resolver throws its own error strings; re-type them so a method-backed resolution
|
|
173
|
+
// failure is indistinguishable from the built-in ones below. Message preserved, original kept
|
|
174
|
+
// as `cause`.
|
|
175
|
+
let resolved;
|
|
176
|
+
try {
|
|
177
|
+
// Called as a method of its own resolver, not detached: an implementation may be a class with
|
|
178
|
+
// private state.
|
|
179
|
+
resolved = historic && methodResolver.resolveHistoric != null ? await methodResolver.resolveHistoric(iss, header) : await methodResolver.resolve(iss, header);
|
|
180
|
+
} catch (cause) {
|
|
181
|
+
// Except the one failure that is not a failure to resolve: "I have this issuer, it has no such
|
|
182
|
+
// key" is what the `kid` branches below report for `did:peer:4` (ordinary errors — see
|
|
183
|
+
// IssuerKeyNotFoundError). Wrapping it would let an unauthenticated header make any issuer read
|
|
184
|
+
// as unresolvable, which callers fail closed on.
|
|
185
|
+
if (isIssuerKeyNotFoundError(cause)) {
|
|
186
|
+
throw cause;
|
|
187
|
+
}
|
|
188
|
+
throw new UnresolvableIssuerError(cause instanceof Error ? cause.message : `Unknown DID: ${iss}`, {
|
|
189
|
+
cause
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
alg: resolved.alg,
|
|
194
|
+
publicKey: resolved.publicKey
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
75
198
|
if (isPeer4(iss)) {
|
|
76
199
|
const shortForm = getPeer4ShortForm(iss);
|
|
77
200
|
if (iss !== shortForm) {
|
|
@@ -87,16 +210,36 @@ function isCodecMatch(codec, bytes) {
|
|
|
87
210
|
};
|
|
88
211
|
}
|
|
89
212
|
if (resolver == null) {
|
|
90
|
-
throw new
|
|
213
|
+
throw new UnresolvableIssuerError(`Unknown DID: ${shortForm}`);
|
|
214
|
+
}
|
|
215
|
+
// Throwing is the normal style for a network-backed resolver, so one that throws must be
|
|
216
|
+
// indistinguishable from one that returns nothing — else the fail-closed guarantee holds only for
|
|
217
|
+
// resolvers that signal failure by returning `undefined`.
|
|
218
|
+
let doc;
|
|
219
|
+
try {
|
|
220
|
+
doc = await resolver(shortForm);
|
|
221
|
+
} catch (cause) {
|
|
222
|
+
throw new UnresolvableIssuerError(`Unknown DID: ${shortForm}`, {
|
|
223
|
+
cause
|
|
224
|
+
});
|
|
91
225
|
}
|
|
92
|
-
const doc = await resolver(shortForm);
|
|
93
226
|
if (doc == null) {
|
|
94
|
-
throw new
|
|
227
|
+
throw new UnresolvableIssuerError(`Unknown DID: ${shortForm}`);
|
|
228
|
+
}
|
|
229
|
+
// An unusable answer — oversized, or not hashing to the DID asked for — counts as unresolvable,
|
|
230
|
+
// not an ordinary fault: else a caller failing closed solely on `UnresolvableIssuerError` takes a
|
|
231
|
+
// broken or lying resolver as "not revoked". No availability cost worth weighing — a resolver
|
|
232
|
+
// willing to lie about documents already controls resolution completely.
|
|
233
|
+
try {
|
|
234
|
+
assertDocWithinMaxSize(doc);
|
|
235
|
+
} catch (cause) {
|
|
236
|
+
throw new UnresolvableIssuerError(cause instanceof Error ? cause.message : `Unknown DID: ${shortForm}`, {
|
|
237
|
+
cause
|
|
238
|
+
});
|
|
95
239
|
}
|
|
96
|
-
assertDocWithinMaxSize(doc);
|
|
97
240
|
const expected = encodePeer4(doc).shortForm;
|
|
98
241
|
if (expected !== shortForm) {
|
|
99
|
-
throw new
|
|
242
|
+
throw new UnresolvableIssuerError('DIDResolver: short form/doc hash mismatch');
|
|
100
243
|
}
|
|
101
244
|
const [alg, publicKey] = resolveKidOrAuth(doc, header.kid);
|
|
102
245
|
return {
|
|
@@ -108,16 +251,30 @@ function isCodecMatch(codec, bytes) {
|
|
|
108
251
|
}
|
|
109
252
|
};
|
|
110
253
|
}
|
|
254
|
+
// `iss` narrows to `never` here (isPeer4's `value is string` collapses the false case), so route
|
|
255
|
+
// the prefix check through a helper taking an unnarrowed `string`.
|
|
256
|
+
if (!hasKeyPrefix(iss)) {
|
|
257
|
+
throw new UnresolvableIssuerError(`Unknown DID: ${iss}`);
|
|
258
|
+
}
|
|
111
259
|
const [alg, publicKey] = getSignatureInfo(iss);
|
|
112
260
|
return {
|
|
113
261
|
alg,
|
|
114
262
|
publicKey
|
|
115
263
|
};
|
|
116
264
|
}
|
|
265
|
+
function hasKeyPrefix(did) {
|
|
266
|
+
return did.startsWith(PREFIX);
|
|
267
|
+
}
|
|
117
268
|
/**
|
|
118
269
|
* Resolve a token issuer to [alg, publicKey]. Backward-compatible wrapper around resolveIssuerWithDoc.
|
|
119
|
-
*/ export async function resolveIssuer(iss, header = {}, resolver) {
|
|
120
|
-
const { alg, publicKey } = await resolveIssuerWithDoc(
|
|
270
|
+
*/ export async function resolveIssuer({ iss, header = {}, resolver, methods, historic = false }) {
|
|
271
|
+
const { alg, publicKey } = await resolveIssuerWithDoc({
|
|
272
|
+
iss,
|
|
273
|
+
header,
|
|
274
|
+
resolver,
|
|
275
|
+
methods,
|
|
276
|
+
historic
|
|
277
|
+
});
|
|
121
278
|
return [
|
|
122
279
|
alg,
|
|
123
280
|
publicKey
|
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
|
};
|
|
@@ -20,11 +23,10 @@ export type SigningIdentity = Identity & {
|
|
|
20
23
|
publicKey: Uint8Array;
|
|
21
24
|
signToken: <Payload extends Record<string, unknown> = Record<string, unknown>>(payload: Payload, options?: SignTokenOptions) => Promise<SignedToken<Payload>>;
|
|
22
25
|
};
|
|
23
|
-
export type
|
|
24
|
-
decrypt(jwe: string): Promise<Uint8Array>;
|
|
26
|
+
export type KeyAgreementIdentity = Identity & {
|
|
25
27
|
agreeKey(ephemeralPublicKey: Uint8Array): Promise<Uint8Array>;
|
|
26
28
|
};
|
|
27
|
-
export type FullIdentity = SigningIdentity &
|
|
29
|
+
export type FullIdentity = SigningIdentity & KeyAgreementIdentity;
|
|
28
30
|
export type OwnIdentity = FullIdentity & {
|
|
29
31
|
privateKey: Uint8Array;
|
|
30
32
|
};
|
|
@@ -32,20 +34,40 @@ export type IdentityProvider<T extends SigningIdentity = SigningIdentity> = {
|
|
|
32
34
|
provideIdentity(keyID: string): Promise<T>;
|
|
33
35
|
};
|
|
34
36
|
export declare function isSigningIdentity(identity: Identity): identity is SigningIdentity;
|
|
35
|
-
export declare function
|
|
37
|
+
export declare function isKeyAgreementIdentity(identity: Identity): identity is KeyAgreementIdentity;
|
|
36
38
|
export declare function isFullIdentity(identity: Identity): identity is FullIdentity;
|
|
37
39
|
export declare function isOwnIdentity(identity: Identity): identity is OwnIdentity;
|
|
40
|
+
/**
|
|
41
|
+
* Create a signing identity for an Ed25519 private key under a caller-supplied DID.
|
|
42
|
+
*
|
|
43
|
+
* `createSigningIdentity` derives the DID *from* the key, which only works for `did:key`. A method
|
|
44
|
+
* whose identifier is not a function of the current key — `did:kokuin:`, where the key rotates under
|
|
45
|
+
* a fixed inception digest — must supply its own `id`, so `iss` is the method's DID, not the key's
|
|
46
|
+
* `did:key`. The caller owns the pairing: nothing here checks `id` resolves to `privateKey`'s public
|
|
47
|
+
* key, and a mismatch produces tokens that verify nowhere.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createSigningIdentityForDID(id: DIDString, privateKey: Uint8Array): SigningIdentity;
|
|
38
50
|
/**
|
|
39
51
|
* Create a signing identity from an Ed25519 private key.
|
|
40
52
|
*/
|
|
41
53
|
export declare function createSigningIdentity(privateKey: Uint8Array): SigningIdentity;
|
|
42
54
|
/**
|
|
43
|
-
* Create a
|
|
55
|
+
* Create a key-agreement identity from an Ed25519 private key.
|
|
44
56
|
* Uses X25519 key derivation for ECDH key agreement.
|
|
45
57
|
*/
|
|
46
|
-
export declare function
|
|
58
|
+
export declare function createKeyAgreementIdentity(privateKey: Uint8Array): KeyAgreementIdentity;
|
|
59
|
+
/**
|
|
60
|
+
* A key-agreement identity for a **raw X25519 private key** under a caller-supplied DID.
|
|
61
|
+
*
|
|
62
|
+
* The counterpart of {@link createSigningIdentityForDID} for the agreement half. Two differences from
|
|
63
|
+
* {@link createKeyAgreementIdentity}: the identifier is supplied (a `did:kokuin:` key rotates under a
|
|
64
|
+
* fixed inception digest, so `id` cannot be a function of the key), and the input is the X25519 scalar
|
|
65
|
+
* itself rather than an Ed25519 key to Montgomery-convert — a controller derives an independent
|
|
66
|
+
* agreement keypair, not the Montgomery form of its signing key.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createKeyAgreementIdentityForDID(id: DIDString, x25519PrivateKey: Uint8Array): KeyAgreementIdentity;
|
|
47
69
|
/**
|
|
48
|
-
* Create a full identity (signing +
|
|
70
|
+
* Create a full identity (signing + key agreement) from an Ed25519 private key.
|
|
49
71
|
*/
|
|
50
72
|
export declare function createFullIdentity(privateKey: Uint8Array): FullIdentity;
|
|
51
73
|
/**
|
|
@@ -83,14 +105,13 @@ export type ResolvedKey = {
|
|
|
83
105
|
publicKey: Uint8Array;
|
|
84
106
|
};
|
|
85
107
|
export type MultiKeyIdentity = {
|
|
86
|
-
id:
|
|
87
|
-
longForm:
|
|
108
|
+
id: DIDString;
|
|
109
|
+
longForm: DIDString;
|
|
88
110
|
doc: DIDDoc;
|
|
89
111
|
keys: Array<ResolvedKey>;
|
|
90
112
|
publicKey: Uint8Array;
|
|
91
113
|
privateKey: Uint8Array;
|
|
92
114
|
signToken<Payload extends Record<string, unknown> = Record<string, unknown>>(payload: Payload, options?: SignTokenOptions): Promise<SignedToken<Payload>>;
|
|
93
|
-
decrypt(jwe: string): Promise<Uint8Array>;
|
|
94
115
|
agreeKey(ephemeralPublicKey: Uint8Array, kid?: string): Promise<Uint8Array>;
|
|
95
116
|
};
|
|
96
117
|
/**
|
package/lib/identity.js
CHANGED
|
@@ -3,7 +3,6 @@ import { ed25519, x25519 } from '@noble/curves/ed25519.js';
|
|
|
3
3
|
import { b64uFromJSON, fromUTF, toB64U } from '@sozai/codec';
|
|
4
4
|
import { withSpan } from '@sozai/otel';
|
|
5
5
|
import { CODECS, getDID, normalizeDID } from './did.js';
|
|
6
|
-
import { decryptToken } from './jwe.js';
|
|
7
6
|
import { encodeMultibase } from './multibase.js';
|
|
8
7
|
import { encodePeer4, isPeer4 } from './peer4.js';
|
|
9
8
|
import { concatBytes } from './utils.js';
|
|
@@ -11,20 +10,29 @@ const tracer = createTracer('token');
|
|
|
11
10
|
export function isSigningIdentity(identity) {
|
|
12
11
|
return 'publicKey' in identity && 'signToken' in identity && typeof identity.signToken === 'function';
|
|
13
12
|
}
|
|
14
|
-
export function
|
|
15
|
-
return '
|
|
13
|
+
export function isKeyAgreementIdentity(identity) {
|
|
14
|
+
return 'agreeKey' in identity && typeof identity.agreeKey === 'function';
|
|
16
15
|
}
|
|
17
16
|
export function isFullIdentity(identity) {
|
|
18
|
-
return isSigningIdentity(identity) &&
|
|
17
|
+
return isSigningIdentity(identity) && isKeyAgreementIdentity(identity);
|
|
19
18
|
}
|
|
20
19
|
export function isOwnIdentity(identity) {
|
|
21
20
|
return isFullIdentity(identity) && 'privateKey' in identity;
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
|
-
* Create a signing identity
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
* Create a signing identity for an Ed25519 private key under a caller-supplied DID.
|
|
24
|
+
*
|
|
25
|
+
* `createSigningIdentity` derives the DID *from* the key, which only works for `did:key`. A method
|
|
26
|
+
* whose identifier is not a function of the current key — `did:kokuin:`, where the key rotates under
|
|
27
|
+
* a fixed inception digest — must supply its own `id`, so `iss` is the method's DID, not the key's
|
|
28
|
+
* `did:key`. The caller owns the pairing: nothing here checks `id` resolves to `privateKey`'s public
|
|
29
|
+
* key, and a mismatch produces tokens that verify nowhere.
|
|
30
|
+
*/ export function createSigningIdentityForDID(id, privateKey) {
|
|
31
|
+
return buildSigningIdentity(id, privateKey, ed25519.getPublicKey(privateKey));
|
|
32
|
+
}
|
|
33
|
+
// Shared body. Takes `publicKey` rather than deriving it, so `createSigningIdentity` — which
|
|
34
|
+
// needs the public key anyway to build the `did:key` — derives it once instead of twice.
|
|
35
|
+
function buildSigningIdentity(id, privateKey, publicKey) {
|
|
28
36
|
async function signToken(payload, options = {}) {
|
|
29
37
|
return withSpan(tracer, KokuinSpanNames.TOKEN_SIGN, {
|
|
30
38
|
attributes: {
|
|
@@ -60,36 +68,51 @@ export function isOwnIdentity(identity) {
|
|
|
60
68
|
};
|
|
61
69
|
}
|
|
62
70
|
/**
|
|
63
|
-
* Create a
|
|
71
|
+
* Create a signing identity from an Ed25519 private key.
|
|
72
|
+
*/ export function createSigningIdentity(privateKey) {
|
|
73
|
+
const publicKey = ed25519.getPublicKey(privateKey);
|
|
74
|
+
return buildSigningIdentity(getDID(CODECS.EdDSA, publicKey), privateKey, publicKey);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a key-agreement identity from an Ed25519 private key.
|
|
64
78
|
* Uses X25519 key derivation for ECDH key agreement.
|
|
65
|
-
*/ export function
|
|
79
|
+
*/ export function createKeyAgreementIdentity(privateKey) {
|
|
66
80
|
const publicKey = ed25519.getPublicKey(privateKey);
|
|
67
81
|
const id = getDID(CODECS.EdDSA, publicKey);
|
|
68
82
|
const x25519Private = ed25519.utils.toMontgomerySecret(privateKey);
|
|
69
|
-
async function decrypt(jwe) {
|
|
70
|
-
return decryptToken({
|
|
71
|
-
id,
|
|
72
|
-
decrypt,
|
|
73
|
-
agreeKey
|
|
74
|
-
}, jwe);
|
|
75
|
-
}
|
|
76
83
|
async function agreeKey(ephemeralPublicKey) {
|
|
77
84
|
return x25519.getSharedSecret(x25519Private, ephemeralPublicKey);
|
|
78
85
|
}
|
|
79
86
|
return {
|
|
80
87
|
id,
|
|
81
|
-
decrypt,
|
|
82
88
|
agreeKey
|
|
83
89
|
};
|
|
84
90
|
}
|
|
85
91
|
/**
|
|
86
|
-
*
|
|
92
|
+
* A key-agreement identity for a **raw X25519 private key** under a caller-supplied DID.
|
|
93
|
+
*
|
|
94
|
+
* The counterpart of {@link createSigningIdentityForDID} for the agreement half. Two differences from
|
|
95
|
+
* {@link createKeyAgreementIdentity}: the identifier is supplied (a `did:kokuin:` key rotates under a
|
|
96
|
+
* fixed inception digest, so `id` cannot be a function of the key), and the input is the X25519 scalar
|
|
97
|
+
* itself rather than an Ed25519 key to Montgomery-convert — a controller derives an independent
|
|
98
|
+
* agreement keypair, not the Montgomery form of its signing key.
|
|
99
|
+
*/ export function createKeyAgreementIdentityForDID(id, x25519PrivateKey) {
|
|
100
|
+
async function agreeKey(ephemeralPublicKey) {
|
|
101
|
+
return x25519.getSharedSecret(x25519PrivateKey, ephemeralPublicKey);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
agreeKey
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create a full identity (signing + key agreement) from an Ed25519 private key.
|
|
87
110
|
*/ export function createFullIdentity(privateKey) {
|
|
88
111
|
const signing = createSigningIdentity(privateKey);
|
|
89
|
-
const
|
|
112
|
+
const keyAgreement = createKeyAgreementIdentity(privateKey);
|
|
90
113
|
return {
|
|
91
114
|
...signing,
|
|
92
|
-
...
|
|
115
|
+
...keyAgreement
|
|
93
116
|
};
|
|
94
117
|
}
|
|
95
118
|
/**
|
|
@@ -217,14 +240,15 @@ function signWith(key, data) {
|
|
|
217
240
|
*
|
|
218
241
|
* A peer:4 identity uses its published `keyAgreement` key. A `did:key` EdDSA identity has no
|
|
219
242
|
* 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 `
|
|
243
|
+
* so it must derive the matching secret the same way, exactly as `createKeyAgreementIdentity` does.
|
|
221
244
|
*/ function pickAgreementSecret(keys, isPeer, kid) {
|
|
222
245
|
if (kid != null) {
|
|
223
246
|
// No birational fallback here, unlike the no-kid branch below: a kid names one specific
|
|
224
247
|
// key, so falling back to a *different*, derived key would silently agree with the wrong
|
|
225
248
|
// secret. This means a did:key identity's agreeKey(epk, '#key-0') throws where
|
|
226
|
-
// agreeKey(epk) succeeds — a known asymmetry, currently unreachable: decryptToken
|
|
227
|
-
// never passes a kid, and
|
|
249
|
+
// agreeKey(epk) succeeds — a known asymmetry, currently unreachable: decryptToken
|
|
250
|
+
// (@kokuin/jwe) never passes a kid, and KeyAgreementIdentity#agreeKey has no kid parameter
|
|
251
|
+
// at all.
|
|
228
252
|
const found = keys.find((key)=>key.fragment === kid);
|
|
229
253
|
if (found == null) throw new Error(`KidNotFound: ${kid}`);
|
|
230
254
|
if (found.purpose !== 'kem' || found.alg !== 'X25519') {
|
|
@@ -256,7 +280,9 @@ function buildIdentity(id, longForm, doc, keys) {
|
|
|
256
280
|
if (embedLongForm === true) return longForm;
|
|
257
281
|
if (embedLongForm === false) return id;
|
|
258
282
|
const aud = payload.aud;
|
|
259
|
-
|
|
283
|
+
// No single named audience: there is nothing to key first-contact on, and the recipient may
|
|
284
|
+
// never have seen this doc. Embed the long form so the token resolves standalone.
|
|
285
|
+
if (typeof aud !== 'string') return longForm;
|
|
260
286
|
const normalizedAud = normalizeDID(aud);
|
|
261
287
|
if (sentTo.has(normalizedAud)) return id;
|
|
262
288
|
// Concurrent sign() calls with the same new aud may both emit long-form; recipient cache writes are idempotent so this is acceptable.
|
|
@@ -289,14 +315,6 @@ function buildIdentity(id, longForm, doc, keys) {
|
|
|
289
315
|
async function agreeKey(ephemeralPublicKey, kid) {
|
|
290
316
|
return x25519.getSharedSecret(pickAgreementSecret(keys, isPeer, kid), ephemeralPublicKey);
|
|
291
317
|
}
|
|
292
|
-
async function decrypt(jwe) {
|
|
293
|
-
pickAgreementSecret(keys, isPeer); // fail fast when this identity cannot agree at all
|
|
294
|
-
return decryptToken({
|
|
295
|
-
id,
|
|
296
|
-
decrypt,
|
|
297
|
-
agreeKey
|
|
298
|
-
}, jwe);
|
|
299
|
-
}
|
|
300
318
|
return {
|
|
301
319
|
id,
|
|
302
320
|
longForm,
|
|
@@ -305,7 +323,6 @@ function buildIdentity(id, longForm, doc, keys) {
|
|
|
305
323
|
publicKey: primarySig.publicKey,
|
|
306
324
|
privateKey: primarySig.privateKey,
|
|
307
325
|
signToken,
|
|
308
|
-
decrypt,
|
|
309
326
|
agreeKey
|
|
310
327
|
};
|
|
311
328
|
}
|