@idpartners/client-attestation-sdk 1.0.1 → 1.4.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.
@@ -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,19 @@ 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. A token is only taken from a
69
+ * line that ends in a newline, whether or not stdout was read to its end: if reading stopped at the 1 MiB
70
+ * or at the end of the 250 ms the last line may be cut off, and no port can tell that from an agent whose
71
+ * last line lacks a newline (real spire-agent output always ends in one), so every port refuses it.
46
72
  *
47
73
  * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
48
74
  * a `ClientAttestationError` on a runtime without it.
@@ -58,20 +84,178 @@ export class SpireWorkloadApiSvidSource {
58
84
  this.spireAgentBin = spireAgentBin;
59
85
  }
60
86
  async fetch(audience) {
61
- const { execFile } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
87
+ const { spawn } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
88
+ // stdout only: stderr goes to the null device, so a log line or version string the agent writes there
89
+ // is never parsed, and a flood of it is never held in memory.
62
90
  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))));
91
+ const args = ["api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath];
92
+ const child = spawn(this.spireAgentBin, args, {
93
+ stdio: ["ignore", "pipe", "ignore"],
94
+ timeout: SPIRE_AGENT_TIMEOUT_MS,
95
+ killSignal: "SIGKILL",
96
+ });
97
+ const chunks = [];
98
+ let size = 0;
99
+ let over = false; // past the cap while the agent was running
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(concatUtf8(chunks, size));
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("error", () => { }); // the outcome comes from "exit" or "close"
145
+ child.on("error", (err) => {
146
+ if (settled)
147
+ return;
148
+ settled = true;
149
+ if (grace)
150
+ clearTimeout(grace);
151
+ child.stdout?.destroy?.();
152
+ reject(spireAgentStartFailure(this.spireAgentBin, err));
153
+ });
154
+ // The agent has exited (on its own, or killed at the timeout): what it wrote is in the pipe, so give
155
+ // stdout a moment to drain, then settle whether or not something else still holds it open.
156
+ child.on("exit", (code, signal) => {
157
+ exited = { code, signal };
158
+ if (!settled)
159
+ grace = setTimeout(() => settle(code, signal), SPIRE_AGENT_STDOUT_GRACE_MS);
160
+ });
161
+ child.on("close", (code, signal) => settle(code, signal));
64
162
  });
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();
163
+ // A token is only taken from a line that ends in a newline: the line after the last newline may have been
164
+ // cut off (reading stopped at the cap or the end of the grace period), and whether stdout was read to its
165
+ // end can't tell that apart from an agent whose last line lacks a newline, so every port refuses it.
166
+ const svid = parseSpireJwtOutput(stdout.slice(0, stdout.lastIndexOf("\n") + 1));
167
+ if (svid)
168
+ return svid;
169
+ // The output may hold a token or bundle that failed to parse, so the error gives only its size.
170
+ const lines = stdout.split("\n").length - (stdout === "" || stdout.endsWith("\n") ? 1 : 0);
171
+ throw new EvidenceNotFound(`could not parse a JWT-SVID from spire-agent output (${utf8Encode(stdout).length} bytes, ${lines} lines; not shown)`);
172
+ }
173
+ }
174
+ /** stdout as text: invalid UTF-8 becomes U+FFFD and a leading BOM is kept, as in the other ports. */
175
+ function concatUtf8(chunks, size) {
176
+ const all = new Uint8Array(size);
177
+ let at = 0;
178
+ for (const chunk of chunks) {
179
+ all.set(chunk, at);
180
+ at += chunk.length;
181
+ }
182
+ return new TextDecoder("utf-8", { ignoreBOM: true }).decode(all);
183
+ }
184
+ /**
185
+ * The error for a `spire-agent` that could not be started: its `code` (`ENOENT` when it is not installed)
186
+ * and nothing else from Node's error.
187
+ */
188
+ function spireAgentStartFailure(bin, err) {
189
+ const code = typeof err.code === "string" ? err.code : "unknown error";
190
+ return Object.assign(new Error(`spire-agent fetch failed: could not run ${bin} (${code})`), { code: err.code });
191
+ }
192
+ /**
193
+ * The error for a `spire-agent` that ran and failed: how it ended, never its output. `code`, `killed` and
194
+ * `signal` are kept.
195
+ */
196
+ function spireAgentExitFailure(code, signal, killed) {
197
+ const how = signal ? `killed with ${signal}` : `exit status ${code}`;
198
+ return Object.assign(new Error(`spire-agent fetch failed: ${how}`), { code, killed, signal });
199
+ }
200
+ const SPIRE_SECTION = /^(token|hint|bundle)\(.*?\):(.*)$/s;
201
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
202
+ /**
203
+ * `s` without the ASCII spaces, tabs and CRs around it, and nothing else: not a BOM, an NBSP or other
204
+ * Unicode space (as `String#trim` would), so every port reads the same output the same way.
205
+ */
206
+ function trimSpireSpace(s) {
207
+ return s.replace(/^[ \t\r]+|[ \t\r]+$/g, "");
208
+ }
209
+ /**
210
+ * The first JWT-SVID in the output of `spire-agent api fetch jwt`, or `undefined`.
211
+ *
212
+ * SPIRE prints each SVID as `token(<spiffe id>):` with the JWT on the next, tab-indented line, then
213
+ * `hint(<spiffe id>):` and `bundle(<trust domain>):` sections laid out the same way. The single-line form
214
+ * `token(<spiffe id>): <jwt>` and a bare JWT line are accepted too. Only a syntactically valid compact JWS
215
+ * is returned; the values of hint and bundle sections are skipped. A label with no value takes the next
216
+ * line as its value only if that line is not itself a label. Only ASCII space, tab and CR around a line or
217
+ * value are ignored.
218
+ */
219
+ function parseSpireJwtOutput(out) {
220
+ const lines = out.split("\n");
221
+ for (let i = 0; i < lines.length; i++) {
222
+ const line = trimSpireSpace(lines[i]);
223
+ const section = SPIRE_SECTION.exec(line);
224
+ if (!section) {
225
+ if (isCompactJws(line))
226
+ return line; // a bare compact JWT line
227
+ continue;
228
+ }
229
+ let value = trimSpireSpace(section[2]);
230
+ if (!value && i + 1 < lines.length) {
231
+ // the value is on the next line, unless that is another label
232
+ const following = trimSpireSpace(lines[i + 1]);
233
+ if (!SPIRE_SECTION.test(following)) {
234
+ value = following;
235
+ i++;
71
236
  }
72
- if ((line.match(/\./g) ?? []).length === 2)
73
- return line;
74
237
  }
75
- throw new Error(`could not parse SVID from spire-agent output:\n${stdout}`);
238
+ if (section[1] === "token" && isCompactJws(value))
239
+ return value;
240
+ }
241
+ return undefined;
242
+ }
243
+ /**
244
+ * Three non-empty base64url segments whose first is the canonical encoding of a strict JSON object (the
245
+ * token validator's reader) with a non-empty string `alg`. It verifies nothing; it tells a JWT from a
246
+ * version string or log line.
247
+ */
248
+ function isCompactJws(s) {
249
+ const parts = s.split(".");
250
+ if (parts.length !== 3 || !parts.every((p) => BASE64URL.test(p)))
251
+ return false;
252
+ try {
253
+ const alg = parseObject(decodeSegment(parts[0]), "header").alg;
254
+ return typeof alg === "string" && alg !== "";
255
+ }
256
+ catch (err) {
257
+ if (err instanceof Malformed)
258
+ return false;
259
+ throw err;
76
260
  }
77
261
  }
