@openwop/openwop-conformance 2.40.1 → 2.40.3
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/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/dist/cli.js +18 -2
- package/dist/lib/certification-bundle-v3.js +12 -4
- package/dist/lib/certification-bundle-verify.js +24 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +232 -43
- package/schemas/CORPUS-STAMP.json +16 -14
- package/src/cli.ts +20 -2
- package/src/lib/certification-bundle-v3.ts +15 -5
- package/src/lib/certification-bundle-verify.ts +23 -1
- package/src/lib/harness-issuer.ts +101 -0
- package/src/scenarios/certification-bundle-redaction.test.ts +30 -7
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +22 -19
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +24 -23
- package/src/scenarios/v2-revocation-honored.test.ts +11 -4
package/src/cli.ts
CHANGED
|
@@ -83,6 +83,8 @@ interface ParsedArgs {
|
|
|
83
83
|
readonly verifyPath: string | undefined;
|
|
84
84
|
/** `--host-key <pem>` — the key `--verify` checks the host signature under. Absent ⇒ the verdict is INCOMPLETE, not clean. */
|
|
85
85
|
readonly verifyHostKeyPath: string | undefined;
|
|
86
|
+
/** RFC 0216 §B — `--as-colocated-companion`. */
|
|
87
|
+
readonly colocatedCompanion: boolean;
|
|
86
88
|
readonly verifierKeyId: string | undefined;
|
|
87
89
|
/**
|
|
88
90
|
* S43 (2026-08-18) — cap on concurrently running scenario FILES, forwarded to
|
|
@@ -112,6 +114,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
112
114
|
let signingKeyPath: string | undefined, signingKeyId: string | undefined, verifierKeyPath: string | undefined, verifierKeyId: string | undefined;
|
|
113
115
|
let targetMajor: 1 | 2 | undefined;
|
|
114
116
|
let maxWorkers: number | undefined = parseMaxWorkers(process.env.OPENWOP_MAX_WORKERS, 'OPENWOP_MAX_WORKERS');
|
|
117
|
+
let colocatedCompanion = false;
|
|
115
118
|
|
|
116
119
|
for (let i = 0; i < argv.length; i++) {
|
|
117
120
|
const arg = argv[i] ?? '';
|
|
@@ -202,6 +205,12 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
202
205
|
case '--host-key':
|
|
203
206
|
verifyHostKeyPath = nextValue();
|
|
204
207
|
break;
|
|
208
|
+
// RFC 0216 §B — marks the bundle as cut from a colocated companion (the
|
|
209
|
+
// served host's image beside the suite, trusting a suite-held anchor).
|
|
210
|
+
// The marker is inside witnessSha256, so a signed companion cannot shed it.
|
|
211
|
+
case '--as-colocated-companion':
|
|
212
|
+
colocatedCompanion = true;
|
|
213
|
+
break;
|
|
205
214
|
case '--require-behavior':
|
|
206
215
|
process.env['OPENWOP_REQUIRE_BEHAVIOR'] = 'true';
|
|
207
216
|
break;
|
|
@@ -240,6 +249,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
240
249
|
maxWorkers,
|
|
241
250
|
verifyPath,
|
|
242
251
|
verifyHostKeyPath,
|
|
252
|
+
colocatedCompanion,
|
|
243
253
|
};
|
|
244
254
|
}
|
|
245
255
|
|
|
@@ -288,6 +298,10 @@ Certification (RFC 0089):
|
|
|
288
298
|
§C) records per-requirement DISPOSITIONS instead of pass/fail/skip
|
|
289
299
|
file lists, so "we could not check" stops being indistinguishable
|
|
290
300
|
from "checked and it holds". See the note it prints.
|
|
301
|
+
--as-colocated-companion v3 (RFC 0216): mark the bundle as cut from a colocated companion — the
|
|
302
|
+
served host's image run beside the suite so it can trust a suite-held
|
|
303
|
+
trust anchor. Signed (inside witnessSha256). A companion is acceptance
|
|
304
|
+
evidence only for spec/v2/harness-trust-anchors.json rows.
|
|
291
305
|
--max-workers <n> Cap concurrently running scenario files (vitest --maxWorkers).
|
|
292
306
|
Default: one worker per CPU. Use a small number against a
|
|
293
307
|
rate-limited production origin so 429s don't read as failures.
|
|
@@ -709,7 +723,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
709
723
|
bundleVersion: '3',
|
|
710
724
|
generatedAt: new Date().toISOString(),
|
|
711
725
|
suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
|
|
712
|
-
host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}) },
|
|
726
|
+
host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}), ...(args.colocatedCompanion ? { deployment: 'colocated-companion' as const } : {}) },
|
|
713
727
|
// `document` is what makes `claimedProfiles[].certified` checkable by
|
|
714
728
|
// someone other than this process (RFC 0148 §B(1)); v2 carried it and v3
|
|
715
729
|
// dropped it. `sha256` is a digest of `canonicalJSON(document)`, so the
|
|
@@ -717,7 +731,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
717
731
|
discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
|
|
718
732
|
claimedProfiles: claimed3,
|
|
719
733
|
results: { totals: totals3, requirements: rows3 },
|
|
720
|
-
witnessSha256: witnessDigest(rows3, relaxations),
|
|
734
|
+
witnessSha256: witnessDigest(rows3, relaxations, args.colocatedCompanion ? 'colocated-companion' : undefined),
|
|
721
735
|
assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
722
736
|
...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
|
|
723
737
|
// RFC 0158 §D: claimed ONLY when these rows support it. The verifier
|
|
@@ -908,6 +922,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
908
922
|
|
|
909
923
|
async function main(): Promise<never> {
|
|
910
924
|
const args = parseArgs(process.argv.slice(2));
|
|
925
|
+
if (args.colocatedCompanion && (!args.certify || args.bundleVersion !== '3')) {
|
|
926
|
+
process.stderr.write('openwop-conformance: --as-colocated-companion marks a v3 certification bundle; it needs --certify with bundle version 3 (RFC 0216 §B)\n');
|
|
927
|
+
process.exit(2);
|
|
928
|
+
}
|
|
911
929
|
// Refused before anything runs: a pinned-port certification that is not
|
|
912
930
|
// single-worker loses the host's traffic to a worker nobody reads (lib/pinned-ports.ts).
|
|
913
931
|
const pinnedConflict = pinnedPortWorkerConflict(process.env, args.maxWorkers, args.certify !== undefined);
|
|
@@ -41,12 +41,14 @@ export interface BundleV3Profile {
|
|
|
41
41
|
readonly certified: boolean;
|
|
42
42
|
}
|
|
43
43
|
export interface BundleV3Relaxation { readonly obligation: string; readonly durability: 'session' | 'deployment' | 'persisted'; readonly reason: string }
|
|
44
|
+
/** RFC 0216 §B — absent means the bundle measures the served host. */
|
|
45
|
+
export type BundleV3Deployment = 'colocated-companion';
|
|
44
46
|
export interface BundleV3Signature { readonly alg: 'ed25519'; readonly keyId: string; readonly sig: string; readonly over: readonly string[]; readonly verifierKeyId?: string }
|
|
45
47
|
export interface BundleV3 {
|
|
46
48
|
bundleVersion: '3';
|
|
47
49
|
generatedAt: string;
|
|
48
50
|
suite: { name: '@openwop/openwop-conformance'; version: string; targetMajor: 1 | 2; specArtifactsVersion: string; stampSha256?: string };
|
|
49
|
-
host: { name: string; version: string; vendor?: string; build: { kind: 'image-digest' | 'commit' | 'artifact-sha256'; id: string }; signingKeyId?: string; relaxations?: BundleV3Relaxation[] };
|
|
51
|
+
host: { name: string; version: string; vendor?: string; build: { kind: 'image-digest' | 'commit' | 'artifact-sha256'; id: string }; signingKeyId?: string; relaxations?: BundleV3Relaxation[]; deployment?: BundleV3Deployment };
|
|
50
52
|
/**
|
|
51
53
|
* `document` is the captured `/.well-known/openwop` payload, OPTIONAL.
|
|
52
54
|
*
|
|
@@ -106,12 +108,20 @@ export { canonicalJSON } from './jcs.js';
|
|
|
106
108
|
* from the same bundle anywhere else (Czech sorts `ch` after `h`). Every
|
|
107
109
|
* committed bundle's ids are `[a-z0-9.-]`, where the two orders coincide, so no
|
|
108
110
|
* stored digest changes (v2-bundle-witness-preimage.test.ts pins that).
|
|
111
|
+
*
|
|
112
|
+
* RFC 0216 §B.7 — a colocated companion's `host.deployment` joins the preimage
|
|
113
|
+
* the same way, ONLY WHEN PRESENT (`{ rows, relaxations?, deployment }`), so a
|
|
114
|
+
* signed companion cannot shed the marker and pass as a served-host bundle, and
|
|
115
|
+
* every bundle without it digests exactly as before.
|
|
109
116
|
*/
|
|
110
|
-
export function witnessDigest(rows: readonly BundleV3Requirement[], relaxations?: readonly BundleV3Relaxation[]): string {
|
|
117
|
+
export function witnessDigest(rows: readonly BundleV3Requirement[], relaxations?: readonly BundleV3Relaxation[], deployment?: BundleV3Deployment): string {
|
|
111
118
|
const canonicalRows = [...rows].sort((a, b) => codeUnitCompare(a.id, b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
|
|
112
119
|
// ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
|
|
113
120
|
...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
|
|
114
|
-
const
|
|
121
|
+
const relaxed = relaxations !== undefined && relaxations.length > 0;
|
|
122
|
+
const preimage = relaxed || deployment !== undefined
|
|
123
|
+
? { rows: canonicalRows, ...(relaxed ? { relaxations } : {}), ...(deployment !== undefined ? { deployment } : {}) }
|
|
124
|
+
: canonicalRows;
|
|
115
125
|
return createHash('sha256').update(canonicalJSON(preimage), 'utf8').digest('hex');
|
|
116
126
|
}
|
|
117
127
|
|
|
@@ -219,8 +229,8 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
219
229
|
const count = (d: BundleV3Result): number => rows.filter((r) => r.result === d).length;
|
|
220
230
|
const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
|
|
221
231
|
for (const k of Object.keys(expected) as (keyof typeof expected)[]) if (bundle.results?.totals?.[k] !== expected[k]) rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
|
|
222
|
-
const digest = refusedAs(rejections, 'witness-digest', 'the rows or declared relaxations', () => witnessDigest(rows, bundle.host?.relaxations));
|
|
223
|
-
if (digest !== undefined && bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows
|
|
232
|
+
const digest = refusedAs(rejections, 'witness-digest', 'the rows or declared relaxations', () => witnessDigest(rows, bundle.host?.relaxations, bundle.host?.deployment));
|
|
233
|
+
if (digest !== undefined && bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows, declared relaxations and deployment marker (${digest.slice(0, 12)})` });
|
|
224
234
|
const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
|
|
225
235
|
if (bundle.assertionCount !== assertions) rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
|
|
226
236
|
const nonPass = rows.filter((r) => r.result !== 'executed-pass');
|
|
@@ -354,6 +354,26 @@ export function scrubEvidence<T>(value: T, secrets: readonly string[]): ScrubRes
|
|
|
354
354
|
* are excluded, and a caller may name values that MUST stay visible (`except`)
|
|
355
355
|
* regardless of which variable carried them.
|
|
356
356
|
*/
|
|
357
|
+
/**
|
|
358
|
+
* Whether an environment VALUE can be a credential at all (suite 2.40.3).
|
|
359
|
+
*
|
|
360
|
+
* The name filter above matches settings ABOUT secrets as well as secrets:
|
|
361
|
+
* openwop-app's `OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S=60` (RFC 0201's
|
|
362
|
+
* rotation overlap) was swept in, and `scrubEvidence` then rewrote every "60"
|
|
363
|
+
* in the bundle — including the one inside `discovery.sha256`, which stopped
|
|
364
|
+
* matching `^[0-9a-f]{64}$` and failed the host's own deploy certify. A short
|
|
365
|
+
* value occurs by chance inside digests, counts and timestamps, so scrubbing it
|
|
366
|
+
* corrupts the evidence without protecting anything: no credential is a bare
|
|
367
|
+
* integer or under 8 characters (NIST SP 800-63B's floor for a memorized
|
|
368
|
+
* secret). The floor applies only to values GUESSED from variable names; a
|
|
369
|
+
* credential the emitter hands over explicitly (`extra`) is always scrubbed,
|
|
370
|
+
* whatever its shape.
|
|
371
|
+
*/
|
|
372
|
+
export function credentialShaped(v: string): boolean {
|
|
373
|
+
const t = v.trim();
|
|
374
|
+
return t.length >= 8 && !/^\d+$/.test(t);
|
|
375
|
+
}
|
|
376
|
+
|
|
357
377
|
export function evidenceSecretsFromEnv(
|
|
358
378
|
env: NodeJS.ProcessEnv,
|
|
359
379
|
extra: readonly (string | undefined)[] = [],
|
|
@@ -365,7 +385,9 @@ export function evidenceSecretsFromEnv(
|
|
|
365
385
|
if (!k.startsWith('OPENWOP_')) continue;
|
|
366
386
|
if (!/(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/.test(k)) continue;
|
|
367
387
|
if (/_ID$/.test(k)) continue; // a key's identifier is published, not secret
|
|
368
|
-
if (v
|
|
388
|
+
if (v === undefined || v.trim() === '' || keep.has(v)) continue;
|
|
389
|
+
if (!credentialShaped(v)) continue; // a setting that names a secret, not a secret
|
|
390
|
+
out.add(v);
|
|
369
391
|
}
|
|
370
392
|
for (const s of extra) if (s !== undefined && s.trim() !== '' && !keep.has(s)) out.add(s);
|
|
371
393
|
out.add(CONFORMANCE_SECRET_CANARY);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suite's harness OIDC issuer, as an INSTRUMENT a host claims.
|
|
3
|
+
*
|
|
4
|
+
* Two major-2 scenarios need a token the host would accept, and the suite can
|
|
5
|
+
* mint one only with a key it holds: `v2-oidc-id-token-audience` (RFC 0200 §D)
|
|
6
|
+
* and `v2-lane-exp-only-bound` (RFC 0210 §B). The suite holds the key of exactly
|
|
7
|
+
* one issuer, the one it stands up at `OPENWOP_TEST_OIDC_ISSUER_URL`.
|
|
8
|
+
*
|
|
9
|
+
* ── Why the gate reads the host's lane, not only the environment ────────────
|
|
10
|
+
* Until 2.40.2 both files recorded `blocked` whenever the harness was not the
|
|
11
|
+
* host's trust root. A PRODUCTION host MUST NOT trust a test issuer — a deployed
|
|
12
|
+
* service that accepts tokens signed by a key living on a test runner has an
|
|
13
|
+
* authentication bypass — so every honest production bundle advertising an
|
|
14
|
+
* `oidc` or `exp-only` lane carried `blocked` rows, and RFC 0168 §E.1 makes one
|
|
15
|
+
* `blocked` row deny certification bundle-wide. Measured on openwop-app
|
|
16
|
+
* (ADR 0745 corpus defect 1); MyndHyve is in the same position.
|
|
17
|
+
*
|
|
18
|
+
* RFC 0168 §C.1 already rules on this shape for the other suite instrument a
|
|
19
|
+
* host has to be configured for, the seams profile: "a scenario that finds the
|
|
20
|
+
* exact advert absent records `inapplicable` — never `blocked`, which is
|
|
21
|
+
* reserved for an advertised profile whose seam does not answer". The harness
|
|
22
|
+
* issuer is claimed the same way, by the host's own advertisement: a lane's
|
|
23
|
+
* `issuers[]` (`spec/v2/facets/auth.schema.json`) names the trust roots that
|
|
24
|
+
* lane accepts. So:
|
|
25
|
+
*
|
|
26
|
+
* - the lane's `issuers[]` does not name the harness URL (or no harness URL is
|
|
27
|
+
* configured) → `inapplicable`, and the reason names the issuers the host
|
|
28
|
+
* DOES trust — a fact about the host, not about the suite;
|
|
29
|
+
* - the lane names it → the host has claimed the instrument; a harness that
|
|
30
|
+
* cannot be served, or a control token the host then refuses, is `blocked`
|
|
31
|
+
* or a failure exactly as before.
|
|
32
|
+
*
|
|
33
|
+
* The cost, stated rather than hidden: a production bundle no longer witnesses
|
|
34
|
+
* these rows at all, just as it witnesses no seam-gated row. The witness comes
|
|
35
|
+
* from a cut whose lane advertises the harness issuer — the reference host, or
|
|
36
|
+
* a colocated boot of the same release image.
|
|
37
|
+
*
|
|
38
|
+
* ── Reachability ─────────────────────────────────────────────────────────────
|
|
39
|
+
* The PORT is `issuerListenPort()` (2.39.4: `OPENWOP_TEST_OIDC_ISSUER_PORT`,
|
|
40
|
+
* else the URL's port). The BIND is `receiverBinding()`, as for the webhook
|
|
41
|
+
* receiver and the OAuth doubles: loopback, or `0.0.0.0` when
|
|
42
|
+
* `OPENWOP_CONFORMANCE_HARNESS_HOST` says the host is in a container or on
|
|
43
|
+
* another box. Until 2.40.2 the issuer always bound `127.0.0.1`, so a host in a
|
|
44
|
+
* Linux container reaching it through `host-gateway` found nothing listening on
|
|
45
|
+
* the interface it arrived at (openwop-app ADR 0745 defect 2). The URL is what
|
|
46
|
+
* the host was configured with and is what `iss` and discovery carry; only the
|
|
47
|
+
* listening socket moves.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { createServer, type Server } from 'node:http';
|
|
51
|
+
import { receiverBinding } from './webhook-receiver.js';
|
|
52
|
+
import { issuerListenPort, type SyntheticOIDCIssuer } from './oidc-issuer.js';
|
|
53
|
+
|
|
54
|
+
const norm = (u: string): string => u.trim().replace(/\/+$/, '');
|
|
55
|
+
|
|
56
|
+
export type HarnessClaim =
|
|
57
|
+
| { readonly ok: true; readonly url: string }
|
|
58
|
+
| { readonly ok: false; readonly kind: 'inapplicable'; readonly reason: string };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Has the host claimed the harness issuer on `lane`? Pure: decided from the
|
|
62
|
+
* advertised lane record and the configured harness URL.
|
|
63
|
+
*/
|
|
64
|
+
export function harnessClaimed(lane: Readonly<Record<string, unknown>>, harnessUrl: string | undefined): HarnessClaim {
|
|
65
|
+
const name = String(lane['lane']);
|
|
66
|
+
const issuers = Array.isArray(lane['issuers']) ? (lane['issuers'] as unknown[]).filter((x): x is string => typeof x === 'string') : [];
|
|
67
|
+
const listed = issuers.length > 0 ? issuers.join(', ') : 'none';
|
|
68
|
+
const url = harnessUrl?.trim();
|
|
69
|
+
if (!url) {
|
|
70
|
+
return { ok: false, kind: 'inapplicable', reason: `lane ${name} trusts ${listed}, and no harness issuer is configured (OPENWOP_TEST_OIDC_ISSUER_URL unset): the suite holds a signing key for none of the host's trust roots, so no token it mints could be accepted. A host claims the harness by listing it in the lane's issuers[] (RFC 0168 §C.1's reading for suite instruments)` };
|
|
71
|
+
}
|
|
72
|
+
if (!issuers.some((i) => norm(i) === norm(url))) {
|
|
73
|
+
return { ok: false, kind: 'inapplicable', reason: `lane ${name} trusts ${listed}, which does not include the harness issuer ${url}: this host has not claimed the suite's issuer as a trust root (a production host must not), so the suite holds no key the host accepts. RFC 0168 §C.1's reading for suite instruments — witnessed on a cut whose lane lists the harness` };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, url: norm(url) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Serve `issuer`'s discovery + JWKS for the host to fetch. Resolves to the listening server. */
|
|
79
|
+
export async function serveHarnessIssuer(issuer: SyntheticOIDCIssuer, url: string): Promise<Server> {
|
|
80
|
+
const port = issuerListenPort(url);
|
|
81
|
+
const { bind } = receiverBinding();
|
|
82
|
+
const srv = createServer((r, res) => {
|
|
83
|
+
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(issuer.jwksJson); return; }
|
|
84
|
+
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(issuer.discoveryJson); return; }
|
|
85
|
+
res.writeHead(404); res.end();
|
|
86
|
+
});
|
|
87
|
+
// Two scenario files serve the one issuer URL, and vitest runs files in
|
|
88
|
+
// parallel: wait for the holder to close rather than fail (each instance has
|
|
89
|
+
// its own kid, so a host that cached the other file's JWKS re-fetches).
|
|
90
|
+
const deadline = Date.now() + 25_000;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const e = await new Promise<NodeJS.ErrnoException | null>((ok) => {
|
|
93
|
+
const onErr = (err: NodeJS.ErrnoException): void => ok(err);
|
|
94
|
+
srv.once('error', onErr);
|
|
95
|
+
srv.listen(port, bind, () => { srv.off('error', onErr); ok(null); });
|
|
96
|
+
});
|
|
97
|
+
if (e === null) return srv;
|
|
98
|
+
if (e.code !== 'EADDRINUSE' || Date.now() > deadline) throw e;
|
|
99
|
+
await new Promise((ok) => setTimeout(ok, 250));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -141,17 +141,40 @@ describe('RFC 0148 §C — certification-bundle-redaction: secret canaries never
|
|
|
141
141
|
|
|
142
142
|
it('evidenceSecretsFromEnv selects OPENWOP_* key/token/secret/password variables, the handed credential, and ALWAYS the canary', () => {
|
|
143
143
|
const env = {
|
|
144
|
-
OPENWOP_API_KEY: 'k1',
|
|
145
|
-
OPENWOP_MESSAGING_BRIDGE_TOKEN: 't1',
|
|
146
|
-
OPENWOP_DB_PASSWORD: 'p1',
|
|
147
|
-
OPENWOP_CLIENT_SECRET: 's1',
|
|
144
|
+
OPENWOP_API_KEY: 'k1-api-key-value',
|
|
145
|
+
OPENWOP_MESSAGING_BRIDGE_TOKEN: 't1-bridge-token',
|
|
146
|
+
OPENWOP_DB_PASSWORD: 'p1-db-password',
|
|
147
|
+
OPENWOP_CLIENT_SECRET: 's1-client-secret',
|
|
148
148
|
OPENWOP_BASE_URL: 'https://example.invalid', // not a secret
|
|
149
149
|
OPENWOP_REQUIRE_BEHAVIOR: 'true',
|
|
150
150
|
PATH: '/usr/bin',
|
|
151
151
|
SOME_OTHER_TOKEN: 'not-ours',
|
|
152
152
|
} as NodeJS.ProcessEnv;
|
|
153
153
|
const secrets = evidenceSecretsFromEnv(env, [API_KEY, undefined, '']);
|
|
154
|
-
expect(secrets.sort(), req('openwop.it.certification-bundle-redaction.evidencesecretsfromenv-selects-openwop-key-token-secret-password-variables-the-h', 'RFC 0148 §C', 'evidenceSecretsFromEnv selects OPENWOP_* key/token/secret/password variables, the handed credential, and ALWAYS the canary')).toEqual([API_KEY, 'k1', 'p1', 's1', 't1', CONFORMANCE_SECRET_CANARY].sort());
|
|
154
|
+
expect(secrets.sort(), req('openwop.it.certification-bundle-redaction.evidencesecretsfromenv-selects-openwop-key-token-secret-password-variables-the-h', 'RFC 0148 §C', 'evidenceSecretsFromEnv selects OPENWOP_* key/token/secret/password variables, the handed credential, and ALWAYS the canary')).toEqual([API_KEY, 'k1-api-key-value', 'p1-db-password', 's1-client-secret', 't1-bridge-token', CONFORMANCE_SECRET_CANARY].sort());
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('evidenceSecretsFromEnv never treats a setting ABOUT a secret as one: a bare integer or a value under 8 characters is not swept from the environment', () => {
|
|
158
|
+
// Suite 2.40.2: openwop-app set OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S=60,
|
|
159
|
+
// the name matched SECRET, and scrubEvidence rewrote the "60" inside
|
|
160
|
+
// discovery.sha256 — the bundle failed ^[0-9a-f]{64}$ and main could not deploy.
|
|
161
|
+
const digest = '4b8d99c002a061a0255e8224ee683162007febadd251b2d171b47c0849695315';
|
|
162
|
+
const env = {
|
|
163
|
+
OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S: '60',
|
|
164
|
+
OPENWOP_TOKEN_TTL_SECONDS_KEY: '86400000',
|
|
165
|
+
OPENWOP_SHORT_KEY: 'abc1234',
|
|
166
|
+
OPENWOP_REAL_SECRET: 'whsec_c2VjcmV0LXZhbHVl',
|
|
167
|
+
} as NodeJS.ProcessEnv;
|
|
168
|
+
const secrets = evidenceSecretsFromEnv(env, ['k9']);
|
|
169
|
+
const R = 'openwop.it.certification-bundle-redaction.setting-about-a-secret-not-swept';
|
|
170
|
+
expect(secrets, req(R, 'RFC 0148 §C', 'a bare-integer setting whose NAME mentions a secret (a rotation overlap, a TTL) MUST NOT be swept as a credential')).not.toContain('60');
|
|
171
|
+
expect(secrets).not.toContain('86400000');
|
|
172
|
+
expect(secrets, req(R, 'RFC 0148 §C', 'a value under 8 characters is not credential-shaped and is not swept from the environment')).not.toContain('abc1234');
|
|
173
|
+
expect(secrets, req(R, 'RFC 0148 §C', 'a credential-shaped value is still swept')).toContain('whsec_c2VjcmV0LXZhbHVl');
|
|
174
|
+
expect(secrets, req(R, 'RFC 0148 §C', 'a credential the emitter hands over explicitly is scrubbed whatever its shape')).toContain('k9');
|
|
175
|
+
const { value } = scrubEvidence({ discovery: { sha256: digest } }, secrets);
|
|
176
|
+
expect(value.discovery.sha256, req(R, 'RFC 0148 §C', 'scrubbing MUST NOT corrupt a digest the bundle carries')).toBe(digest);
|
|
177
|
+
expect(value.discovery.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
155
178
|
});
|
|
156
179
|
|
|
157
180
|
it('evidenceSecretsFromEnv never scrubs a key IDENTIFIER: *_ID variables are excluded and `except` values stay visible whichever variable carried them', () => {
|
|
@@ -162,14 +185,14 @@ describe('RFC 0148 §C — certification-bundle-redaction: secret canaries never
|
|
|
162
185
|
OPENWOP_BUNDLE_SIGNING_KEY: '-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----',
|
|
163
186
|
OPENWOP_BUNDLE_SIGNING_KEY_ID: 'myndhyve-bundle-2026-09',
|
|
164
187
|
OPENWOP_BUNDLE_VERIFIER_KEY_ID: 'steward-verifier-1',
|
|
165
|
-
OPENWOP_API_KEY: 'k1',
|
|
188
|
+
OPENWOP_API_KEY: 'k1-api-key-value',
|
|
166
189
|
OPENWOP_ODDLY_NAMED_KEY: 'published-key-id-in-a-secret-shaped-variable',
|
|
167
190
|
} as NodeJS.ProcessEnv;
|
|
168
191
|
const secrets = evidenceSecretsFromEnv(env, [], ['published-key-id-in-a-secret-shaped-variable']);
|
|
169
192
|
expect(secrets, req('openwop.it.certification-bundle-redaction.key-identifier-never-scrubbed', 'RFC 0168 §E.2', 'the keyId a host publishes in discovery.document.signingKeys[] MUST survive evidence scrubbing — a *_ID variable is an identifier, not a secret')).not.toContain('myndhyve-bundle-2026-09');
|
|
170
193
|
expect(secrets).not.toContain('steward-verifier-1');
|
|
171
194
|
expect(secrets, req('openwop.it.certification-bundle-redaction.key-identifier-never-scrubbed', 'RFC 0168 §E.2', 'a value the emitter names as `except` (the keyId it is about to publish) is never scrubbed, whichever variable carried it')).not.toContain('published-key-id-in-a-secret-shaped-variable');
|
|
172
|
-
expect(secrets).toContain('k1');
|
|
195
|
+
expect(secrets).toContain('k1-api-key-value');
|
|
173
196
|
expect(secrets.some((s) => s.includes('BEGIN PRIVATE KEY'))).toBe(true);
|
|
174
197
|
expect(secrets).toContain(CONFORMANCE_SECRET_CANARY);
|
|
175
198
|
});
|
|
@@ -23,9 +23,14 @@
|
|
|
23
23
|
* test.
|
|
24
24
|
*
|
|
25
25
|
* **Gate:** an advertised `auth.lanes[]` member with `revocation: "exp-only"`
|
|
26
|
-
* (`inapplicable`, naming that, otherwise)
|
|
27
|
-
* synthetic issuer the
|
|
28
|
-
*
|
|
26
|
+
* (`inapplicable`, naming that, otherwise) whose `issuers[]` lists
|
|
27
|
+
* `OPENWOP_TEST_OIDC_ISSUER_URL`, the synthetic issuer the suite holds the key for. A lane
|
|
28
|
+
* that does not list it records `inapplicable` naming the issuers it does trust: the
|
|
29
|
+
* harness is a suite instrument the host claims by advertising it (RFC 0168 §C.1's reading
|
|
30
|
+
* for the seams profile; lib/harness-issuer.ts), and a production host must never trust a
|
|
31
|
+
* test issuer. Corrected in 2.40.1; this was `blocked`, which denied certification to
|
|
32
|
+
* every honest production bundle. A claimed harness that cannot be served is `blocked` —
|
|
33
|
+
* without a token the host would ever accept, "it refused a string" witnesses nothing.
|
|
29
34
|
* A window under 120 s is `blocked`: the control token needs room inside it.
|
|
30
35
|
*
|
|
31
36
|
* **Each refusal leg is skewed so it isolates ONE bound.** A token minted at `now` with
|
|
@@ -54,13 +59,14 @@
|
|
|
54
59
|
|
|
55
60
|
import { afterAll, describe, it, expect } from 'vitest';
|
|
56
61
|
import { randomBytes } from 'node:crypto';
|
|
57
|
-
import {
|
|
62
|
+
import type { Server } from 'node:http';
|
|
58
63
|
import { driver } from '../lib/driver.js';
|
|
59
64
|
import { softSkip } from '../lib/soft-skip.js';
|
|
60
65
|
import { req } from '../lib/requirement-ids.js';
|
|
61
66
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
62
67
|
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
63
|
-
import { createSyntheticOIDCIssuer,
|
|
68
|
+
import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
69
|
+
import { harnessClaimed, serveHarnessIssuer } from '../lib/harness-issuer.js';
|
|
64
70
|
|
|
65
71
|
export const HOST_CALLBACK_NOT_REQUIRED =
|
|
66
72
|
'the suite stands up the synthetic OIDC issuer and the host fetches its JWKS; no request returns to the suite\'s own API, so no host-reachable callback is needed';
|
|
@@ -113,23 +119,20 @@ async function gate(): Promise<Gate | { readonly kind: 'inapplicable' | 'blocked
|
|
|
113
119
|
if (window < 2 * MARGIN) {
|
|
114
120
|
return { kind: 'blocked', reason: `lane ${String(lane['lane'])} advertises a ${window}s window; the control token needs ${MARGIN}s of room inside it and the sabotage tokens ${MARGIN}s outside, so a window under ${2 * MARGIN}s cannot be probed without the two cases overlapping` };
|
|
115
121
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
// The harness is an instrument the host claims by listing it in THIS lane's issuers[]
|
|
123
|
+
// (RFC 0168 §C.1's reading; lib/harness-issuer.ts). A production host lists its real
|
|
124
|
+
// IdP and must never list a test issuer, so it records `inapplicable` naming the issuers
|
|
125
|
+
// it does trust. Before 2.40.1 this was `blocked`, which denied certification to every
|
|
126
|
+
// honest production bundle advertising an exp-only lane.
|
|
127
|
+
const claim = harnessClaimed(lane, process.env['OPENWOP_TEST_OIDC_ISSUER_URL']);
|
|
128
|
+
if (!claim.ok) return { kind: claim.kind, reason: claim.reason };
|
|
129
|
+
const url = claim.url;
|
|
120
130
|
const audience = process.env['OPENWOP_TEST_OIDC_AUDIENCE']?.trim() ?? 'openwop-conformance';
|
|
121
131
|
if (issuer === null) {
|
|
122
132
|
const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
res.writeHead(404); res.end();
|
|
127
|
-
});
|
|
128
|
-
await new Promise<void>((resolve, reject) => {
|
|
129
|
-
srv.once('error', reject);
|
|
130
|
-
srv.listen(issuerListenPort(url), '127.0.0.1', () => resolve());
|
|
131
|
-
});
|
|
132
|
-
server = srv;
|
|
133
|
+
try { server = await serveHarnessIssuer(made, url); } catch (e) {
|
|
134
|
+
return { kind: 'blocked', reason: `lane ${String(lane['lane'])} lists the harness issuer, but it could not be served for ${url}: ${(e as Error).message}` };
|
|
135
|
+
}
|
|
133
136
|
issuer = made;
|
|
134
137
|
}
|
|
135
138
|
return { issuer, url, window, lane: String(lane['lane']) };
|
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
* ID-token case explicitly as admissible under the audience MUST `identity.md` §2.1
|
|
11
11
|
* already carries, and refuses everything else.
|
|
12
12
|
*
|
|
13
|
-
* **Gate:** an advertised `oidc` lane
|
|
14
|
-
* synthetic issuer the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* **Gate:** an advertised `oidc` lane whose `issuers[]` lists `OPENWOP_TEST_OIDC_ISSUER_URL`,
|
|
14
|
+
* the synthetic issuer the suite holds the key for. A lane that does not list it (every
|
|
15
|
+
* production host, which must not trust a test issuer) records `inapplicable`, naming the
|
|
16
|
+
* issuers it does trust — the harness is a suite instrument the host claims by advertising
|
|
17
|
+
* it, RFC 0168 §C.1's reading for the seams profile (lib/harness-issuer.ts; corrected in
|
|
18
|
+
* 2.40.1, when this was `blocked` and denied certification to every production bundle).
|
|
19
|
+
* A claimed harness that cannot be served, or whose same-audience control is refused, is
|
|
20
|
+
* `blocked`: an assertion that a random string is refused would witness nothing.
|
|
17
21
|
*
|
|
18
22
|
* `HOST_CALLBACK_NOT_REQUIRED`: the suite stands the issuer up itself and the host reaches
|
|
19
23
|
* it for JWKS; nothing calls back into the suite's own API.
|
|
@@ -29,13 +33,14 @@
|
|
|
29
33
|
|
|
30
34
|
import { afterAll, describe, it, expect } from 'vitest';
|
|
31
35
|
import { randomBytes } from 'node:crypto';
|
|
32
|
-
import {
|
|
36
|
+
import type { Server } from 'node:http';
|
|
33
37
|
import { driver } from '../lib/driver.js';
|
|
34
38
|
import { softSkip } from '../lib/soft-skip.js';
|
|
35
39
|
import { req } from '../lib/requirement-ids.js';
|
|
36
40
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
37
|
-
import { createSyntheticOIDCIssuer,
|
|
41
|
+
import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
38
42
|
import { prmGate } from '../lib/protected-resource.js';
|
|
43
|
+
import { harnessClaimed, serveHarnessIssuer } from '../lib/harness-issuer.js';
|
|
39
44
|
|
|
40
45
|
export const HOST_CALLBACK_NOT_REQUIRED =
|
|
41
46
|
'the suite stands up the synthetic OIDC issuer and the host fetches its JWKS; no request returns to the suite\'s own API, so no host-reachable callback is needed';
|
|
@@ -50,22 +55,11 @@ afterAll(async () => {
|
|
|
50
55
|
issuer = null;
|
|
51
56
|
});
|
|
52
57
|
|
|
53
|
-
/** Stand the issuer up on the URL the
|
|
54
|
-
async function harness(audience: string): Promise<{ url: string; issuer: SyntheticOIDCIssuer } |
|
|
55
|
-
const url = process.env['OPENWOP_TEST_OIDC_ISSUER_URL']?.trim();
|
|
56
|
-
if (!url) return null;
|
|
58
|
+
/** Stand the issuer up on the URL the host's oidc lane lists (the claim is checked by the caller). */
|
|
59
|
+
async function harness(url: string, audience: string): Promise<{ url: string; issuer: SyntheticOIDCIssuer } | string> {
|
|
57
60
|
if (issuer !== null) return { url, issuer };
|
|
58
61
|
const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
|
|
59
|
-
|
|
60
|
-
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
|
|
61
|
-
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
|
|
62
|
-
res.writeHead(404); res.end();
|
|
63
|
-
});
|
|
64
|
-
await new Promise<void>((resolve, reject) => {
|
|
65
|
-
srv.once('error', reject);
|
|
66
|
-
srv.listen(issuerListenPort(url), '127.0.0.1', () => resolve());
|
|
67
|
-
});
|
|
68
|
-
server = srv;
|
|
62
|
+
try { server = await serveHarnessIssuer(made, url); } catch (e) { return `the harness issuer could not be served for ${url}: ${(e as Error).message}`; }
|
|
69
63
|
issuer = made;
|
|
70
64
|
return { url, issuer: made };
|
|
71
65
|
}
|
|
@@ -76,9 +70,16 @@ describe('RFC 0200 §D — v2-oidc-id-token-audience (gated on an oidc lane and
|
|
|
76
70
|
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
77
71
|
const oidc = g.lanes.find((l) => l.lane === 'oidc');
|
|
78
72
|
if (oidc === undefined) return softSkip('inapplicable', 'no oidc lane advertised — §D names the ID-token case ON that lane and binds no other');
|
|
73
|
+
// The harness is an instrument the host claims by listing it in the lane's issuers[]
|
|
74
|
+
// (RFC 0168 §C.1's reading; lib/harness-issuer.ts). A production host lists its real
|
|
75
|
+
// IdP and must never list a test issuer, so that host records `inapplicable` with the
|
|
76
|
+
// issuers it does trust, not `blocked` — before 2.40.1 this was `blocked` and denied
|
|
77
|
+
// certification to every honest production bundle with an oidc lane.
|
|
78
|
+
const claim = harnessClaimed(oidc as unknown as Record<string, unknown>, process.env['OPENWOP_TEST_OIDC_ISSUER_URL']);
|
|
79
|
+
if (!claim.ok) return softSkip(claim.kind, claim.reason);
|
|
79
80
|
const audience = process.env['OPENWOP_TEST_OIDC_AUDIENCE']?.trim() ?? 'openwop-conformance';
|
|
80
|
-
const h = await harness(audience);
|
|
81
|
-
if (h ===
|
|
81
|
+
const h = await harness(claim.url, audience);
|
|
82
|
+
if (typeof h === 'string') return softSkip('blocked', `the oidc lane lists the harness issuer, but ${h}`);
|
|
82
83
|
|
|
83
84
|
// An ID token, not an access token: `nonce` and `auth_time` are what make it one.
|
|
84
85
|
const good = h.issuer.mint({ sub: `conformance-${randomBytes(6).toString('hex')}`, nonce: randomBytes(8).toString('hex'), auth_time: Math.floor(Date.now() / 1000) });
|
|
@@ -90,7 +91,7 @@ describe('RFC 0200 §D — v2-oidc-id-token-audience (gated on an oidc lane and
|
|
|
90
91
|
// acceptance DOES establish, once it holds, is that the refusal below is caused by the
|
|
91
92
|
// audience and by nothing else: same issuer, same key, same token shape.
|
|
92
93
|
if (probe.status === 401) {
|
|
93
|
-
return softSkip('blocked', `a same-audience ID token from ${h.url} was refused (401 ${readErrorCode(probe.json) ?? ''}) — either the host
|
|
94
|
+
return softSkip('blocked', `a same-audience ID token from ${h.url} was refused (401 ${readErrorCode(probe.json) ?? ''}) although the oidc lane lists that issuer — either the host's trust configuration does not match its advertisement, or OPENWOP_TEST_OIDC_AUDIENCE does not name the audience it is configured with; with no accepted token the foreign-audience refusal would prove nothing`);
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
const foreign = h.issuer.mint({ sub: `conformance-${randomBytes(6).toString('hex')}`, aud: `other-client-${randomBytes(6).toString('hex')}`, nonce: randomBytes(8).toString('hex'), auth_time: Math.floor(Date.now() / 1000) });
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* drives `POST /conformance/seams/sample/auth/credential/{mint,revoke}` (the
|
|
12
12
|
* v1-shaped address `/v1/host/sample/auth/credential/…` through `seamPath()`)
|
|
13
13
|
* with `{ lane }` / `{ lane, credential }` and records `blocked` when the host
|
|
14
|
-
* does not mount it.
|
|
14
|
+
* does not mount it. A mint answer carrying `presentation: { kind: "cookie", name }`
|
|
15
|
+
* (2.40.3) is presented as `Cookie: <name>=<credential>`, so a cookie-borne
|
|
16
|
+
* `session` lane is witnessed as its production credential travels; bearer otherwise. Windowed lanes (`exp-and-recheck`, `short-lived`, `rebind`)
|
|
15
17
|
* have no wire-observable "next request" and record `inapplicable`.
|
|
16
18
|
*/
|
|
17
19
|
|
|
@@ -48,17 +50,22 @@ describe('v2 revocation-honored (RFC 0170 §B.3 — seam-gated)', () => {
|
|
|
48
50
|
const minted = await http(() => driver.post(MINT, { lane }));
|
|
49
51
|
if (minted === null) return softSkip('blocked', `${MINT} unreachable (fetch failed)`);
|
|
50
52
|
if (minted.status === 404 || minted.status === 403) return seamAbsent(`${MINT} not mounted (${minted.status}) — the host advertises the seams profile but does not serve the RFC 0170 §B.3 credential mint/revoke seams (api/seams-v2.yaml mintLaneCredential / revokeLaneCredential; host-sample-test-seams.md)`);
|
|
51
|
-
const
|
|
53
|
+
const body = minted.json as { credential?: unknown; presentation?: { kind?: unknown; name?: unknown } } | undefined;
|
|
54
|
+
const credential = body?.credential;
|
|
55
|
+
// 2.40.3 — a cookie-borne lane (a `session` lane) is presented the way its
|
|
56
|
+
// production credential is, `Cookie: <name>=<credential>`. Bearer otherwise.
|
|
57
|
+
const cookieName = body?.presentation?.kind === 'cookie' && typeof body.presentation.name === 'string' ? body.presentation.name : null;
|
|
58
|
+
const present = (c: unknown): Record<string, string> => (cookieName ? { Cookie: `${cookieName}=${String(c)}` } : { Authorization: `Bearer ${String(c)}` });
|
|
52
59
|
expect(typeof credential, req('openwop.requirement.0170.revocation-honored', DOC, `the mint seam MUST answer { credential } for lane ${lane}`)).toBe('string');
|
|
53
60
|
|
|
54
61
|
// Positive control — the minted credential authenticates before revocation.
|
|
55
|
-
const before = await http(() => driver.get('/runs/openwop-conformance-tenant/revocationprobe0123456789', { authenticated: false, headers:
|
|
62
|
+
const before = await http(() => driver.get('/runs/openwop-conformance-tenant/revocationprobe0123456789', { authenticated: false, headers: present(credential) }));
|
|
56
63
|
expect(before !== null && before.status !== 401, req('openwop.requirement.0170.revocation-honored', DOC, `a freshly minted ${lane} credential MUST authenticate before revocation (got ${before?.status ?? 'no response'})`)).toBe(true);
|
|
57
64
|
|
|
58
65
|
const revoked = await http(() => driver.post(REVOKE, { lane, credential }));
|
|
59
66
|
expect(revoked !== null && revoked.status < 400, req('openwop.requirement.0170.revocation-honored', DOC, `the revoke seam MUST accept the credential it minted for lane ${lane} (got ${revoked?.status ?? 'no response'})`)).toBe(true);
|
|
60
67
|
|
|
61
|
-
const after = await http(() => driver.get('/runs/openwop-conformance-tenant/revocationprobe0123456789', { authenticated: false, headers:
|
|
68
|
+
const after = await http(() => driver.get('/runs/openwop-conformance-tenant/revocationprobe0123456789', { authenticated: false, headers: present(credential) }));
|
|
62
69
|
expect(after?.status, req('openwop.requirement.0170.revocation-honored', DOC, `lane ${lane} MUST refuse a revoked credential on the next request with 401`)).toBe(401);
|
|
63
70
|
expect(readErrorCode(after?.json), req('openwop.requirement.0170.revocation-honored', DOC, `the refusal code MUST be credential_revoked (lane ${lane})`)).toBe('credential_revoked');
|
|
64
71
|
}
|