@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.40.3] — 2026-09-26 — a cookie-borne lane can be revoke-witnessed, and a secret-named setting no longer corrupts the discovery digest
|
|
4
|
+
|
|
5
|
+
- **`v2-revocation-honored` presents a cookie-lane credential as a cookie.** When the mint seam answers `presentation: { kind: "cookie", name }`, the probe sends `Cookie: <name>=<credential>` instead of a bearer token. The seams contract's `mintLaneCredential` response gains that optional member. Bearer stays the default when `presentation` is absent. The cookie branch is unexercised until a host serves it; openwop-app's `session` lane is the first expected.
|
|
6
|
+
- **RFC 0215 gap G1 closes in the contract peer.** `@openwop/spec-artifacts`' `spec/v1/gaps.json` records MyndHyve's measurement (openwop#1595), and `spec/v2/core/webhooks.md`'s `Stable` banner cites RFC 0215 now that it is `Accepted`. No scenario change.
|
|
7
|
+
- **A setting that names a secret is not scrubbed as one.** `evidenceSecretsFromEnv` sweeps every `OPENWOP_*` value whose NAME mentions a key/token/secret/password, and `scrubEvidence` then rewrites that value everywhere in the bundle. openwop-app's RFC 0201 setting `OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S=60` was swept, the "60" inside `discovery.sha256` was rewritten, the bundle failed `^[0-9a-f]{64}$`, and its deploy certify refused to ship. Values guessed from names are now swept only when credential-shaped (at least 8 characters and not a bare integer); a credential the emitter hands over explicitly is scrubbed whatever its shape. Sabotage: without the floor the new redaction leg fails.
|
|
8
|
+
- **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.40.3` because `2.40.2` is tagged. Not tagged, not published.
|
|
9
|
+
|
|
10
|
+
## [2.40.2] — 2026-09-26 — production bundles stop carrying harness-issuer blocked rows, and a colocated companion is marked
|
|
11
|
+
|
|
12
|
+
- **RFC 0216: `--as-colocated-companion`, and a companion witnesses only the harness-trust-anchor rows.** The flag marks a v3 bundle `host.deployment: "colocated-companion"`. It is refused without `--certify` and bundle version 3. `witnessDigest` takes the marker into the preimage when present (`{ rows, relaxations?, deployment }`), so a signed companion cannot shed it: `--verify` then refuses with `witness-digest`. New coherence scenario `v2-colocated-companion` (7 legs, one id each) drives `scripts/check-harness-trust-anchors.mjs` and `scripts/check-companion-pairing.mjs` on tmpdir fixtures signed by the suite's own `signBundleV3`. Five sabotages were each run red.
|
|
13
|
+
- **The harness OIDC issuer is an instrument a host claims, so an honest production bundle is no longer uncertifiable.** Before this, `v2-oidc-id-token-audience` (`0200.id-token-aud`) and `v2-lane-exp-only-bound` (the four `0210.exp-only-*` / `credential-lifetime-code` rows) recorded `blocked` whenever the host did not trust `OPENWOP_TEST_OIDC_ISSUER_URL`. A production host must never trust a test issuer, and one `blocked` row denies certification bundle-wide (RFC 0168 §E.1). Every production bundle advertising an `oidc` or `exp-only` lane was therefore uncertifiable (openwop-app ADR 0745, corpus defect 1). The rows are `seam-gated`, and RFC 0168 §C.1 already rules that an unclaimed suite instrument is `inapplicable`, with `blocked` reserved for a claimed instrument that does not answer. The new `lib/harness-issuer.ts` treats the harness as claimed when the gated lane's advertised `issuers[]` lists it. When the lane does not list it, the row is `inapplicable` and names the issuers the host does trust. When it does, the legs run as before (a failed serve or a refused control is still `blocked`). RFC 0200 and RFC 0210 carry dated correction notes, and `SECURITY/invariants.yaml` `lane-exp-only-lifetime-bounded` is updated. No MUST changes. The residual is stated in the RFC 0210 note: a production bundle does not witness these rows, just as it witnesses no seam-gated row. The witness comes from a cut whose lane lists the harness. **The issuer now has a bind knob:** it listens via `receiverBinding()`, which is loopback, or `0.0.0.0` under `OPENWOP_CONFORMANCE_HARNESS_HOST`, on 2.39.4's `issuerListenPort()`. It waits out `EADDRINUSE` while the other scenario holds the shared URL. Before this, it always bound `127.0.0.1`, so a host in a Linux container reaching it through `host-gateway` found nothing listening (ADR 0745 defect 2; 2.39.4 fixed the port half). **`requirements.json` resolves `const ID` declared inside a test body** (`generate-requirement-registry.mjs`). Before, only module-level constants resolved, so 14 real ids recorded `explicitId: null`: `0210.lane-rule-surfaces-agree` (ADR 0745 defect 4), three `0199.*`, six `0201.*` and four `0214.*`. Those rows were labelled by their title-derived id whenever the leg did not reach its assertion. ADR 0745 defect 5, the reused kid, was already fixed in 2.37.0: every instance mints `openwop-conformance-key-<instance>-<n>`, pinned by `oidc-issuer.test.ts`. New suite self-test `src/lib/harness-issuer.test.ts` covers the claim predicate and the port knob. It is sabotage-proved: dropping the `issuers[]` check makes it fail 2/4.
|
|
14
|
+
- **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.40.2` because `2.40.1` is released, and `spec-artifacts` now carries a changed `spec/v2/core/oauth.md` (RFC 0199's text restored; editorial, `COMPATIBILITY.md` §3 entry of 2026-09-26). No scenario changes. Not tagged, not published.
|
|
15
|
+
|
|
3
16
|
## [2.40.1] — 2026-09-26 — three declared-unwitnessable rows are reached and say why
|
|
4
17
|
|
|
5
18
|
- **Three Falsifiability rows declared non-executable now have a leg that records the reason** (RFC 0174 §B.1 rule 4, second branch: "the suite must reach the leg and record a reason"). Each RFC's table declares one of these rows unwitnessable, but no suite leg referenced it, so no bundle could account for it. Each of RFCs 0197, 0205 and 0209 was held from `Accepted` by that one row. No leg can record `executed-pass` without observing the requirement.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
|
|
12
12
|
# default resolver refuses. npm 10.9 fails outright with
|
|
13
13
|
# "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
|
|
14
|
-
npm install --legacy-peer-deps @openwop/openwop-conformance@2.40.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.40.3 @openwop/spec-artifacts@2.40.3
|
|
15
15
|
# or run without install:
|
|
16
16
|
npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
|
|
17
17
|
```
|
package/dist/cli.js
CHANGED
|
@@ -70,6 +70,7 @@ function parseArgs(argv) {
|
|
|
70
70
|
let signingKeyPath, signingKeyId, verifierKeyPath, verifierKeyId;
|
|
71
71
|
let targetMajor;
|
|
72
72
|
let maxWorkers = parseMaxWorkers(process.env.OPENWOP_MAX_WORKERS, 'OPENWOP_MAX_WORKERS');
|
|
73
|
+
let colocatedCompanion = false;
|
|
73
74
|
for (let i = 0; i < argv.length; i++) {
|
|
74
75
|
const arg = argv[i] ?? '';
|
|
75
76
|
if (arg === '-h' || arg === '--help') {
|
|
@@ -180,6 +181,12 @@ function parseArgs(argv) {
|
|
|
180
181
|
case '--host-key':
|
|
181
182
|
verifyHostKeyPath = nextValue();
|
|
182
183
|
break;
|
|
184
|
+
// RFC 0216 §B — marks the bundle as cut from a colocated companion (the
|
|
185
|
+
// served host's image beside the suite, trusting a suite-held anchor).
|
|
186
|
+
// The marker is inside witnessSha256, so a signed companion cannot shed it.
|
|
187
|
+
case '--as-colocated-companion':
|
|
188
|
+
colocatedCompanion = true;
|
|
189
|
+
break;
|
|
183
190
|
case '--require-behavior':
|
|
184
191
|
process.env['OPENWOP_REQUIRE_BEHAVIOR'] = 'true';
|
|
185
192
|
break;
|
|
@@ -215,6 +222,7 @@ function parseArgs(argv) {
|
|
|
215
222
|
maxWorkers,
|
|
216
223
|
verifyPath,
|
|
217
224
|
verifyHostKeyPath,
|
|
225
|
+
colocatedCompanion,
|
|
218
226
|
};
|
|
219
227
|
}
|
|
220
228
|
/** Parse a `--max-workers` / `OPENWOP_MAX_WORKERS` value: a positive integer, else exit 2. */
|
|
@@ -261,6 +269,10 @@ Certification (RFC 0089):
|
|
|
261
269
|
§C) records per-requirement DISPOSITIONS instead of pass/fail/skip
|
|
262
270
|
file lists, so "we could not check" stops being indistinguishable
|
|
263
271
|
from "checked and it holds". See the note it prints.
|
|
272
|
+
--as-colocated-companion v3 (RFC 0216): mark the bundle as cut from a colocated companion — the
|
|
273
|
+
served host's image run beside the suite so it can trust a suite-held
|
|
274
|
+
trust anchor. Signed (inside witnessSha256). A companion is acceptance
|
|
275
|
+
evidence only for spec/v2/harness-trust-anchors.json rows.
|
|
264
276
|
--max-workers <n> Cap concurrently running scenario files (vitest --maxWorkers).
|
|
265
277
|
Default: one worker per CPU. Use a small number against a
|
|
266
278
|
rate-limited production origin so 429s don't read as failures.
|
|
@@ -680,7 +692,7 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
680
692
|
bundleVersion: '3',
|
|
681
693
|
generatedAt: new Date().toISOString(),
|
|
682
694
|
suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
|
|
683
|
-
host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}) },
|
|
695
|
+
host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}), ...(args.colocatedCompanion ? { deployment: 'colocated-companion' } : {}) },
|
|
684
696
|
// `document` is what makes `claimedProfiles[].certified` checkable by
|
|
685
697
|
// someone other than this process (RFC 0148 §B(1)); v2 carried it and v3
|
|
686
698
|
// dropped it. `sha256` is a digest of `canonicalJSON(document)`, so the
|
|
@@ -688,7 +700,7 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
688
700
|
discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
|
|
689
701
|
claimedProfiles: claimed3,
|
|
690
702
|
results: { totals: totals3, requirements: rows3 },
|
|
691
|
-
witnessSha256: witnessDigest(rows3, relaxations),
|
|
703
|
+
witnessSha256: witnessDigest(rows3, relaxations, args.colocatedCompanion ? 'colocated-companion' : undefined),
|
|
692
704
|
assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
693
705
|
...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
|
|
694
706
|
// RFC 0158 §D: claimed ONLY when these rows support it. The verifier
|
|
@@ -856,6 +868,10 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
856
868
|
}
|
|
857
869
|
async function main() {
|
|
858
870
|
const args = parseArgs(process.argv.slice(2));
|
|
871
|
+
if (args.colocatedCompanion && (!args.certify || args.bundleVersion !== '3')) {
|
|
872
|
+
process.stderr.write('openwop-conformance: --as-colocated-companion marks a v3 certification bundle; it needs --certify with bundle version 3 (RFC 0216 §B)\n');
|
|
873
|
+
process.exit(2);
|
|
874
|
+
}
|
|
859
875
|
// Refused before anything runs: a pinned-port certification that is not
|
|
860
876
|
// single-worker loses the host's traffic to a worker nobody reads (lib/pinned-ports.ts).
|
|
861
877
|
const pinnedConflict = pinnedPortWorkerConflict(process.env, args.maxWorkers, args.certify !== undefined);
|
|
@@ -49,12 +49,20 @@ export { canonicalJSON } from './jcs.js';
|
|
|
49
49
|
* from the same bundle anywhere else (Czech sorts `ch` after `h`). Every
|
|
50
50
|
* committed bundle's ids are `[a-z0-9.-]`, where the two orders coincide, so no
|
|
51
51
|
* stored digest changes (v2-bundle-witness-preimage.test.ts pins that).
|
|
52
|
+
*
|
|
53
|
+
* RFC 0216 §B.7 — a colocated companion's `host.deployment` joins the preimage
|
|
54
|
+
* the same way, ONLY WHEN PRESENT (`{ rows, relaxations?, deployment }`), so a
|
|
55
|
+
* signed companion cannot shed the marker and pass as a served-host bundle, and
|
|
56
|
+
* every bundle without it digests exactly as before.
|
|
52
57
|
*/
|
|
53
|
-
export function witnessDigest(rows, relaxations) {
|
|
58
|
+
export function witnessDigest(rows, relaxations, deployment) {
|
|
54
59
|
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 }),
|
|
55
60
|
// ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
|
|
56
61
|
...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
|
|
57
|
-
const
|
|
62
|
+
const relaxed = relaxations !== undefined && relaxations.length > 0;
|
|
63
|
+
const preimage = relaxed || deployment !== undefined
|
|
64
|
+
? { rows: canonicalRows, ...(relaxed ? { relaxations } : {}), ...(deployment !== undefined ? { deployment } : {}) }
|
|
65
|
+
: canonicalRows;
|
|
58
66
|
return createHash('sha256').update(canonicalJSON(preimage), 'utf8').digest('hex');
|
|
59
67
|
}
|
|
60
68
|
/** The bytes the attestation covers. */
|
|
@@ -140,9 +148,9 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
140
148
|
for (const k of Object.keys(expected))
|
|
141
149
|
if (bundle.results?.totals?.[k] !== expected[k])
|
|
142
150
|
rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
|
|
143
|
-
const digest = refusedAs(rejections, 'witness-digest', 'the rows or declared relaxations', () => witnessDigest(rows, bundle.host?.relaxations));
|
|
151
|
+
const digest = refusedAs(rejections, 'witness-digest', 'the rows or declared relaxations', () => witnessDigest(rows, bundle.host?.relaxations, bundle.host?.deployment));
|
|
144
152
|
if (digest !== undefined && bundle.witnessSha256 !== digest)
|
|
145
|
-
rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows
|
|
153
|
+
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)})` });
|
|
146
154
|
const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
|
|
147
155
|
if (bundle.assertionCount !== assertions)
|
|
148
156
|
rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
|
|
@@ -267,6 +267,25 @@ export function scrubEvidence(value, secrets) {
|
|
|
267
267
|
* are excluded, and a caller may name values that MUST stay visible (`except`)
|
|
268
268
|
* regardless of which variable carried them.
|
|
269
269
|
*/
|
|
270
|
+
/**
|
|
271
|
+
* Whether an environment VALUE can be a credential at all (suite 2.40.3).
|
|
272
|
+
*
|
|
273
|
+
* The name filter above matches settings ABOUT secrets as well as secrets:
|
|
274
|
+
* openwop-app's `OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S=60` (RFC 0201's
|
|
275
|
+
* rotation overlap) was swept in, and `scrubEvidence` then rewrote every "60"
|
|
276
|
+
* in the bundle — including the one inside `discovery.sha256`, which stopped
|
|
277
|
+
* matching `^[0-9a-f]{64}$` and failed the host's own deploy certify. A short
|
|
278
|
+
* value occurs by chance inside digests, counts and timestamps, so scrubbing it
|
|
279
|
+
* corrupts the evidence without protecting anything: no credential is a bare
|
|
280
|
+
* integer or under 8 characters (NIST SP 800-63B's floor for a memorized
|
|
281
|
+
* secret). The floor applies only to values GUESSED from variable names; a
|
|
282
|
+
* credential the emitter hands over explicitly (`extra`) is always scrubbed,
|
|
283
|
+
* whatever its shape.
|
|
284
|
+
*/
|
|
285
|
+
export function credentialShaped(v) {
|
|
286
|
+
const t = v.trim();
|
|
287
|
+
return t.length >= 8 && !/^\d+$/.test(t);
|
|
288
|
+
}
|
|
270
289
|
export function evidenceSecretsFromEnv(env, extra = [], except = []) {
|
|
271
290
|
const keep = new Set(except.filter((s) => s !== undefined && s.trim() !== ''));
|
|
272
291
|
const out = new Set();
|
|
@@ -277,8 +296,11 @@ export function evidenceSecretsFromEnv(env, extra = [], except = []) {
|
|
|
277
296
|
continue;
|
|
278
297
|
if (/_ID$/.test(k))
|
|
279
298
|
continue; // a key's identifier is published, not secret
|
|
280
|
-
if (v
|
|
281
|
-
|
|
299
|
+
if (v === undefined || v.trim() === '' || keep.has(v))
|
|
300
|
+
continue;
|
|
301
|
+
if (!credentialShaped(v))
|
|
302
|
+
continue; // a setting that names a secret, not a secret
|
|
303
|
+
out.add(v);
|
|
282
304
|
}
|
|
283
305
|
for (const s of extra)
|
|
284
306
|
if (s !== undefined && s.trim() !== '' && !keep.has(s))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.40.
|
|
3
|
+
"version": "2.40.3",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.40.
|
|
59
|
+
"@openwop/spec-artifacts": "2.40.3"
|
|
60
60
|
}
|
|
61
61
|
}
|