@idpartners/client-attestation-sdk 1.0.0 → 1.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/signer.js CHANGED
@@ -7,29 +7,52 @@
7
7
  import { base64url, calculateJwkThumbprint, exportJWK, importSPKI } from "jose";
8
8
  import { TransportError } from "./errors.js";
9
9
  import { base64Encode } from "./runtime.js";
10
- import { parseJsonObject, request as httpRequest, snippet } from "./http.js";
10
+ import { checkSecure, parseJsonObject, request as httpRequest, snippet } from "./http.js";
11
+ import { SUPPORTED_ALGORITHMS } from "./signing-key-pair.js";
11
12
  /** Per-call timeout for the vault. */
12
13
  const OPENBAO_TIMEOUT_MS = 5_000;
13
14
  const b64url = (bytes) => base64url.encode(bytes);
15
+ /** The length of a raw `r||s` JWS signature for each ECDSA algorithm. */
16
+ const EC_SIGNATURE_BYTES = { ES256: 64, ES384: 96, ES512: 132 };
14
17
  /** Assemble a compact JWS whose signature is produced by an external signer (header carries `kid`; external
18
+ * signers hold issuing keys, referenced by id, never an embedded jwk). The signer's algorithm must be one the
19
+ * SDK signs with (never `none`), and its signature must not be empty and, for ES*, must be the fixed-width
20
+ * `r||s` (not DER, not truncated).
15
21
  *
16
- * @internal
17
- * signers hold issuing keys, referenced by id, never an embedded jwk). */
22
+ * @internal */
18
23
  export async function signExternal(claims, signer, typ) {
24
+ const alg = signer.algorithm;
25
+ if (!SUPPORTED_ALGORITHMS.includes(alg)) {
26
+ throw new Error(`external signer algorithm ${JSON.stringify(alg)} is not allowed: use ES256/384/512, RS256/384/512 or PS256/384/512`);
27
+ }
19
28
  const enc = new TextEncoder();
20
- const header = { alg: signer.algorithm, typ, kid: signer.keyId };
29
+ const header = { alg, typ, kid: signer.keyId };
21
30
  const signingInput = `${b64url(enc.encode(JSON.stringify(header)))}.${b64url(enc.encode(JSON.stringify(claims)))}`;
22
31
  const signature = await signer.sign(enc.encode(signingInput));
32
+ if (!signature || signature.length === 0) {
33
+ throw new Error("external signer returned an empty signature");
34
+ }
35
+ const want = EC_SIGNATURE_BYTES[alg];
36
+ if (want !== undefined && signature.length !== want) {
37
+ throw new Error(`external signer returned a ${signature.length}-byte ${alg} signature; want the ${want}-byte raw r||s`);
38
+ }
23
39
  return `${signingInput}.${b64url(signature)}`;
24
40
  }
25
41
  const KEY_TYPE_ALG = { "ecdsa-p256": "ES256", "ecdsa-p384": "ES384", "ecdsa-p521": "ES512" };
26
42
  const ALG_HASH = { ES256: "sha2-256", ES384: "sha2-384", ES512: "sha2-512" };
43
+ /** The key name goes into the request path, so "/", "?", "#", "%" and dot segments are refused. */
44
+ const KEY_NAME = /^[A-Za-z0-9_.-]+$/;
27
45
  /**
28
46
  * A {@link JwsSigner} backed by an OpenBao / HashiCorp Vault transit engine: the attestation is signed
29
47
  * inside the vault (`POST /v1/transit/sign/<key>` with `marshaling_algorithm=jws`, which returns the
30
48
  * fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
31
49
  * latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
32
50
  *
51
+ * `baoAddr` must be https unless its host is loopback (`localhost`, `127.0.0.1`, `::1`) or
52
+ * `allowInsecure: true` is passed: the vault token rides on every call, and the public key read from the
53
+ * vault becomes the attester's published JWK. `keyName` may contain only letters, digits, `_`, `.` and `-`
54
+ * (and is not `.` or `..`).
55
+ *
33
56
  * @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
57
  */
