@openwop/openwop-conformance 1.127.0 → 1.128.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.127.0",
3
+ "version": "1.128.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
3
- "suiteVersion": "1.127.0",
4
- "corpusCommit": "5e2aefe837a81b82004c12c308cb1c3973c0233c"
3
+ "suiteVersion": "1.128.0",
4
+ "corpusCommit": "04f59e91eb096ce1782b16722599a9d2cfa7aae1"
5
5
  }
@@ -52,7 +52,13 @@
52
52
  "required": ["subject"],
53
53
  "properties": {
54
54
  "subject": { "type": "string", "minLength": 1 },
55
- "issuer": { "type": "string", "minLength": 1 }
55
+ "issuer": { "type": "string", "minLength": 1 },
56
+ "scopes": {
57
+ "type": "array",
58
+ "uniqueItems": true,
59
+ "items": { "type": "string", "minLength": 1 },
60
+ "description": "RFC 0154 §B — the effective scopes VERIFIED for this hop from its proof (RFC 0049 scope grammar). OPTIONAL and provenance, not authorization: the host still evaluates the resolved principal's own scopes. When present on consecutive hops, a later hop's scopes MUST NOT exceed the previous hop's (`auth.md` §\"Bounds\" — scope amplification is refused: `delegation_scope_amplified`)."
61
+ }
56
62
  }
57
63
  }
58
64
  },
@@ -167,7 +167,7 @@ describe('RFC 0154 §A — workload identity resolution (capability-gated behavi
167
167
  ),
168
168
  ).toBe(false);
169
169
  expect(
170
- ['identity_unverified', 'identity_unresolvable', 'audience_mismatch', 'delegation_expired', 'sender_constraint_missing'],
170
+ ['identity_unverified', 'identity_unresolvable', 'audience_mismatch', 'delegation_expired', 'sender_constraint_missing', 'delegation_chain_too_long', 'delegation_chain_cyclic', 'delegation_scope_amplified'],
171
171
  driver.describe('RFCS/0154 §A', 'failures use a closed reason vocabulary'),
172
172
  ).toContain(readErrorCode(r.json));
173
173
  });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * RFC 0154 §B "Bounds" — the delegation chain is bounded, acyclic, and cannot
