@idpartners/client-attestation-sdk 0.0.0-stage → 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/dist/http.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The one internal HTTP transport every outbound call in the SDK goes through. Not exported from the
3
+ * package index.
4
+ *
5
+ * Uniform behaviour, because these requests carry credentials (Authorization, DPoP, client attestation
6
+ * headers, X-Vault-Token, SVIDs and proofs in bodies):
7
+ *
8
+ * - every call has a timeout, covering the body read as well as the headers;
9
+ * - redirects are never followed — a 3xx is a {@link TransportError}, and the target is never contacted;
10
+ * - the response body is capped (default 1 MiB), by `content-length` and again while streaming;
11
+ * - {@link request} returns non-2xx responses as data; {@link jsonOk} turns them, and malformed JSON,
12
+ * into a {@link TransportError}.
13
+ */
14
+ import { TransportError } from "./errors.js";
15
+ export const DEFAULT_MAX_BYTES = 1024 * 1024;
16
+ export const DEFAULT_TIMEOUT_MS = 20_000;
17
+ /** At most this many characters of a response body are carried in an error. */
18
+ export const ERROR_BODY_CHARS = 512;
19
+ /** The first {@link ERROR_BODY_CHARS} characters of a body, for an error message. */
20
+ export function snippet(body) {
21
+ return body.length > ERROR_BODY_CHARS ? body.slice(0, ERROR_BODY_CHARS) : body;
22
+ }
23
+ function describe(err) {
24
+ return err instanceof Error ? err.message : String(err);
25
+ }
26
+ function isAbort(err) {
27
+ const name = err?.name;
28
+ return name === "TimeoutError" || name === "AbortError";
29
+ }
30
+ /**
31
+ * Make one HTTP request. Resolves with the status, headers and body text for any non-3xx response
32
+ * (non-2xx included); rejects with a {@link TransportError} for a network failure, timeout, redirect or
33
+ * oversized body.
34
+ */
35
+ export async function request(url, opts = {}) {
36
+ const method = opts.method ?? "GET";
37
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
38
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
39
+ const where = `${method} ${url}`;
40
+ const timedOut = (cause) => new TransportError(`${where} timed out after ${timeoutMs} ms`, "timeout", { cause });
41
+ let res;
42
+ try {
43
+ res = await fetch(url, {
44
+ method,
45
+ headers: opts.headers,
46
+ body: opts.body,
47
+ redirect: "manual",
48
+ signal: AbortSignal.timeout(timeoutMs),
49
+ });
50
+ }
51
+ catch (err) {
52
+ if (isAbort(err))
53
+ throw timedOut(err);
54
+ throw new TransportError(`${where} failed: ${describe(err)}`, "network", { cause: err });
55
+ }
56
+ const status = res.status;
57
+ if (res.type === "opaqueredirect" || (status >= 300 && status < 400)) {
58
+ await discard(res);
59
+ throw new TransportError(`${where} answered with a redirect (HTTP ${status}); redirects are not followed`, "redirect", {
60
+ status,
61
+ });
62
+ }
63
+ const tooLarge = () => new TransportError(`${where} response is larger than ${maxBytes} bytes`, "too_large", { status });
64
+ const declared = Number(res.headers?.get("content-length") ?? NaN);
65
+ if (declared > maxBytes) {
66
+ await discard(res);
67
+ throw tooLarge();
68
+ }
69
+ let body;
70
+ try {
71
+ body = await readCapped(res, maxBytes, tooLarge);
72
+ }
73
+ catch (err) {
74
+ if (err instanceof TransportError)
75
+ throw err;
76
+ if (isAbort(err))
77
+ throw timedOut(err);
78
+ throw new TransportError(`${where} failed reading the response: ${describe(err)}`, "network", { cause: err });
79
+ }
80
+ return {
81
+ method,
82
+ url,
83
+ status,
84
+ ok: status >= 200 && status < 300,
85
+ headers: res.headers ?? new Headers(),
86
+ body,
87
+ };
88
+ }
89
+ async function discard(res) {
90
+ try {
91
+ await res.body?.cancel();
92
+ }
93
+ catch {
94
+ // nothing to do: the response is being refused anyway
95
+ }
96
+ }
97
+ async function readCapped(res, maxBytes, tooLarge) {
98
+ if (!res.body) {
99
+ // only reached by stand-in Response objects (tests); a real fetch always has a stream
100
+ const text = await res.text();
101
+ if (Buffer.byteLength(text, "utf8") > maxBytes)
102
+ throw tooLarge();
103
+ return text;
104
+ }
105
+ const reader = res.body.getReader();
106
+ const chunks = [];
107
+ let total = 0;
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done)
111
+ break;
112
+ total += value.byteLength;
113
+ if (total > maxBytes) {
114
+ await reader.cancel().catch(() => undefined);
115
+ throw tooLarge();
116
+ }
117
+ chunks.push(value);
118
+ }
119
+ return Buffer.concat(chunks).toString("utf8");
120
+ }
121
+ /**
122
+ * Parse `res.body` as a JSON object. Throws a {@link TransportError} for a non-2xx status (carrying the
123
+ * status and at most 512 characters of the body) and for a body that is not a JSON object. An empty body
124
+ * reads as `{}` when `allowEmpty` is set.
125
+ */
126
+ export function jsonOk(res, opts = {}) {
127
+ if (!res.ok) {
128
+ const body = snippet(res.body);
129
+ throw new TransportError(`${res.method} ${res.url} returned HTTP ${res.status}: ${body}`, "status", {
130
+ status: res.status,
131
+ body,
132
+ });
133
+ }
134
+ return parseJsonObject(res, opts);
135
+ }
136
+ /** Parse `res.body` as a JSON object regardless of status; a {@link TransportError} if it is not one. */
137
+ export function parseJsonObject(res, opts = {}) {
138
+ if (opts.allowEmpty && res.body === "")
139
+ return {};
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(res.body);
143
+ }
144
+ catch (err) {
145
+ throw new TransportError(`${res.method} ${res.url} returned malformed JSON: ${describe(err)}`, "json", {
146
+ status: res.status,
147
+ body: snippet(res.body),
148
+ cause: err,
149
+ });
150
+ }
151
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
152
+ throw new TransportError(`${res.method} ${res.url} did not return a JSON object`, "json", {
153
+ status: res.status,
154
+ body: snippet(res.body),
155
+ });
156
+ }
157
+ return parsed;
158
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Client-side builder SDK for OAuth Attestation-Based Client Authentication.
3
+ */
4
+ export { SigningKeyPair } from "./signing-key-pair.js";
5
+ export { ClientAttestationBuilder, DpopProofBuilder, PopBuilder, ATTESTATION_TYP, POP_TYP, DPOP_TYP, } from "./builders.js";
6
+ export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, } from "./credential.js";
7
+ export { OpenBaoTransitSigner } from "./signer.js";
8
+ export type { JwsSigner } from "./signer.js";
9
+ export { AttestationIssuanceClient, IssuedAttestation, IssuanceError, INSTANCE_PROOF_TYP, } from "./issuance.js";
10
+ export { StaticSvidSource, HttpSvidSource, SpireWorkloadApiSvidSource, } from "./svid-source.js";
11
+ export type { SvidSource } from "./svid-source.js";
12
+ export { EvidenceNotFound, FileTokenSvidSource, GoogleMetadataSvidSource, autoDetectEvidence, ENV_SVID, ENV_TOKEN_FILE, ENV_SPIFFE_SOCKET, DEFAULT_TOKEN_FILE, DEFAULT_SPIFFE_SOCKET, } from "./evidence.js";
13
+ export { ClientAttestation, ClientAttestationError, TransportError, normalizeBaseUrl, jwtSub, DEFAULT_EXPIRY_SKEW_SECONDS, WELL_KNOWN_PATH, } from "./token-source.js";
14
+ export type { ClientAttestationOptions } from "./token-source.js";
15
+ export type { TransportFailure } from "./errors.js";
16
+ export declare const VERSION = "0.2.0";
17
+ /** draft-ietf-oauth-attestation-based-client-auth revision this SDK implements. */
18
+ export declare const DRAFT_REVISION = "10";
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Client-side builder SDK for OAuth Attestation-Based Client Authentication.
3
+ */
4
+ export { SigningKeyPair } from "./signing-key-pair.js";
5
+ export { ClientAttestationBuilder, DpopProofBuilder, PopBuilder, ATTESTATION_TYP, POP_TYP, DPOP_TYP, } from "./builders.js";
6
+ export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, } from "./credential.js";
7
+ export { OpenBaoTransitSigner } from "./signer.js";
8
+ export { AttestationIssuanceClient, IssuedAttestation, IssuanceError, INSTANCE_PROOF_TYP, } from "./issuance.js";
9
+ export { StaticSvidSource, HttpSvidSource, SpireWorkloadApiSvidSource, } from "./svid-source.js";
10
+ export { EvidenceNotFound, FileTokenSvidSource, GoogleMetadataSvidSource, autoDetectEvidence, ENV_SVID, ENV_TOKEN_FILE, ENV_SPIFFE_SOCKET, DEFAULT_TOKEN_FILE, DEFAULT_SPIFFE_SOCKET, } from "./evidence.js";
11
+ export { ClientAttestation, ClientAttestationError, TransportError, normalizeBaseUrl, jwtSub, DEFAULT_EXPIRY_SKEW_SECONDS, WELL_KNOWN_PATH, } from "./token-source.js";
12
+ export const VERSION = "0.2.0";
13
+ /** draft-ietf-oauth-attestation-based-client-auth revision this SDK implements. */
14
+ export const DRAFT_REVISION = "10";
@@ -0,0 +1,42 @@
1
+ import { SigningKeyPair } from "./signing-key-pair.js";
2
+ import type { SvidSource } from "./svid-source.js";
3
+ /** The instance-key proof typ presented at the issuance endpoint (server InstanceKeyProofValidator.TYP). */
4
+ export declare const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt";
5
+ /**
6
+ * Thrown when the issuance endpoint rejects the request (non-2xx). Transport failures — unreachable,
7
+ * timeout, redirect, oversized or malformed response — are a {@link TransportError} instead.
8
+ */
9
+ export declare class IssuanceError extends Error {
10
+ readonly status: number;
11
+ readonly body: string;
12
+ constructor(status: number, body: string);
13
+ }
14
+ /** A minted Client Attestation plus the instance key it is bound to. */
15
+ export declare class IssuedAttestation {
16
+ readonly attestation: string;
17
+ readonly expiresIn: number | undefined;
18
+ readonly instanceKey: SigningKeyPair;
19
+ constructor(attestation: string, expiresIn: number | undefined, instanceKey: SigningKeyPair);
20
+ /** A fresh Client Attestation PoP for the token endpoint, signed by the bound instance key. */
21
+ pop(audience: string, clientId?: string, challenge?: string): Promise<string>;
22
+ /**
23
+ * A fresh DPoP proof (RFC 9449) for the token endpoint (attestation combined mode, PoP method
24
+ * `dpop_combined`), signed by the same bound instance key.
25
+ */
26
+ dpop(method: string, uri: string, challenge?: string): Promise<string>;
27
+ }
28
+ /** Bootstraps a Client Attestation for a workload from its SPIFFE JWT-SVID. */
29
+ export declare class AttestationIssuanceClient {
30
+ readonly endpoint: string;
31
+ readonly clientId: string;
32
+ readonly attesterIssuer: string;
33
+ private readonly source;
34
+ readonly instanceKey: SigningKeyPair;
35
+ private constructor();
36
+ /** Generate a fresh ES256 instance key and return a ready client. */
37
+ static create(endpoint: string, clientId: string, attesterIssuer: string, source: SvidSource, opts?: {
38
+ instanceKey?: SigningKeyPair;
39
+ }): Promise<AttestationIssuanceClient>;
40
+ /** Fetch the SVID, prove possession of the instance key, and exchange them for a minted attestation. */
41
+ bootstrap(authorizationDetails?: Array<Record<string, unknown>>, challenge?: string): Promise<IssuedAttestation>;
42
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Client for the Client Attestation **issuance** endpoint (`POST /federation/attestation`).
3
+ *
4
+ * The inverse of {@link ClientAttestationBuilder}: instead of building an attestation locally with an
5
+ * attester key, a workload proves its SPIFFE identity (a JWT-SVID) and the Authorization Server
6
+ * (PingFederate) validates it and **mints** a Client Attestation bound to the workload's own instance
7
+ * key — bootstrapping a client credential from infra identity, no pre-shared secret.
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { DpopProofBuilder, PopBuilder } from "./builders.js";
11
+ import { jsonOk, request } from "./http.js";
12
+ import { SigningKeyPair, signCompact } from "./signing-key-pair.js";
13
+ /** The instance-key proof typ presented at the issuance endpoint (server InstanceKeyProofValidator.TYP). */
14
+ export const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt";
15
+ /** Per-call timeout for the issuance endpoint. */
16
+ const ISSUANCE_TIMEOUT_MS = 20_000;
17
+ /**
18
+ * Thrown when the issuance endpoint rejects the request (non-2xx). Transport failures — unreachable,
19
+ * timeout, redirect, oversized or malformed response — are a {@link TransportError} instead.
20
+ */
21
+ export class IssuanceError extends Error {
22
+ status;
23
+ body;
24
+ constructor(status, body) {
25
+ super(`attestation issuance failed: HTTP ${status}: ${body}`);
26
+ this.status = status;
27
+ this.body = body;
28
+ this.name = "IssuanceError";
29
+ }
30
+ }
31
+ /** A minted Client Attestation plus the instance key it is bound to. */
32
+ export class IssuedAttestation {
33
+ attestation;
34
+ expiresIn;
35
+ instanceKey;
36
+ constructor(attestation, expiresIn, instanceKey) {
37
+ this.attestation = attestation;
38
+ this.expiresIn = expiresIn;
39
+ this.instanceKey = instanceKey;
40
+ }
41
+ /** A fresh Client Attestation PoP for the token endpoint, signed by the bound instance key. */
42
+ async pop(audience, clientId, challenge) {
43
+ const builder = new PopBuilder(this.instanceKey).audience(audience);
44
+ if (clientId)
45
+ builder.clientId(clientId);
46
+ if (challenge)
47
+ builder.challenge(challenge);
48
+ return builder.build();
49
+ }
50
+ /**
51
+ * A fresh DPoP proof (RFC 9449) for the token endpoint (attestation combined mode, PoP method
52
+ * `dpop_combined`), signed by the same bound instance key.
53
+ */
54
+ async dpop(method, uri, challenge) {
55
+ const builder = new DpopProofBuilder(this.instanceKey).method(method).uri(uri);
56
+ if (challenge)
57
+ builder.nonce(challenge);
58
+ return builder.build();
59
+ }
60
+ }
61
+ /** Bootstraps a Client Attestation for a workload from its SPIFFE JWT-SVID. */
62
+ export class AttestationIssuanceClient {
63
+ endpoint;
64
+ clientId;
65
+ attesterIssuer;
66
+ source;
67
+ instanceKey;
68
+ constructor(endpoint, clientId, attesterIssuer, source, instanceKey) {
69
+ this.endpoint = endpoint;
70
+ this.clientId = clientId;
71
+ this.attesterIssuer = attesterIssuer;
72
+ this.source = source;
73
+ this.instanceKey = instanceKey;
74
+ }
75
+ /** Generate a fresh ES256 instance key and return a ready client. */
76
+ static async create(endpoint, clientId, attesterIssuer, source, opts) {
77
+ const key = opts?.instanceKey ?? (await SigningKeyPair.generate("ES256"));
78
+ return new AttestationIssuanceClient(endpoint.replace(/\/$/, ""), clientId, attesterIssuer, source, key);
79
+ }
80
+ /** Fetch the SVID, prove possession of the instance key, and exchange them for a minted attestation. */
81
+ async bootstrap(authorizationDetails, challenge) {
82
+ const svid = await this.source.fetch(this.attesterIssuer);
83
+ const proofClaims = {
84
+ aud: this.attesterIssuer,
85
+ jti: randomUUID(),
86
+ iat: Math.floor(Date.now() / 1000),
87
+ };
88
+ if (challenge)
89
+ proofClaims.challenge = challenge;
90
+ const proof = await signCompact(proofClaims, this.instanceKey, INSTANCE_PROOF_TYP, false);
91
+ const body = {
92
+ client_id: this.clientId,
93
+ instance_key: this.instanceKey.publicJwk(),
94
+ svid,
95
+ proof,
96
+ };
97
+ if (authorizationDetails)
98
+ body.authorization_details = authorizationDetails;
99
+ const res = await request(this.endpoint, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
102
+ body: JSON.stringify(body),
103
+ timeoutMs: ISSUANCE_TIMEOUT_MS,
104
+ });
105
+ if (!res.ok)
106
+ throw new IssuanceError(res.status, res.body);
107
+ const json = jsonOk(res);
108
+ return new IssuedAttestation(json.attestation, json.expires_in, this.instanceKey);
109
+ }
110
+ }
@@ -0,0 +1,33 @@
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
+ /** Assemble a compact JWS whose signature is produced by an external signer (header carries `kid`; external
17
+ * signers hold issuing keys, referenced by id, never an embedded jwk). */
18
+ export declare function signExternal(claims: Record<string, unknown>, signer: JwsSigner, typ: string): Promise<string>;
19
+ /**
20
+ * A {@link JwsSigner} backed by an OpenBao / HashiCorp Vault transit engine: the attestation is signed
21
+ * inside the vault (`POST /v1/transit/sign/<key>` with `marshaling_algorithm=jws`, which returns the
22
+ * fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
23
+ * latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
24
+ */
25
+ export declare class OpenBaoTransitSigner implements JwsSigner {
26
+ #private;
27
+ readonly algorithm: string;
28
+ readonly keyId: string;
29
+ private constructor();
30
+ static create(baoAddr: string, token: string, keyName: string): Promise<OpenBaoTransitSigner>;
31
+ publicJwk(): JWK;
32
+ sign(signingInput: Uint8Array): Promise<Uint8Array>;
33
+ }
package/dist/signer.js ADDED
@@ -0,0 +1,116 @@
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 { calculateJwkThumbprint, exportJWK, importSPKI } from "jose";
8
+ import { TransportError } from "./errors.js";
9
+ import { parseJsonObject, request as httpRequest, snippet } from "./http.js";
10
+ /** Per-call timeout for the vault. */
11
+ const OPENBAO_TIMEOUT_MS = 5_000;
12
+ const b64url = (bytes) => Buffer.from(bytes).toString("base64url");
13
+ /** Assemble a compact JWS whose signature is produced by an external signer (header carries `kid`; external
14
+ * signers hold issuing keys, referenced by id, never an embedded jwk). */
15
+ export async function signExternal(claims, signer, typ) {
16
+ const enc = new TextEncoder();
17
+ const header = { alg: signer.algorithm, typ, kid: signer.keyId };
18
+ const signingInput = `${b64url(enc.encode(JSON.stringify(header)))}.${b64url(enc.encode(JSON.stringify(claims)))}`;
19
+ const signature = await signer.sign(enc.encode(signingInput));
20
+ return `${signingInput}.${b64url(signature)}`;
21
+ }
22
+ const KEY_TYPE_ALG = { "ecdsa-p256": "ES256", "ecdsa-p384": "ES384", "ecdsa-p521": "ES512" };
23
+ const ALG_HASH = { ES256: "sha2-256", ES384: "sha2-384", ES512: "sha2-512" };
24
+ /**
25
+ * A {@link JwsSigner} backed by an OpenBao / HashiCorp Vault transit engine: the attestation is signed
26
+ * inside the vault (`POST /v1/transit/sign/<key>` with `marshaling_algorithm=jws`, which returns the
27
+ * fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
28
+ * latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
29
+ */
30
+ export class OpenBaoTransitSigner {
31
+ algorithm;
32
+ keyId;
33
+ #base;
34
+ #token;
35
+ #keyName;
36
+ #hash;
37
+ #keyVersion;
38
+ #publicJwk;
39
+ constructor(base, token, keyName, algorithm, hash, keyVersion, publicJwk, keyId) {
40
+ this.#base = base;
41
+ this.#token = token;
42
+ this.#keyName = keyName;
43
+ this.algorithm = algorithm;
44
+ this.#hash = hash;
45
+ this.#keyVersion = keyVersion;
46
+ this.#publicJwk = publicJwk;
47
+ this.keyId = keyId;
48
+ }
49
+ static async create(baoAddr, token, keyName) {
50
+ const base = baoAddr.replace(/\/+$/, "");
51
+ const data = await request("GET", base, token, `/v1/transit/keys/${keyName}`);
52
+ const keyType = String(data.type);
53
+ const algorithm = KEY_TYPE_ALG[keyType];
54
+ if (!algorithm) {
55
+ throw new Error(`transit key '${keyName}' has unsupported type for JWS signing: ${keyType}`);
56
+ }
57
+ const keyVersion = Number(data.latest_version);
58
+ const keys = data.keys;
59
+ const pem = keys[String(keyVersion)].public_key;
60
+ const jwk = await exportJWK(await importSPKI(pem, algorithm, { extractable: true }));
61
+ const keyId = await calculateJwkThumbprint(jwk, "sha256");
62
+ const publicJwk = { ...jwk, kid: keyId, alg: algorithm };
63
+ return new OpenBaoTransitSigner(base, token, keyName, algorithm, ALG_HASH[algorithm], keyVersion, publicJwk, keyId);
64
+ }
65
+ publicJwk() {
66
+ return { ...this.#publicJwk };
67
+ }
68
+ async sign(signingInput) {
69
+ const body = JSON.stringify({
70
+ input: Buffer.from(signingInput).toString("base64"),
71
+ marshaling_algorithm: "jws",
72
+ hash_algorithm: this.#hash,
73
+ key_version: this.#keyVersion,
74
+ });
75
+ const data = await request("POST", this.#base, this.#token, `/v1/transit/sign/${this.#keyName}`, body);
76
+ const signature = data.signature;
77
+ if (!signature) {
78
+ throw new Error("transit sign response carried no signature");
79
+ }
80
+ const raw = signature.slice(signature.lastIndexOf(":") + 1); // envelope: vault:v<n>:<base64url(r||s)>
81
+ return new Uint8Array(Buffer.from(raw, "base64url"));
82
+ }
83
+ }
84
+ async function request(method, base, token, path, body) {
85
+ let response;
86
+ try {
87
+ response = await httpRequest(base + path, {
88
+ method,
89
+ headers: { "X-Vault-Token": token, ...(body ? { "Content-Type": "application/json" } : {}) },
90
+ body,
91
+ timeoutMs: OPENBAO_TIMEOUT_MS,
92
+ });
93
+ }
94
+ catch (exc) {
95
+ if (exc instanceof TransportError && (exc.kind === "network" || exc.kind === "timeout")) {
96
+ const why = exc.kind === "timeout"
97
+ ? `timed out after ${OPENBAO_TIMEOUT_MS} ms`
98
+ : exc.cause instanceof Error
99
+ ? exc.cause.message
100
+ : String(exc.cause);
101
+ throw new TransportError(`OpenBao unreachable at ${base}: ${why}`, exc.kind, { cause: exc });
102
+ }
103
+ throw exc;
104
+ }
105
+ if (response.status !== 200) {
106
+ throw new TransportError(`OpenBao returned HTTP ${response.status} for ${path}`, "status", {
107
+ status: response.status,
108
+ body: snippet(response.body),
109
+ });
110
+ }
111
+ const parsed = parseJsonObject(response);
112
+ if (!parsed.data || typeof parsed.data !== "object") {
113
+ throw new Error("OpenBao response carried no data");
114
+ }
115
+ return parsed.data;
116
+ }
@@ -0,0 +1,50 @@
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 extractable 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
+ static generate(algorithm: string): Promise<SigningKeyPair>;
27
+ /**
28
+ * Wrap an existing JWK (which must contain the private component `d`) for the given algorithm.
29
+ */
30
+ static fromJwk(jwk: JWK, algorithm: string): Promise<SigningKeyPair>;
31
+ /** The private CryptoKey — package-internal; used by the JWS signer. */
32
+ get privateKey(): CryptoKey;
33
+ /**
34
+ * The public-only JWK (including `kid`) as a fresh object — suitable as an attestation
35
+ * `cnf.jwk` or a DPoP header value (from which callers strip `kid`).
36
+ */
37
+ publicJwk(): JWK;
38
+ }
39
+ /** The JWS algorithms {@link SigningKeyPair} can generate and sign with. */
40
+ export declare const SUPPORTED_ALGORITHMS: readonly string[];
41
+ /** RFC 7638 JWK thumbprint (SHA-256, base64url) over the required members only. */
42
+ export declare function thumbprint(publicJwk: JWK): Promise<string>;
43
+ /**
44
+ * Sign claims into a compact JWS with an explicit `typ` header, keyed by `key`.
45
+ *
46
+ * When `embedJwk` is set the public key travels in a `jwk` header (as DPoP requires); otherwise
47
+ * the key's thumbprint `kid` is set.
48
+ */
49
+ export declare function signCompact(claims: Record<string, unknown>, key: SigningKeyPair, typ: string, embedJwk: boolean): Promise<string>;
50
+ export declare function requireText(value: string | null | undefined, field: string): string;
@@ -0,0 +1,115 @@
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 extractable 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
+ static async generate(algorithm) {
34
+ checkAlgorithm(algorithm);
35
+ const { privateKey, publicKey } = await generateKeyPair(algorithm, {
36
+ extractable: true,
37
+ ...(algorithm.startsWith("ES") ? {} : { modulusLength: RSA_MODULUS_BITS }),
38
+ });
39
+ const publicJwk = publicMembers(await exportJWK(publicKey));
40
+ const keyId = await calculateJwkThumbprint(publicJwk, "sha256");
41
+ return new SigningKeyPair(privateKey, algorithm, publicJwk, keyId);
42
+ }
43
+ /**
44
+ * Wrap an existing JWK (which must contain the private component `d`) for the given algorithm.
45
+ */
46
+ static async fromJwk(jwk, algorithm) {
47
+ if (!jwk.d) {
48
+ throw new Error("JWK does not contain a private key");
49
+ }
50
+ checkAlgorithm(algorithm);
51
+ const privateKey = (await importJWK(jwk, algorithm));
52
+ // Derive the public-only JWK from the supplied members (drop the private ones).
53
+ const publicJwk = publicMembers(jwk);
54
+ const keyId = await calculateJwkThumbprint(publicJwk, "sha256");
55
+ return new SigningKeyPair(privateKey, algorithm, publicJwk, keyId);
56
+ }
57
+ /** The private CryptoKey — package-internal; used by the JWS signer. */
58
+ get privateKey() {
59
+ return this.#privateKey;
60
+ }
61
+ /**
62
+ * The public-only JWK (including `kid`) as a fresh object — suitable as an attestation
63
+ * `cnf.jwk` or a DPoP header value (from which callers strip `kid`).
64
+ */
65
+ publicJwk() {
66
+ return { ...this.#publicJwk };
67
+ }
68
+ }
69
+ /** The JWS algorithms {@link SigningKeyPair} can generate and sign with. */
70
+ export const SUPPORTED_ALGORITHMS = [
71
+ "ES256", "ES384", "ES512",
72
+ "RS256", "RS384", "RS512",
73
+ "PS256", "PS384", "PS512",
74
+ ];
75
+ /** RSA modulus size for generated RS* / PS* keys. */
76
+ const RSA_MODULUS_BITS = 2048;
77
+ function checkAlgorithm(algorithm) {
78
+ if (!SUPPORTED_ALGORITHMS.includes(algorithm)) {
79
+ throw new Error(`unsupported signing algorithm: ${algorithm}`);
80
+ }
81
+ }
82
+ /** The public members of an EC or RSA JWK (no private parameters, no metadata). */
83
+ function publicMembers(jwk) {
84
+ return jwk.kty === "RSA"
85
+ ? { kty: jwk.kty, n: jwk.n, e: jwk.e }
86
+ : { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
87
+ }
88
+ /** RFC 7638 JWK thumbprint (SHA-256, base64url) over the required members only. */
89
+ export async function thumbprint(publicJwk) {
90
+ return calculateJwkThumbprint(publicJwk, "sha256");
91
+ }
92
+ /**
93
+ * Sign claims into a compact JWS with an explicit `typ` header, keyed by `key`.
94
+ *
95
+ * When `embedJwk` is set the public key travels in a `jwk` header (as DPoP requires); otherwise
96
+ * the key's thumbprint `kid` is set.
97
+ */
98
+ export async function signCompact(claims, key, typ, embedJwk) {
99
+ const header = { alg: key.algorithm, typ };
100
+ if (embedJwk) {
101
+ const publicJwk = key.publicJwk();
102
+ delete publicJwk.kid;
103
+ header.jwk = publicJwk;
104
+ }
105
+ else {
106
+ header.kid = key.keyId;
107
+ }
108
+ return new SignJWT(claims).setProtectedHeader(header).sign(key.privateKey);
109
+ }
110
+ export function requireText(value, field) {
111
+ if (value == null || String(value).trim() === "") {
112
+ throw new Error(`${field} is required`);
113
+ }
114
+ return value;
115
+ }