35
58
  export class OpenBaoTransitSigner {
@@ -51,8 +74,12 @@ export class OpenBaoTransitSigner {
51
74
  this.#publicJwk = publicJwk;
52
75
  this.keyId = keyId;
53
76
  }
54
- static async create(baoAddr, token, keyName) {
77
+ static async create(baoAddr, token, keyName, opts) {
55
78
  const base = baoAddr.replace(/\/+$/, "");
79
+ checkSecure(base, "baoAddr", opts?.allowInsecure ?? false);
80
+ if (!KEY_NAME.test(keyName) || keyName === "." || keyName === "..") {
81
+ throw new Error(`invalid transit key name '${keyName}': use only letters, digits, '_', '.' and '-'`);
82
+ }
56
83
  const data = await request("GET", base, token, `/v1/transit/keys/${keyName}`);
57
84
  const keyType = String(data.type);
58
85
  const algorithm = KEY_TYPE_ALG[keyType];
@@ -32,6 +32,12 @@ export declare class SigningKeyPair {
32
32
  }): Promise<SigningKeyPair>;
33
33
  /**
34
34
  * Wrap an existing JWK (which must contain the private component `d`) for the given algorithm.
35
+ *
36
+ * The key must suit the algorithm: an EC key on its curve for `ES256` / `ES384` / `ES512`
37
+ * (P-256 / P-384 / P-521), an RSA key of at least 2048 bits for `RS*` / `PS*`, and its public
38
+ * members must belong to its private key. Only the key material is kept: the published JWK is
39
+ * re-encoded canonically (full-width EC coordinates, minimal RSA integers) and its `kid` is the
40
+ * RFC 7638 thumbprint, whatever `kid` or other members the input had.
35
41
  */
36
42
  static fromJwk(jwk: JWK, algorithm: string): Promise<SigningKeyPair>;
37
43
  /** The private CryptoKey — package-internal; used by the JWS signer. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Signing keys for attestation-based client authentication.
3
3
  */
4
- import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, } from "jose";
4
+ import { CompactSign, SignJWT, base64url, calculateJwkThumbprint, compactVerify, exportJWK, generateKeyPair, importJWK, } from "jose";
5
5
  /**
6
6
  * A signing key pair (public + private) with its JWS `alg` and RFC 7638 thumbprint `kid`.
7
7
  *
@@ -46,15 +46,22 @@ export class SigningKeyPair {
46
46
  }
47
47
  /**
48
48
  * Wrap an existing JWK (which must contain the private component `d`) for the given algorithm.
49
+ *
50
+ * The key must suit the algorithm: an EC key on its curve for `ES256` / `ES384` / `ES512`
51
+ * (P-256 / P-384 / P-521), an RSA key of at least 2048 bits for `RS*` / `PS*`, and its public
52
+ * members must belong to its private key. Only the key material is kept: the published JWK is
53
+ * re-encoded canonically (full-width EC coordinates, minimal RSA integers) and its `kid` is the
54
+ * RFC 7638 thumbprint, whatever `kid` or other members the input had.
49
55
  */
50
56
  static async fromJwk(jwk, algorithm) {
51
57
  if (!jwk.d) {
52
58
  throw new Error("JWK does not contain a private key");
53
59
  }
54
60
  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);
61
+ const canonical = canonicalPrivateJwk(jwk, algorithm);
62
+ const privateKey = (await importJWK(canonical, algorithm));
63
+ const publicJwk = publicMembers(canonical);
64
+ await checkKeyPair(privateKey, publicJwk, algorithm);
58
65
  const keyId = await calculateJwkThumbprint(publicJwk, "sha256");
59
66
  return new SigningKeyPair(privateKey, algorithm, publicJwk, keyId);
60
67
  }
@@ -80,8 +87,87 @@ export const SUPPORTED_ALGORITHMS = [
80
87
  "RS256", "RS384", "RS512",
81
88
  "PS256", "PS384", "PS512",
82
89
  ];
83
- /** RSA modulus size for generated RS* / PS* keys. */
90
+ /** RSA modulus size for generated RS* / PS* keys, and the smallest one the SDK signs with. */
84
91
  const RSA_MODULUS_BITS = 2048;
