@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.
package/dist/issuance.js CHANGED
@@ -8,15 +8,17 @@
8
8
  */
9
9
  import { MintRequest, PingFederateProfile } from "./attester.js";
10
10
  import { DpopProofBuilder, PopBuilder } from "./builders.js";
11
- import { jsonOk, request } from "./http.js";
11
+ import { ClientAttestationError } from "./errors.js";
12
+ import { checkSecure, jsonOk, request, snippet } from "./http.js";
12
13
  import { SigningKeyPair } from "./signing-key-pair.js";
13
14
  /** The instance-key proof typ the PingFederate profile presents (server InstanceKeyProofValidator.TYP). */
14
15
  export { INSTANCE_PROOF_TYP } from "./attester.js";
15
16
  /** Per-call timeout for the issuance endpoint. */
16
17
  const ISSUANCE_TIMEOUT_MS = 20_000;
17
18
  /**
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.
19
+ * Thrown when the issuance endpoint rejects the request (non-2xx); `body` is at most the first 512
20
+ * characters of the response. Transport failures — unreachable, timeout, redirect, oversized or malformed
21
+ * response — are a {@link TransportError} instead.
20
22
  */
21
23
  export class IssuanceError extends Error {
22
24
  status;
@@ -28,15 +30,22 @@ export class IssuanceError extends Error {
28
30
  this.name = "IssuanceError";
29
31
  }
30
32
  }
