Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/registry-evidence-client-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ napi-build.workspace = true
base64.workspace = true
ed25519-dalek.workspace = true
getrandom.workspace = true
p256.workspace = true
# The committed policy fixture stands for a relying party's own verification
# policy document, so the tests validate it against the frozen contract that
# governs one. `jsonschema` compiles that contract and `serde_norway` reads it.
Expand Down
8 changes: 6 additions & 2 deletions crates/registry-evidence-client-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ in CI.
## JS surface

```js
const client = new EvidenceClient({ baseUrl, trustedJwks, token, ... });
const client = new EvidenceClient({ baseUrl, trustedJwks, revokedKeyIds, token });

const prepared = client.prepare(spec); // synchronous, no I/O
const definitions = await client.discover();
Expand Down Expand Up @@ -79,7 +79,7 @@ cargo test -p registry-evidence-client-node --test golden_fixture -- --ignored r

never by hand-editing the fixture files. The JS tests under `__test__/` take
the opposite approach for their own live round trip: `helpers/live-signing.js`
signs a fresh Evidence payload with Node's built-in `crypto` (Ed25519) for
signs a fresh Evidence payload with Node's built-in `crypto` (P-256/ES256) for
whatever nonce the prepared request actually generated, because neither
`registry-evidence-verifier` nor `registry-evidence-client` exposes its test
signer outside `cfg(test)`.
Expand Down Expand Up @@ -110,6 +110,10 @@ workspace-wide forbid would reject outright.

- `trustedRootCertificates` accepts a PEM-encoded string only, not a Buffer or
DER bytes.
- `trustedJwks` and `revokedKeyIds` are both required trust inputs.
`revokedKeyIds` contains current service-key RFC 7638 thumbprints and
overrides a matching key even when it remains in `trustedJwks` or in an
older prepared request's policy.
- Exactly two token providers are supported: `token: { static: "..." }` and
`token: { privateKeyJwt: { tokenEndpoint, clientId, clientKey, ... } }`. A
caller-supplied custom token provider is out of scope for this binding.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ function validConfig(overrides = {}) {
trustedJwks: {
keys: [
{
kty: 'OKP',
crv: 'Ed25519',
kid: 'construction-test-key',
x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
kty: 'EC',
crv: 'P-256',
kid: '_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo',
alg: 'ES256',
x: '3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4',
y: 'GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU',
},
],
},
revokedKeyIds: [],
token: { static: 'construction-test-token' },
...overrides,
};
Expand Down Expand Up @@ -45,6 +48,16 @@ test('an empty trusted key set is refused', () => {
assertConfigurationRefusal(() => new EvidenceClient(validConfig({ trustedJwks: { keys: [] } })));
});

test('the current revoked key list is required', () => {
const config = validConfig();
delete config.revokedKeyIds;
assertConfigurationRefusal(() => new EvidenceClient(config));
});

test('a malformed revoked key identifier is refused', () => {
assertConfigurationRefusal(() => new EvidenceClient(validConfig({ revokedKeyIds: ['not-a-thumbprint'] })));
});

test('a base URL with an empty path segment is refused', () => {
assertConfigurationRefusal(
() => new EvidenceClient(validConfig({ baseUrl: 'https://evidence.example.org/prefix//suffix' })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function clientAgainst(stub, bounds = {}) {
return new EvidenceClient({
baseUrl: stub.baseUrl,
trustedJwks: GOLDEN_JWKS,
revokedKeyIds: [],
token: { static: 'discovery-test-token' },
...bounds,
});
Expand Down
13 changes: 9 additions & 4 deletions crates/registry-evidence-client-node/__test__/errors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ const { generateSigningKey, signEvidence, requestSpec, evidenceFor } = require('
const DUMMY_JWKS = {
keys: [
{
kty: 'OKP',
crv: 'Ed25519',
kid: 'errors-test-key',
x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
kty: 'EC',
crv: 'P-256',
kid: '_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo',
alg: 'ES256',
x: '3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4',
y: 'GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU',
},
],
};
Expand All @@ -25,6 +27,7 @@ async function clientAndPrepared(stub) {
const client = new EvidenceClient({
baseUrl: stub.baseUrl,
trustedJwks: DUMMY_JWKS,
revokedKeyIds: [],
token: { static: 'errors-test-token' },
});
return { client, prepared: client.prepare(requestSpec()) };
Expand Down Expand Up @@ -170,6 +173,7 @@ test('a response over maxResponseBytes is refused as a transport failure, not a
const client = new EvidenceClient({
baseUrl: stub.baseUrl,
trustedJwks: DUMMY_JWKS,
revokedKeyIds: [],
token: { static: 'errors-test-token' },
maxResponseBytes: 16,
});
Expand Down Expand Up @@ -202,6 +206,7 @@ test('verifyAsOf refuses a non-finite or unrepresentable asOfMillis as a configu
const client = new EvidenceClient({
baseUrl: stub.baseUrl,
trustedJwks: signingKey.jwks,
revokedKeyIds: [],
token: { static: 'as-of-millis-token' },
});
const prepared = client.prepare(spec);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ test('a prepared request round-trips through send and verify against a live stub
const client = new EvidenceClient({
baseUrl: stub.baseUrl,
trustedJwks: signingKey.jwks,
revokedKeyIds: [],
token: { static: 'happy-path-token' },
});

Expand Down Expand Up @@ -67,6 +68,7 @@ test('requestAndVerify performs the same round trip in one call', async () => {
const client = new EvidenceClient({
baseUrl: stub.baseUrl,
trustedJwks: signingKey.jwks,
revokedKeyIds: [],
token: { static: 'happy-path-token' },
});

Expand All @@ -92,6 +94,7 @@ test('a second send on the same prepared request is refused locally, and the stu
const client = new EvidenceClient({
baseUrl: stub.baseUrl,
trustedJwks: signingKey.jwks,
revokedKeyIds: [],
token: { static: 'one-send-guard-token' },
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const EVIDENCE_SCHEMA_V1 = 'registry.assertion-evidence/v1';
const EVIDENCE_JWS_MEDIA_TYPE = 'application/jose+json';

/**
* A fresh Ed25519 signing key for one stub deployment.
* A fresh P-256 signing key for one stub deployment.
*
* Neither crate that can sign a real Evidence response
* (`registry-evidence-verifier`, `registry-evidence-client`) exposes its test
Expand All @@ -22,28 +22,35 @@ const EVIDENCE_JWS_MEDIA_TYPE = 'application/jose+json';
* `registry-platform-crypto` on the Rust side. This key is generated fresh
* per test and never written anywhere.
*/
function generateSigningKey(kid) {
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
function generateSigningKey() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
});
const jwk = publicKey.export({ format: 'jwk' });
const thumbprintInput = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
const kid = crypto.createHash('sha256').update(thumbprintInput).digest('base64url');
return {
kid,
privateKey,
jwks: { keys: [{ ...jwk, kid, alg: 'EdDSA' }] },
jwks: { keys: [{ ...jwk, kid, alg: 'ES256' }] },
};
}

/** Sign an Evidence payload as a flattened JWS, matching the wire format `verify_flattened_jws` expects. */
function signEvidence(evidence, signingKey) {
const protectedHeader = {
alg: 'EdDSA',
alg: 'ES256',
kid: signingKey.kid,
typ: EVIDENCE_JWS_TYP,
cty: EVIDENCE_JWS_CTY,
};
const protectedSegment = Buffer.from(JSON.stringify(protectedHeader)).toString('base64url');
const payloadSegment = Buffer.from(JSON.stringify(evidence)).toString('base64url');
const signingInput = `${protectedSegment}.${payloadSegment}`;
const signature = crypto.sign(null, Buffer.from(signingInput), signingKey.privateKey);
const signature = crypto.sign('sha256', Buffer.from(signingInput), {
key: signingKey.privateKey,
dsaEncoding: 'ieee-p1363',
});
return {
protected: protectedSegment,
payload: payloadSegment,
Expand Down
6 changes: 3 additions & 3 deletions crates/registry-evidence-client-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
/** A relying party's connection to one Evidence deployment. */
export declare class EvidenceClient {
/**
* Build a client for one deployment. `trustedJwks` is mandatory; a key set
* the verifier could never use is refused, exactly as the Rust
* configuration refuses it.
* Build a client for one deployment. `trustedJwks` and `revokedKeyIds` are
* mandatory trust inputs. A key set or revoked-key list the verifier could
* never use is refused, exactly as the Rust configuration refuses it.
*
* `maxResponseBytes` bounds the signed response `send` reads.
* `maxMetadataBytes` bounds the documents `discover` and `fetchJwks` read,
Expand Down
Loading
Loading