92
+ /** The curve, and its coordinate width in bytes, each ECDSA algorithm signs on. */
93
+ const EC_CURVES = {
94
+ ES256: ["P-256", 32],
95
+ ES384: ["P-384", 48],
96
+ ES512: ["P-521", 66],
97
+ };
98
+ /** Strict base64url decoding; the error names the member, never its value. */
99
+ function decodeMember(jwk, member) {
100
+ const value = jwk[member];
101
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value)) {
102
+ throw new Error(`JWK member '${member}' is missing or not base64url`);
103
+ }
104
+ return base64url.decode(value);
105
+ }
106
+ function stripLeadingZeros(bytes) {
107
+ let i = 0;
108
+ while (i < bytes.length - 1 && bytes[i] === 0)
109
+ i++;
110
+ return bytes.subarray(i);
111
+ }
112
+ /**
113
+ * The key members of a private JWK, checked against `algorithm` and re-encoded canonically: EC
114
+ * coordinates and `d` at the curve's full width, RSA `n` and `e` in their minimal octets (RFC 7518
115
+ * section 6). Every other member (`kid`, `alg`, `use`, ...) is dropped.
116
+ */
117
+ function canonicalPrivateJwk(jwk, algorithm) {
118
+ const ec = EC_CURVES[algorithm];
119
+ if (ec) {
120
+ const [crv, size] = ec;
121
+ if (jwk.kty !== "EC") {
122
+ throw new Error(`${algorithm} needs an EC key, not kty ${JSON.stringify(jwk.kty)}`);
123
+ }
124
+ if (jwk.crv !== crv) {
125
+ throw new Error(`${algorithm} needs an EC key on ${crv}`);
126
+ }
127
+ const out = { kty: "EC", crv };
128
+ for (const member of ["x", "y", "d"]) {
129
+ const value = stripLeadingZeros(decodeMember(jwk, member));
130
+ if (value.length > size) {
131
+ throw new Error(`JWK member '${member}' is too long for ${crv}`);
132
+ }
133
+ const fixed = new Uint8Array(size);
134
+ fixed.set(value, size - value.length);
135
+ out[member] = base64url.encode(fixed);
136
+ }
137
+ return out;
138
+ }
139
+ if (jwk.kty !== "RSA") {
140
+ throw new Error(`${algorithm} needs an RSA key, not kty ${JSON.stringify(jwk.kty)}`);
141
+ }
142
+ const out = {};
143
+ for (const member of ["kty", "n", "e", "d", "p", "q", "dp", "dq", "qi", "oth"]) {
144
+ if (jwk[member] !== undefined) {
145
+ out[member] = jwk[member];
146
+ }
147
+ }
148
+ const n = stripLeadingZeros(decodeMember(jwk, "n"));
149
+ out.n = base64url.encode(n);
150
+ out.e = base64url.encode(stripLeadingZeros(decodeMember(jwk, "e")));
151
+ const bits = n[0] === 0 ? 0 : (n.length - 1) * 8 + (32 - Math.clz32(n[0]));
152
+ if (bits < RSA_MODULUS_BITS) {
153
+ throw new Error(`RSA keys must be at least ${RSA_MODULUS_BITS} bits`);
154
+ }
155
+ return out;
156
+ }
157
+ /**
158
+ * Refuse a private key whose published public half is another key: signing with one key while
159
+ * advertising another (as `kid`, `cnf.jwk` or the DPoP `jwk`) gives credentials no verifier accepts.
160
+ */
161
+ async function checkKeyPair(privateKey, publicJwk, algorithm) {
162
+ try {
163
+ const probe = new TextEncoder().encode("client-attestation-sdk key pair check");
164
+ const jws = await new CompactSign(probe).setProtectedHeader({ alg: algorithm }).sign(privateKey);
165
+ await compactVerify(jws, await importJWK(publicJwk, algorithm));
166
+ }
167
+ catch {
168
+ throw new Error("the JWK's public key does not match its private key");
169
+ }
170
+ }
85
171
  function checkAlgorithm(algorithm) {
86
172
  if (!SUPPORTED_ALGORITHMS.includes(algorithm)) {
87
173
  throw new Error(`unsupported signing algorithm: ${algorithm}`);
@@ -4,7 +4,7 @@ export interface SvidSource {
4
4
  }
5
5
  /** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
6
6
  export declare class StaticSvidSource implements SvidSource {
7
- private readonly svid;
7
+ #private;
8
8
  constructor(svid: string);
9
9
  fetch(_audience: string): Promise<string>;
10
10
  }
@@ -25,7 +25,18 @@ export declare class HttpSvidSource implements SvidSource {
25
25
  /**
26
26
  * The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
27
27
  * CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
28
- * subprocess.
28
+ * subprocess. An agent that has not exited within 30 s is killed and `fetch` rejects. `fetch` returns the
29
+ * first JWT-SVID the agent prints on stdout and throws {@link EvidenceNotFound} if there is none. The
30
+ * agent's stderr is discarded, never read as output or held in memory.
31
+ *
32
+ * The agent's exit decides the outcome, as in the other ports. While the agent is running, more than
33
+ * 1 MiB of stdout kills it and `fetch` rejects. Once it has exited, stdout is read for at most 250 ms more
34
+ * and at most 1 MiB in all (a helper the agent started can inherit the pipe and keep writing to it or hold
35
+ * it open), then the pipe is closed. On exit status 0 what was read is parsed; on any other status, or if
36
+ * the agent was killed, `fetch` rejects. So output a helper writes after the agent has exited never turns a
37
+ * success into a failure, and never keeps `fetch` waiting longer than that. If reading stopped at the
38
+ * 1 MiB or at the end of the 250 ms, the last line may be cut off, so a token is only taken from a line
39
+ * that ends in a newline.
29
40
  *
30
41
  * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
31
42
  * a `ClientAttestationError` on a runtime without it.
@@ -3,18 +3,32 @@
3
3
  * bootstrapping ({@link AttestationIssuanceClient}). The default {@link SpireWorkloadApiSvidSource}
4
4
  * fetches it from a real SPIRE agent; the others cover dev and tests.
5
5
  */
6
+ import { EvidenceNotFound } from "./evidence.js";
6
7
  import { jsonOk, request } from "./http.js";
7
- import { importNodeBuiltin } from "./runtime.js";
8
+ import { importNodeBuiltin, utf8Encode } from "./runtime.js";
9
+ import { decodeSegment, Malformed, parseObject } from "./validator/strict.js";
8
10
  /** Per-call timeout for {@link HttpSvidSource}. */
9
11
  const HTTP_SVID_TIMEOUT_MS = 20_000;
12
+ /** How long `spire-agent` may run before it is killed (as in the other ports). */
13
+ const SPIRE_AGENT_TIMEOUT_MS = 30_000;
14
+ /**
15
+ * The most stdout read from `spire-agent` (as in the other ports). More while the agent is running fails the
16
+ * fetch; once it has exited, reading stops there.
17
+ */
18
+ const SPIRE_AGENT_MAX_OUTPUT = 1 << 20;
19
+ /**
20
+ * How long stdout is still read after `spire-agent` has exited (as in the other ports). A helper the agent
21
+ * started can inherit the pipe and hold it open; after this the pipe is closed.
22
+ */
23
+ const SPIRE_AGENT_STDOUT_GRACE_MS = 250;
10
24
  /** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
11
25
  export class StaticSvidSource {
12
- svid;
26
+ #svid; // private, so JSON.stringify and console.log don't show it
13
27
  constructor(svid) {
14
- this.svid = svid;
28
+ this.#svid = svid;
15
29
  }
16
30
  async fetch(_audience) {
17
- return this.svid;
31
+ return this.#svid;
18
32
  }
19
33
  }
20
34
  /**
@@ -42,7 +56,18 @@ export class HttpSvidSource {
42
56
  /**
43
57
  * The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
44
58
  * CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
45
- * subprocess.
59
+ * subprocess. An agent that has not exited within 30 s is killed and `fetch` rejects. `fetch` returns the
60
+ * first JWT-SVID the agent prints on stdout and throws {@link EvidenceNotFound} if there is none. The
61
+ * agent's stderr is discarded, never read as output or held in memory.
62
+ *
63
+ * The agent's exit decides the outcome, as in the other ports. While the agent is running, more than
64
+ * 1 MiB of stdout kills it and `fetch` rejects. Once it has exited, stdout is read for at most 250 ms more
65
+ * and at most 1 MiB in all (a helper the agent started can inherit the pipe and keep writing to it or hold
66
+ * it open), then the pipe is closed. On exit status 0 what was read is parsed; on any other status, or if
67
+ * the agent was killed, `fetch` rejects. So output a helper writes after the agent has exited never turns a
68
+ * success into a failure, and never keeps `fetch` waiting longer than that. If reading stopped at the
69
+ * 1 MiB or at the end of the 250 ms, the last line may be cut off, so a token is only taken from a line
70
+ * that ends in a newline.
46
71
  *
47
72
  * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
48
73
  * a `ClientAttestationError` on a runtime without it.
@@ -58,20 +83,182 @@ export class SpireWorkloadApiSvidSource {
58
83
  this.spireAgentBin = spireAgentBin;
59
84
  }
60
85
  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))));
86
+ const { spawn } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
87
+ // stdout only: stderr goes to the null device, so a log line or version string the agent writes there
88
+ // is never parsed, and a flood of it is never held in memory.
89
+ const { stdout, complete } = await new Promise((resolve, reject) => {
90
+ const args = ["api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath];
91
+ const child = spawn(this.spireAgentBin, args, {
92
+ stdio: ["ignore", "pipe", "ignore"],
93
+ timeout: SPIRE_AGENT_TIMEOUT_MS,
94
+ killSignal: "SIGKILL",
95
+ });
96
+ const chunks = [];
97
+ let size = 0;
98
+ let over = false; // past the cap while the agent was running
99
+ let ended = false; // stdout was read to its end
100
+ let exited;
101
+ let settled = false;
102
+ let grace;
103
+ // Settle once, and let go of stdout: a helper the agent started can inherit the pipe and hold it open
104
+ // after the agent is gone, so neither the timeout nor the size cap may wait for stdout to close.
105
+ const settle = (code, signal) => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ if (grace)
110
+ clearTimeout(grace);
111
+ child.stdout?.destroy?.();
112
+ if (over) {
113
+ const message = `spire-agent fetch failed: output is larger than ${SPIRE_AGENT_MAX_OUTPUT} bytes`;
114
+ reject(Object.assign(new Error(message), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
115
+ }
116
+ else if (code === 0) {
117
+ resolve({ stdout: concatUtf8(chunks, size), complete: ended });
118
+ }
119
+ else {
120
+ reject(spireAgentExitFailure(code, signal, child.killed));
121
+ }
122
+ };
123
+ child.stdout?.on("data", (chunk) => {
124
+ if (over || settled)
125
+ return;
126
+ if (size + chunk.length > SPIRE_AGENT_MAX_OUTPUT) {
127
+ if (exited) {
128
+ // The agent has exited: stop at the cap, and let its exit status decide.
129
+ chunks.push(chunk.subarray(0, SPIRE_AGENT_MAX_OUTPUT - size));
130
+ size = SPIRE_AGENT_MAX_OUTPUT;
131
+ settle(exited.code, exited.signal);
132
+ return;
133
+ }
134
+ // Still running: kill it and fail.
135
+ over = true;
136
+ chunks.length = 0;
137
+ child.kill("SIGKILL");
138
+ settle(null, "SIGKILL");
139
+ return;
140
+ }
141
+ size += chunk.length;
142
+ chunks.push(chunk);
143
+ });
144
+ child.stdout?.on("end", () => {
145
+ if (!settled)
146
+ ended = true;
147
+ });
148
+ child.stdout?.on("error", () => { }); // the outcome comes from "exit" or "close"
149
+ child.on("error", (err) => {
150
+ if (settled)
151
+ return;
152
+ settled = true;
153
+ if (grace)
154
+ clearTimeout(grace);
155
+ child.stdout?.destroy?.();
156
+ reject(spireAgentStartFailure(this.spireAgentBin, err));
157
+ });
158
+ // The agent has exited (on its own, or killed at the timeout): what it wrote is in the pipe, so give
159
+ // stdout a moment to drain, then settle whether or not something else still holds it open.
160
+ child.on("exit", (code, signal) => {
161
+ exited = { code, signal };
162
+ if (!settled)
163
+ grace = setTimeout(() => settle(code, signal), SPIRE_AGENT_STDOUT_GRACE_MS);
164
+ });
165
+ child.on("close", (code, signal) => settle(code, signal));
64
166
  });
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();
167
+ // If reading stopped before the end of stdout (at the cap or the end of the grace period), the line
168
+ // after the last newline may have been cut off.
169
+ const svid = parseSpireJwtOutput(complete ? stdout : stdout.slice(0, stdout.lastIndexOf("\n") + 1));
170
+ if (svid)
171
+ return svid;
172
+ // The output may hold a token or bundle that failed to parse, so the error gives only its size.
173
+ const lines = stdout.split("\n").length - (stdout === "" || stdout.endsWith("\n") ? 1 : 0);
174
+ throw new EvidenceNotFound(`could not parse a JWT-SVID from spire-agent output (${utf8Encode(stdout).length} bytes, ${lines} lines; not shown)`);
175
+ }
176
+ }
177
+ /** stdout as text: invalid UTF-8 becomes U+FFFD and a leading BOM is kept, as in the other ports. */
178
+ function concatUtf8(chunks, size) {
179
+ const all = new Uint8Array(size);
180
+ let at = 0;
181
+ for (const chunk of chunks) {
182
+ all.set(chunk, at);
183
+ at += chunk.length;
184
+ }
185
+ return new TextDecoder("utf-8", { ignoreBOM: true }).decode(all);
186
+ }
187
+ /**
188
+ * The error for a `spire-agent` that could not be started: its `code` (`ENOENT` when it is not installed)
189
+ * and nothing else from Node's error.
190
+ */
191
+ function spireAgentStartFailure(bin, err) {
192
+ const code = typeof err.code === "string" ? err.code : "unknown error";
193
+ return Object.assign(new Error(`spire-agent fetch failed: could not run ${bin} (${code})`), { code: err.code });
194
+ }
195
+ /**
196
+ * The error for a `spire-agent` that ran and failed: how it ended, never its output. `code`, `killed` and
197
+ * `signal` are kept.
198
+ */
199
+ function spireAgentExitFailure(code, signal, killed) {
200
+ const how = signal ? `killed with ${signal}` : `exit status ${code}`;
201
+ return Object.assign(new Error(`spire-agent fetch failed: ${how}`), { code, killed, signal });
202
+ }
203
+ const SPIRE_SECTION = /^(token|hint|bundle)\(.*?\):(.*)$/s;
204
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
205
+ /**
206
+ * `s` without the ASCII spaces, tabs and CRs around it, and nothing else: not a BOM, an NBSP or other
207
+ * Unicode space (as `String#trim` would), so every port reads the same output the same way.
208
+ */
209
+ function trimSpireSpace(s) {
210
+ return s.replace(/^[ \t\r]+|[ \t\r]+$/g, "");
211
+ }
212
+ /**
213
+ * The first JWT-SVID in the output of `spire-agent api fetch jwt`, or `undefined`.
214
+ *
215
+ * SPIRE prints each SVID as `token(<spiffe id>):` with the JWT on the next, tab-indented line, then
216
+ * `hint(<spiffe id>):` and `bundle(<trust domain>):` sections laid out the same way. The single-line form
217
+ * `token(<spiffe id>): <jwt>` and a bare JWT line are accepted too. Only a syntactically valid compact JWS
218
+ * is returned; the values of hint and bundle sections are skipped. A label with no value takes the next
219
+ * line as its value only if that line is not itself a label. Only ASCII space, tab and CR around a line or
220
+ * value are ignored.
221
+ */
222
+ function parseSpireJwtOutput(out) {
223
+ const lines = out.split("\n");
224
+ for (let i = 0; i < lines.length; i++) {
225
+ const line = trimSpireSpace(lines[i]);
226
+ const section = SPIRE_SECTION.exec(line);
227
+ if (!section) {
228
+ if (isCompactJws(line))
229
+ return line; // a bare compact JWT line
230
+ continue;
231
+ }
232
+ let value = trimSpireSpace(section[2]);
233
+ if (!value && i + 1 < lines.length) {
234
+ // the value is on the next line, unless that is another label
235
+ const following = trimSpireSpace(lines[i + 1]);
236
+ if (!SPIRE_SECTION.test(following)) {
237
+ value = following;
238
+ i++;
71
239
  }
72
- if ((line.match(/\./g) ?? []).length === 2)
73
- return line;
74
240
  }
75
- throw new Error(`could not parse SVID from spire-agent output:\n${stdout}`);
241
+ if (section[1] === "token" && isCompactJws(value))
242
+ return value;
243
+ }
244
+ return undefined;
245
+ }
246
+ /**
247
+ * Three non-empty base64url segments whose first is the canonical encoding of a strict JSON object (the
248
+ * token validator's reader) with a non-empty string `alg`. It verifies nothing; it tells a JWT from a
249
+ * version string or log line.
250
+ */
251
+ function isCompactJws(s) {
252
+ const parts = s.split(".");
253
+ if (parts.length !== 3 || !parts.every((p) => BASE64URL.test(p)))
254
+ return false;
255
+ try {
256
+ const alg = parseObject(decodeSegment(parts[0]), "header").alg;
257
+ return typeof alg === "string" && alg !== "";
258
+ }
259
+ catch (err) {
260
+ if (err instanceof Malformed)
261
+ return false;
262
+ throw err;
76
263
  }
77
264
  }
@@ -32,6 +32,16 @@ export interface ClientAttestationOptions {
32
32
  * endpoint and PoP audience). When set, no discovery request is made; the profile still mints.
33
33
  */
34
34
  metadata?: AttesterMetadata;
35
+ /**
36
+ * The audience the workload's platform identity token is requested for, which the attester's
37
+ * discovery document must name. Required unless `metadata` is given: every evidence source (GCP,
38
+ * AWS, Azure, SPIRE) fetches a token for whatever audience it is asked for, so without a pin an
39
+ * attester could have the workload mint a token for another service that trusts it. A missing pin or
40
+ * a different discovered audience throws {@link ClientAttestationError} before any evidence is
41
+ * fetched. With static `metadata`, the pin is optional and, when given, must match
42
+ * `metadata.evidenceAudience`.
43
+ */
44
+ evidenceAudience?: string;
35
45
  /**
36
46
  * How to get the workload's platform identity. Leave unset to auto-detect the runtime (env token,
37
47
  * projected token file, SPIRE socket, or GCP metadata). Pass an {@link SvidSource} to override.
@@ -53,16 +63,18 @@ export interface ClientAttestationOptions {
53
63
  operator?: Record<string, unknown>;
54
64
  /** Instance-key signature algorithm (default `ES256`). */
55
65
  algorithm?: string;
56
- /** Refresh this long before the token expires (default 30s). */
66
+ /** Refresh this long before the token expires (default 30s). Must be a finite number, not negative. */
57
67
  expirySkewSeconds?: number;
58
68
  /** Per-request HTTP timeout in milliseconds (default 20000). */
59
69
  timeoutMs?: number;
60
70
  /**
61
71
  * How to prove possession of the instance key at the token endpoint: the dedicated
62
72
  * `OAuth-Client-Attestation-PoP` JWT (`"attestation_pop_jwt"`, the default), or a `DPoP` proof
63
- * carrying the same challenge in `nonce` (`"dpop_combined"`).
73
+ * carrying the same challenge in `nonce` (`"dpop_combined"`). `undefined`, `null` or `""` means the
74
+ * default. The names are case-sensitive; the constructor throws a `RangeError` for any other value,
75
+ * including one with surrounding whitespace or a non-string.
64
76
  */
65
- popMethod?: "attestation_pop_jwt" | "dpop_combined";
77
+ popMethod?: "attestation_pop_jwt" | "dpop_combined" | "" | null;
66
78
  /**
67
79
  * Whether the attester-issued challenge also rides in the token-endpoint proof (the PoP's
68
80
  * `challenge` claim, or the DPoP `nonce`), on top of the mint-time instance proof it always
@@ -95,7 +107,7 @@ export interface ClientAttestationOptions {
95
107
  * Gets and caches an access token using client attestation.
96
108
  *
97
109
  * `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
98
- * discovery) or a custom `opts.profile`.
110
+ * discovery) or a custom `opts.profile`. A client that discovers its attester needs `opts.evidenceAudience`.
99
111
  *
100
112
  * @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
101
113
  * `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
@@ -125,6 +137,17 @@ export declare class ClientAttestation {
125
137
  * - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
126
138
  * token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
127
139
  * url's origin returned, if any.
140
+ *
141
+ * `url` must be https, like every other URL the client sends a credential to (loopback hosts and
142
+ * `allowInsecure` excepted).
143
+ *
144
+ * Pass the exact URL your HTTP client sends: the proof is only valid for the `htu` it names. A url with
145
+ * a backslash, ASCII whitespace or a control character before its query or fragment is refused with a
146
+ * {@link ClientAttestationError}, because URL parsers disagree on where it goes. For a DPoP-bound token,
147
+ * so is a url that is not an absolute http(s) URL with a valid ASCII host, port and path; with
148
+ * `popMethod: "dpop_combined"` that is checked before the token is fetched. The path is signed as
149
+ * written, so pass `new URL(u).href` (what `fetch` sends) rather than a hand-written string: `fetch` may
150
+ * escape characters (a `{`, say) that the string leaves as they are. {@link fetch} does this for you.
128
151
  */
129
152
  requestHeaders(method: string, url: string, opts?: {
130
153
  forceRefresh?: boolean;
@@ -134,6 +157,7 @@ export declare class ClientAttestation {
134
157
  * as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
135
158
  * a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
136
159
  * unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
160
+ * Redirects are returned without following them, even if the input or init requests redirect following.
137
161
  */
138
162
  fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
139
163
  /**