@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/README.md +142 -22
- package/dist/attester.d.ts +14 -6
- package/dist/attester.js +36 -12
- package/dist/builders.d.ts +11 -3
- package/dist/builders.js +157 -16
- package/dist/errors.d.ts +6 -2
- package/dist/errors.js +6 -2
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.js +46 -8
- package/dist/http.js +69 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/issuance.d.ts +19 -5
- package/dist/issuance.js +38 -15
- package/dist/logging.js +41 -7
- package/dist/runtime.js +9 -0
- package/dist/signer.d.ts +8 -1
- package/dist/signer.js +32 -5
- package/dist/signing-key-pair.d.ts +6 -0
- package/dist/signing-key-pair.js +91 -5
- package/dist/svid-source.d.ts +13 -2
- package/dist/svid-source.js +204 -17
- package/dist/token-source.d.ts +28 -4
- package/dist/token-source.js +234 -47
- package/dist/validator/config.d.ts +11 -1
- package/dist/validator/config.js +77 -8
- package/dist/validator/index.d.ts +1 -1
- package/dist/validator/index.js +1 -1
- package/dist/validator/jwks.js +65 -20
- package/dist/validator/resource.d.ts +9 -1
- package/dist/validator/resource.js +57 -3
- package/dist/validator/strict.js +6 -1
- package/dist/validator/validator.js +42 -11
- package/package.json +1 -1
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
|
|
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. */
|
package/dist/signing-key-pair.js
CHANGED
|
@@ -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
|
|
56
|
-
|
|
57
|
-
const publicJwk = publicMembers(
|
|
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}`);
|
package/dist/svid-source.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/svid-source.js
CHANGED
|
@@ -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
|
|
28
|
+
this.#svid = svid;
|
|
15
29
|
}
|
|
16
30
|
async fetch(_audience) {
|
|
17
|
-
return this
|
|
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 {
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/token-source.d.ts
CHANGED
|
@@ -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
|
/**
|