@@ -13,6 +13,7 @@ export interface AttesterDiscovery {
13
13
  attestation_endpoint?: string;
14
14
  challenge_endpoint?: string;
15
15
  token_endpoint?: string;
16
+ token_challenge_endpoint?: string;
16
17
  pop_audience?: string;
17
18
  }
18
19
  /** Options for {@link ClientAttestation}. */
@@ -32,6 +33,16 @@ export interface ClientAttestationOptions {
32
33
  * endpoint and PoP audience). When set, no discovery request is made; the profile still mints.
33
34
  */
34
35
  metadata?: AttesterMetadata;
36
+ /**
37
+ * The audience the workload's platform identity token is requested for, which the attester's
38
+ * discovery document must name. Required unless `metadata` is given: every evidence source (GCP,
39
+ * AWS, Azure, SPIRE) fetches a token for whatever audience it is asked for, so without a pin an
40
+ * attester could have the workload mint a token for another service that trusts it. A missing pin or
41
+ * a different discovered audience throws {@link ClientAttestationError} before any evidence is
42
+ * fetched. With static `metadata`, the pin is optional and, when given, must match
43
+ * `metadata.evidenceAudience`.
44
+ */
45
+ evidenceAudience?: string;
35
46
  /**
36
47
  * How to get the workload's platform identity. Leave unset to auto-detect the runtime (env token,
37
48
  * projected token file, SPIRE socket, or GCP metadata). Pass an {@link SvidSource} to override.
@@ -53,16 +64,18 @@ export interface ClientAttestationOptions {
53
64
  operator?: Record<string, unknown>;
54
65
  /** Instance-key signature algorithm (default `ES256`). */
