@openwop/openwop-conformance 2.37.1 → 2.38.0
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 +9 -0
- package/README.md +3 -3
- package/dist/cli.js +8 -18
- package/dist/lib/certification-bundle-v3.js +46 -18
- package/dist/lib/jcs.js +274 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +247 -33
- package/scenario-majors.json +7 -3
- package/schemas/CORPUS-STAMP.json +29 -29
- package/src/cli.ts +8 -16
- package/src/lib/certification-bundle-v3.ts +42 -17
- package/src/lib/front-mux.ts +36 -1
- package/src/lib/jcs.ts +229 -0
- package/src/lib/llm-cache-key-recipe.ts +10 -20
- package/src/lib/scoped-receiver.ts +2 -2
- package/src/scenarios/jcs-vectors.test.ts +108 -0
- package/src/scenarios/semantic-digest-vectors.test.ts +8 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +28 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +18 -4
- package/src/scenarios/v2-content-locale-keys.test.ts +10 -1
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +7 -1
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +7 -1
- package/vectors/jcs-v1.json +294 -0
- package/vectors/semantic-request-digest-v2.json +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.38.0] — 2026-09-24 — two RFCs add legs, and six rows that could not pass on a conforming host now can
|
|
4
|
+
|
|
5
|
+
- **Two rows the 2026-09-24 public v2-reference cut could not explain now say why, and one leg gets the time a tunnel needs.** `v2-mrtr-rounds-ceiling`'s `.refused` leg recorded only `expected 400 to be 422` on that cut, but passes on every loopback run of the host. Its failure message now carries the host's error code, how many `tools/call` the suite's MCP fake served, and the response body. Those facts separate "the host refused a round the fake answered" from "a round the tunnel failed to carry". `v2-oauth-client-pkce-state-iss`'s state leg gets a 120 s per-test timeout instead of 30 s: it makes eight sequential round trips through the AS double's tunnel, and on that cut it alone timed out while its shorter sibling legs passed. No assertion, requirement or window changes.
|
|
6
|
+
- **`v2-a2ui-v09-surface`'s two §C.11 fork legs could never pass on a conforming host.** Both recorded a surface on the suspended `conformance-approval` run and then forked at `fromSeq: sequence + 1`. The surface is the last event of that run, so `sequence + 1` names no event, and `spec/v2/core/runs.md` §Fork requires `422 fork_point_invalid` for a `fromSeq` not in the source log. The two ids affected were `openwop.it.v2-a2ui-v09-surface.recorded-as-recorded` and `openwop.requirement.0209.legacy-readable`. Each leg now records one more envelope: an `updateDataModel` on the same live surface (version 2), or a second version-1 surface (legacy). It then forks at the sequence the host reported for that envelope, so the surface is fixed history (`sequence < fromSeq`) and the fork point exists. What the fork must carry is unchanged. New suite self-test `src/lib/fork-point-recorded.test.ts` refuses any scenario whose `fromSeq` is arithmetic on another sequence (`fromSeq: x + 1`). It is sabotage-proved: restoring the old file makes it fail and name both lines (`:220`, `:237`). No other scenario has the pattern.
|
|
7
|
+
- **RFC 0212 — canonical JSON is JCS over I-JSON, and the certification digest no longer depends on the machine's locale.** New `src/lib/jcs.ts` is the suite's one canonicalizer: `canonicalJSON` (RFC 8785, refuses non-finite numbers, non-JSON values and lone surrogates) and `parseIJson` (refuses duplicate names and integer literals beyond ±(2^53 − 1), which `JSON.parse` would silently resolve). `certification-bundle-v3.ts` re-exports it and sorts `witnessSha256` rows by UTF-16 code units (was `localeCompare`); `cli.ts` reads the discovery document and a `--verify` bundle through `parseIJson` and drops its private copy; `llm-cache-key-recipe.ts` sorts `tools[]` by code units. New `vectors/jcs-v1.json`, server-free scenario `jcs-vectors` (both majors), coherence test `v2-bundle-witness-preimage` (every committed v3 bundle re-derives from the prose preimage; count asserted), and `semantic-request-digest-v2.json` gains `tools-code-unit-order` / `-reversed`. No committed bundle's digest changed. **Behavior change:** `--certify` now refuses (exit 2) a host whose discovery document is not I-JSON (a duplicate member name or an integer literal beyond ±(2^53 − 1)), and `verifyBundleV3` turns a non-I-JSON value into a `witness-digest` / `discovery-digest` / `signature-invalid` rejection instead of hashing coerced bytes (self-tested, sabotage-checked).
|
|
8
|
+
- **A public front that carries a PATH no longer sends every delivery to "addressed elsewhere".** `lib/front-mux.ts` (2.37.0) nonce-paths each suite fixture behind the operator's single front, and `lib/scoped-receiver.ts` (#1526) put the webhook receiver on it. Both matched only request paths STARTING with `/fx/`. A tunnel forwards the whole path, and `cut-public.sh` fronts the receiver at `${RX_URL}/hook`, so the pinned listener saw `/hook/fx/<nonce>` and answered every delivery as a stranger's. Measured on the 2026-09-24 public v2-reference cut on suite 2.37.1 (reported by openwop-75): `0171.webhook-delivery-shape`, `0173.webhook-durable-delivery` (+ `.dead-letter`) and `0187.bound-id-kinds.webhook-emitted` failed, deterministically on any path-bearing front and invisibly on loopback, where there is no front. `routeFronted` now strips the front's own path before matching (`frontPath` / `withoutFrontPath`), which also gives the pinned owner its own fronted traffic without the prefix; `scoped-receiver`'s `foreign()` count reads the same stripped path. New host-free legs use a path-bearing front for the webhook receiver (own delivery, sibling routing) and the A2A peer (a scenario-owned fake and the owner's own card); removing the strip reds all three.
|
|
9
|
+
- **`0206.delivery-extended-locale` could never execute: `v2-content-locale-keys` gated on a `supported` field the v2 `content` record cannot carry.** The gate was `content === null || content['supported'] !== true`. The v2 `content` record in `schemas/v2/capabilities.schema.json` is `additionalProperties: false` with no `supported` property — at major 2 presence of the record is the claim (RFC 0169 §A.2) — so on every schema-valid host — MyndHyve's committed record (`content: {baseLocale, supportedLocales}`, suite 2.35.1, which predates the scenario) included — the row would record `inapplicable` for a reason that was not true, even on a host advertising `es-419`. The gate is now presence (`familyAdvertised`); the rest of the scenario is unchanged. New suite self-test `src/lib/v2-family-gate-no-supported.test.ts` refuses any `v2-*` scenario comparing `supported` to a boolean, pins the premise (no v2 capability record declares `supported`), and is sabotage-proved: restoring the old line reds it naming `v2-content-locale-keys.test.ts:55`. A sweep of every `v2-*` scenario and the libraries they call found no second instance — the other `supported` reads are error-body arrays (`details.supported`, `error.data.supported`) and `lib/toolCatalog.ts` already resolves through `familyAdvertised` at major 2. On a current bundle the row stays `inapplicable` (no committed host advertises a tag outside the RFC 0103 subset), now for the stated reason.
|
|
10
|
+
- **Suite `2.38.0`**: 558 scenario files. `@openwop/spec-artifacts` moves in lockstep at the same exact pin.
|
|
11
|
+
|
|
3
12
|
## [2.37.1] — 2026-09-24 — records that say why, and identities a host must remember minted fresh per run
|
|
4
13
|
|
|
5
14
|
- **`0213.in-flight-loser-outcome` no longer blocks a host that replays every loser.** `v2-idempotency-in-flight` ended with `softSkip('blocked', …)` when all five same-key creates succeeded. RFC 0213 §B explicitly permits that outcome: a loser MAY wait and receive the winner's final outcome, marked `OpenWOP-Idempotent-Replay: true`. The file's own header already promised `partial-witness`. Blocking denied certification (RFC 0168 §E.1) to every host fast enough, or serial enough, that no loser was ever in flight. It was measured on the v2-reference host with the published 2.37.0 (loopback rehearsal: all 5 answers were successes, 4 of them marked replays). The note is now `inapplicable`, which at major 2 records the row as a partial witness after the leg's assertions. Certification counts it, and the acceptance predicate still refuses it, because the 409 branch was never observed.
|
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.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.38.0 @openwop/spec-artifacts@2.38.0
|
|
15
15
|
# or run without install:
|
|
16
16
|
npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
|
|
17
17
|
```
|
|
@@ -135,7 +135,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
135
135
|
|
|
136
136
|
## What's Covered
|
|
137
137
|
|
|
138
|
-
The current suite has
|
|
138
|
+
The current suite has 558 scenario files under `src/scenarios/`.
|
|
139
139
|
- 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
|
|
140
140
|
- 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.
|
|
141
141
|
- 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.
|
|
@@ -481,7 +481,7 @@ Server-required (added in 1.7.0):
|
|
|
481
481
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
482
482
|
| **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. |
|
|
483
483
|
|
|
484
|
-
Current source tree:
|
|
484
|
+
Current source tree: 558 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
485
485
|
|
|
486
486
|
## Remaining Gaps
|
|
487
487
|
|
package/dist/cli.js
CHANGED
|
@@ -45,6 +45,7 @@ import { deriveRung, emittedByNewerSuite } from './lib/durability-evidence.js';
|
|
|
45
45
|
import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
|
|
46
46
|
import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
|
|
47
47
|
import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
|
|
48
|
+
import { canonicalJSON, parseIJson } from './lib/jcs.js';
|
|
48
49
|
import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
|
|
49
50
|
import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
|
|
50
51
|
import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2ProfileIds } from './lib/v2-profiles.js';
|
|
@@ -301,21 +302,6 @@ function suiteVersion() {
|
|
|
301
302
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
302
303
|
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
303
304
|
}
|
|
304
|
-
/**
|
|
305
|
-
* Deterministic canonical-JSON serialization (RFC 8785 spirit): object keys
|
|
306
|
-
* sorted lexicographically at every level, arrays preserved in order. Used to
|
|
307
|
-
* compute `discovery.sha256` so a verifier can re-derive the same digest from
|
|
308
|
-
* a live `/.well-known/openwop` fetch regardless of incidental key order.
|
|
309
|
-
*/
|
|
310
|
-
function canonicalJSON(value) {
|
|
311
|
-
if (value === null || typeof value !== 'object')
|
|
312
|
-
return JSON.stringify(value);
|
|
313
|
-
if (Array.isArray(value))
|
|
314
|
-
return `[${value.map(canonicalJSON).join(',')}]`;
|
|
315
|
-
const obj = value;
|
|
316
|
-
const keys = Object.keys(obj).sort();
|
|
317
|
-
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`).join(',')}}`;
|
|
318
|
-
}
|
|
319
305
|
/**
|
|
320
306
|
* The full set of profiles a discovery document derives — the closed
|
|
321
307
|
* `deriveProfiles` catalog plus the two operational annexes
|
|
@@ -474,7 +460,11 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
474
460
|
process.stderr.write(`openwop-conformance --certify: GET ${discoveryUrl} returned HTTP ${resp.status}.\n`);
|
|
475
461
|
process.exit(2);
|
|
476
462
|
}
|
|
477
|
-
|
|
463
|
+
// RFC 0212 §B — read the TEXT through the I-JSON parser: `resp.json()`
|
|
464
|
+
// keeps the last of two duplicate names and rounds an out-of-range integer,
|
|
465
|
+
// and `discovery.sha256` would then attest to a document other verifiers
|
|
466
|
+
// read differently.
|
|
467
|
+
document = parseIJson(await resp.text());
|
|
478
468
|
}
|
|
479
469
|
catch (err) {
|
|
480
470
|
process.stderr.write(`openwop-conformance --certify: failed to fetch ${discoveryUrl}: ${String(err)}\n`);
|
|
@@ -887,10 +877,10 @@ async function main() {
|
|
|
887
877
|
}
|
|
888
878
|
let bundle;
|
|
889
879
|
try {
|
|
890
|
-
bundle =
|
|
880
|
+
bundle = parseIJson(readFileSync(args.verifyPath, 'utf8'));
|
|
891
881
|
}
|
|
892
882
|
catch (e) {
|
|
893
|
-
process.stderr.write(`openwop-conformance --verify: ${args.verifyPath} is not readable JSON — ${e.message}\n`);
|
|
883
|
+
process.stderr.write(`openwop-conformance --verify: ${args.verifyPath} is not readable I-JSON (RFC 0212 §B) — ${e.message}\n`);
|
|
894
884
|
process.exit(3);
|
|
895
885
|
}
|
|
896
886
|
const hostKey = args.verifyHostKeyPath !== undefined ? readFileSync(args.verifyHostKeyPath, 'utf8') : undefined;
|
|
@@ -21,16 +21,14 @@ import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify a
|
|
|
21
21
|
import { profileDerivable } from './profiles.js';
|
|
22
22
|
import { checkRungClaim } from './durability-evidence.js';
|
|
23
23
|
import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
|
|
24
|
+
import { canonicalJSON, codeUnitCompare, JcsRefusal } from './jcs.js';
|
|
24
25
|
export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'];
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
return JSON.stringify(value);
|
|
33
|
-
}
|
|
26
|
+
/**
|
|
27
|
+
* RFC 0212 §A — the bytes every attestation and digest here covers are RFC 8785
|
|
28
|
+
* (JCS), and a non-I-JSON value is refused, never coerced. Re-exported so
|
|
29
|
+
* existing importers keep one name for one algorithm.
|
|
30
|
+
*/
|
|
31
|
+
export { canonicalJSON } from './jcs.js';
|
|
34
32
|
/**
|
|
35
33
|
* RFC 0148 §C — the digest over the reporter record (the requirement rows),
|
|
36
34
|
* and, from 2.35.0, the operator's declared relaxations when there are any.
|
|
@@ -44,9 +42,16 @@ export function canonicalJSON(value) {
|
|
|
44
42
|
* committed bundles digest unchanged, pinned in durability-evidence.test.ts),
|
|
45
43
|
* and `{ rows, relaxations }` otherwise. An older verifier fails closed on a
|
|
46
44
|
* 2.35.0 bundle that declares relaxations — `witness-digest`, never a pass.
|
|
45
|
+
*
|
|
46
|
+
* RFC 0212 §C — rows sort by `id` in UTF-16 code-unit order. This was
|
|
47
|
+
* `localeCompare` in the process default locale: a bundle cut on a machine
|
|
48
|
+
* whose locale is Czech, Slovak, Lithuanian or Hawaiian digested differently
|
|
49
|
+
* from the same bundle anywhere else (Czech sorts `ch` after `h`). Every
|
|
50
|
+
* committed bundle's ids are `[a-z0-9.-]`, where the two orders coincide, so no
|
|
51
|
+
* stored digest changes (v2-bundle-witness-preimage.test.ts pins that).
|
|
47
52
|
*/
|
|
48
53
|
export function witnessDigest(rows, relaxations) {
|
|
49
|
-
const canonicalRows = [...rows].sort((a, b) => a.id
|
|
54
|
+
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 }),
|
|
50
55
|
// ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
|
|
51
56
|
...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
|
|
52
57
|
const preimage = relaxations !== undefined && relaxations.length > 0 ? { rows: canonicalRows, relaxations } : canonicalRows;
|
|
@@ -97,6 +102,24 @@ export function optedOutFromRows(rows) {
|
|
|
97
102
|
}
|
|
98
103
|
return [...names].sort();
|
|
99
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* RFC 0212 §B — a verifier that meets non-I-JSON in a value it must
|
|
107
|
+
* re-canonicalize MUST fail verification. The canonicalizer throws; a verifier
|
|
108
|
+
* returns a verdict, so the refusal becomes a rejection of the named kind rather
|
|
109
|
+
* than an exception out of `verifyBundleV3` (a library caller that parsed the
|
|
110
|
+
* bundle with `JSON.parse` can still hand it a lone surrogate).
|
|
111
|
+
*/
|
|
112
|
+
function refusedAs(rejections, kind, what, fn) {
|
|
113
|
+
try {
|
|
114
|
+
return fn();
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (!(e instanceof JcsRefusal))
|
|
118
|
+
throw e;
|
|
119
|
+
rejections.push({ kind, detail: `${what} is not I-JSON, so it has no canonical bytes (RFC 0212 §B): ${e.message}` });
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
100
123
|
export function verifyBundleV3(bundle, opts = {}) {
|
|
101
124
|
const rejections = [];
|
|
102
125
|
if (bundle.bundleVersion !== '3')
|
|
@@ -117,8 +140,8 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
117
140
|
for (const k of Object.keys(expected))
|
|
118
141
|
if (bundle.results?.totals?.[k] !== expected[k])
|
|
119
142
|
rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
|
|
120
|
-
const digest = witnessDigest(rows, bundle.host?.relaxations);
|
|
121
|
-
if (bundle.witnessSha256 !== digest)
|
|
143
|
+
const digest = refusedAs(rejections, 'witness-digest', 'the rows or declared relaxations', () => witnessDigest(rows, bundle.host?.relaxations));
|
|
144
|
+
if (digest !== undefined && bundle.witnessSha256 !== digest)
|
|
122
145
|
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)})` });
|
|
123
146
|
const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
|
|
124
147
|
if (bundle.assertionCount !== assertions)
|
|
@@ -137,8 +160,9 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
137
160
|
rejections.push({ kind: 'signature-over', detail: `signature.over must be ${JSON.stringify(SIGNATURE_OVER)}` });
|
|
138
161
|
else if (opts.hostPublicKeyPem) {
|
|
139
162
|
const key = createPublicKey(opts.hostPublicKeyPem);
|
|
140
|
-
|
|
141
|
-
|
|
163
|
+
const payload = refusedAs(rejections, 'signature-invalid', 'the attested members', () => attestationPayload(bundle));
|
|
164
|
+
signatureVerified = payload !== undefined && edVerify(null, payload, key, fromBase64url(sig.sig));
|
|
165
|
+
if (payload !== undefined && !signatureVerified)
|
|
142
166
|
rejections.push({ kind: 'signature-invalid', detail: 'the attestation does not verify under the host key' });
|
|
143
167
|
}
|
|
144
168
|
// Independent tier
|
|
@@ -151,8 +175,9 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
151
175
|
else if (vs.keyId === sig?.keyId || vs.keyId === bundle.host?.signingKeyId)
|
|
152
176
|
rejections.push({ kind: 'independent-self-signed', detail: 'the verifier key must be distinct from the host key (RFC 0148 R5)' });
|
|
153
177
|
else if (opts.verifierPublicKeyPem) {
|
|
154
|
-
|
|
155
|
-
|
|
178
|
+
const payload = refusedAs(rejections, 'verifier-signature-invalid', 'the attested members', () => attestationPayload(bundle));
|
|
179
|
+
verifierSignatureVerified = payload !== undefined && edVerify(null, payload, createPublicKey(opts.verifierPublicKeyPem), fromBase64url(vs.sig));
|
|
180
|
+
if (payload !== undefined && !verifierSignatureVerified)
|
|
156
181
|
rejections.push({ kind: 'verifier-signature-invalid', detail: 'the verifier attestation does not verify' });
|
|
157
182
|
}
|
|
158
183
|
else
|
|
@@ -196,8 +221,11 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
196
221
|
const document = bundle.discovery?.document;
|
|
197
222
|
let derivabilityChecked = false;
|
|
198
223
|
if (document !== undefined) {
|
|
199
|
-
const digest = createHash('sha256').update(canonicalJSON(document)).digest('hex');
|
|
200
|
-
if (digest
|
|
224
|
+
const digest = refusedAs(rejections, 'discovery-digest', 'discovery.document', () => createHash('sha256').update(canonicalJSON(document)).digest('hex'));
|
|
225
|
+
if (digest === undefined) {
|
|
226
|
+
// Already rejected as non-I-JSON; nothing derivable from a document with no canonical bytes.
|
|
227
|
+
}
|
|
228
|
+
else if (digest !== bundle.discovery?.sha256) {
|
|
201
229
|
rejections.push({ kind: 'discovery-digest', detail: `discovery.document hashes to ${digest.slice(0, 12)} but discovery.sha256 is ${String(bundle.discovery?.sha256).slice(0, 12)} — the captured document is not the one the signature attests to` });
|
|
202
230
|
}
|
|
203
231
|
else {
|
package/dist/lib/jcs.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0212 — canonical JSON is RFC 8785 (JCS), and the input MUST be I-JSON.
|
|
3
|
+
*
|
|
4
|
+
* Every signature and digest in the corpus is over these bytes: pack
|
|
5
|
+
* signatures (`ed25519-canonical-json`), the certification-bundle attestation,
|
|
6
|
+
* `discovery.sha256`, `witnessSha256`, and the RFC 0150 semantic request digest.
|
|
7
|
+
* `conformance/vectors/jcs-v1.json` is the normative test of this module and of
|
|
8
|
+
* any other-language implementation.
|
|
9
|
+
*
|
|
10
|
+
* Two entry points, because two of the refusals are invisible after a parse:
|
|
11
|
+
*
|
|
12
|
+
* - `canonicalJSON(value)` — the VALUE boundary. Refuses what a native value
|
|
13
|
+
* can still show: non-finite numbers, non-JSON values (undefined, functions,
|
|
14
|
+
* bigint, symbols, class instances such as Date) and lone surrogates.
|
|
15
|
+
* - `parseIJson(text)` — the TEXT boundary. `JSON.parse` keeps the last of two
|
|
16
|
+
* duplicate names and rounds `9007199254740993` to `…992` silently; both are
|
|
17
|
+
* refused here, before coercion. Read a document you will re-canonicalize
|
|
18
|
+
* through this, not `JSON.parse`.
|
|
19
|
+
*
|
|
20
|
+
* Why not `Object.keys(v).sort()` + `JSON.stringify` with no checks (the code
|
|
21
|
+
* this replaces): it is JCS for well-formed input — the default sort IS UTF-16
|
|
22
|
+
* code-unit order and ES number/string serialization IS JCS §3.2.2 — but it
|
|
23
|
+
* turns NaN into `null`, emits the text `undefined`, and signs a rounded
|
|
24
|
+
* integer. Each of those is a document two verifiers read differently with no
|
|
25
|
+
* error on either side.
|
|
26
|
+
*/
|
|
27
|
+
export class JcsRefusal extends Error {
|
|
28
|
+
kind;
|
|
29
|
+
constructor(kind, message) {
|
|
30
|
+
super(`RFC 0212 §B refusal (${kind}): ${message}`);
|
|
31
|
+
this.name = 'JcsRefusal';
|
|
32
|
+
this.kind = kind;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** A JSON number literal, matched in place (sticky) so parsing stays linear. */
|
|
36
|
+
const NUMBER = /-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/y;
|
|
37
|
+
/** ±(2^53 − 1), for the integer-literal refusal at the text boundary. */
|
|
38
|
+
const MAX_EXACT_BIG = 2n ** 53n - 1n;
|
|
39
|
+
/**
|
|
40
|
+
* RFC 8785 §3.2.3 — compare by UTF-16 code units. Explicit rather than the
|
|
41
|
+
* `Array.prototype.sort` default so the rule is visible at every call site, and
|
|
42
|
+
* never `localeCompare`: collation is locale-dependent (Czech sorts `ch` after
|
|
43
|
+
* `h`; English puts `a` before `A` and ignores `-`), so a digest computed with
|
|
44
|
+
* it depends on the machine that computed it.
|
|
45
|
+
*/
|
|
46
|
+
export function codeUnitCompare(a, b) {
|
|
47
|
+
const n = Math.min(a.length, b.length);
|
|
48
|
+
for (let i = 0; i < n; i += 1) {
|
|
49
|
+
const d = a.charCodeAt(i) - b.charCodeAt(i);
|
|
50
|
+
if (d !== 0)
|
|
51
|
+
return d;
|
|
52
|
+
}
|
|
53
|
+
return a.length - b.length;
|
|
54
|
+
}
|
|
55
|
+
function assertWellFormed(s, where) {
|
|
56
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
57
|
+
const u = s.charCodeAt(i);
|
|
58
|
+
if (u >= 0xd800 && u <= 0xdbff) {
|
|
59
|
+
const next = s.charCodeAt(i + 1);
|
|
60
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
throw new JcsRefusal('lone-surrogate', `lone high surrogate U+${u.toString(16).toUpperCase()} in ${where}`);
|
|
65
|
+
}
|
|
66
|
+
if (u >= 0xdc00 && u <= 0xdfff)
|
|
67
|
+
throw new JcsRefusal('lone-surrogate', `lone low surrogate U+${u.toString(16).toUpperCase()} in ${where}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function serializeNumber(n) {
|
|
71
|
+
if (!Number.isFinite(n))
|
|
72
|
+
throw new JcsRefusal('non-finite', `${String(n)} is not a JSON number`);
|
|
73
|
+
// No magnitude check here: a double is already exact, and JCS serializes it
|
|
74
|
+
// (RFC 8785 Appendix B includes 9007199254740994). The integer-range refusal
|
|
75
|
+
// is about a LITERAL that no double holds, which only the text shows — see
|
|
76
|
+
// `parseIJson`.
|
|
77
|
+
return Object.is(n, -0) ? '0' : String(n);
|
|
78
|
+
}
|
|
79
|
+
function isPlainObject(v) {
|
|
80
|
+
const proto = Object.getPrototypeOf(v);
|
|
81
|
+
return proto === Object.prototype || proto === null;
|
|
82
|
+
}
|
|
83
|
+
/** RFC 8785 serialization of an I-JSON value. Throws `JcsRefusal` instead of coercing. */
|
|
84
|
+
export function canonicalJSON(value) {
|
|
85
|
+
if (value === null)
|
|
86
|
+
return 'null';
|
|
87
|
+
switch (typeof value) {
|
|
88
|
+
case 'boolean': return value ? 'true' : 'false';
|
|
89
|
+
case 'number': return serializeNumber(value);
|
|
90
|
+
case 'string':
|
|
91
|
+
assertWellFormed(value, 'a string');
|
|
92
|
+
return JSON.stringify(value);
|
|
93
|
+
case 'object': break;
|
|
94
|
+
default: throw new JcsRefusal('not-json', `a ${typeof value} is not a JSON value`);
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
const parts = [];
|
|
98
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
99
|
+
if (!(i in value))
|
|
100
|
+
throw new JcsRefusal('not-json', 'a sparse array hole is not a JSON value');
|
|
101
|
+
parts.push(canonicalJSON(value[i]));
|
|
102
|
+
}
|
|
103
|
+
return `[${parts.join(',')}]`;
|
|
104
|
+
}
|
|
105
|
+
if (!isPlainObject(value))
|
|
106
|
+
throw new JcsRefusal('not-json', `a ${value.constructor?.name ?? 'non-plain'} object is not a JSON value`);
|
|
107
|
+
const obj = value;
|
|
108
|
+
const keys = Object.keys(obj).sort(codeUnitCompare);
|
|
109
|
+
return `{${keys.map((k) => { assertWellFormed(k, 'a member name'); return `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`; }).join(',')}}`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse JSON text, refusing what `JSON.parse` would silently accept or change:
|
|
113
|
+
* duplicate member names, integer literals outside ±(2^53 − 1), literals that
|
|
114
|
+
* overflow to ±Infinity, and lone surrogates. Members are defined, not
|
|
115
|
+
* assigned, so a member named `__proto__` is data, not a prototype write.
|
|
116
|
+
*/
|
|
117
|
+
export function parseIJson(text) {
|
|
118
|
+
let i = 0;
|
|
119
|
+
const fail = (m) => { throw new JcsRefusal('not-json', `${m} at offset ${i}`); };
|
|
120
|
+
const ws = () => { while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r'))
|
|
121
|
+
i += 1; };
|
|
122
|
+
const str = () => {
|
|
123
|
+
if (text[i] !== '"')
|
|
124
|
+
fail('expected a string');
|
|
125
|
+
i += 1;
|
|
126
|
+
let out = '';
|
|
127
|
+
for (;;) {
|
|
128
|
+
if (i >= text.length)
|
|
129
|
+
fail('unterminated string');
|
|
130
|
+
const c = text[i];
|
|
131
|
+
i += 1;
|
|
132
|
+
if (c === '"')
|
|
133
|
+
break;
|
|
134
|
+
if (c === '\\') {
|
|
135
|
+
const e = text[i];
|
|
136
|
+
i += 1;
|
|
137
|
+
switch (e) {
|
|
138
|
+
case '"':
|
|
139
|
+
out += '"';
|
|
140
|
+
break;
|
|
141
|
+
case '\\':
|
|
142
|
+
out += '\\';
|
|
143
|
+
break;
|
|
144
|
+
case '/':
|
|
145
|
+
out += '/';
|
|
146
|
+
break;
|
|
147
|
+
case 'b':
|
|
148
|
+
out += '\b';
|
|
149
|
+
break;
|
|
150
|
+
case 'f':
|
|
151
|
+
out += '\f';
|
|
152
|
+
break;
|
|
153
|
+
case 'n':
|
|
154
|
+
out += '\n';
|
|
155
|
+
break;
|
|
156
|
+
case 'r':
|
|
157
|
+
out += '\r';
|
|
158
|
+
break;
|
|
159
|
+
case 't':
|
|
160
|
+
out += '\t';
|
|
161
|
+
break;
|
|
162
|
+
case 'u': {
|
|
163
|
+
const h = text.slice(i, i + 4);
|
|
164
|
+
if (!/^[0-9a-fA-F]{4}$/.test(h))
|
|
165
|
+
fail('bad \\u escape');
|
|
166
|
+
out += String.fromCharCode(parseInt(h, 16));
|
|
167
|
+
i += 4;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
default: fail('bad escape');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
if (c.charCodeAt(0) < 0x20)
|
|
175
|
+
fail('raw control character in a string');
|
|
176
|
+
out += c;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
assertWellFormed(out, 'a string');
|
|
180
|
+
return out;
|
|
181
|
+
};
|
|
182
|
+
const num = () => {
|
|
183
|
+
NUMBER.lastIndex = i;
|
|
184
|
+
const m = NUMBER.exec(text);
|
|
185
|
+
if (m === null)
|
|
186
|
+
return fail('bad number');
|
|
187
|
+
i += m[0].length;
|
|
188
|
+
if (m[2] === undefined && m[3] === undefined) {
|
|
189
|
+
const b = BigInt(m[0]);
|
|
190
|
+
if (b > MAX_EXACT_BIG || b < -MAX_EXACT_BIG)
|
|
191
|
+
throw new JcsRefusal('integer-out-of-range', `integer literal ${m[0]} is outside ±(2^53 − 1)`);
|
|
192
|
+
}
|
|
193
|
+
const v = Number(m[0]);
|
|
194
|
+
if (!Number.isFinite(v))
|
|
195
|
+
throw new JcsRefusal('non-finite', `${m[0]} overflows to a non-finite number`);
|
|
196
|
+
return v;
|
|
197
|
+
};
|
|
198
|
+
const val = () => {
|
|
199
|
+
ws();
|
|
200
|
+
const c = text[i];
|
|
201
|
+
if (c === '{') {
|
|
202
|
+
i += 1;
|
|
203
|
+
const obj = {};
|
|
204
|
+
ws();
|
|
205
|
+
if (text[i] === '}') {
|
|
206
|
+
i += 1;
|
|
207
|
+
return obj;
|
|
208
|
+
}
|
|
209
|
+
for (;;) {
|
|
210
|
+
ws();
|
|
211
|
+
const k = str();
|
|
212
|
+
if (Object.prototype.hasOwnProperty.call(obj, k))
|
|
213
|
+
throw new JcsRefusal('duplicate-name', `duplicate member name ${JSON.stringify(k)}`);
|
|
214
|
+
ws();
|
|
215
|
+
if (text[i] !== ':')
|
|
216
|
+
fail('expected :');
|
|
217
|
+
i += 1;
|
|
218
|
+
Object.defineProperty(obj, k, { value: val(), enumerable: true, writable: true, configurable: true });
|
|
219
|
+
ws();
|
|
220
|
+
const d = text[i];
|
|
221
|
+
i += 1;
|
|
222
|
+
if (d === '}')
|
|
223
|
+
return obj;
|
|
224
|
+
if (d !== ',')
|
|
225
|
+
fail('expected , or }');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (c === '[') {
|
|
229
|
+
i += 1;
|
|
230
|
+
const arr = [];
|
|
231
|
+
ws();
|
|
232
|
+
if (text[i] === ']') {
|
|
233
|
+
i += 1;
|
|
234
|
+
return arr;
|
|
235
|
+
}
|
|
236
|
+
for (;;) {
|
|
237
|
+
arr.push(val());
|
|
238
|
+
ws();
|
|
239
|
+
const d = text[i];
|
|
240
|
+
i += 1;
|
|
241
|
+
if (d === ']')
|
|
242
|
+
return arr;
|
|
243
|
+
if (d !== ',')
|
|
244
|
+
fail('expected , or ]');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (c === '"')
|
|
248
|
+
return str();
|
|
249
|
+
if (text.startsWith('true', i)) {
|
|
250
|
+
i += 4;
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
if (text.startsWith('false', i)) {
|
|
254
|
+
i += 5;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
if (text.startsWith('null', i)) {
|
|
258
|
+
i += 4;
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
if (c === '-' || (c !== undefined && c >= '0' && c <= '9'))
|
|
262
|
+
return num();
|
|
263
|
+
return fail('unexpected token');
|
|
264
|
+
};
|
|
265
|
+
const v = val();
|
|
266
|
+
ws();
|
|
267
|
+
if (i !== text.length)
|
|
268
|
+
fail('trailing data');
|
|
269
|
+
return v;
|
|
270
|
+
}
|
|
271
|
+
/** JCS bytes of a JSON text, with every §B refusal applied. */
|
|
272
|
+
export function canonicalizeText(text) {
|
|
273
|
+
return canonicalJSON(parseIJson(text));
|
|
274
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.38.0",
|
|
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.
|
|
59
|
+
"@openwop/spec-artifacts": "2.38.0"
|
|
60
60
|
}
|
|
61
61
|
}
|