3
+ * amplify scope hop-to-hop (`auth.md` §"Workload identity and delegated actor
4
+ * chain" → Bounds; threat model A4 "chain launderer").
5
+ *
6
+ * `workload-identity-behavior.test.ts` proves the resolver verifies audience and
7
+ * expiry. It does not touch the chain's SHAPE as a source of authority, and that
8
+ * is where laundering lives: a chain that is one hop longer than the host said
9
+ * it would accept, a chain that loops back through a subject it already passed,
10
+ * or a chain whose second hop claims a scope its first hop never held. Each is a
11
+ * verified-looking presentation that a passthrough resolver accepts and a real
12
+ * one refuses, which is what makes the legs non-vacuous — `resolved: true` on
13
+ * any of them is the finding.
14
+ *
15
+ * All three go through the §20 seam (`host-sample-test-seams.md`) with the
16
+ * three closed reasons added for them: `delegation_chain_too_long`,
17
+ * `delegation_chain_cyclic`, `delegation_scope_amplified`. Every leg is gated on
18
+ * `auth.workloadIdentity.delegation.supported` via `behaviorGate` (a host doing
19
+ * §A-only identity resolution has not claimed §B) — soft-skips without it,
20
+ * HARD-FAILS under `OPENWOP_REQUIRE_BEHAVIOR=true`, and records the RFC 0148 §A
21
+ * disposition either way. A seam that answers 404/403 is `blocked`, not a pass.
22
+ *
23
+ * Per-hop `scopes` on the wire are OPTIONAL (`workload-identity.schema.json`,
24
+ * 2026-08-16); the amplification leg presents them explicitly, so a host that
25
+ * ignores the field will resolve the chain and fail the leg — which is the
26
+ * honest outcome, because a host that cannot see hop scopes cannot enforce the
27
+ * MUST NOT either.
28
+ *
29
+ * Registered invariants: `delegation-chain-acyclic`,
30
+ * `delegation-no-scope-amplification` (`SECURITY/invariants.yaml`), and the
31
+ * length half of `delegation-chain-bounded`.
32
+ */
33
+
34
+ import { describe, it, expect } from 'vitest';
35
+ import { readErrorCode, readRetriable } from '../lib/error-envelope.js';
36
+ import { driver } from '../lib/driver.js';
37
+ import { behaviorGate } from '../lib/behavior-gate.js';
38
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
39
+ import { seamAbsent } from '../lib/soft-skip.js';
40
+
41
+ const DELEGATION_PROFILE = 'openwop-workload-identity-delegation';
42
+ const SEAM = '/v1/host/sample/test/workload-identity/resolve';
43
+
44
+ interface AuthCaps {
45
+ readonly workloadIdentity?: {
46
+ readonly supported?: boolean;
47
+ readonly delegation?: { readonly supported?: boolean; readonly maxChainDepth?: number };
48
+ };
49
+ }
50
+
51
+ async function delegationCaps(): Promise<{ supported: boolean; maxChainDepth: number | null }> {
52
+ const disco = await driver.get('/.well-known/openwop');
53
+ const d = capabilityFamily<AuthCaps>(disco.json, 'auth')?.workloadIdentity?.delegation;
54
+ return {
55
+ supported: d?.supported === true,
56
+ maxChainDepth: typeof d?.maxChainDepth === 'number' && Number.isInteger(d.maxChainDepth) && d.maxChainDepth > 0 ? d.maxChainDepth : null,
57
+ };
58
+ }
59
+
60
+ async function resolve(body: Record<string, unknown>): Promise<{ status: number; json: unknown } | null> {
61
+ const r = await driver.post(SEAM, body);
62
+ if (r.status === 404 || r.status === 403) {
63
+ seamAbsent(`host advertises \`auth.workloadIdentity.delegation.supported: true\` but ${SEAM} answered ${r.status} — RFC 0154 §B chain bounds are unobservable (host-sample-test-seams.md §20)`);
64
+ return null;
65
+ }
66
+ return { status: r.status, json: r.json };
67
+ }
68
+
69
+ const IDENTITY = { scheme: 'spiffe', subject: 'spiffe://example/dispatcher', issuer: 'spiffe://example', audience: 'openwop-host' };
70
+ const LIVE = '2099-01-01T00:00:00Z';
71
+
72
+ function hop(n: number, scopes?: readonly string[]): Record<string, unknown> {
73
+ const h: Record<string, unknown> = { subject: `spiffe://example/hop-${n}`, issuer: 'spiffe://example' };
74
+ if (scopes !== undefined) h['scopes'] = [...scopes];
75
+ return h;
76
+ }
77
+
78
+ /** A refusal: 4xx, non-retriable, the named closed reason. */
79
+ function expectRefusal(r: { status: number; json: unknown }, reason: string, why: string): void {
80
+ expect(r.status >= 400, driver.describe('RFCS/0154 §B', why)).toBe(true);
81
+ expect(readErrorCode(r.json), driver.describe('spec/v1/host-sample-test-seams.md §20', `closed reason \`${reason}\``)).toBe(reason);
82
+ expect(readRetriable(r.json), driver.describe('spec/v1/host-sample-test-seams.md §20', 'a chain the host refuses will be refused on retry')).toBe(false);
83
+ }
84
+
85
+ describe('RFC 0154 §B — delegation chain bounds (capability-gated behavior)', () => {
86
+ it('a chain longer than the advertised maxChainDepth is refused', async () => {
87
+ const caps = await delegationCaps();
88
+ if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
89
+ expect(
90
+ caps.maxChainDepth,
91
+ driver.describe('spec/v1/auth.md §Bounds', 'a host advertising delegation MUST advertise a positive integer `maxChainDepth`'),
92
+ ).not.toBeNull();
93
+ const depth = caps.maxChainDepth as number;
94
+ const chain = Array.from({ length: depth + 1 }, (_, i) => hop(i + 1));
95
+ const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
96
+ if (r === null) return;
97
+ expectRefusal(r, 'delegation_chain_too_long', `a chain of ${depth + 1} hops exceeds the advertised bound of ${depth} — each hop is another party the host trusts transitively`);
98
+ });
99
+
100
+ it('a chain that revisits a subject is refused as cyclic', async () => {
101
+ const caps = await delegationCaps();
102
+ if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
103
+ // Two distinct hops then the first subject again: length 3, so on any
104
+ // host with maxChainDepth >= 3 the ONLY reason to refuse it is the cycle.
105
+ // On a host with maxChainDepth < 3 the too-long leg already covers refusal
106
+ // and this leg accepts either reason, saying so.
107
+ const chain = [hop(1), hop(2), hop(1)];
108
+ const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
109
+ if (r === null) return;
110
+ const bound = caps.maxChainDepth ?? Number.POSITIVE_INFINITY;
111
+ if (bound < 3) {
112
+ expect(r.status >= 400, driver.describe('RFCS/0154 §B', 'a cyclic chain is refused')).toBe(true);
113
+ expect(['delegation_chain_cyclic', 'delegation_chain_too_long'], driver.describe('spec/v1/host-sample-test-seams.md §20', 'cyclic or too-long — the bound is below the cycle length')).toContain(readErrorCode(r.json));
114
+ expect(readRetriable(r.json)).toBe(false);
115
+ return;
116
+ }
117
+ expectRefusal(r, 'delegation_chain_cyclic', 'a subject appearing twice is a chain that loops back through authority it already spent — unbounded laundering with a bounded length');
118
+ });
119
+
120
+ it('a later hop claiming a scope the previous hop did not hold is refused', async () => {
121
+ const caps = await delegationCaps();
122
+ if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
123
+ const chain = [hop(1, ['runs:read']), hop(2, ['runs:read', 'runs:write'])];
124
+ const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
125
+ if (r === null) return;
126
+ expectRefusal(r, 'delegation_scope_amplified', 'the effective scopes at any hop MUST NOT exceed the hop before it — a chain is provenance, and provenance cannot mint `runs:write` from `runs:read`');
127
+ });
128
+
129
+ it('a well-formed bounded, acyclic, non-amplifying chain still resolves (the negatives are not a blanket refusal)', async () => {
130
+ const caps = await delegationCaps();
131
+ if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
132
+ const chain = [hop(1, ['runs:read', 'manifest:read']), hop(2, ['runs:read'])];
133
+ const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
134
+ if (r === null) return;
135
+ // A host that refuses EVERY chain passes the three negatives vacuously; this
136
+ // is the positive that keeps them honest. Scopes narrow hop-to-hop, which
137
+ // is the one direction §B permits.
138
+ expect(r.status, driver.describe('RFCS/0154 §B', 'a compliant chain resolves; the bounds refuse laundering, not delegation')).toBe(200);
139
+ expect((r.json as { resolved?: unknown }).resolved).toBe(true);
140
+ });
141
+ });
@@ -151,6 +151,15 @@ describe('RFC 0154 §B — delegated actor chain', () => {
151
151
  it('a chain hop cannot carry extra fields', () => {
152
152
  expect(validate({ ...base, delegation: { chain: [{ subject: 'x', token: 'y' }], audience: 'h' } })).toBe(false);
153
153
  });
154
+
155
+ it('a chain hop MAY carry its verified scopes, as a set of non-empty strings', () => {
156
+ // RFC 0154 §B "Bounds": scope amplification is only observable if a hop can
157
+ // state the scopes its proof carried. Provenance, not authorization.
158
+ expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: ['runs:read'] }, { subject: 'y', scopes: ['runs:read'] }], audience: 'h' } })).toBe(true);
159
+ expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: 'runs:read' }], audience: 'h' } })).toBe(false);
160
+ expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: ['runs:read', 'runs:read'] }], audience: 'h' } })).toBe(false);
161
+ expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: [''] }], audience: 'h' } })).toBe(false);
162
+ });
154
163
  });
155
164
 
156
165
  describe.skipIf(RFCS_DIR === null)('RFC 0154 — what this does NOT establish', () => {