@idpartners/client-attestation-sdk 0.0.0-stage → 1.0.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 +202 -0
- package/README.md +142 -2
- package/dist/attester.d.ts +93 -0
- package/dist/attester.js +139 -0
- package/dist/builders.d.ts +92 -0
- package/dist/builders.js +284 -0
- package/dist/credential.d.ts +22 -0
- package/dist/credential.js +44 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +28 -0
- package/dist/evidence.d.ts +55 -0
- package/dist/evidence.js +168 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +183 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +20 -0
- package/dist/issuance.d.ts +61 -0
- package/dist/issuance.js +117 -0
- package/dist/logging.d.ts +12 -0
- package/dist/logging.js +77 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +64 -0
- package/dist/signer.d.ts +32 -0
- package/dist/signer.js +121 -0
- package/dist/signing-key-pair.d.ts +44 -0
- package/dist/signing-key-pair.js +130 -0
- package/dist/svid-source.d.ts +41 -0
- package/dist/svid-source.js +77 -0
- package/dist/token-source.d.ts +146 -0
- package/dist/token-source.js +510 -0
- package/dist/validator/config.d.ts +61 -0
- package/dist/validator/config.js +61 -0
- package/dist/validator/errors.d.ts +20 -0
- package/dist/validator/errors.js +18 -0
- package/dist/validator/index.d.ts +19 -0
- package/dist/validator/index.js +12 -0
- package/dist/validator/jwks.d.ts +2 -0
- package/dist/validator/jwks.js +150 -0
- package/dist/validator/resource.d.ts +37 -0
- package/dist/validator/resource.js +98 -0
- package/dist/validator/result.d.ts +17 -0
- package/dist/validator/result.js +28 -0
- package/dist/validator/strict.d.ts +1 -0
- package/dist/validator/strict.js +202 -0
- package/dist/validator/validator.d.ts +41 -0
- package/dist/validator/validator.js +240 -0
- package/package.json +56 -4
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral helpers, so the package loads on edge runtimes (Cloudflare Workers, Vercel Edge,
|
|
3
|
+
* Deno Deploy) as well as Node. Nothing here, or anywhere else in `src`, imports a Node builtin
|
|
4
|
+
* statically: the few features that need one load it lazily through {@link importNodeBuiltin}.
|
|
5
|
+
* Not exported from the package index.
|
|
6
|
+
*/
|
|
7
|
+
import { ClientAttestationError } from "./errors.js";
|
|
8
|
+
const textEncoder = new TextEncoder();
|
|
9
|
+
const textDecoder = new TextDecoder();
|
|
10
|
+
/**
|
|
11
|
+
* UTF-8 bytes of a string.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export function utf8Encode(text) {
|
|
16
|
+
return textEncoder.encode(text);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A string from UTF-8 bytes; malformed sequences become U+FFFD, as `Buffer#toString` does.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export function utf8Decode(bytes) {
|
|
24
|
+
return textDecoder.decode(bytes);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Standard (padded) base64 of some bytes.
|
|
28
|
+
*
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export function base64Encode(bytes) {
|
|
32
|
+
let binary = "";
|
|
33
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
34
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
35
|
+
}
|
|
36
|
+
return btoa(binary);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* An environment variable, or `undefined` on a runtime with no `process.env` (most edge runtimes).
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export function envVar(name) {
|
|
44
|
+
const proc = globalThis.process;
|
|
45
|
+
return proc?.env?.[name];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Load a Node builtin (`node:<name>`) on first use. Throws a {@link ClientAttestationError} naming the
|
|
49
|
+
* feature when the runtime has no such module (an edge runtime without Node compatibility).
|
|
50
|
+
*
|
|
51
|
+
* The specifier is built at run time so bundlers for edge targets leave it alone instead of failing to
|
|
52
|
+
* resolve it.
|
|
53
|
+
*
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
export async function importNodeBuiltin(name, feature) {
|
|
57
|
+
const specifier = "node:" + name;
|
|
58
|
+
try {
|
|
59
|
+
return (await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier));
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
throw new ClientAttestationError(`${feature} needs the Node.js module '${specifier}', which this runtime does not provide`, { cause: err });
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/signer.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External JWS signing — keys that live outside the process (a vault, HSM, or KMS).
|
|
3
|
+
*
|
|
4
|
+
* {@link SigningKeyPair} covers the local-key case; {@link OpenBaoTransitSigner} signs inside an
|
|
5
|
+
* OpenBao / HashiCorp Vault transit engine so the attester's private key never leaves it.
|
|
6
|
+
*/
|
|
7
|
+
import { type JWK } from "jose";
|
|
8
|
+
/** A JWS signing capability whose private key may live outside the process. `sign` returns the RAW JWS
|
|
9
|
+
* signature over the signing input — for ECDSA the fixed-width `r||s`, not ASN.1/DER. */
|
|
10
|
+
export interface JwsSigner {
|
|
11
|
+
readonly algorithm: string;
|
|
12
|
+
readonly keyId: string;
|
|
13
|
+
publicJwk(): JWK;
|
|
14
|
+
sign(signingInput: Uint8Array): Promise<Uint8Array>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A {@link JwsSigner} backed by an OpenBao / HashiCorp Vault transit engine: the attestation is signed
|
|
18
|
+
* inside the vault (`POST /v1/transit/sign/<key>` with `marshaling_algorithm=jws`, which returns the
|
|
19
|
+
* fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
|
|
20
|
+
* latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
|
|
21
|
+
*
|
|
22
|
+
* @experimental See docs/api.md: a client for a third-party API that authenticates with a static token only; its constructor will change when other auth methods are added.
|
|
23
|
+
*/
|
|
24
|
+
export declare class OpenBaoTransitSigner implements JwsSigner {
|
|
25
|
+
#private;
|
|
26
|
+
readonly algorithm: string;
|
|
27
|
+
readonly keyId: string;
|
|
28
|
+
private constructor();
|
|
29
|
+
static create(baoAddr: string, token: string, keyName: string): Promise<OpenBaoTransitSigner>;
|
|
30
|
+
publicJwk(): JWK;
|
|
31
|
+
sign(signingInput: Uint8Array): Promise<Uint8Array>;
|
|
32
|
+
}
|
package/dist/signer.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External JWS signing — keys that live outside the process (a vault, HSM, or KMS).
|
|
3
|
+
*
|
|
4
|
+
* {@link SigningKeyPair} covers the local-key case; {@link OpenBaoTransitSigner} signs inside an
|
|
5
|
+
* OpenBao / HashiCorp Vault transit engine so the attester's private key never leaves it.
|
|
6
|
+
*/
|
|
7
|
+
import { base64url, calculateJwkThumbprint, exportJWK, importSPKI } from "jose";
|
|
8
|
+
import { TransportError } from "./errors.js";
|
|
9
|
+
import { base64Encode } from "./runtime.js";
|
|
10
|
+
import { parseJsonObject, request as httpRequest, snippet } from "./http.js";
|
|
11
|
+
/** Per-call timeout for the vault. */
|
|
12
|
+
const OPENBAO_TIMEOUT_MS = 5_000;
|
|
13
|
+
const b64url = (bytes) => base64url.encode(bytes);
|
|
14
|
+
/** Assemble a compact JWS whose signature is produced by an external signer (header carries `kid`; external
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
* signers hold issuing keys, referenced by id, never an embedded jwk). */
|
|
18
|
+
export async function signExternal(claims, signer, typ) {
|
|
19
|
+
const enc = new TextEncoder();
|
|
20
|
+
const header = { alg: signer.algorithm, typ, kid: signer.keyId };
|
|
21
|
+
const signingInput = `${b64url(enc.encode(JSON.stringify(header)))}.${b64url(enc.encode(JSON.stringify(claims)))}`;
|
|
22
|
+
const signature = await signer.sign(enc.encode(signingInput));
|
|
23
|
+
return `${signingInput}.${b64url(signature)}`;
|
|
24
|
+
}
|
|
25
|
+
const KEY_TYPE_ALG = { "ecdsa-p256": "ES256", "ecdsa-p384": "ES384", "ecdsa-p521": "ES512" };
|
|
26
|
+
const ALG_HASH = { ES256: "sha2-256", ES384: "sha2-384", ES512: "sha2-512" };
|
|
27
|
+
/**
|
|
28
|
+
* A {@link JwsSigner} backed by an OpenBao / HashiCorp Vault transit engine: the attestation is signed
|
|
29
|
+
* inside the vault (`POST /v1/transit/sign/<key>` with `marshaling_algorithm=jws`, which returns the
|
|
30
|
+
* fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
|
|
31
|
+
* latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
|
|
32
|
+
*
|
|
33
|
+
* @experimental See docs/api.md: a client for a third-party API that authenticates with a static token only; its constructor will change when other auth methods are added.
|
|
34
|
+
*/
|
|
35
|
+
export class OpenBaoTransitSigner {
|
|
36
|
+
algorithm;
|
|
37
|
+
keyId;
|
|
38
|
+
#base;
|
|
39
|
+
#token;
|
|
40
|
+
#keyName;
|
|
41
|
+
#hash;
|
|
42
|
+
#keyVersion;
|
|
43
|
+
#publicJwk;
|
|
44
|
+
constructor(base, token, keyName, algorithm, hash, keyVersion, publicJwk, keyId) {
|
|
45
|
+
this.#base = base;
|
|
46
|
+
this.#token = token;
|
|
47
|
+
this.#keyName = keyName;
|
|
48
|
+
this.algorithm = algorithm;
|
|
49
|
+
this.#hash = hash;
|
|
50
|
+
this.#keyVersion = keyVersion;
|
|
51
|
+
this.#publicJwk = publicJwk;
|
|
52
|
+
this.keyId = keyId;
|
|
53
|
+
}
|
|
54
|
+
static async create(baoAddr, token, keyName) {
|
|
55
|
+
const base = baoAddr.replace(/\/+$/, "");
|
|
56
|
+
const data = await request("GET", base, token, `/v1/transit/keys/${keyName}`);
|
|
57
|
+
const keyType = String(data.type);
|
|
58
|
+
const algorithm = KEY_TYPE_ALG[keyType];
|
|
59
|
+
if (!algorithm) {
|
|
60
|
+
throw new Error(`transit key '${keyName}' has unsupported type for JWS signing: ${keyType}`);
|
|
61
|
+
}
|
|
62
|
+
const keyVersion = Number(data.latest_version);
|
|
63
|
+
const keys = data.keys;
|
|
64
|
+
const pem = keys[String(keyVersion)].public_key;
|
|
65
|
+
const jwk = await exportJWK(await importSPKI(pem, algorithm, { extractable: true }));
|
|
66
|
+
const keyId = await calculateJwkThumbprint(jwk, "sha256");
|
|
67
|
+
const publicJwk = { ...jwk, kid: keyId, alg: algorithm };
|
|
68
|
+
return new OpenBaoTransitSigner(base, token, keyName, algorithm, ALG_HASH[algorithm], keyVersion, publicJwk, keyId);
|
|
69
|
+
}
|
|
70
|
+
publicJwk() {
|
|
71
|
+
return { ...this.#publicJwk };
|
|
72
|
+
}
|
|
73
|
+
async sign(signingInput) {
|
|
74
|
+
const body = JSON.stringify({
|
|
75
|
+
input: base64Encode(signingInput),
|
|
76
|
+
marshaling_algorithm: "jws",
|
|
77
|
+
hash_algorithm: this.#hash,
|
|
78
|
+
key_version: this.#keyVersion,
|
|
79
|
+
});
|
|
80
|
+
const data = await request("POST", this.#base, this.#token, `/v1/transit/sign/${this.#keyName}`, body);
|
|
81
|
+
const signature = data.signature;
|
|
82
|
+
if (!signature) {
|
|
83
|
+
throw new Error("transit sign response carried no signature");
|
|
84
|
+
}
|
|
85
|
+
const raw = signature.slice(signature.lastIndexOf(":") + 1); // envelope: vault:v<n>:<base64url(r||s)>
|
|
86
|
+
return base64url.decode(raw);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function request(method, base, token, path, body) {
|
|
90
|
+
let response;
|
|
91
|
+
try {
|
|
92
|
+
response = await httpRequest(base + path, {
|
|
93
|
+
method,
|
|
94
|
+
headers: { "X-Vault-Token": token, ...(body ? { "Content-Type": "application/json" } : {}) },
|
|
95
|
+
body,
|
|
96
|
+
timeoutMs: OPENBAO_TIMEOUT_MS,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (exc) {
|
|
100
|
+
if (exc instanceof TransportError && (exc.kind === "network" || exc.kind === "timeout")) {
|
|
101
|
+
const why = exc.kind === "timeout"
|
|
102
|
+
? `timed out after ${OPENBAO_TIMEOUT_MS} ms`
|
|
103
|
+
: exc.cause instanceof Error
|
|
104
|
+
? exc.cause.message
|
|
105
|
+
: String(exc.cause);
|
|
106
|
+
throw new TransportError(`OpenBao unreachable at ${base}: ${why}`, exc.kind, { cause: exc });
|
|
107
|
+
}
|
|
108
|
+
throw exc;
|
|
109
|
+
}
|
|
110
|
+
if (response.status !== 200) {
|
|
111
|
+
throw new TransportError(`OpenBao returned HTTP ${response.status} for ${path}`, "status", {
|
|
112
|
+
status: response.status,
|
|
113
|
+
body: snippet(response.body),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const parsed = parseJsonObject(response);
|
|
117
|
+
if (!parsed.data || typeof parsed.data !== "object") {
|
|
118
|
+
throw new Error("OpenBao response carried no data");
|
|
119
|
+
}
|
|
120
|
+
return parsed.data;
|
|
121
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing keys for attestation-based client authentication.
|
|
3
|
+
*/
|
|
4
|
+
import { type CryptoKey, type JWK } from "jose";
|
|
5
|
+
/**
|
|
6
|
+
* A signing key pair (public + private) with its JWS `alg` and RFC 7638 thumbprint `kid`.
|
|
7
|
+
*
|
|
8
|
+
* Used both as the client instance key — which signs PoP / DPoP proofs and is bound into an
|
|
9
|
+
* attestation's `cnf` — and as a Client Attester's issuing key.
|
|
10
|
+
*
|
|
11
|
+
* jose's crypto operations are async, so instances are created via the static async
|
|
12
|
+
* factories {@link SigningKeyPair.generate} and {@link SigningKeyPair.fromJwk}.
|
|
13
|
+
*/
|
|
14
|
+
export declare class SigningKeyPair {
|
|
15
|
+
#private;
|
|
16
|
+
/** The JWS algorithm (e.g. `ES256`). */
|
|
17
|
+
readonly algorithm: string;
|
|
18
|
+
/** The RFC 7638 JWK thumbprint (SHA-256, base64url) — this key's `kid`. */
|
|
19
|
+
readonly keyId: string;
|
|
20
|
+
private constructor();
|
|
21
|
+
/**
|
|
22
|
+
* Generate a fresh key pair for the given JWS algorithm: `ES256` / `ES384` / `ES512`
|
|
23
|
+
* (EC P-256 / P-384 / P-521), or `RS256` / `RS384` / `RS512` / `PS256` / `PS384` / `PS512`
|
|
24
|
+
* (RSA 2048).
|
|
25
|
+
*
|
|
26
|
+
* The private key is not extractable unless `options.extractable` is true: nothing in the SDK needs
|
|
27
|
+
* to export it, so by default it can't leak through `exportJWK`. Pass `{ extractable: true }` only to
|
|
28
|
+
* persist the key yourself (then restore it with {@link SigningKeyPair.fromJwk}).
|
|
29
|
+
*/
|
|
30
|
+
static generate(algorithm: string, options?: {
|
|
31
|
+
extractable?: boolean;
|
|
32
|
+
}): Promise<SigningKeyPair>;
|
|
33
|
+
/**
|
|
34
|
+
* Wrap an existing JWK (which must contain the private component `d`) for the given algorithm.
|
|
35
|
+
*/
|
|
36
|
+
static fromJwk(jwk: JWK, algorithm: string): Promise<SigningKeyPair>;
|
|
37
|
+
/** The private CryptoKey — package-internal; used by the JWS signer. */
|
|
38
|
+
get privateKey(): CryptoKey;
|
|
39
|
+
/**
|
|
40
|
+
* The public-only JWK (including `kid`) as a fresh object — suitable as an attestation
|
|
41
|
+
* `cnf.jwk` or a DPoP header value (from which callers strip `kid`).
|
|
42
|
+
*/
|
|
43
|
+
publicJwk(): JWK;
|
|
44
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing keys for attestation-based client authentication.
|
|
3
|
+
*/
|
|
4
|
+
import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, } from "jose";
|
|
5
|
+
/**
|
|
6
|
+
* A signing key pair (public + private) with its JWS `alg` and RFC 7638 thumbprint `kid`.
|
|
7
|
+
*
|
|
8
|
+
* Used both as the client instance key — which signs PoP / DPoP proofs and is bound into an
|
|
9
|
+
* attestation's `cnf` — and as a Client Attester's issuing key.
|
|
10
|
+
*
|
|
11
|
+
* jose's crypto operations are async, so instances are created via the static async
|
|
12
|
+
* factories {@link SigningKeyPair.generate} and {@link SigningKeyPair.fromJwk}.
|
|
13
|
+
*/
|
|
14
|
+
export class SigningKeyPair {
|
|
15
|
+
/** The JWS algorithm (e.g. `ES256`). */
|
|
16
|
+
algorithm;
|
|
17
|
+
/** The RFC 7638 JWK thumbprint (SHA-256, base64url) — this key's `kid`. */
|
|
18
|
+
keyId;
|
|
19
|
+
#privateKey;
|
|
20
|
+
#publicJwk;
|
|
21
|
+
constructor(privateKey, algorithm, publicJwk, keyId) {
|
|
22
|
+
this.#privateKey = privateKey;
|
|
23
|
+
this.algorithm = algorithm;
|
|
24
|
+
// Store the public JWK with its kid stamped, so publicJwk() hands out a copy including kid.
|
|
25
|
+
this.#publicJwk = { ...publicJwk, kid: keyId };
|
|
26
|
+
this.keyId = keyId;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Generate a fresh key pair for the given JWS algorithm: `ES256` / `ES384` / `ES512`
|
|
30
|
+
* (EC P-256 / P-384 / P-521), or `RS256` / `RS384` / `RS512` / `PS256` / `PS384` / `PS512`
|
|
31
|
+
* (RSA 2048).
|
|
32
|
+
*
|
|
33
|
+
* The private key is not extractable unless `options.extractable` is true: nothing in the SDK needs
|
|
34
|
+
* to export it, so by default it can't leak through `exportJWK`. Pass `{ extractable: true }` only to
|
|
35
|
+
* persist the key yourself (then restore it with {@link SigningKeyPair.fromJwk}).
|
|
36
|
+
*/
|
|
37
|
+
static async generate(algorithm, options = {}) {
|
|
38
|
+
checkAlgorithm(algorithm);
|
|
39
|
+
const { privateKey, publicKey } = await generateKeyPair(algorithm, {
|
|
40
|
+
extractable: options.extractable ?? false,
|
|
41
|
+
...(algorithm.startsWith("ES") ? {} : { modulusLength: RSA_MODULUS_BITS }),
|
|
42
|
+
});
|
|
43
|
+
const publicJwk = publicMembers(await exportJWK(publicKey));
|
|
44
|
+
const keyId = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
45
|
+
return new SigningKeyPair(privateKey, algorithm, publicJwk, keyId);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Wrap an existing JWK (which must contain the private component `d`) for the given algorithm.
|
|
49
|
+
*/
|
|
50
|
+
static async fromJwk(jwk, algorithm) {
|
|
51
|
+
if (!jwk.d) {
|
|
52
|
+
throw new Error("JWK does not contain a private key");
|
|
53
|
+
}
|
|
54
|
+
checkAlgorithm(algorithm);
|
|
55
|
+
const privateKey = (await importJWK(jwk, algorithm));
|
|
56
|
+
// Derive the public-only JWK from the supplied members (drop the private ones).
|
|
57
|
+
const publicJwk = publicMembers(jwk);
|
|
58
|
+
const keyId = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
59
|
+
return new SigningKeyPair(privateKey, algorithm, publicJwk, keyId);
|
|
60
|
+
}
|
|
61
|
+
/** The private CryptoKey — package-internal; used by the JWS signer. */
|
|
62
|
+
get privateKey() {
|
|
63
|
+
return this.#privateKey;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The public-only JWK (including `kid`) as a fresh object — suitable as an attestation
|
|
67
|
+
* `cnf.jwk` or a DPoP header value (from which callers strip `kid`).
|
|
68
|
+
*/
|
|
69
|
+
publicJwk() {
|
|
70
|
+
return { ...this.#publicJwk };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The JWS algorithms {@link SigningKeyPair} can generate and sign with.
|
|
75
|
+
*
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
78
|
+
export const SUPPORTED_ALGORITHMS = [
|
|
79
|
+
"ES256", "ES384", "ES512",
|
|
80
|
+
"RS256", "RS384", "RS512",
|
|
81
|
+
"PS256", "PS384", "PS512",
|
|
82
|
+
];
|
|
83
|
+
/** RSA modulus size for generated RS* / PS* keys. */
|
|
84
|
+
const RSA_MODULUS_BITS = 2048;
|
|
85
|
+
function checkAlgorithm(algorithm) {
|
|
86
|
+
if (!SUPPORTED_ALGORITHMS.includes(algorithm)) {
|
|
87
|
+
throw new Error(`unsupported signing algorithm: ${algorithm}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** The public members of an EC or RSA JWK (no private parameters, no metadata). */
|
|
91
|
+
function publicMembers(jwk) {
|
|
92
|
+
return jwk.kty === "RSA"
|
|
93
|
+
? { kty: jwk.kty, n: jwk.n, e: jwk.e }
|
|
94
|
+
: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* RFC 7638 JWK thumbprint (SHA-256, base64url) over the required members only.
|
|
98
|
+
*
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
export async function thumbprint(publicJwk) {
|
|
102
|
+
return calculateJwkThumbprint(publicJwk, "sha256");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Sign claims into a compact JWS with an explicit `typ` header, keyed by `key`.
|
|
106
|
+
*
|
|
107
|
+
* When `embedJwk` is set the public key travels in a `jwk` header (as DPoP requires); otherwise
|
|
108
|
+
* the key's thumbprint `kid` is set.
|
|
109
|
+
*
|
|
110
|
+
* @internal
|
|
111
|
+
*/
|
|
112
|
+
export async function signCompact(claims, key, typ, embedJwk) {
|
|
113
|
+
const header = { alg: key.algorithm, typ };
|
|
114
|
+
if (embedJwk) {
|
|
115
|
+
const publicJwk = key.publicJwk();
|
|
116
|
+
delete publicJwk.kid;
|
|
117
|
+
header.jwk = publicJwk;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
header.kid = key.keyId;
|
|
121
|
+
}
|
|
122
|
+
return new SignJWT(claims).setProtectedHeader(header).sign(key.privateKey);
|
|
123
|
+
}
|
|
124
|
+
/** @internal */
|
|
125
|
+
export function requireText(value, field) {
|
|
126
|
+
if (value == null || String(value).trim() === "") {
|
|
127
|
+
throw new Error(`${field} is required`);
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Anything that can produce a compact JWT-SVID for an audience. */
|
|
2
|
+
export interface SvidSource {
|
|
3
|
+
fetch(audience: string): Promise<string>;
|
|
4
|
+
}
|
|
5
|
+
/** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
|
|
6
|
+
export declare class StaticSvidSource implements SvidSource {
|
|
7
|
+
private readonly svid;
|
|
8
|
+
constructor(svid: string);
|
|
9
|
+
fetch(_audience: string): Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint.
|
|
13
|
+
*
|
|
14
|
+
* For development and testing only. It trusts whatever the URL returns and the endpoint has no caller
|
|
15
|
+
* authentication, so anyone who can reach it can obtain the workload's SVID. In production use a SPIRE,
|
|
16
|
+
* file or cloud source.
|
|
17
|
+
*
|
|
18
|
+
* @experimental See docs/api.md: a development and test source with no caller authentication.
|
|
19
|
+
*/
|
|
20
|
+
export declare class HttpSvidSource implements SvidSource {
|
|
21
|
+
private readonly url;
|
|
22
|
+
constructor(url: string);
|
|
23
|
+
fetch(_audience: string): Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
|
|
27
|
+
* CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
|
|
28
|
+
* subprocess.
|
|
29
|
+
*
|
|
30
|
+
* Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
|
|
31
|
+
* a `ClientAttestationError` on a runtime without it.
|
|
32
|
+
*
|
|
33
|
+
* @experimental See docs/api.md: it shells out to the spire-agent CLI, and its constructor will change
|
|
34
|
+
* when it moves to the Workload API socket.
|
|
35
|
+
*/
|
|
36
|
+
export declare class SpireWorkloadApiSvidSource implements SvidSource {
|
|
37
|
+
private readonly socketPath;
|
|
38
|
+
private readonly spireAgentBin;
|
|
39
|
+
constructor(socketPath?: string, spireAgentBin?: string);
|
|
40
|
+
fetch(audience: string): Promise<string>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable sources for a workload's SPIFFE JWT-SVID — the infra-identity input to attestation
|
|
3
|
+
* bootstrapping ({@link AttestationIssuanceClient}). The default {@link SpireWorkloadApiSvidSource}
|
|
4
|
+
* fetches it from a real SPIRE agent; the others cover dev and tests.
|
|
5
|
+
*/
|
|
6
|
+
import { jsonOk, request } from "./http.js";
|
|
7
|
+
import { importNodeBuiltin } from "./runtime.js";
|
|
8
|
+
/** Per-call timeout for {@link HttpSvidSource}. */
|
|
9
|
+
const HTTP_SVID_TIMEOUT_MS = 20_000;
|
|
10
|
+
/** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
|
|
11
|
+
export class StaticSvidSource {
|
|
12
|
+
svid;
|
|
13
|
+
constructor(svid) {
|
|
14
|
+
this.svid = svid;
|
|
15
|
+
}
|
|
16
|
+
async fetch(_audience) {
|
|
17
|
+
return this.svid;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint.
|
|
22
|
+
*
|
|
23
|
+
* For development and testing only. It trusts whatever the URL returns and the endpoint has no caller
|
|
24
|
+
* authentication, so anyone who can reach it can obtain the workload's SVID. In production use a SPIRE,
|
|
25
|
+
* file or cloud source.
|
|
26
|
+
*
|
|
27
|
+
* @experimental See docs/api.md: a development and test source with no caller authentication.
|
|
28
|
+
*/
|
|
29
|
+
export class HttpSvidSource {
|
|
30
|
+
url;
|
|
31
|
+
constructor(url) {
|
|
32
|
+
this.url = url;
|
|
33
|
+
}
|
|
34
|
+
async fetch(_audience) {
|
|
35
|
+
const res = await request(this.url, { method: "GET", timeoutMs: HTTP_SVID_TIMEOUT_MS });
|
|
36
|
+
const body = jsonOk(res);
|
|
37
|
+
if (typeof body.svid !== "string" || !body.svid)
|
|
38
|
+
throw new Error(`no svid in response from ${this.url}`);
|
|
39
|
+
return body.svid;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
|
|
44
|
+
* CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
|
|
45
|
+
* subprocess.
|
|
46
|
+
*
|
|
47
|
+
* Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
|
|
48
|
+
* a `ClientAttestationError` on a runtime without it.
|
|
49
|
+
*
|
|
50
|
+
* @experimental See docs/api.md: it shells out to the spire-agent CLI, and its constructor will change
|
|
51
|
+
* when it moves to the Workload API socket.
|
|
52
|
+
*/
|
|
53
|
+
export class SpireWorkloadApiSvidSource {
|
|
54
|
+
socketPath;
|
|
55
|
+
spireAgentBin;
|
|
56
|
+
constructor(socketPath = "/tmp/spire-agent/public/api.sock", spireAgentBin = "spire-agent") {
|
|
57
|
+
this.socketPath = socketPath;
|
|
58
|
+
this.spireAgentBin = spireAgentBin;
|
|
59
|
+
}
|
|
60
|
+
async fetch(audience) {
|
|
61
|
+
const { execFile } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
|
|
62
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
63
|
+
execFile(this.spireAgentBin, ["api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath], (err, out) => (err ? reject(err) : resolve(String(out))));
|
|
64
|
+
});
|
|
65
|
+
for (let line of stdout.split("\n")) {
|
|
66
|
+
line = line.trim();
|
|
67
|
+
if (line.startsWith("token(")) {
|
|
68
|
+
const idx = line.indexOf("):");
|
|
69
|
+
if (idx >= 0)
|
|
70
|
+
return line.slice(idx + 2).trim();
|
|
71
|
+
}
|
|
72
|
+
if ((line.match(/\./g) ?? []).length === 2)
|
|
73
|
+
return line;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`could not parse SVID from spire-agent output:\n${stdout}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type AttesterMetadata, type AttesterProfile } from "./attester.js";
|
|
2
|
+
import { type Logger } from "./logging.js";
|
|
3
|
+
import type { SvidSource } from "./svid-source.js";
|
|
4
|
+
export declare const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
5
|
+
export declare const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
6
|
+
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
7
|
+
export declare const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
8
|
+
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
9
|
+
/** The attester's discovery document (PingFederate: `/.well-known/client-attester`). */
|
|
10
|
+
export interface AttesterDiscovery {
|
|
11
|
+
[member: string]: unknown;
|
|
12
|
+
evidence_audience?: string;
|
|
13
|
+
attestation_endpoint?: string;
|
|
14
|
+
challenge_endpoint?: string;
|
|
15
|
+
token_endpoint?: string;
|
|
16
|
+
pop_audience?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Options for {@link ClientAttestation}. */
|
|
19
|
+
export interface ClientAttestationOptions {
|
|
20
|
+
/**
|
|
21
|
+
* The attester's host, when the options object is the only argument. The positional argument wins
|
|
22
|
+
* when both are given.
|
|
23
|
+
*/
|
|
24
|
+
attesterUrl?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The attester profile that discovers the attester and mints the attestation. Default
|
|
27
|
+
* {@link PingFederateProfile}, the behaviour of every earlier release. See docs/attester-profiles.md.
|
|
28
|
+
*/
|
|
29
|
+
profile?: AttesterProfile;
|
|
30
|
+
/**
|
|
31
|
+
* Static attester metadata (evidence audience, attestation and token endpoints, optionally challenge
|
|
32
|
+
* endpoint and PoP audience). When set, no discovery request is made; the profile still mints.
|
|
33
|
+
*/
|
|
34
|
+
metadata?: AttesterMetadata;
|
|
35
|
+
/**
|
|
36
|
+
* How to get the workload's platform identity. Leave unset to auto-detect the runtime (env token,
|
|
37
|
+
* projected token file, SPIRE socket, or GCP metadata). Pass an {@link SvidSource} to override.
|
|
38
|
+
*/
|
|
39
|
+
evidence?: SvidSource;
|
|
40
|
+
/**
|
|
41
|
+
* Optional RFC 9396 access request sent at mint time; the attester grants at most what the workload
|
|
42
|
+
* is entitled to.
|
|
43
|
+
*/
|
|
44
|
+
authorizationDetails?: Array<Record<string, unknown>>;
|
|
45
|
+
/**
|
|
46
|
+
* The client's operator - who answers for this workload. Sent in the mint request body as
|
|
47
|
+
* `operator`, so the attester (not the client) is what states it; an attester that does not know
|
|
48
|
+
* this key ignores it, one that does can refuse to name an operator it cannot corroborate. When set,
|
|
49
|
+
* it is also repeated at the token endpoint as the form parameter `operator` (compact JSON, sorted
|
|
50
|
+
* keys) so an AS can populate RFC 8693 `may_act` from it without parsing the attestation; an AS that
|
|
51
|
+
* does not know the parameter ignores it. Unset (or empty) sends nothing.
|
|
52
|
+
*/
|
|
53
|
+
operator?: Record<string, unknown>;
|
|
54
|
+
/** Instance-key signature algorithm (default `ES256`). */
|
|
55
|
+
algorithm?: string;
|
|
56
|
+
/** Refresh this long before the token expires (default 30s). */
|
|
57
|
+
expirySkewSeconds?: number;
|
|
58
|
+
/** Per-request HTTP timeout in milliseconds (default 20000). */
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
/**
|
|
61
|
+
* How to prove possession of the instance key at the token endpoint: the dedicated
|
|
62
|
+
* `OAuth-Client-Attestation-PoP` JWT (`"attestation_pop_jwt"`, the default), or a `DPoP` proof
|
|
63
|
+
* carrying the same challenge in `nonce` (`"dpop_combined"`).
|
|
64
|
+
*/
|
|
65
|
+
popMethod?: "attestation_pop_jwt" | "dpop_combined";
|
|
66
|
+
/**
|
|
67
|
+
* Whether the attester-issued challenge also rides in the token-endpoint proof (the PoP's
|
|
68
|
+
* `challenge` claim, or the DPoP `nonce`), on top of the mint-time instance proof it always
|
|
69
|
+
* protects. The default `true` binds token issuance to the same challenge as the mint, so a captured
|
|
70
|
+
* attestation cannot be redeemed for a token without it - but it requires an attester whose
|
|
71
|
+
* challenge store tolerates being checked at both points. Some attesters treat a challenge as
|
|
72
|
+
* consumed the moment the mint's instance proof uses it, and then reject a token request that
|
|
73
|
+
* presents the same value as "unknown or expired" - set this `false` against one of those (the
|
|
74
|
+
* challenge still protects the mint; only the token-endpoint copy is dropped).
|
|
75
|
+
*/
|
|
76
|
+
challengeAtToken?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* When the attester advertises a `challenge_endpoint`, fail the flow if the challenge cannot be
|
|
79
|
+
* fetched (default `true`). Set `false` to proceed without a challenge instead.
|
|
80
|
+
*/
|
|
81
|
+
requireChallenge?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Allow plain `http` for the attester URL and the endpoints the attester advertises (default
|
|
84
|
+
* `false`). Loopback hosts (`localhost`, `127.0.0.1`, `::1`) are always allowed.
|
|
85
|
+
*/
|
|
86
|
+
allowInsecure?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Where to send log events (see docs/logging.md). Any object with `debug`, `info`, `warn` and
|
|
89
|
+
* `error(msg, attrs)` works, `console` included. Unset, nothing is logged. Tokens, proofs,
|
|
90
|
+
* attestations, SVIDs and key material are never logged.
|
|
91
|
+
*/
|
|
92
|
+
logger?: Logger;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Gets and caches an access token using client attestation.
|
|
96
|
+
*
|
|
97
|
+
* `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
|
|
98
|
+
* discovery) or a custom `opts.profile`.
|
|
99
|
+
*
|
|
100
|
+
* @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
|
|
101
|
+
* `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
|
|
102
|
+
* is given.
|
|
103
|
+
*/
|
|
104
|
+
export declare class ClientAttestation {
|
|
105
|
+
#private;
|
|
106
|
+
/** The normalised attester URL, or `""` when the client was built from static metadata alone. */
|
|
107
|
+
readonly baseUrl: string;
|
|
108
|
+
constructor(attesterUrl?: string | ClientAttestationOptions, opts?: ClientAttestationOptions);
|
|
109
|
+
/** Return a valid access token, minting a new one only when the cached one is near expiry. */
|
|
110
|
+
token(opts?: {
|
|
111
|
+
forceRefresh?: boolean;
|
|
112
|
+
}): Promise<string>;
|
|
113
|
+
/**
|
|
114
|
+
* `{ Authorization: "<scheme> <token>" }`. For a Bearer token that is all a resource needs. For a
|
|
115
|
+
* DPoP-bound token (`token_type` DPoP) the scheme is `DPoP` and every request ALSO needs a fresh
|
|
116
|
+
* DPoP proof — use {@link requestHeaders} (or {@link fetch}) instead, which adds it.
|
|
117
|
+
*/
|
|
118
|
+
authorizationHeader(opts?: {
|
|
119
|
+
forceRefresh?: boolean;
|
|
120
|
+
}): Promise<Record<string, string>>;
|
|
121
|
+
/**
|
|
122
|
+
* The headers to send to a resource for one request, obtaining or refreshing the token as needed.
|
|
123
|
+
*
|
|
124
|
+
* - Bearer token: `{ Authorization: "Bearer <t>" }`
|
|
125
|
+
* - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
|
|
126
|
+
* token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
|
|
127
|
+
* url's origin returned, if any.
|
|
128
|
+
*/
|
|
129
|
+
requestHeaders(method: string, url: string, opts?: {
|
|
130
|
+
forceRefresh?: boolean;
|
|
131
|
+
}): Promise<Record<string, string>>;
|
|
132
|
+
/**
|
|
133
|
+
* A `fetch` wrapper that attaches the token (refreshing it as needed) to the request. Same signature
|
|
134
|
+
* as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
|
|
135
|
+
* a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
|
|
136
|
+
* unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
|
|
137
|
+
*/
|
|
138
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
139
|
+
/**
|
|
140
|
+
* The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
|
|
141
|
+
* then cached. With static `metadata`, the fields that were given, in the document's snake_case.
|
|
142
|
+
*/
|
|
143
|
+
discovery(): Promise<AttesterDiscovery>;
|
|
144
|
+
/** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
|
|
145
|
+
attesterMetadata(): Promise<AttesterMetadata>;
|
|
146
|
+
}
|