31
- /** A minted Client Attestation plus the instance key it is bound to. */
33
+ /**
34
+ * A minted Client Attestation plus the instance key it is bound to. The attestation is a getter over a
35
+ * private field, so `JSON.stringify` and `console.log` of this object don't show it.
36
+ */
32
37
  export class IssuedAttestation {
33
- attestation;
34
38
  expiresIn;
35
39
  instanceKey;
40
+ #attestation;
36
41
  constructor(attestation, expiresIn, instanceKey) {
37
- this.attestation = attestation;
38
42
  this.expiresIn = expiresIn;
39
43
  this.instanceKey = instanceKey;
44
+ this.#attestation = attestation;
45
+ }
46
+ /** The Client Attestation (a compact JWT). */
47
+ get attestation() {
48
+ return this.#attestation;
40
49
  }
41
50
  /** A fresh Client Attestation PoP for the token endpoint, signed by the bound instance key. */
42
51
  async pop(audience, clientId, challenge) {
@@ -63,6 +72,9 @@ export class IssuedAttestation {
63
72
  *
64
73
  * `opts.profile` sets the mint protocol (default {@link PingFederateProfile}; see
65
74
  * docs/attester-profiles.md). The endpoint is given directly, so the profile's discovery is not used.
75
+ *
76
+ * The SVID is a bearer credential to the attester, so the endpoint, and every URL the profile calls, must be
77
+ * https unless its host is loopback (`localhost`, `127.0.0.1`, `::1`) or `opts.allowInsecure` is set.
66
78
  *
67
79
  * @experimental See docs/api.md: it calls a PingFederate demo issuance servlet (/federation/attestation)
68
80
  * whose protocol is not standardised.
@@ -74,36 +86,47 @@ export class AttestationIssuanceClient {
74
86
  source;
75
87
  instanceKey;
76
88
  profile;
77
- constructor(endpoint, clientId, attesterIssuer, source, instanceKey, profile) {
89
+ transport;
90
+ constructor(endpoint, clientId, attesterIssuer, source, instanceKey, profile, transport) {
78
91
  this.endpoint = endpoint;
79
92
  this.clientId = clientId;
80
93
  this.attesterIssuer = attesterIssuer;
81
94
  this.source = source;
82
95
  this.instanceKey = instanceKey;
83
96
  this.profile = profile;
97
+ this.transport = transport;
84
98
  }
85
- /** Generate a fresh ES256 instance key and return a ready client. */
99
+ /**
100
+ * Generate a fresh ES256 instance key and return a ready client. `opts.allowInsecure` permits plain-http
101
+ * URLs on hosts that are not loopback (default `false`).
102
+ */
86
103
  static async create(endpoint, clientId, attesterIssuer, source, opts) {
104
+ const allowInsecure = opts?.allowInsecure ?? false;
105
+ const trimmed = endpoint.replace(/\/$/, "");
106
+ checkSecure(trimmed, "endpoint", allowInsecure);
87
107
  const key = opts?.instanceKey ?? (await SigningKeyPair.generate("ES256"));
88
108
  const profile = opts?.profile ?? new PingFederateProfile();
89
- return new AttestationIssuanceClient(endpoint.replace(/\/$/, ""), clientId, attesterIssuer, source, key, profile);
109
+ return new AttestationIssuanceClient(trimmed, clientId, attesterIssuer, source, key, profile, issuanceTransport(allowInsecure));
90
110
  }
91
111
  /** Fetch the SVID, prove possession of the instance key, and exchange them for a minted attestation. */
92
112
  async bootstrap(authorizationDetails, challenge) {
93
113
  const svid = await this.source.fetch(this.attesterIssuer);
94
- const minted = await this.profile.mint(issuanceTransport, { evidenceAudience: this.attesterIssuer, attestationEndpoint: this.endpoint }, new MintRequest({
114
+ const minted = await this.profile.mint(this.transport, { evidenceAudience: this.attesterIssuer, attestationEndpoint: this.endpoint }, new MintRequest({
95
115
  instanceKey: this.instanceKey,
96
116
  evidence: svid,
97
117
  challenge: challenge || undefined,
98
118
  clientId: this.clientId,
99
119
  authorizationDetails,
100
120
  }));
121
+ if (!minted?.attestation)
122
+ throw new ClientAttestationError("attester response contained no attestation");
101
123
  return new IssuedAttestation(minted.attestation, minted.expiresIn, this.instanceKey);
102
124
  }
103
125
  }
104
- /** The issuance endpoint's transport: a non-2xx answer is an {@link IssuanceError}. */
105
- const issuanceTransport = {
106
- async requestJson(_step, method, url, body) {
126
+ /** The issuance endpoint's transport: the https rule, and a non-2xx answer is an {@link IssuanceError}. */
127
+ const issuanceTransport = (allowInsecure) => ({
128
+ async requestJson(step, method, url, body) {
129
+ checkSecure(url, `${step} URL`, allowInsecure);
107
130
  const res = await request(url, {
108
131
  method,
109
132
  headers: { "Content-Type": "application/json", Accept: "application/json" },
@@ -111,7 +134,7 @@ const issuanceTransport = {
111
134
  timeoutMs: ISSUANCE_TIMEOUT_MS,
112
135
  });
113
136
  if (!res.ok)
114
- throw new IssuanceError(res.status, res.body);
137
+ throw new IssuanceError(res.status, snippet(res.body));
115
138
  return jsonOk(res);
116
139
  },
117
- };
140
+ });
package/dist/logging.js CHANGED
@@ -4,25 +4,59 @@
4
4
  * other ports; see docs/logging.md.
5
5
  *
6
6
  * Never logged: tokens, attestations, proofs, SVIDs, challenge values, key material, header values, or
7
- * URLs with their query string. As a backstop, any string attribute containing `eyJ` (how a JWT's
8
- * base64url-encoded header begins) is replaced with `<redacted:N bytes>` before it reaches the logger.
7
+ * URLs with their query string. As a backstop, an attribute under a credential key (`token`, `*_token`,
8
+ * `auth_req_id`, `code`, ...), or a string attribute that carries a credential (contains `eyJ`, how a
9
+ * JWT's base64url-encoded header begins; a Vault/OpenBao token; an `Authorization` header value), is
10
+ * replaced with `<redacted:N bytes>` before it reaches the logger.
9
11
  */
10
12
  import { TransportError } from "./errors.js";
11
13
  import { utf8Encode } from "./runtime.js";
12
14
  const noop = () => undefined;
13
15
  /** The default: drops everything. */
14
16
  export const NOOP_LOGGER = { debug: noop, info: noop, warn: noop, error: noop };
15
- function guard(value) {
17
+ // The backstop (docs/logging.md, the same rule in every port): an attribute is redacted when its key names
18
+ // a credential, or its value carries one this SDK handles. Keys are matched in lower case.
19
+ const SECRET_KEYS = new Set([
20
+ "svid", "proof", "assertion", "client_assertion", "subject_token", "actor_token", "client_secret",
21
+ "attestation", "access_token", "refresh_token", "id_token", "authorization", "dpop",
22
+ "oauth-client-attestation", "oauth-client-attestation-pop", "auth_req_id", "code", "handle", "token",
23
+ "secret", "password", "private_key", "vault_token", "x-vault-token",
24
+ ]);
25
+ const SECRET_KEY_SUFFIXES = ["_token", "-token", "_secret", "-secret", "_password"];
26
+ // Vault/OpenBao tokens: hvs./hvb./hvr. and the older s./b./r. forms, standing alone.
27
+ const VAULT_TOKEN = /(?:^|[^A-Za-z0-9._-])(?:hv[sbr]\.[A-Za-z0-9_-]{20,}|[sbr]\.[A-Za-z0-9_-]{24,}(?:$|[^A-Za-z0-9._-]))/;
28
+ // An Authorization header value anywhere in the string: a scheme this SDK sends, standing alone, then
29
+ // whitespace and the first character of a credential ("Bearer x", " Bearer x", "Authorization: Bearer x").
30
+ // A bare scheme name ("Bearer", "token_type=Bearer", "dpop_combined") is not one.
31
+ const AUTH_SCHEME = /(?:^|[^A-Za-z0-9_-])(?:bearer|dpop|basic)[ \t]+[A-Za-z0-9._~+/-]/i;
32
+ function secretKey(key) {
33
+ const k = key.toLowerCase();
34
+ return SECRET_KEYS.has(k) || SECRET_KEY_SUFFIXES.some((suffix) => k.endsWith(suffix));
35
+ }
36
+ function redacted(value) {
37
+ return `<redacted:${utf8Encode(value).length} bytes>`;
38
+ }
39
+ function guard(key, value) {
40
+ if (secretKey(key)) {
41
+ try {
42
+ return redacted(typeof value === "string" ? value : String(value));
43
+ }
44
+ catch {
45
+ // a value whose toString throws (or an object with no prototype) must not throw out of logging
46
+ return "<redacted>";
47
+ }
48
+ }
16
49
  if (typeof value !== "string")
17
50
  return value;
18
51
  // A JWT's header and claims (any base64url-encoded JSON object) begin with "eyJ".
19
- if (value.includes("eyJ")) {
20
- return `<redacted:${utf8Encode(value).length} bytes>`;
52
+ if (value.includes("eyJ") || VAULT_TOKEN.test(value) || AUTH_SCHEME.test(value)) {
53
+ return redacted(value);
21
54
  }
22
55
  return value;
23
56
  }
24
57
  /**
25
- * Emit one event: undefined attributes dropped, JWT-looking values redacted. Never throws.
58
+ * Emit one event: undefined attributes dropped, credential keys and credential-looking values redacted.
59
+ * Never throws.
26
60
  *
27
61
  * @internal
28
62
  */
@@ -32,7 +66,7 @@ export function emit(logger, level, event, attrs = {}) {
32
66
  const clean = {};
33
67
  for (const [key, value] of Object.entries(attrs)) {
34
68
  if (value !== undefined && value !== null)
35
- clean[key] = guard(value);
69
+ clean[key] = guard(key, value);
36
70
  }
37
71
  try {
38
72
  logger[level](event, clean);
package/dist/runtime.js CHANGED
@@ -35,6 +35,15 @@ export function base64Encode(bytes) {
35
35
  }
36
36
  return btoa(binary);
37
37
  }
38
+ /**
39
+ * Milliseconds on a monotonic clock (`performance.now()`, which Node, Deno, Bun and workerd provide), for
40
+ * measuring how long something has been cached: unlike `Date.now()`, a wall-clock step does not move it.
41
+ *
42
+ * @internal
43
+ */
44
+ export function monotonicMs() {
45
+ return performance.now();
46
+ }
38
47
  /**
39
48
  * An environment variable, or `undefined` on a runtime with no `process.env` (most edge runtimes).
40
49
  *
package/dist/signer.d.ts CHANGED
@@ -19,6 +19,11 @@ export interface JwsSigner {
19
19
  * fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
20
20
  * latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
21
21
  *
22
+ * `baoAddr` must be https unless its host is loopback (`localhost`, `127.0.0.1`, `::1`) or
23
+ * `allowInsecure: true` is passed: the vault token rides on every call, and the public key read from the
24
+ * vault becomes the attester's published JWK. `keyName` may contain only letters, digits, `_`, `.` and `-`
25
+ * (and is not `.` or `..`).
26
+ *
22
27
  * @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
28
  */
24
29
  export declare class OpenBaoTransitSigner implements JwsSigner {
@@ -26,7 +31,9 @@ export declare class OpenBaoTransitSigner implements JwsSigner {
26
31
  readonly algorithm: string;
27
32
  readonly keyId: string;
28
33
  private constructor();
29
- static create(baoAddr: string, token: string, keyName: string): Promise<OpenBaoTransitSigner>;
34
+ static create(baoAddr: string, token: string, keyName: string, opts?: {
35
+ allowInsecure?: boolean;
36
+ }): Promise<OpenBaoTransitSigner>;
30
37
  publicJwk(): JWK;
31
38
  sign(signingInput: Uint8Array): Promise<Uint8Array>;
32
39
  }
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,19 @@ 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. A token is only taken from a
38
+ * line that ends in a newline, whether or not stdout was read to its end: if reading stopped at the 1 MiB
39
+ * 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
40
+ * last line lacks a newline (real spire-agent output always ends in one), so every port refuses it.
29
41
  *
30
42
  * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
31
43
  * a `ClientAttestationError` on a runtime without it.