@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/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 preimage = relaxations !== undefined && relaxations.length > 0 ? { rows: canonicalRows, relaxations } : canonicalRows;
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 and declared relaxations (${digest.slice(0, 12)})` });
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 !== undefined && v.trim() !== '' && !keep.has(v)) out.add(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) AND `OPENWOP_TEST_OIDC_ISSUER_URL` naming a
27
- * synthetic issuer the operator has configured that lane to trust (`blocked` otherwise —
28
- * without a token the host would ever accept, "it refused a string" witnesses nothing).
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 { createServer, type Server } from 'node:http';
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, issuerListenPort, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
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
- const url = process.env['OPENWOP_TEST_OIDC_ISSUER_URL']?.trim();
117
- if (!url) {
118
- return { kind: 'blocked', reason: 'OPENWOP_TEST_OIDC_ISSUER_URL is not set — without an issuer the host is configured to trust, every minted token is refused for the wrong reason and the control leg (which is what makes the refusals non-vacuous) cannot run' };
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
- const srv = createServer((r, res) => {
124
- if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
125
- if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
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 AND `OPENWOP_TEST_OIDC_ISSUER_URL` naming a
14
- * synthetic issuer the host trusts as that lane's trust root. Without the harness issuer
15
- * the suite cannot mint a token the host would ever accept, so the leg is `blocked` — an
16
- * assertion that a random string is refused would witness nothing.
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 { createServer, type Server } from 'node:http';
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, issuerListenPort, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
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 operator told the host to trust. */
54
- async function harness(audience: string): Promise<{ url: string; issuer: SyntheticOIDCIssuer } | null> {
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
- const srv = createServer((r, res) => {
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 === null) return softSkip('blocked', 'OPENWOP_TEST_OIDC_ISSUER_URL is not set — without an issuer the host trusts, no minted token could ever be accepted and the admissible half of §D cannot run');
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 does not trust the harness issuer as its oidc lane trust root, 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
+ 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. Windowed lanes (`exp-and-recheck`, `short-lived`, `rebind`)
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 credential = (minted.json as { credential?: unknown } | undefined)?.credential;
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: { Authorization: `Bearer ${String(credential)}` } }));
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: { Authorization: `Bearer ${String(credential)}` } }));
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
  }