@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +257 -14
- package/dist/lib/certification-bundle-v3.js +142 -0
- package/dist/lib/requirement-ids.js +23 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/requirement-registry.js +20 -0
- package/dist/lib/scenario-disposition.js +5 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2939 -4
- package/scenario-majors.json +164 -2
- package/schemas/CORPUS-STAMP.json +21 -21
- package/src/cli.ts +235 -19
- package/src/lib/certification-bundle-v3.ts +175 -0
- package/src/lib/driver.ts +9 -1
- package/src/lib/era2-seed.ts +197 -0
- package/src/lib/requirement-ids.ts +21 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/requirement-registry.ts +21 -0
- package/src/lib/scenario-disposition.ts +5 -1
- package/src/lib/seams.ts +45 -0
- package/src/lib/v2.ts +62 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +108 -0
- package/src/scenarios/v2-approver-enforced.test.ts +97 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
- package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
- package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
- package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
- package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
- package/src/scenarios/v2-configurable-closed.test.ts +67 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
- package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
- package/src/scenarios/v2-era-key.test.ts +86 -0
- package/src/scenarios/v2-era-stamp-universal.test.ts +103 -0
- package/src/scenarios/v2-error-registry.test.ts +89 -0
- package/src/scenarios/v2-event-type-closed.test.ts +97 -0
- package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
- package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
- package/src/scenarios/v2-header-scheme.test.ts +67 -0
- package/src/scenarios/v2-id-grammar.test.ts +77 -0
- package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
- package/src/scenarios/v2-min-client-version.test.ts +48 -0
- package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
- package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
- package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
- package/src/scenarios/v2-pack-isolation.test.ts +147 -0
- package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
- package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
- package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
- package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
- package/src/scenarios/v2-provider-conflict.test.ts +115 -0
- package/src/scenarios/v2-refresh-sla.test.ts +78 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
- package/src/scenarios/v2-revocation-honored.test.ts +66 -0
- package/src/scenarios/v2-subject-link-record.test.ts +102 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
- package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
- package/src/scenarios/v2-version-header-honored.test.ts +124 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
- package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
- package/src/setup.ts +40 -4
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
112
112
|
|
|
113
113
|
## What's Covered
|
|
114
114
|
|
|
115
|
-
The current suite has
|
|
115
|
+
The current suite has 498 scenario files under `src/scenarios/`.
|
|
116
116
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
117
117
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
118
118
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
@@ -457,7 +457,7 @@ Server-required (added in 1.7.0):
|
|
|
457
457
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
458
458
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
459
459
|
|
|
460
|
-
Current source tree:
|
|
460
|
+
Current source tree: 498 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
461
461
|
|
|
462
462
|
## Remaining Gaps
|
|
463
463
|
|
package/dist/cli.js
CHANGED
|
@@ -34,8 +34,9 @@
|
|
|
34
34
|
import { spawnSync } from 'node:child_process';
|
|
35
35
|
import { fileURLToPath } from 'node:url';
|
|
36
36
|
import { dirname, resolve as resolvePath, join } from 'node:path';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
37
38
|
import { createHash } from 'node:crypto';
|
|
38
|
-
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
39
|
+
import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
39
40
|
import { tmpdir } from 'node:os';
|
|
40
41
|
import Ajv2020 from 'ajv/dist/2020.js';
|
|
41
42
|
import addFormats from 'ajv-formats';
|
|
@@ -43,7 +44,9 @@ import { SCHEMAS_DIR } from './lib/paths.js';
|
|
|
43
44
|
import { readLedgerFile } from './lib/requirement-ledger.js';
|
|
44
45
|
import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
|
|
45
46
|
import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
|
|
46
|
-
import {
|
|
47
|
+
import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
|
|
48
|
+
import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
|
|
49
|
+
import { setV2ProfileFloors } from './lib/requirement-registry.js';
|
|
47
50
|
function parseArgs(argv) {
|
|
48
51
|
let baseUrl;
|
|
49
52
|
let apiKey;
|
|
@@ -56,7 +59,10 @@ function parseArgs(argv) {
|
|
|
56
59
|
// Suite 1.152.0: bundle v2 (RFC 0148) is the default. v1 stays reachable via
|
|
57
60
|
// `--bundle-version 1` through the RFC 0148 migration window (ends
|
|
58
61
|
// 2026-11-10) and is removed at v2.0 (spec/v1/deprecations.json).
|
|
59
|
-
let bundleVersion = '
|
|
62
|
+
let bundleVersion = '3';
|
|
63
|
+
let hostBuild;
|
|
64
|
+
let evidenceTier = 'self';
|
|
65
|
+
let signingKeyPath, signingKeyId, verifierKeyPath, verifierKeyId;
|
|
60
66
|
let targetMajor;
|
|
61
67
|
let maxWorkers = parseMaxWorkers(process.env.OPENWOP_MAX_WORKERS, 'OPENWOP_MAX_WORKERS');
|
|
62
68
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -110,17 +116,47 @@ function parseArgs(argv) {
|
|
|
110
116
|
implVersion = nextValue();
|
|
111
117
|
break;
|
|
112
118
|
case '--bundle-version': {
|
|
113
|
-
const v =
|
|
114
|
-
if (v !== '
|
|
115
|
-
process.stderr.write(
|
|
119
|
+
const v = argv[++i];
|
|
120
|
+
if (v !== '2' && v !== '3') {
|
|
121
|
+
process.stderr.write(`openwop-conformance: --bundle-version must be 2 or 3 (got ${String(v)}); v1 is gone in suite 2.0.0 (RFC 0168 §E.3)\n`);
|
|
116
122
|
process.exit(2);
|
|
117
123
|
}
|
|
124
|
+
if (v === '2')
|
|
125
|
+
process.stderr.write('openwop-conformance: --bundle-version 2 is DEPRECATED — v2 bundles stop substantiating a certification at v1 end-of-support (RFC 0168 §E.3); the default is 3.\n');
|
|
118
126
|
bundleVersion = v;
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case '--host-build': {
|
|
130
|
+
const v = argv[++i] ?? '';
|
|
131
|
+
const mm = /^(image-digest|commit|artifact-sha256):(.+)$/.exec(v);
|
|
132
|
+
if (!mm) {
|
|
133
|
+
process.stderr.write(`openwop-conformance: --host-build must be <image-digest|commit|artifact-sha256>:<id> (got ${JSON.stringify(v)})\n`);
|
|
134
|
+
process.exit(2);
|
|
135
|
+
}
|
|
136
|
+
hostBuild = { kind: mm[1], id: mm[2] };
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case '--evidence-tier': {
|
|
140
|
+
const v = argv[++i];
|
|
141
|
+
if (v !== 'self' && v !== 'steward' && v !== 'independent') {
|
|
142
|
+
process.stderr.write('openwop-conformance: --evidence-tier must be self, steward or independent\n');
|
|
143
|
+
process.exit(2);
|
|
121
144
|
}
|
|
145
|
+
evidenceTier = v;
|
|
122
146
|
break;
|
|
123
147
|
}
|
|
148
|
+
case '--signing-key':
|
|
149
|
+
signingKeyPath = argv[++i];
|
|
150
|
+
break;
|
|
151
|
+
case '--signing-key-id':
|
|
152
|
+
signingKeyId = argv[++i];
|
|
153
|
+
break;
|
|
154
|
+
case '--verifier-key':
|
|
155
|
+
verifierKeyPath = argv[++i];
|
|
156
|
+
break;
|
|
157
|
+
case '--verifier-key-id':
|
|
158
|
+
verifierKeyId = argv[++i];
|
|
159
|
+
break;
|
|
124
160
|
case '--certify':
|
|
125
161
|
certify = nextValue();
|
|
126
162
|
break;
|
|
@@ -144,6 +180,12 @@ function parseArgs(argv) {
|
|
|
144
180
|
certify,
|
|
145
181
|
bundleVersion,
|
|
146
182
|
targetMajor,
|
|
183
|
+
hostBuild,
|
|
184
|
+
evidenceTier,
|
|
185
|
+
signingKeyPath,
|
|
186
|
+
signingKeyId,
|
|
187
|
+
verifierKeyPath,
|
|
188
|
+
verifierKeyId,
|
|
147
189
|
maxWorkers,
|
|
148
190
|
};
|
|
149
191
|
}
|
|
@@ -183,7 +225,11 @@ Certification (RFC 0089):
|
|
|
183
225
|
--target-major <1|2> Suite 2.0.0 (RFC 0168 §D.3): run the scenarios for this protocol
|
|
184
226
|
major. Default: the host's preferredVersion (RFC 0179), else
|
|
185
227
|
max(protocolVersions[]), else 1. Selection is scenario-majors.json.
|
|
186
|
-
--
|
|
228
|
+
--host-build <k>:<id> v3: the build identity (image-digest|commit|artifact-sha256), or OPENWOP_HOST_BUILD.
|
|
229
|
+
--signing-key <pem> v3: Ed25519 private key (PKCS8 PEM) that signs the bundle, or OPENWOP_BUNDLE_SIGNING_KEY.
|
|
230
|
+
--signing-key-id <id> v3: the keyId the host publishes for that key, or OPENWOP_BUNDLE_SIGNING_KEY_ID.
|
|
231
|
+
--evidence-tier <t> v3: self | steward | independent (independent needs --verifier-key/--verifier-key-id).
|
|
232
|
+
--bundle-version <2|3> Certification bundle format. Default 2 (corrected 2026-09-03, RFC 0168 §E.4; the text said 1 while the code set 2). Version 2 (RFC 0148
|
|
187
233
|
§C) records per-requirement DISPOSITIONS instead of pass/fail/skip
|
|
188
234
|
file lists, so "we could not check" stops being indistinguishable
|
|
189
235
|
from "checked and it holds". See the note it prints.
|
|
@@ -245,6 +291,101 @@ function claimedProfilesFor(doc) {
|
|
|
245
291
|
profiles.push('openwop-agent-platform');
|
|
246
292
|
return profiles;
|
|
247
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* The v2 profile registry is a set of predicates over the DECLARATION
|
|
296
|
+
* (RFC 0169 §C.1): every listed family present as a record, every listed
|
|
297
|
+
* metadata key present. The v1 derivation cannot stand in — `isCore` wants a
|
|
298
|
+
* root `protocolVersion` plus `supportedEnvelopes`/`schemaVersions`/`limits`,
|
|
299
|
+
* shapes a closed v2 root does not have — so a major-2 run claimed NOTHING and
|
|
300
|
+
* no v2 host could ever certify. Falls back to the empty set only when the
|
|
301
|
+
* registry is genuinely absent from the layout, and says so.
|
|
302
|
+
*/
|
|
303
|
+
function v2RegistryPath(conformanceRoot) {
|
|
304
|
+
const candidates = [];
|
|
305
|
+
try {
|
|
306
|
+
const req = createRequire(resolvePath(conformanceRoot, 'package.json'));
|
|
307
|
+
candidates.push(resolvePath(dirname(req.resolve('@openwop/spec-artifacts/package.json')), 'spec', 'v2', 'profiles.json'));
|
|
308
|
+
}
|
|
309
|
+
catch { /* not installed as a package; the repo-layout candidates below */ }
|
|
310
|
+
candidates.push(resolvePath(conformanceRoot, 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec', 'v2', 'profiles.json'), resolvePath(conformanceRoot, '..', 'spec-artifacts', 'spec', 'v2', 'profiles.json'));
|
|
311
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
312
|
+
}
|
|
313
|
+
function claimedProfilesForV2(doc, conformanceRoot) {
|
|
314
|
+
// Resolve the peer the way `lib/paths.ts` does — through Node's resolver from
|
|
315
|
+
// this package — instead of guessing directory shapes. Hand-rolled candidates
|
|
316
|
+
// found the registry in a repo checkout and missed it in every published
|
|
317
|
+
// install, where npm hoists the peer to a SIBLING package dir: the probe that
|
|
318
|
+
// walked one level up landed on the `@openwop/` scope directory, not a
|
|
319
|
+
// package, so a real host run silently claimed nothing.
|
|
320
|
+
const found = v2RegistryPath(conformanceRoot);
|
|
321
|
+
if (found === null) {
|
|
322
|
+
process.stderr.write('openwop-conformance --certify: spec/v2/profiles.json not found in this layout; claimedProfiles is empty (RFC 0169 §C.1).\n');
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
let registry;
|
|
326
|
+
try {
|
|
327
|
+
registry = JSON.parse(readFileSync(found, 'utf8'));
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
process.stderr.write(`openwop-conformance --certify: ${found} is unreadable; claimedProfiles is empty.\n`);
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
const root = doc;
|
|
334
|
+
const isRecord = (k) => {
|
|
335
|
+
const v = root[k];
|
|
336
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
337
|
+
};
|
|
338
|
+
const out = [];
|
|
339
|
+
for (const p of registry.profiles ?? []) {
|
|
340
|
+
if (typeof p.id !== 'string')
|
|
341
|
+
continue;
|
|
342
|
+
const families = Array.isArray(p.predicate?.families) ? p.predicate.families.map(String) : [];
|
|
343
|
+
const metadata = Array.isArray(p.predicate?.metadata) ? p.predicate.metadata.map(String) : [];
|
|
344
|
+
if (families.every(isRecord) && metadata.every((k) => root[k] !== undefined))
|
|
345
|
+
out.push(p.id);
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* `spec/v2/profiles.json` `floorScenarios` → scenario file names. A
|
|
351
|
+
* `planned:<name>` entry names a scenario the registry expects but that may not
|
|
352
|
+
* exist yet; it resolves to `v2-<name>.test.ts` when that file is in the
|
|
353
|
+
* manifest and is dropped otherwise, so a planned-but-unwritten floor cannot
|
|
354
|
+
* fail a host for the corpus's own backlog.
|
|
355
|
+
*/
|
|
356
|
+
function v2ProfileFloors(conformanceRoot) {
|
|
357
|
+
const registryPath = v2RegistryPath(conformanceRoot);
|
|
358
|
+
if (registryPath === null)
|
|
359
|
+
return {};
|
|
360
|
+
let known;
|
|
361
|
+
try {
|
|
362
|
+
known = new Set(Object.keys(JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8')).majors));
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
known = new Set();
|
|
366
|
+
}
|
|
367
|
+
let registry;
|
|
368
|
+
try {
|
|
369
|
+
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return {};
|
|
373
|
+
}
|
|
374
|
+
const out = {};
|
|
375
|
+
for (const p of registry.profiles ?? []) {
|
|
376
|
+
if (typeof p.id !== 'string')
|
|
377
|
+
continue;
|
|
378
|
+
const raw = Array.isArray(p.floorScenarios) ? p.floorScenarios.map(String) : [];
|
|
379
|
+
const files = [];
|
|
380
|
+
for (const entry of raw) {
|
|
381
|
+
const name = entry.startsWith('planned:') ? `v2-${entry.slice('planned:'.length)}.test.ts` : entry.endsWith('.test.ts') ? entry : `${entry}.test.ts`;
|
|
382
|
+
if (known.size === 0 || known.has(name))
|
|
383
|
+
files.push(name);
|
|
384
|
+
}
|
|
385
|
+
out[p.id] = files;
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
248
389
|
/**
|
|
249
390
|
* Reduce a vitest JSON report into a per-scenario-file terminal state, keyed by
|
|
250
391
|
* the test-file basename (e.g. `discovery.test.ts`) to align with the basenames
|
|
@@ -294,11 +435,14 @@ function scenarioStatesFromReport(report) {
|
|
|
294
435
|
async function resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot) {
|
|
295
436
|
let major = args.targetMajor;
|
|
296
437
|
let source = 'flag';
|
|
438
|
+
let dualStack = false;
|
|
297
439
|
if (major === undefined && baseUrl) {
|
|
298
440
|
try {
|
|
299
441
|
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/.well-known/openwop`, { headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} });
|
|
300
442
|
const doc = (await res.json());
|
|
301
443
|
const pv = doc.preferredVersion ?? (doc.protocolVersions ?? []).map((v) => v).sort((a, b) => Number(b.split('.')[0]) - Number(a.split('.')[0]))[0];
|
|
444
|
+
if ((doc.protocolVersions ?? []).some((v) => v.startsWith('2.')))
|
|
445
|
+
dualStack = true;
|
|
302
446
|
if (pv) {
|
|
303
447
|
major = Number(pv.split('.')[0]) >= 2 ? 2 : 1;
|
|
304
448
|
source = doc.preferredVersion ? 'preferredVersion' : 'max(protocolVersions[])';
|
|
@@ -310,6 +454,11 @@ async function resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot) {
|
|
|
310
454
|
major = 1;
|
|
311
455
|
source = 'default';
|
|
312
456
|
}
|
|
457
|
+
// Through the overlap `preferredVersion` names the 1.x member (versioning.md
|
|
458
|
+
// §1.1), so auto-detection on a dual-stack host resolves to major 1 by design.
|
|
459
|
+
// Say so, or an operator reads a v1 run as the host's only option.
|
|
460
|
+
if (major === 1 && dualStack)
|
|
461
|
+
source += ' (the host also serves 2.x — pass --target-major 2 to measure it)';
|
|
313
462
|
const manifest = JSON.parse(readFileSync(resolvePath(conformanceRoot, 'scenario-majors.json'), 'utf8'));
|
|
314
463
|
const files = Object.entries(manifest.majors).filter(([, m]) => m.includes(major)).map(([f]) => `src/scenarios/${f}`);
|
|
315
464
|
return { major, files, source };
|
|
@@ -319,10 +468,19 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
319
468
|
if (outPath === undefined)
|
|
320
469
|
process.exit(2);
|
|
321
470
|
// (a) Fetch /.well-known/openwop verbatim + its canonical-JSON SHA-256.
|
|
471
|
+
// The target is resolved FIRST: `/.well-known/openwop` is one resource whose
|
|
472
|
+
// representation the `OpenWOP-Version` header selects, and through the overlap
|
|
473
|
+
// the header-less representation is the v1 document (spec/v2/core/versioning.md
|
|
474
|
+
// §1.1). Fetching it header-less on a major-2 run captured the v1 rendering, so
|
|
475
|
+
// `claimedProfiles` came out as the v1 set and the bundle described a contract
|
|
476
|
+
// the run never measured.
|
|
477
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
478
|
+
const conformanceRoot = resolvePath(here, '..');
|
|
479
|
+
const target = await resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot);
|
|
322
480
|
const discoveryUrl = `${baseUrl.replace(/\/$/, '')}/.well-known/openwop`;
|
|
323
481
|
let document;
|
|
324
482
|
try {
|
|
325
|
-
const resp = await fetch(discoveryUrl, { headers: { Accept: 'application/json' } });
|
|
483
|
+
const resp = await fetch(discoveryUrl, { headers: { Accept: 'application/json', ...(target.major === 2 ? { 'OpenWOP-Version': '2.0' } : {}) } });
|
|
326
484
|
if (!resp.ok) {
|
|
327
485
|
process.stderr.write(`openwop-conformance --certify: GET ${discoveryUrl} returned HTTP ${resp.status}.\n`);
|
|
328
486
|
process.exit(2);
|
|
@@ -335,11 +493,13 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
335
493
|
}
|
|
336
494
|
const sha256 = createHash('sha256').update(canonicalJSON(document)).digest('hex');
|
|
337
495
|
// (b) Derive claimedProfiles from the captured document.
|
|
338
|
-
const claimedProfiles = claimedProfilesFor(document);
|
|
496
|
+
const claimedProfiles = target.major === 2 ? claimedProfilesForV2(document, conformanceRoot) : claimedProfilesFor(document);
|
|
497
|
+
// Major-2 floors come from spec/v2/profiles.json. Without this the derivation
|
|
498
|
+
// measures a v2 host against v1 scenario files a major-2 run never executes,
|
|
499
|
+
// and refuses certification for not running them.
|
|
500
|
+
setV2ProfileFloors(target.major === 2 ? v2ProfileFloors(conformanceRoot) : null);
|
|
339
501
|
// (c) Run the suite, capturing per-scenario terminal state via the vitest
|
|
340
502
|
// JSON reporter. server-targeted scenarios live under src/scenarios/.
|
|
341
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
342
|
-
const conformanceRoot = resolvePath(here, '..');
|
|
343
503
|
const reportDir = mkdtempSync(join(tmpdir(), 'owp-certify-'));
|
|
344
504
|
const reportFile = join(reportDir, 'vitest-report.json');
|
|
345
505
|
// RFC 0148 §A ledger sink (S6): every scenario file records its disposition
|
|
@@ -354,7 +514,6 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
354
514
|
env.OPENWOP_IMPLEMENTATION_NAME = args.impl;
|
|
355
515
|
if (args.implVersion)
|
|
356
516
|
env.OPENWOP_IMPLEMENTATION_VERSION = args.implVersion;
|
|
357
|
-
const target = await resolveTargetMajor(args, baseUrl, apiKey, conformanceRoot);
|
|
358
517
|
env.OPENWOP_TARGET_MAJOR = String(target.major);
|
|
359
518
|
process.stderr.write(`openwop-conformance --certify: target major ${target.major} (${target.source}); ${target.files.length} scenario file(s)\n`);
|
|
360
519
|
const vitestArgs = [
|
|
@@ -460,6 +619,90 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
460
619
|
' evidence is inspectable, but it does not certify and this process exits 3.\n' +
|
|
461
620
|
rejectedProfiles.map((v) => ` - ${v.profile}: ${v.unclassified.join(', ')}\n`).join(''));
|
|
462
621
|
}
|
|
622
|
+
if (args.bundleVersion === '3') {
|
|
623
|
+
// ── Bundle v3 (RFC 0168 §E) ─────────────────────────────────────────────
|
|
624
|
+
const build = args.hostBuild ?? (() => {
|
|
625
|
+
const mm = /^(image-digest|commit|artifact-sha256):(.+)$/.exec(process.env['OPENWOP_HOST_BUILD'] ?? '');
|
|
626
|
+
return mm ? { kind: mm[1], id: mm[2] } : undefined;
|
|
627
|
+
})();
|
|
628
|
+
const signingKeyPem = args.signingKeyPath ? readFileSync(args.signingKeyPath, 'utf8') : process.env['OPENWOP_BUNDLE_SIGNING_KEY'];
|
|
629
|
+
const keyId = args.signingKeyId ?? process.env['OPENWOP_BUNDLE_SIGNING_KEY_ID'];
|
|
630
|
+
if (!build || !signingKeyPem || !keyId) {
|
|
631
|
+
process.stderr.write('openwop-conformance --certify: a v3 bundle needs --host-build <kind>:<id> (or OPENWOP_HOST_BUILD), --signing-key <pem> (or OPENWOP_BUNDLE_SIGNING_KEY) and --signing-key-id (or OPENWOP_BUNDLE_SIGNING_KEY_ID) — an unsigned bundle does not exist in v3 (RFC 0168 §E.2).\n');
|
|
632
|
+
process.exit(2);
|
|
633
|
+
}
|
|
634
|
+
const rows3 = derived.requirements.map((r) => ({ id: r.requirementId, scenario: r.scenarioId, result: r.disposition, ...(r.assertionCount === undefined ? {} : { assertions: r.assertionCount }), ...(r.detail === undefined ? {} : { detail: r.detail }) }));
|
|
635
|
+
const totals3 = derived.totals;
|
|
636
|
+
const doc3 = document;
|
|
637
|
+
const protocolVersions = Array.isArray(doc3['protocolVersions']) ? doc3['protocolVersions'] : [String(doc3['protocolVersion'] ?? '')];
|
|
638
|
+
const preferredVersion = typeof doc3['preferredVersion'] === 'string' ? doc3['preferredVersion'] : (protocolVersions[0] ?? '');
|
|
639
|
+
// witnessCount: executed-pass rows on the profile's floor (PROFILE_FLOOR_SCENARIOS required + requiredAnyPrefix).
|
|
640
|
+
const witnessCountFor = (profile) => {
|
|
641
|
+
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
642
|
+
if (!floor)
|
|
643
|
+
return 0;
|
|
644
|
+
const onFloor = (scenario) => floor.required.includes(scenario) || (floor.requiredAnyPrefix ?? []).some((pre) => scenario.startsWith(pre));
|
|
645
|
+
return derived.requirements.filter((r) => r.disposition === 'executed-pass' && onFloor(r.scenarioId)).length;
|
|
646
|
+
};
|
|
647
|
+
const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
|
|
648
|
+
let relaxations;
|
|
649
|
+
if (process.env['OPENWOP_HOST_RELAXATIONS']) {
|
|
650
|
+
try {
|
|
651
|
+
relaxations = JSON.parse(process.env['OPENWOP_HOST_RELAXATIONS']);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
process.stderr.write('openwop-conformance --certify: OPENWOP_HOST_RELAXATIONS is not JSON\n');
|
|
655
|
+
process.exit(2);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
|
|
659
|
+
const lock = existsSync(lockPath) ? JSON.parse(readFileSync(lockPath, 'utf8')) : undefined;
|
|
660
|
+
const nonPass = rows3.filter((r) => r.result !== 'executed-pass');
|
|
661
|
+
const unsigned = {
|
|
662
|
+
bundleVersion: '3',
|
|
663
|
+
generatedAt: new Date().toISOString(),
|
|
664
|
+
suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
|
|
665
|
+
host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}) },
|
|
666
|
+
discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion },
|
|
667
|
+
claimedProfiles: claimed3,
|
|
668
|
+
results: { totals: totals3, requirements: rows3 },
|
|
669
|
+
witnessSha256: witnessDigest(rows3),
|
|
670
|
+
assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
671
|
+
...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
|
|
672
|
+
};
|
|
673
|
+
const signature = signBundleV3(unsigned, signingKeyPem, keyId);
|
|
674
|
+
const v3 = { ...unsigned, signature };
|
|
675
|
+
if (args.evidenceTier === 'independent') {
|
|
676
|
+
const vk = args.verifierKeyPath ? readFileSync(args.verifierKeyPath, 'utf8') : process.env['OPENWOP_BUNDLE_VERIFIER_KEY'];
|
|
677
|
+
const vkId = args.verifierKeyId ?? process.env['OPENWOP_BUNDLE_VERIFIER_KEY_ID'];
|
|
678
|
+
if (!vk || !vkId) {
|
|
679
|
+
process.stderr.write('openwop-conformance --certify: --evidence-tier independent needs --verifier-key and --verifier-key-id (RFC 0168 §E.2)\n');
|
|
680
|
+
process.exit(2);
|
|
681
|
+
}
|
|
682
|
+
v3.verifierSignature = verifierSign(unsigned, vk, vkId);
|
|
683
|
+
}
|
|
684
|
+
const secrets3 = evidenceSecretsFromEnv(process.env, [apiKey, signingKeyPem]);
|
|
685
|
+
const scrubbed3 = scrubEvidence(v3, secrets3);
|
|
686
|
+
const v3Out = scrubbed3.value;
|
|
687
|
+
const audit3 = verifyBundleV3(v3Out, { hostPublicKeyPem: publicKeyFromPrivate(signingKeyPem) });
|
|
688
|
+
const emitterDefects = audit3.rejections.filter((r) => !['blocked-certified', 'relaxed-profile-certified', 'independent-unverifiable'].includes(r.kind));
|
|
689
|
+
if (emitterDefects.length > 0) {
|
|
690
|
+
process.stderr.write('openwop-conformance --certify: assembled v3 bundle FAILED self-verification (emitter defect):\n' + emitterDefects.map((r) => ` - [${r.kind}] ${r.detail}`).join('\n') + '\n');
|
|
691
|
+
process.exit(2);
|
|
692
|
+
}
|
|
693
|
+
const v3Schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'certification-bundle.schema.json'), 'utf8'));
|
|
694
|
+
const v3Ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
695
|
+
addFormats(v3Ajv);
|
|
696
|
+
v3Ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'ids.schema.json'), 'utf8')));
|
|
697
|
+
const v3Validate = v3Ajv.compile(v3Schema);
|
|
698
|
+
if (!v3Validate(v3Out)) {
|
|
699
|
+
process.stderr.write('openwop-conformance --certify: assembled v3 bundle FAILED schema validation:\n' + `${JSON.stringify(v3Validate.errors, null, 2)}\n`);
|
|
700
|
+
process.exit(2);
|
|
701
|
+
}
|
|
702
|
+
writeFileSync(outPath, `${JSON.stringify(v3Out, null, 2)}\n`);
|
|
703
|
+
process.stdout.write(`openwop-conformance --certify: wrote bundle v3 → ${outPath} (${rows3.length} requirement rows, ${v3Out.assertionCount} assertions, witness ${v3Out.witnessSha256.slice(0, 12)}, signed by ${keyId}; certified: ${audit3.certifiedProfiles.join(', ') || 'none'})\n`);
|
|
704
|
+
process.exit(rejectedProfiles.length > 0 ? 3 : failed.length > 0 ? 1 : 0);
|
|
705
|
+
}
|
|
463
706
|
if (args.bundleVersion === '2') {
|
|
464
707
|
const scenarioIds = [...passed, ...failed, ...skipped].sort();
|
|
465
708
|
const manifestSha = createHash('sha256').update(scenarioIds.join('\n'), 'utf8').digest('hex');
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Certification bundle v3 (RFC 0168 §E; schemas/v2/certification-bundle.schema.json).
|
|
3
|
+
*
|
|
4
|
+
* What v3 adds over v2 and why:
|
|
5
|
+
* - a closed root and `witnessSha256` over the reporter record (RFC 0148 §C
|
|
6
|
+
* asked for it; the v2 schema listed it optional and no emitter wrote it);
|
|
7
|
+
* - `assertionCount`, `host.build`, per-profile `evidenceTier`/`witnessCount`,
|
|
8
|
+
* `detail` (required when any result is not executed-pass);
|
|
9
|
+
* - an Ed25519 attestation over the canonical JSON of
|
|
10
|
+
* { witnessSha256, host.build, suite.version, discovery.sha256 } — the same
|
|
11
|
+
* commit rebuilt once scored 283/22 and 303/2 and nothing in the bundle could
|
|
12
|
+
* tell them apart; the build id plus the signature can;
|
|
13
|
+
* - `evidenceTier: independent` REQUIRES a verifier signature under a key id
|
|
14
|
+
* distinct from the host's (RFC 0148 R5: a malicious generator fabricates
|
|
15
|
+
* witnesses — a self-signed "independent" claim is refused, not warned).
|
|
16
|
+
*
|
|
17
|
+
* This module is schema-agnostic on purpose (the schema check is a separate step
|
|
18
|
+
* in cli.ts) and pure: it never reads the environment.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
21
|
+
export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'];
|
|
22
|
+
/** Deterministic JSON: keys sorted at every level, no whitespace. */
|
|
23
|
+
export function canonicalJSON(value) {
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return `[${value.map(canonicalJSON).join(',')}]`;
|
|
26
|
+
if (value !== null && typeof value === 'object') {
|
|
27
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJSON(value[k])}`).join(',')}}`;
|
|
28
|
+
}
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
/** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
|
|
32
|
+
export function witnessDigest(rows) {
|
|
33
|
+
const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(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 }) }));
|
|
34
|
+
return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
|
|
35
|
+
}
|
|
36
|
+
/** The bytes the attestation covers. */
|
|
37
|
+
export function attestationPayload(b) {
|
|
38
|
+
return Buffer.from(canonicalJSON({ witnessSha256: b.witnessSha256, 'host.build': b.host.build, 'suite.version': b.suite.version, 'discovery.sha256': b.discovery.sha256 }), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
function toBase64url(buf) { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
|
41
|
+
function fromBase64url(s) { return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
|
|
42
|
+
export function signBundleV3(bundle, privateKeyPem, keyId) {
|
|
43
|
+
const key = createPrivateKey(privateKeyPem);
|
|
44
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
45
|
+
throw new Error(`bundle v3 signing key must be Ed25519, got ${String(key.asymmetricKeyType)}`);
|
|
46
|
+
const sig = edSign(null, attestationPayload(bundle), key);
|
|
47
|
+
return { alg: 'ed25519', keyId, sig: toBase64url(sig), over: [...SIGNATURE_OVER] };
|
|
48
|
+
}
|
|
49
|
+
export function verifierSign(bundle, privateKeyPem, keyId) {
|
|
50
|
+
const key = createPrivateKey(privateKeyPem);
|
|
51
|
+
return { alg: 'ed25519', keyId, sig: toBase64url(edSign(null, attestationPayload(bundle), key)) };
|
|
52
|
+
}
|
|
53
|
+
export function publicKeyFromPrivate(privateKeyPem) {
|
|
54
|
+
return createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' });
|
|
55
|
+
}
|
|
56
|
+
export function verifyBundleV3(bundle, opts = {}) {
|
|
57
|
+
const rejections = [];
|
|
58
|
+
if (bundle.bundleVersion !== '3')
|
|
59
|
+
rejections.push({ kind: 'not-v3', detail: `bundleVersion is ${JSON.stringify(bundle.bundleVersion)}, expected "3"` });
|
|
60
|
+
const rows = bundle.results?.requirements ?? [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
if (seen.has(r.id))
|
|
64
|
+
rejections.push({ kind: 'duplicate-requirement', detail: `${r.id}: more than one row (RFC 0148 §A)` });
|
|
65
|
+
seen.add(r.id);
|
|
66
|
+
if (r.result !== 'executed-pass' && !(r.detail && r.detail.trim()))
|
|
67
|
+
rejections.push({ kind: 'reason-missing', detail: `${r.id}: '${r.result}' recorded without a reason` });
|
|
68
|
+
if (r.result === 'executed-pass' && !(r.assertions && r.assertions > 0))
|
|
69
|
+
rejections.push({ kind: 'vacuous-pass', detail: `${r.id}: executed-pass with no assertions is a witness of nothing (RFC 0148 §C)` });
|
|
70
|
+
}
|
|
71
|
+
const count = (d) => rows.filter((r) => r.result === d).length;
|
|
72
|
+
const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
|
|
73
|
+
for (const k of Object.keys(expected))
|
|
74
|
+
if (bundle.results?.totals?.[k] !== expected[k])
|
|
75
|
+
rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
|
|
76
|
+
const digest = witnessDigest(rows);
|
|
77
|
+
if (bundle.witnessSha256 !== digest)
|
|
78
|
+
rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows (${digest.slice(0, 12)})` });
|
|
79
|
+
const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
|
|
80
|
+
if (bundle.assertionCount !== assertions)
|
|
81
|
+
rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
|
|
82
|
+
const nonPass = rows.filter((r) => r.result !== 'executed-pass');
|
|
83
|
+
if (nonPass.length > 0 && !bundle.detail)
|
|
84
|
+
rejections.push({ kind: 'detail-missing', detail: `${nonPass.length} non-pass row(s) and no detail block` });
|
|
85
|
+
if (!bundle.host?.build?.kind || !bundle.host?.build?.id)
|
|
86
|
+
rejections.push({ kind: 'build-missing', detail: 'host.build.{kind,id} is required — a bundle attributes to a BUILD, not a commit' });
|
|
87
|
+
// Signature
|
|
88
|
+
let signatureVerified = false;
|
|
89
|
+
const sig = bundle.signature;
|
|
90
|
+
if (!sig || sig.alg !== 'ed25519' || !sig.keyId || !sig.sig)
|
|
91
|
+
rejections.push({ kind: 'signature-missing', detail: 'signature.{alg: ed25519, keyId, sig} is required in v3' });
|
|
92
|
+
else if (JSON.stringify(sig.over) !== JSON.stringify(SIGNATURE_OVER))
|
|
93
|
+
rejections.push({ kind: 'signature-over', detail: `signature.over must be ${JSON.stringify(SIGNATURE_OVER)}` });
|
|
94
|
+
else if (opts.hostPublicKeyPem) {
|
|
95
|
+
const key = createPublicKey(opts.hostPublicKeyPem);
|
|
96
|
+
signatureVerified = edVerify(null, attestationPayload(bundle), key, fromBase64url(sig.sig));
|
|
97
|
+
if (!signatureVerified)
|
|
98
|
+
rejections.push({ kind: 'signature-invalid', detail: 'the attestation does not verify under the host key' });
|
|
99
|
+
}
|
|
100
|
+
// Independent tier
|
|
101
|
+
let verifierSignatureVerified = false;
|
|
102
|
+
const claimsIndependent = (bundle.claimedProfiles ?? []).some((p) => p.evidenceTier === 'independent');
|
|
103
|
+
if (claimsIndependent) {
|
|
104
|
+
const vs = bundle.verifierSignature;
|
|
105
|
+
if (!vs || !vs.keyId || !vs.sig)
|
|
106
|
+
rejections.push({ kind: 'independent-unsigned', detail: 'evidenceTier independent requires a verifierSignature (RFC 0168 §E.2)' });
|
|
107
|
+
else if (vs.keyId === sig?.keyId || vs.keyId === bundle.host?.signingKeyId)
|
|
108
|
+
rejections.push({ kind: 'independent-self-signed', detail: 'the verifier key must be distinct from the host key (RFC 0148 R5)' });
|
|
109
|
+
else if (opts.verifierPublicKeyPem) {
|
|
110
|
+
verifierSignatureVerified = edVerify(null, attestationPayload(bundle), createPublicKey(opts.verifierPublicKeyPem), fromBase64url(vs.sig));
|
|
111
|
+
if (!verifierSignatureVerified)
|
|
112
|
+
rejections.push({ kind: 'verifier-signature-invalid', detail: 'the verifier attestation does not verify' });
|
|
113
|
+
}
|
|
114
|
+
else
|
|
115
|
+
rejections.push({ kind: 'independent-unverifiable', detail: 'an independent claim needs the verifier public key to verify; refused, not assumed' });
|
|
116
|
+
}
|
|
117
|
+
// Relaxations: a relaxed obligation's profile cannot certify (RFC 0173 §A.2).
|
|
118
|
+
const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
|
|
119
|
+
const certifiedProfiles = [];
|
|
120
|
+
for (const p of bundle.claimedProfiles ?? []) {
|
|
121
|
+
if (p.certified && relaxed.size > 0 && [...relaxed].some((o) => p.id.includes(o)))
|
|
122
|
+
rejections.push({ kind: 'relaxed-profile-certified', profile: p.id, detail: `${p.id} is marked certified while a relaxation on ${[...relaxed].join(', ')} is recorded (RFC 0173 §A.2)` });
|
|
123
|
+
else if (p.certified)
|
|
124
|
+
certifiedProfiles.push(p.id);
|
|
125
|
+
}
|
|
126
|
+
if (expected.blocked > 0 && certifiedProfiles.length > 0)
|
|
127
|
+
rejections.push({ kind: 'blocked-certified', detail: `${expected.blocked} blocked row(s): a bundle with blocked > 0 does not certify (RFC 0168 §E.1)` });
|
|
128
|
+
// RFC 0168 §E.2: the verifier REFUSES, it does not warn — but a refusal is
|
|
129
|
+
// scoped to what it names. A rejection carrying a `profile` removes that
|
|
130
|
+
// profile only (a relaxation on one obligation does not poison an unrelated
|
|
131
|
+
// one, RFC 0173 §A.2); a rejection that names no profile is a statement about
|
|
132
|
+
// the bundle — a bad witness digest, an unverifiable signature, a self-signed
|
|
133
|
+
// `independent` claim, a blocked row — and nothing in it certifies.
|
|
134
|
+
const bundleWide = rejections.filter((r) => !('profile' in r) || !r.profile);
|
|
135
|
+
const scoped = new Set(rejections.map((r) => r.profile).filter((p) => typeof p === 'string'));
|
|
136
|
+
return {
|
|
137
|
+
rejections,
|
|
138
|
+
signatureVerified,
|
|
139
|
+
verifierSignatureVerified,
|
|
140
|
+
certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -69,6 +69,29 @@ export function scenarioFileOfItId(requirementId) {
|
|
|
69
69
|
return null;
|
|
70
70
|
return `${rest.slice(0, dot)}.test.ts`;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Every FILE-derived id back to its file: the per-test `openwop.it.<stem>.<title>`
|
|
74
|
+
* plus the per-file rows the ledger also carries, `openwop.scenario.<stem>` and
|
|
75
|
+
* `openwop.floor.<stem>`. Gate rows (`openwop.profile.*`, `openwop.family.*`),
|
|
76
|
+
* explicit `req()` ids and the `openwop.floor.any.<prefix>` GROUP name no single
|
|
77
|
+
* file and return null. A caller that understood only `it` ids read a per-file
|
|
78
|
+
* row as an un-cited requirement and demanded a `req()` literal that, by
|
|
79
|
+
* construction, never exists.
|
|
80
|
+
*/
|
|
81
|
+
export function scenarioFileOfId(requirementId) {
|
|
82
|
+
const it = scenarioFileOfItId(requirementId);
|
|
83
|
+
if (it !== null)
|
|
84
|
+
return it;
|
|
85
|
+
for (const prefix of ['openwop.scenario.', 'openwop.floor.']) {
|
|
86
|
+
if (!requirementId.startsWith(prefix))
|
|
87
|
+
continue;
|
|
88
|
+
const rest = requirementId.slice(prefix.length);
|
|
89
|
+
if (rest.length === 0 || rest.startsWith('any.'))
|
|
90
|
+
return null;
|
|
91
|
+
return `${rest}.test.ts`;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
72
95
|
/**
|
|
73
96
|
* Collision suffixing: the first occurrence keeps the bare id, later ones get
|
|
74
97
|
* `~2`, `~3`, … Keyed per file so a worker running many files never crosses
|
|
@@ -63,6 +63,7 @@ export function recordRequirement(requirementId, disposition, detail, extras) {
|
|
|
63
63
|
disposition,
|
|
64
64
|
...(detail === undefined ? {} : { detail }),
|
|
65
65
|
...(extras?.assertionCount === undefined ? {} : { assertionCount: extras.assertionCount }),
|
|
66
|
+
...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
|
|
66
67
|
};
|
|
67
68
|
ledger.set(requirementId, entry);
|
|
68
69
|
journal.push(entry);
|
|
@@ -76,7 +76,27 @@ export function floorFilesFor(profile, document) {
|
|
|
76
76
|
* discovery-conditional floor (RFC 0148 §C G7 — `openwop-replay-fork`): without
|
|
77
77
|
* it such a floor is UNEVALUABLE and this returns `null`, never `[]`.
|
|
78
78
|
*/
|
|
79
|
+
/**
|
|
80
|
+
* Major-2 floors come from `spec/v2/profiles.json`, not from the v1 table.
|
|
81
|
+
* `PROFILE_FLOOR_SCENARIOS` names v1 scenario FILES (`runs-lifecycle.test.ts`,
|
|
82
|
+
* `discovery.test.ts`, …) that `scenario-majors.json` assigns to major 1 and a
|
|
83
|
+
* major-2 run therefore never executes — so every one of them came back
|
|
84
|
+
* unclassified and a v2 host was refused certification for not running v1
|
|
85
|
+
* scenarios. Set by the runner before deriving; empty means the registry
|
|
86
|
+
* declares no floor for that profile, which is a real "witnesses nothing yet",
|
|
87
|
+
* not an unclassified return.
|
|
88
|
+
*/
|
|
89
|
+
let v2Floors = null;
|
|
90
|
+
export function setV2ProfileFloors(floors) {
|
|
91
|
+
v2Floors = floors;
|
|
92
|
+
}
|
|
79
93
|
export function requirementsFor(profile, document) {
|
|
94
|
+
if (v2Floors !== null) {
|
|
95
|
+
const files = v2Floors[profile];
|
|
96
|
+
if (files === undefined)
|
|
97
|
+
return null;
|
|
98
|
+
return files.map(requirementIdForScenario);
|
|
99
|
+
}
|
|
80
100
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
81
101
|
if (floor === undefined)
|
|
82
102
|
return null;
|
|
@@ -241,7 +241,11 @@ document) {
|
|
|
241
241
|
// them. This is the granularity RFC 0148 §A describes and the G8 fix.
|
|
242
242
|
const emitted = new Set(rows.map((r) => r.requirementId));
|
|
243
243
|
for (const e of [...ledger].sort((a, b) => a.requirementId.localeCompare(b.requirementId))) {
|
|
244
|
-
|
|
244
|
+
// Prefer the file the entry recorded: an explicit `req()` id
|
|
245
|
+
// (`openwop.requirement.…`) is authored, not file-derived, so deriving a
|
|
246
|
+
// file from the id alone returned null and the row was dropped — every
|
|
247
|
+
// explicit requirement id was missing from bundle v3 for that reason.
|
|
248
|
+
const file = e.scenarioFile ?? scenarioFileOfItId(e.requirementId);
|
|
245
249
|
// Attribute only to files this run reported on: a worker's ledger can carry
|
|
246
250
|
// rows from files outside the certified set (the suite's own lib tests, or
|
|
247
251
|
// a filtered run), and those are not evidence about the host.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "@openwop/spec-artifacts",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
4
|
-
"stampSha256": "
|
|
3
|
+
"version": "2.0.0-rc.10",
|
|
4
|
+
"stampSha256": "9bd46582060eb1408c0bcac781805f0cee84cd4b2955251bed4a5b328e19e4af"
|
|
5
5
|
}
|