qredential 0.2.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/LICENSE +21 -0
- package/README.md +307 -0
- package/dist/base45.d.ts +11 -0
- package/dist/base45.js +62 -0
- package/dist/bytes.d.ts +10 -0
- package/dist/bytes.js +73 -0
- package/dist/compress.d.ts +8 -0
- package/dist/compress.js +56 -0
- package/dist/crypto.d.ts +13 -0
- package/dist/crypto.js +72 -0
- package/dist/duration.d.ts +3 -0
- package/dist/duration.js +24 -0
- package/dist/envelope.d.ts +12 -0
- package/dist/envelope.js +57 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +461 -0
- package/dist/qr.d.ts +36 -0
- package/dist/qr.js +59 -0
- package/dist/sdjwt.d.ts +85 -0
- package/dist/sdjwt.js +310 -0
- package/dist/status.d.ts +49 -0
- package/dist/status.js +122 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.js +1 -0
- package/package.json +81 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const PREFIX = "QC1:";
|
|
2
|
+
/**
|
|
3
|
+
* Wrap a credential string into the scannable envelope.
|
|
4
|
+
*
|
|
5
|
+
* Compression is attempted and then kept only if it actually helped. Short payloads sometimes grow
|
|
6
|
+
* under deflate, and silently shipping the larger one would be a bug nobody would ever notice.
|
|
7
|
+
* React Native has no CompressionStream, so the uncompressed path is a supported outcome rather
|
|
8
|
+
* than a failure: the flag byte tells the decoder which one it got.
|
|
9
|
+
*/
|
|
10
|
+
export declare function pack(payload: string): Promise<string>;
|
|
11
|
+
export declare function unpack(envelope: string): Promise<string>;
|
|
12
|
+
export declare function isEnvelope(text: string): boolean;
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { encodeBase45, decodeBase45 } from './base45.js';
|
|
2
|
+
import { utf8, fromUtf8, concat } from './bytes.js';
|
|
3
|
+
import { deflate, inflate, hasCompression } from './compress.js';
|
|
4
|
+
import { QredentialError } from './errors.js';
|
|
5
|
+
export const PREFIX = 'QC1:';
|
|
6
|
+
const FLAG_RAW = 0x00;
|
|
7
|
+
const FLAG_DEFLATE = 0x01;
|
|
8
|
+
/**
|
|
9
|
+
* Wrap a credential string into the scannable envelope.
|
|
10
|
+
*
|
|
11
|
+
* Compression is attempted and then kept only if it actually helped. Short payloads sometimes grow
|
|
12
|
+
* under deflate, and silently shipping the larger one would be a bug nobody would ever notice.
|
|
13
|
+
* React Native has no CompressionStream, so the uncompressed path is a supported outcome rather
|
|
14
|
+
* than a failure: the flag byte tells the decoder which one it got.
|
|
15
|
+
*/
|
|
16
|
+
export async function pack(payload) {
|
|
17
|
+
const raw = utf8(payload);
|
|
18
|
+
let flag = FLAG_RAW;
|
|
19
|
+
let body = raw;
|
|
20
|
+
if (hasCompression()) {
|
|
21
|
+
const squeezed = await deflate(raw);
|
|
22
|
+
if (squeezed.length < raw.length) {
|
|
23
|
+
flag = FLAG_DEFLATE;
|
|
24
|
+
body = squeezed;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return PREFIX + encodeBase45(concat(new Uint8Array([flag]), body));
|
|
28
|
+
}
|
|
29
|
+
export async function unpack(envelope) {
|
|
30
|
+
if (!envelope.startsWith(PREFIX)) {
|
|
31
|
+
throw new QredentialError('malformed_envelope', `not a qredential envelope: expected the ${PREFIX} prefix`);
|
|
32
|
+
}
|
|
33
|
+
let bytes;
|
|
34
|
+
try {
|
|
35
|
+
bytes = decodeBase45(envelope.slice(PREFIX.length));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new QredentialError('malformed_envelope', 'envelope is not valid base45', { cause: error });
|
|
39
|
+
}
|
|
40
|
+
// One byte is a complete envelope: the flag, with an empty payload after it.
|
|
41
|
+
if (bytes.length < 1)
|
|
42
|
+
throw new QredentialError('malformed_envelope', 'envelope is truncated');
|
|
43
|
+
const flag = bytes[0];
|
|
44
|
+
const body = bytes.subarray(1);
|
|
45
|
+
if (flag === FLAG_RAW)
|
|
46
|
+
return fromUtf8(body);
|
|
47
|
+
if (flag === FLAG_DEFLATE) {
|
|
48
|
+
if (!hasCompression()) {
|
|
49
|
+
throw new QredentialError('unsupported_runtime', 'this credential is deflate compressed and the runtime has no DecompressionStream');
|
|
50
|
+
}
|
|
51
|
+
return fromUtf8(await inflate(body));
|
|
52
|
+
}
|
|
53
|
+
throw new QredentialError('malformed_envelope', `unknown envelope encoding flag: 0x${flag.toString(16)}`);
|
|
54
|
+
}
|
|
55
|
+
export function isEnvelope(text) {
|
|
56
|
+
return text.startsWith(PREFIX);
|
|
57
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error model, in one place.
|
|
3
|
+
*
|
|
4
|
+
* There are exactly two contracts, and the split is deliberate:
|
|
5
|
+
*
|
|
6
|
+
* 1. **`verify()` never throws.** Its entire job is hostile input, and a verifier that throws is one
|
|
7
|
+
* a caller wraps in a try/catch that waves people through. It returns a discriminated union, and
|
|
8
|
+
* a failure carries a `reason` from {@link FailReason}.
|
|
9
|
+
*
|
|
10
|
+
* 2. **Everything else throws {@link QredentialError}.** These are conditions a caller fixes in
|
|
11
|
+
* code: a bad option, a malformed key, a credential string that was damaged before it reached
|
|
12
|
+
* the library. Every one carries a stable `code`.
|
|
13
|
+
*
|
|
14
|
+
* `code` values are part of the public API and follow semver: a code is never repurposed, and new
|
|
15
|
+
* ones are added only in minor versions. Message text is *not* part of the API. Branch on `code`,
|
|
16
|
+
* never on the message.
|
|
17
|
+
*/
|
|
18
|
+
import type { FailReason } from './types.js';
|
|
19
|
+
export type ErrorCode =
|
|
20
|
+
/** An argument the API cannot use: an unknown claim name, an index outside the list, a bad duration. */
|
|
21
|
+
'invalid_option'
|
|
22
|
+
/** `present()` was asked to reveal a claim the issuer never made disclosable. */
|
|
23
|
+
| 'not_disclosable'
|
|
24
|
+
/** The SD-JWT combined form is not well formed. */
|
|
25
|
+
| 'malformed_credential'
|
|
26
|
+
/** The QR envelope is not well formed. Its `cause` carries the codec error when there was one. */
|
|
27
|
+
| 'malformed_envelope'
|
|
28
|
+
/** A status list token is not well formed. */
|
|
29
|
+
| 'malformed_status_list'
|
|
30
|
+
/** Text that should have been base45 or base64url is not. */
|
|
31
|
+
| 'invalid_encoding'
|
|
32
|
+
/** An algorithm this version does not implement. */
|
|
33
|
+
| 'unsupported_alg'
|
|
34
|
+
/** The platform is missing something required, such as DecompressionStream on React Native. */
|
|
35
|
+
| 'unsupported_runtime'
|
|
36
|
+
/** WebCrypto refused a key or an operation. Its `cause` is the original DOMException. */
|
|
37
|
+
| 'crypto_failure'
|
|
38
|
+
/** Only from {@link assertVerified}, for callers who prefer try/catch over the result union. */
|
|
39
|
+
| 'verification_failed';
|
|
40
|
+
export interface QredentialErrorOptions {
|
|
41
|
+
cause?: unknown;
|
|
42
|
+
/** Present only on `verification_failed`, carrying the reason the credential was rejected. */
|
|
43
|
+
reason?: FailReason;
|
|
44
|
+
}
|
|
45
|
+
export declare class QredentialError extends Error {
|
|
46
|
+
/** Stable, documented, safe to branch on. */
|
|
47
|
+
readonly code: ErrorCode;
|
|
48
|
+
/** Set only when `code` is `verification_failed`. */
|
|
49
|
+
readonly reason?: FailReason;
|
|
50
|
+
constructor(code: ErrorCode, message: string, options?: QredentialErrorOptions);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Narrowing type guard.
|
|
54
|
+
*
|
|
55
|
+
* It checks the shape rather than the prototype, so it still works when two copies of the package
|
|
56
|
+
* end up in one dependency tree, which is the usual reason `instanceof` quietly stops matching.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isQredentialError(value: unknown): value is QredentialError;
|
|
59
|
+
/** Wrap anything WebCrypto throws, so a caller never sees a bare DOMException from this library. */
|
|
60
|
+
export declare function asCryptoFailure(what: string, cause: unknown): QredentialError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error model, in one place.
|
|
3
|
+
*
|
|
4
|
+
* There are exactly two contracts, and the split is deliberate:
|
|
5
|
+
*
|
|
6
|
+
* 1. **`verify()` never throws.** Its entire job is hostile input, and a verifier that throws is one
|
|
7
|
+
* a caller wraps in a try/catch that waves people through. It returns a discriminated union, and
|
|
8
|
+
* a failure carries a `reason` from {@link FailReason}.
|
|
9
|
+
*
|
|
10
|
+
* 2. **Everything else throws {@link QredentialError}.** These are conditions a caller fixes in
|
|
11
|
+
* code: a bad option, a malformed key, a credential string that was damaged before it reached
|
|
12
|
+
* the library. Every one carries a stable `code`.
|
|
13
|
+
*
|
|
14
|
+
* `code` values are part of the public API and follow semver: a code is never repurposed, and new
|
|
15
|
+
* ones are added only in minor versions. Message text is *not* part of the API. Branch on `code`,
|
|
16
|
+
* never on the message.
|
|
17
|
+
*/
|
|
18
|
+
export class QredentialError extends Error {
|
|
19
|
+
/** Stable, documented, safe to branch on. */
|
|
20
|
+
code;
|
|
21
|
+
/** Set only when `code` is `verification_failed`. */
|
|
22
|
+
reason;
|
|
23
|
+
constructor(code, message, options = {}) {
|
|
24
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
25
|
+
this.name = 'QredentialError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
if (options.reason !== undefined)
|
|
28
|
+
this.reason = options.reason;
|
|
29
|
+
// Keeps instanceof working when the output is transpiled down to ES5 by a consumer's bundler.
|
|
30
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Narrowing type guard.
|
|
35
|
+
*
|
|
36
|
+
* It checks the shape rather than the prototype, so it still works when two copies of the package
|
|
37
|
+
* end up in one dependency tree, which is the usual reason `instanceof` quietly stops matching.
|
|
38
|
+
*/
|
|
39
|
+
export function isQredentialError(value) {
|
|
40
|
+
return (value instanceof Error &&
|
|
41
|
+
value.name === 'QredentialError' &&
|
|
42
|
+
typeof value.code === 'string');
|
|
43
|
+
}
|
|
44
|
+
/** Wrap anything WebCrypto throws, so a caller never sees a bare DOMException from this library. */
|
|
45
|
+
export function asCryptoFailure(what, cause) {
|
|
46
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : '';
|
|
47
|
+
return new QredentialError('crypto_failure', `${what}${detail}`, { cause });
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { KeyBindingRequest, IssueOptions, IssueResult, VerifiedCredential, VerifyOptions, VerifyResult } from './types.js';
|
|
2
|
+
export type * from './types.js';
|
|
3
|
+
export { QredentialError, isQredentialError } from './errors.js';
|
|
4
|
+
export type { ErrorCode, QredentialErrorOptions } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Turn a rejected result into a thrown {@link QredentialError}, for callers who would rather use
|
|
7
|
+
* try/catch than branch on `result.ok`.
|
|
8
|
+
*
|
|
9
|
+
* The thrown error carries `code: 'verification_failed'` and the original `reason`, so nothing is
|
|
10
|
+
* lost by choosing this style.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const credential = assertVerified(await verify(scanned, { trust }))
|
|
14
|
+
* console.log(credential.claims.over_18)
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare function assertVerified(result: VerifyResult): VerifiedCredential;
|
|
18
|
+
export { fits } from './qr.js';
|
|
19
|
+
export type { ErrorCorrection, FitResult } from './qr.js';
|
|
20
|
+
export { pack, unpack, isEnvelope } from './envelope.js';
|
|
21
|
+
export { createStatusList } from './status.js';
|
|
22
|
+
export { encodeBase45, decodeBase45 } from './base45.js';
|
|
23
|
+
export declare function issue(options: IssueOptions): Promise<IssueResult>;
|
|
24
|
+
/**
|
|
25
|
+
* Narrow a credential to the claims the holder is willing to show, then wrap it for scanning.
|
|
26
|
+
*
|
|
27
|
+
* The signed JWT is never touched. Withholding a claim just means its disclosure does not travel,
|
|
28
|
+
* so the verifier ends up holding a digest it can never open.
|
|
29
|
+
*/
|
|
30
|
+
export declare function present(credential: string, options: {
|
|
31
|
+
disclose: string[];
|
|
32
|
+
keyBinding?: KeyBindingRequest;
|
|
33
|
+
}): Promise<string>;
|
|
34
|
+
export declare function verify(input: string, options: VerifyOptions): Promise<VerifyResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { b64url, b64urlJson, unb64urlJson } from './bytes.js';
|
|
2
|
+
import { algForJwk, importPrivateKey, importPublicKey, sign, verifySignature } from './crypto.js';
|
|
3
|
+
import { seconds, nowSeconds } from './duration.js';
|
|
4
|
+
import { pack, unpack, isEnvelope } from './envelope.js';
|
|
5
|
+
import { parseStatusList, readStatus, isStale } from './status.js';
|
|
6
|
+
import { REGISTERED_CLAIMS, digest, sdHash, makeDisclosure, splitCombined, joinCombined, disclosureLocations, reconstructClaims, } from './sdjwt.js';
|
|
7
|
+
import { QredentialError } from './errors.js';
|
|
8
|
+
export { QredentialError, isQredentialError } from './errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* Turn a rejected result into a thrown {@link QredentialError}, for callers who would rather use
|
|
11
|
+
* try/catch than branch on `result.ok`.
|
|
12
|
+
*
|
|
13
|
+
* The thrown error carries `code: 'verification_failed'` and the original `reason`, so nothing is
|
|
14
|
+
* lost by choosing this style.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const credential = assertVerified(await verify(scanned, { trust }))
|
|
18
|
+
* console.log(credential.claims.over_18)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function assertVerified(result) {
|
|
22
|
+
if (result.ok)
|
|
23
|
+
return result;
|
|
24
|
+
throw new QredentialError('verification_failed', result.message, { reason: result.reason });
|
|
25
|
+
}
|
|
26
|
+
export { fits } from './qr.js';
|
|
27
|
+
export { pack, unpack, isEnvelope } from './envelope.js';
|
|
28
|
+
export { createStatusList } from './status.js';
|
|
29
|
+
export { encodeBase45, decodeBase45 } from './base45.js';
|
|
30
|
+
function reject(reason, message) {
|
|
31
|
+
return { ok: false, reason, message };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Fisher-Yates over a cryptographic source.
|
|
35
|
+
*
|
|
36
|
+
* The digest order is what hides which claims the issuer considered sensitive, so the permutation
|
|
37
|
+
* is a privacy property, not a cosmetic one. Math.random cannot carry it: V8 seeds xorshift128+
|
|
38
|
+
* from a recoverable state, and an observer with enough credentials from one issuer could predict
|
|
39
|
+
* the permutation and map digest positions back to claim order.
|
|
40
|
+
*
|
|
41
|
+
* Rejection sampling keeps the distribution uniform; taking a modulo of a random word would bias
|
|
42
|
+
* the low indices.
|
|
43
|
+
*/
|
|
44
|
+
function shuffle(items) {
|
|
45
|
+
const out = [...items];
|
|
46
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
47
|
+
const j = randomBelow(i + 1);
|
|
48
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function randomBelow(bound) {
|
|
53
|
+
if (bound <= 1)
|
|
54
|
+
return 0;
|
|
55
|
+
const limit = Math.floor(0xffffffff / bound) * bound;
|
|
56
|
+
const word = new Uint32Array(1);
|
|
57
|
+
for (;;) {
|
|
58
|
+
crypto.getRandomValues(word);
|
|
59
|
+
if (word[0] < limit)
|
|
60
|
+
return word[0] % bound;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function issue(options) {
|
|
64
|
+
const alg = options.alg ?? 'ES256';
|
|
65
|
+
const iat = nowSeconds();
|
|
66
|
+
const disclosable = options.disclose ?? [];
|
|
67
|
+
const unknown = disclosable.filter((name) => !(name in options.claims));
|
|
68
|
+
if (unknown.length > 0) {
|
|
69
|
+
throw new QredentialError('invalid_option', `disclose lists claims that are not in the credential: ${unknown.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
// RFC 9901 section 4.2.1: a disclosure's claim name must not be _sd, ..., or a claim that
|
|
72
|
+
// describes the token rather than the subject. Without this the library would happily issue a
|
|
73
|
+
// credential that its own verifier refuses, which is the worst kind of inconsistency to ship.
|
|
74
|
+
const reserved = disclosable.filter((name) => name === '...' || REGISTERED_CLAIMS.has(name));
|
|
75
|
+
if (reserved.length > 0) {
|
|
76
|
+
throw new QredentialError('invalid_option', `these claim names describe the token, not the subject, and cannot be made disclosable: ${reserved.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
const disclosures = disclosable.map((name) => makeDisclosure(name, options.claims[name]));
|
|
79
|
+
const digests = shuffle(await Promise.all(disclosures.map((d) => digest(d.raw))));
|
|
80
|
+
const payload = { iss: options.issuer, iat };
|
|
81
|
+
for (const [key, value] of Object.entries(options.claims)) {
|
|
82
|
+
if (!disclosable.includes(key))
|
|
83
|
+
payload[key] = value;
|
|
84
|
+
}
|
|
85
|
+
if (options.subject !== undefined)
|
|
86
|
+
payload['sub'] = options.subject;
|
|
87
|
+
if (options.vct !== undefined)
|
|
88
|
+
payload['vct'] = options.vct;
|
|
89
|
+
if (options.expiresIn !== undefined)
|
|
90
|
+
payload['exp'] = iat + seconds(options.expiresIn);
|
|
91
|
+
if (options.notBefore !== undefined)
|
|
92
|
+
payload['nbf'] = iat + seconds(options.notBefore);
|
|
93
|
+
if (options.status !== undefined) {
|
|
94
|
+
payload['status'] = { status_list: { idx: options.status.idx, uri: options.status.uri } };
|
|
95
|
+
}
|
|
96
|
+
if (options.holderKey !== undefined) {
|
|
97
|
+
const held = { ...options.holderKey };
|
|
98
|
+
// A private component here would be an issuer publishing the holder's secret inside a signed,
|
|
99
|
+
// widely copied credential. Refuse rather than strip it silently.
|
|
100
|
+
if (held.d !== undefined) {
|
|
101
|
+
throw new QredentialError('invalid_option', 'holderKey must be the public key; this one carries a private component');
|
|
102
|
+
}
|
|
103
|
+
delete held.key_ops;
|
|
104
|
+
payload['cnf'] = { jwk: held };
|
|
105
|
+
}
|
|
106
|
+
if (digests.length > 0) {
|
|
107
|
+
payload['_sd'] = digests;
|
|
108
|
+
payload['_sd_alg'] = 'sha-256';
|
|
109
|
+
}
|
|
110
|
+
const header = { alg, typ: 'dc+sd-jwt', kid: options.kid };
|
|
111
|
+
const signingInput = `${b64urlJson(header)}.${b64urlJson(payload)}`;
|
|
112
|
+
const key = await importPrivateKey(options.key, alg);
|
|
113
|
+
const jwt = `${signingInput}.${b64url(await sign(signingInput, key, alg))}`;
|
|
114
|
+
const credential = joinCombined(jwt, disclosures.map((d) => d.raw));
|
|
115
|
+
const qr = await pack(credential);
|
|
116
|
+
return { credential, qr, bytes: qr.length, disclosable };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Narrow a credential to the claims the holder is willing to show, then wrap it for scanning.
|
|
120
|
+
*
|
|
121
|
+
* The signed JWT is never touched. Withholding a claim just means its disclosure does not travel,
|
|
122
|
+
* so the verifier ends up holding a digest it can never open.
|
|
123
|
+
*/
|
|
124
|
+
export async function present(credential, options) {
|
|
125
|
+
const source = isEnvelope(credential) ? await unpack(credential) : credential;
|
|
126
|
+
const { jwt, disclosures } = splitCombined(source);
|
|
127
|
+
// Selectors are paths: a bare `over_18` for a top level claim, `address.locality` for one nested
|
|
128
|
+
// in an object, `nationalities[0]` for an array element. Indices are positions in the credential
|
|
129
|
+
// as issued, so a selector keeps meaning what it said whatever else the holder withholds.
|
|
130
|
+
let payload;
|
|
131
|
+
try {
|
|
132
|
+
payload = unb64urlJson(jwt.split('.')[1] ?? '');
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
throw new QredentialError('malformed_credential', 'credential payload is not readable', {
|
|
136
|
+
cause: error,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const locations = await disclosureLocations(payload, disclosures);
|
|
140
|
+
const byPath = new Map();
|
|
141
|
+
for (const [raw, where] of locations)
|
|
142
|
+
byPath.set(where.path, raw);
|
|
143
|
+
const missing = options.disclose.filter((path) => !byPath.has(path));
|
|
144
|
+
if (missing.length > 0) {
|
|
145
|
+
throw new QredentialError('not_disclosable', `this credential cannot disclose: ${missing.join(', ')}`);
|
|
146
|
+
}
|
|
147
|
+
// RFC 9901 section 4.2.6: a nested disclosure is illegal without the one that contains it, since
|
|
148
|
+
// its digest only appears once the parent is resolved. Asking for address.locality therefore
|
|
149
|
+
// means sending address, and working that out is this library's job rather than the caller's.
|
|
150
|
+
const keep = new Set();
|
|
151
|
+
for (const path of options.disclose) {
|
|
152
|
+
const raw = byPath.get(path);
|
|
153
|
+
keep.add(raw);
|
|
154
|
+
for (const parent of locations.get(raw)?.requires ?? [])
|
|
155
|
+
keep.add(parent);
|
|
156
|
+
}
|
|
157
|
+
// Order is preserved as issued, which keeps the presentation stable across calls.
|
|
158
|
+
const kept = disclosures.filter((raw) => keep.has(raw));
|
|
159
|
+
if (options.keyBinding === undefined)
|
|
160
|
+
return pack(joinCombined(jwt, kept));
|
|
161
|
+
const bound = readHolderKey(jwt);
|
|
162
|
+
if (bound === null) {
|
|
163
|
+
throw new QredentialError('invalid_option', 'this credential has no cnf claim, so the issuer never bound a holder key and a proof would mean nothing');
|
|
164
|
+
}
|
|
165
|
+
const alg = options.keyBinding.alg ?? algForJwk(options.keyBinding.key);
|
|
166
|
+
const kbPayload = {
|
|
167
|
+
iat: nowSeconds(),
|
|
168
|
+
aud: options.keyBinding.audience,
|
|
169
|
+
nonce: options.keyBinding.nonce,
|
|
170
|
+
// Commits to exactly this set of disclosures, so a relay cannot add or strip one afterwards.
|
|
171
|
+
sd_hash: await sdHash(jwt, kept),
|
|
172
|
+
};
|
|
173
|
+
const kbInput = `${b64urlJson({ alg, typ: 'kb+jwt' })}.${b64urlJson(kbPayload)}`;
|
|
174
|
+
const holderKey = await importPrivateKey(options.keyBinding.key, alg);
|
|
175
|
+
const kbJwt = `${kbInput}.${b64url(await sign(kbInput, holderKey, alg))}`;
|
|
176
|
+
return pack(joinCombined(jwt, kept, kbJwt));
|
|
177
|
+
}
|
|
178
|
+
/** Pull the bound holder key out of a credential's payload, without verifying anything. */
|
|
179
|
+
function readHolderKey(jwt) {
|
|
180
|
+
const segments = jwt.split('.');
|
|
181
|
+
if (segments.length !== 3)
|
|
182
|
+
return null;
|
|
183
|
+
try {
|
|
184
|
+
const payload = unb64urlJson(segments[1]);
|
|
185
|
+
const cnf = payload['cnf'];
|
|
186
|
+
return cnf?.jwk ?? null;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export async function verify(input, options) {
|
|
193
|
+
const now = options.now ?? nowSeconds();
|
|
194
|
+
const skew = options.clockSkew ?? 60;
|
|
195
|
+
let combined;
|
|
196
|
+
try {
|
|
197
|
+
combined = isEnvelope(input) ? await unpack(input) : input;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
return reject('malformed', error.message);
|
|
201
|
+
}
|
|
202
|
+
let jwt;
|
|
203
|
+
let disclosures;
|
|
204
|
+
let keyBinding;
|
|
205
|
+
try {
|
|
206
|
+
// Strict parsing throws, and verify() is the one place that must never do that: hostile input
|
|
207
|
+
// is its whole job, and a verifier that throws gets wrapped in a try/catch that waves people
|
|
208
|
+
// through.
|
|
209
|
+
;
|
|
210
|
+
({ jwt, disclosures, keyBinding } = splitCombined(combined));
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
return reject('malformed', error.message);
|
|
214
|
+
}
|
|
215
|
+
const segments = jwt.split('.');
|
|
216
|
+
if (segments.length !== 3)
|
|
217
|
+
return reject('malformed', 'credential JWT does not have three segments');
|
|
218
|
+
let header;
|
|
219
|
+
let payload;
|
|
220
|
+
try {
|
|
221
|
+
header = unb64urlJson(segments[0]);
|
|
222
|
+
payload = unb64urlJson(segments[1]);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return reject('malformed', `could not parse the credential: ${error.message}`);
|
|
226
|
+
}
|
|
227
|
+
const issuer = typeof payload['iss'] === 'string' ? payload['iss'] : undefined;
|
|
228
|
+
if (!issuer)
|
|
229
|
+
return reject('malformed', 'credential has no iss claim');
|
|
230
|
+
const entry = options.trust.issuers[issuer];
|
|
231
|
+
if (!entry)
|
|
232
|
+
return reject('unknown_issuer', `no trusted key for issuer ${issuer}`);
|
|
233
|
+
const candidates = header.kid ? entry.keys.filter((k) => k.kid === header.kid) : entry.keys;
|
|
234
|
+
if (candidates.length === 0) {
|
|
235
|
+
return reject('unknown_key', `issuer ${issuer} has no trusted key with kid ${String(header.kid)}`);
|
|
236
|
+
}
|
|
237
|
+
// The trusted key decides the algorithm, never the attacker supplied header.
|
|
238
|
+
const key = candidates.find((k) => k.alg === header.alg);
|
|
239
|
+
if (!key) {
|
|
240
|
+
return reject('unsupported_alg', `header asks for ${String(header.alg)} but the trusted key for that kid is ${candidates[0].alg}`);
|
|
241
|
+
}
|
|
242
|
+
const signingInput = `${segments[0]}.${segments[1]}`;
|
|
243
|
+
const publicKey = await importPublicKey(key.jwk, key.alg);
|
|
244
|
+
if (!(await verifySignature(signingInput, segments[2], publicKey, key.alg))) {
|
|
245
|
+
return reject('bad_signature', 'the issuer signature does not check out');
|
|
246
|
+
}
|
|
247
|
+
const exp = typeof payload['exp'] === 'number' ? payload['exp'] : undefined;
|
|
248
|
+
const nbf = typeof payload['nbf'] === 'number' ? payload['nbf'] : undefined;
|
|
249
|
+
if (exp !== undefined && now > exp + skew) {
|
|
250
|
+
return reject('expired', `credential expired at ${new Date(exp * 1000).toISOString()}`);
|
|
251
|
+
}
|
|
252
|
+
if (nbf !== undefined && now + skew < nbf) {
|
|
253
|
+
return reject('not_yet_valid', `credential is not valid until ${new Date(nbf * 1000).toISOString()}`);
|
|
254
|
+
}
|
|
255
|
+
const holderProof = await checkHolderProof({
|
|
256
|
+
payload,
|
|
257
|
+
jwt,
|
|
258
|
+
disclosures,
|
|
259
|
+
keyBinding,
|
|
260
|
+
options,
|
|
261
|
+
now,
|
|
262
|
+
skew,
|
|
263
|
+
});
|
|
264
|
+
if (holderProof.rejected)
|
|
265
|
+
return holderProof.rejected;
|
|
266
|
+
let claims;
|
|
267
|
+
let disclosed;
|
|
268
|
+
let withheld;
|
|
269
|
+
try {
|
|
270
|
+
const rebuilt = await reconstructClaims(payload, disclosures);
|
|
271
|
+
claims = rebuilt.claims;
|
|
272
|
+
disclosed = rebuilt.disclosed;
|
|
273
|
+
withheld = rebuilt.withheld;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
return reject('digest_mismatch', error.message);
|
|
277
|
+
}
|
|
278
|
+
let revocationChecked = false;
|
|
279
|
+
const pointer = payload['status']?.status_list;
|
|
280
|
+
if (pointer) {
|
|
281
|
+
if (!options.status) {
|
|
282
|
+
return reject('status_unavailable', `this credential points at the status list ${pointer.uri} and no cached copy was provided`);
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const { payload: listPayload, list } = await parseStatusList(options.status);
|
|
286
|
+
const listIssuer = String(listPayload['iss'] ?? '');
|
|
287
|
+
const listEntry = options.trust.issuers[listIssuer];
|
|
288
|
+
if (!listEntry)
|
|
289
|
+
return reject('unknown_issuer', `the status list is signed by the untrusted issuer ${listIssuer}`);
|
|
290
|
+
// Bind the list to this credential. Without both checks any list from any trusted issuer
|
|
291
|
+
// would clear any credential, and an index means something different in every list.
|
|
292
|
+
if (listIssuer !== issuer) {
|
|
293
|
+
return reject('status_unavailable', `the status list is issued by ${listIssuer} but the credential is issued by ${issuer}`);
|
|
294
|
+
}
|
|
295
|
+
if (list.uri !== undefined && list.uri !== pointer.uri) {
|
|
296
|
+
return reject('status_unavailable', `the credential points at ${pointer.uri} but the cached list is ${list.uri}`);
|
|
297
|
+
}
|
|
298
|
+
const listSegments = options.status.split('.');
|
|
299
|
+
const listHeader = unb64urlJson(listSegments[0]);
|
|
300
|
+
const listKey = listEntry.keys.find((k) => (!listHeader.kid || k.kid === listHeader.kid) && k.alg === listHeader.alg);
|
|
301
|
+
if (!listKey)
|
|
302
|
+
return reject('unknown_key', 'no trusted key matches the status list signature');
|
|
303
|
+
const listPublic = await importPublicKey(listKey.jwk, listKey.alg);
|
|
304
|
+
const listInput = `${listSegments[0]}.${listSegments[1]}`;
|
|
305
|
+
if (!(await verifySignature(listInput, listSegments[2], listPublic, listKey.alg))) {
|
|
306
|
+
return reject('bad_signature', 'the status list signature does not check out');
|
|
307
|
+
}
|
|
308
|
+
if (isStale(list, options.maxStatusAge, now)) {
|
|
309
|
+
return reject('status_list_stale', 'the cached status list is older than maxStatusAge, so revocation cannot be ruled out');
|
|
310
|
+
}
|
|
311
|
+
const state = readStatus(list, pointer.idx);
|
|
312
|
+
if (state === 'invalid')
|
|
313
|
+
return reject('revoked', 'the issuer has revoked this credential');
|
|
314
|
+
if (state === 'suspended')
|
|
315
|
+
return reject('revoked', 'the issuer has suspended this credential');
|
|
316
|
+
if (state === 'unknown') {
|
|
317
|
+
// The index falls outside the list, so nothing was actually checked. Reporting this as a
|
|
318
|
+
// clean result would be the worst outcome available: a false assurance.
|
|
319
|
+
return reject('status_unavailable', `index ${pointer.idx} is outside the cached status list, so revocation was not checked`);
|
|
320
|
+
}
|
|
321
|
+
revocationChecked = true;
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
return reject('status_unavailable', `could not read the status list: ${error.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
claims,
|
|
330
|
+
issuer,
|
|
331
|
+
subject: typeof payload['sub'] === 'string' ? payload['sub'] : undefined,
|
|
332
|
+
vct: typeof payload['vct'] === 'string' ? payload['vct'] : undefined,
|
|
333
|
+
issuedAt: typeof payload['iat'] === 'number' ? payload['iat'] : undefined,
|
|
334
|
+
expiresAt: exp,
|
|
335
|
+
disclosed,
|
|
336
|
+
withheld,
|
|
337
|
+
revocationChecked,
|
|
338
|
+
holderVerified: holderProof.holderVerified,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Decide whether the person presenting this credential proved it is theirs.
|
|
343
|
+
*
|
|
344
|
+
* Four states, and only one of them is a silent pass:
|
|
345
|
+
*
|
|
346
|
+
* - bound key, valid proof -> holderVerified true
|
|
347
|
+
* - bound key, no proof -> refuse, unless the caller opted out
|
|
348
|
+
* - no bound key (a static credential) -> refuse, unless the caller opted out
|
|
349
|
+
* - proof without a bound key -> always refuse, it is signed by nobody in particular
|
|
350
|
+
*/
|
|
351
|
+
async function checkHolderProof(input) {
|
|
352
|
+
const { payload, jwt, disclosures, keyBinding, options, now, skew } = input;
|
|
353
|
+
const cnf = payload['cnf'];
|
|
354
|
+
const boundKey = cnf?.jwk;
|
|
355
|
+
if (keyBinding === undefined) {
|
|
356
|
+
if (options.acceptWithoutHolderProof === true)
|
|
357
|
+
return { holderVerified: false };
|
|
358
|
+
return {
|
|
359
|
+
rejected: reject('holder_proof_missing', boundKey
|
|
360
|
+
? 'this credential is bound to a holder key but the presentation carries no proof. Pass nonce and audience to require one, or acceptWithoutHolderProof to accept a copyable presentation.'
|
|
361
|
+
: 'the issuer bound no holder key, so anyone with a copy of this code can present it. Pass acceptWithoutHolderProof if that is acceptable for this credential.'),
|
|
362
|
+
holderVerified: false,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (!boundKey) {
|
|
366
|
+
return {
|
|
367
|
+
rejected: reject('holder_proof_invalid', 'the presentation carries a holder proof but the issuer bound no key to this credential, so the proof attests to nothing'),
|
|
368
|
+
holderVerified: false,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (typeof options.nonce !== 'string' || typeof options.audience !== 'string') {
|
|
372
|
+
return {
|
|
373
|
+
rejected: reject('holder_proof_invalid', 'checking a holder proof needs the nonce and audience this verifier issued for this scan'),
|
|
374
|
+
holderVerified: false,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
const segments = keyBinding.split('.');
|
|
378
|
+
if (segments.length !== 3) {
|
|
379
|
+
return { rejected: reject('holder_proof_invalid', 'the holder proof is not a JWT'), holderVerified: false };
|
|
380
|
+
}
|
|
381
|
+
let kbHeader;
|
|
382
|
+
let kbPayload;
|
|
383
|
+
try {
|
|
384
|
+
kbHeader = unb64urlJson(segments[0]);
|
|
385
|
+
kbPayload = unb64urlJson(segments[1]);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
return {
|
|
389
|
+
rejected: reject('holder_proof_invalid', `could not read the holder proof: ${error.message}`),
|
|
390
|
+
holderVerified: false,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (kbHeader.typ !== 'kb+jwt') {
|
|
394
|
+
return {
|
|
395
|
+
rejected: reject('holder_proof_invalid', `holder proof has typ ${String(kbHeader.typ)}, expected kb+jwt`),
|
|
396
|
+
holderVerified: false,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// The bound key decides the algorithm, never the header the presenter supplied.
|
|
400
|
+
let alg;
|
|
401
|
+
try {
|
|
402
|
+
alg = algForJwk(boundKey);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
return { rejected: reject('holder_proof_invalid', error.message), holderVerified: false };
|
|
406
|
+
}
|
|
407
|
+
if (kbHeader.alg !== alg) {
|
|
408
|
+
return {
|
|
409
|
+
rejected: reject('holder_proof_invalid', `holder proof claims ${String(kbHeader.alg)} but the bound key is ${alg}`),
|
|
410
|
+
holderVerified: false,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
let holderPublic;
|
|
414
|
+
try {
|
|
415
|
+
holderPublic = await importPublicKey(boundKey, alg);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
return { rejected: reject('holder_proof_invalid', error.message), holderVerified: false };
|
|
419
|
+
}
|
|
420
|
+
const kbInput = `${segments[0]}.${segments[1]}`;
|
|
421
|
+
if (!(await verifySignature(kbInput, segments[2], holderPublic, alg))) {
|
|
422
|
+
return {
|
|
423
|
+
rejected: reject('holder_proof_invalid', 'the holder proof is not signed by the key the issuer bound'),
|
|
424
|
+
holderVerified: false,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (kbPayload['aud'] !== options.audience) {
|
|
428
|
+
return {
|
|
429
|
+
rejected: reject('holder_proof_invalid', `the holder proof was made for ${String(kbPayload['aud'])}, not for ${options.audience}`),
|
|
430
|
+
holderVerified: false,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (kbPayload['nonce'] !== options.nonce) {
|
|
434
|
+
return {
|
|
435
|
+
rejected: reject('holder_proof_invalid', 'the holder proof answers a different challenge, which is what a replayed presentation looks like'),
|
|
436
|
+
holderVerified: false,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
const expected = await sdHash(jwt, disclosures);
|
|
440
|
+
if (kbPayload['sd_hash'] !== expected) {
|
|
441
|
+
return {
|
|
442
|
+
rejected: reject('holder_proof_invalid', 'the holder proof commits to a different set of disclosures than the one presented'),
|
|
443
|
+
holderVerified: false,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const iat = typeof kbPayload['iat'] === 'number' ? kbPayload['iat'] : undefined;
|
|
447
|
+
if (iat === undefined) {
|
|
448
|
+
return { rejected: reject('holder_proof_invalid', 'the holder proof has no iat'), holderVerified: false };
|
|
449
|
+
}
|
|
450
|
+
const maxAge = seconds(options.maxKeyBindingAge ?? 300);
|
|
451
|
+
if (now - iat > maxAge) {
|
|
452
|
+
return {
|
|
453
|
+
rejected: reject('holder_proof_invalid', `the holder proof is ${now - iat} seconds old, past the ${maxAge} second limit`),
|
|
454
|
+
holderVerified: false,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (iat - now > skew) {
|
|
458
|
+
return { rejected: reject('holder_proof_invalid', 'the holder proof is dated in the future'), holderVerified: false };
|
|
459
|
+
}
|
|
460
|
+
return { holderVerified: true };
|
|
461
|
+
}
|