55
66
  algorithm?: string;
56
- /** Refresh this long before the token expires (default 30s). */
67
+ /** Refresh this long before the token expires (default 30s). Must be a finite number, not negative. */
57
68
  expirySkewSeconds?: number;
58
69
  /** Per-request HTTP timeout in milliseconds (default 20000). */
59
70
  timeoutMs?: number;
60
71
  /**
61
72
  * How to prove possession of the instance key at the token endpoint: the dedicated
62
73
  * `OAuth-Client-Attestation-PoP` JWT (`"attestation_pop_jwt"`, the default), or a `DPoP` proof
63
- * carrying the same challenge in `nonce` (`"dpop_combined"`).
74
+ * carrying the same challenge in `nonce` (`"dpop_combined"`). `undefined`, `null` or `""` means the
75
+ * default. The names are case-sensitive; the constructor throws a `RangeError` for any other value,
76
+ * including one with surrounding whitespace or a non-string.
64
77
  */
65
- popMethod?: "attestation_pop_jwt" | "dpop_combined";
78
+ popMethod?: "attestation_pop_jwt" | "dpop_combined" | "" | null;
66
79
  /**
67
80
  * Whether the attester-issued challenge also rides in the token-endpoint proof (the PoP's
68
81
  * `challenge` claim, or the DPoP `nonce`), on top of the mint-time instance proof it always
@@ -95,7 +108,7 @@ export interface ClientAttestationOptions {
95
108
  * Gets and caches an access token using client attestation.
96
109
  *
97
110
  * `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
98
- * discovery) or a custom `opts.profile`.
111
+ * discovery) or a custom `opts.profile`. A client that discovers its attester needs `opts.evidenceAudience`.
99
112
  *
100
113
  * @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
101
114
  * `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
@@ -125,6 +138,17 @@ export declare class ClientAttestation {
125
138
  * - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
126
139
  * token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
127
140
  * url's origin returned, if any.
141
+ *
142
+ * `url` must be https, like every other URL the client sends a credential to (loopback hosts and
143
+ * `allowInsecure` excepted).
144
+ *
145
+ * Pass the exact URL your HTTP client sends: the proof is only valid for the `htu` it names. A url with
146
+ * a backslash, ASCII whitespace or a control character before its query or fragment is refused with a
147
+ * {@link ClientAttestationError}, because URL parsers disagree on where it goes. For a DPoP-bound token,
148
+ * so is a url that is not an absolute http(s) URL with a valid ASCII host, port and path; with
149
+ * `popMethod: "dpop_combined"` that is checked before the token is fetched. The path is signed as
150
+ * written, so pass `new URL(u).href` (what `fetch` sends) rather than a hand-written string: `fetch` may
151
+ * escape characters (a `{`, say) that the string leaves as they are. {@link fetch} does this for you.
128
152
  */
129
153
  requestHeaders(method: string, url: string, opts?: {
130
154
  forceRefresh?: boolean;