Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ specific attack.
> and its *published contracts*, but changing either changes the evidence
> digest, visibly, in the attested report.

### Corollary: third-party constraint documents are vendored, never fetched

Some checks judge a target against a standard *someone else* publishes — the
first is `digital-link-resolver`, which holds a declared Digital Link interface
to GS1's published resolver description-file schema
(`https://ref.gs1.org/standards/resolver/description-file-schema`).

Fetching that schema at verification time would add a **sixth input** to the
invariant above — a document its publisher can edit at any moment, which no
replay could reproduce — and `Observer.observe` refuses off-origin fetches
anyway. So the published bytes are **vendored and digest-pinned**
(`src/gs1-resolver.ts` carries the provenance record; `test/fixtures/` carries
the bytes), the runtime constraint is a hand-written `MiniSchema` translation
of them, and a test audits that translation against the vendored bytes keyword
by keyword — in both directions, so neither a dropped constraint nor an
invented one passes silently. Constraints the published schema states but the
translation deliberately does not enforce (draft-07 `format`, which is an
annotation; a member GS1 misplaced outside a keyword position) are enumerated
with reasons and named in the check's own verdict text. A verifier that is
stricter than the standard it cites is lying in the other direction.

### Attacks and mitigations

| # | Attack | Mitigation | Status |
Expand Down
142 changes: 142 additions & 0 deletions src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ import {
} from './discovery.js'
import { isPubliclyRoutableSameOrigin, isPublicHttpsOffOriginAllowed } from './http.js'
import { validateSchema } from './schema.js'
import {
GS1_DESCRIPTION_FILE_SCHEMA_SOURCE,
GS1_RESOLVER_WELL_KNOWN_PATH,
urlOriginOrUndefined,
renderGs1Violations,
validateGs1DescriptionFile,
} from './gs1-resolver.js'
import { contractDiff } from './contract.js'
import type { CheckResult, Evidence, EvidenceBundle, MiniSchema, Verdict } from './types.js'

Expand Down Expand Up @@ -743,6 +750,132 @@ export function runChecks(bundle: EvidenceBundle): CheckResult[] {
streamEvidence, judgeUiStreamParity(us)))
}

// ── GS1 Digital Link resolver — an OPTIONAL, card-DECLARED interface ──────
// The GS1 resolver standard fixes a discovery indicator: a conformant
// resolver SHALL serve a Resolver Description File at the RFC 8615
// well-known `/.well-known/gs1resolver`, and "the presence or absence of
// this file can be used to determine whether or not the URI points to a
// service conformant to this standard".
//
// DECLARATION-ARMED, exactly like the uiMessageStream face: the card
// naming `interfaces.digitalLink` is the whole gate. A card that OMITS
// the key SKIPs — no fetch, no budget, no verdict, and nothing that was
// conforming yesterday starts failing. A card that DECLARES it is judged
// STRICTLY, because a machine-readable claim a verifier will believe is
// worse than a prose one: the well-known must be same-origin, answer 2xx
// with JSON, and validate against GS1's PUBLISHED description-file schema
// (vendored + digest-pinned in gs1-resolver.ts — never fetched at
// verification time, which would put a third party inside the determinism
// contract).
//
// SCOPE, stated so the verdict is not read as more than it is: this check
// verifies the DISCOVERY INDICATOR, not resolution behaviour. RFC 9264
// linkset responses (`Accept: application/linkset+json` / `linkType=
// linkset`), redirect semantics and Accept-Language are NOT covered here.
// axItem is undefined — this is an additive readiness dimension and it
// moves no AX point.
{
const dl = agents.digitalLink
const dlEvidence = [ROLE.agentsJson, ROLE.gs1Resolver]
const problems: string[] = []
let result: { verdict: Verdict; detail: string } | undefined
if (!dl) {
result = {
verdict: 'skip',
detail:
'no Digital Link interface declared (agents.json `interfaces.digitalLink` absent) — the interface is OPTIONAL and this card does not claim it, so nothing was fetched and nothing is judged; under a pinned must:pass this fails closed',
}
} else if (dl.malformed) {
result = {
verdict: 'fail',
detail:
`interfaces.digitalLink is present but is not a JSON object (got ${dl.malformedAs}) — the card claims a Digital Link resolver face in a shape no verifier can check. ` +
'Declare an object (optionally `{ "wellKnown": …, "resolverRoot": … }`), or OMIT the key entirely to declare no Digital Link interface — omitting it is fully conforming.',
}
} else {
const origin = bundle.target
const declaredOrigin = urlOriginOrUndefined(dl.wellKnown)
if (declaredOrigin === undefined) {
problems.push(
`card declares interfaces.digitalLink.wellKnown = ${JSON.stringify(dl.wellKnownRaw ?? dl.wellKnown)}, which does not resolve to a URL — refused without fetching`,
)
} else if (declaredOrigin !== origin) {
problems.push(
`card declares its Digital Link well-known at ${dl.wellKnown} (origin ${declaredOrigin}), which is NOT the target origin ${origin} — a card must not claim another origin's resolver; api.qa refused to fetch it`,
)
} else if (!isPubliclyRoutableSameOrigin(dl.wellKnown, origin)) {
problems.push(
`card declares its Digital Link well-known at ${dl.wellKnown}, which is not a publicly-routable target for ${origin} — refused without fetching (SSRF guard)`,
)
} else {
const ev = findEvidence(bundle, ROLE.gs1Resolver)
if (!ok(ev)) {
problems.push(
`GET ${dl.wellKnown} did not answer 2xx — ${!ev ? 'not fetched' : ev.status === null ? `fetch failed (${ev.error ?? 'unknown'})` : `status ${ev.status}`}. ` +
`The card DECLARES a Digital Link interface, so the GS1 resolver description file (RFC 8615 ${GS1_RESOLVER_WELL_KNOWN_PATH}) must be served`,
)
} else {
const ct = (ev!.contentType ?? '').toLowerCase()
if (!ct.includes('json')) {
problems.push(
`${dl.wellKnown} answered ${ev!.status} with content-type ${JSON.stringify(ev!.contentType ?? '(none)')} — the resolver description file is a JSON document and must be served with a JSON media type`,
)
}
const doc = parseJsonBody(ev)
if (doc === undefined) {
problems.push(`${dl.wellKnown} answered ${ev!.status} but its body did not parse as JSON`)
} else {
const violations = validateGs1DescriptionFile(doc)
if (violations.length > 0) {
problems.push(
`description file fails GS1's published description-file schema (${GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.$id}) — ${renderGs1Violations(violations)}`,
)
} else {
// Schema-valid ⇒ resolverRoot is a present string. Two SEMANTIC
// rules beyond the schema, named as such: the served document
// must name the origin it was actually reached on (one Snippet
// on N hostnames still answers with the reached host), and the
// card's own resolverRoot, if it declares one, must agree.
const rr = (doc as Record<string, unknown>).resolverRoot as string
const rrOrigin = urlOriginOrUndefined(rr)
if (rrOrigin === undefined) {
problems.push(
`resolverRoot ${JSON.stringify(rr)} is not an absolute URL — GS1 annotates it \`format: "uri"\`, which draft-07 leaves unenforced, but a resolverRoot naming no origin cannot be reconciled with the origin that served it`,
)
} else if (rrOrigin !== origin) {
problems.push(
`resolverRoot names origin ${rrOrigin} but the description file was served from ${origin} — a resolver must name the host the caller actually reached`,
)
}
if (dl.resolverRoot !== undefined && trimTrailingSlash(dl.resolverRoot) !== trimTrailingSlash(rr)) {
problems.push(
`card declares interfaces.digitalLink.resolverRoot ${JSON.stringify(dl.resolverRoot)} but the description file declares ${JSON.stringify(rr)} — the two claims disagree`,
)
}
if (problems.length === 0) {
const keys = (doc as Record<string, unknown>).supportedPrimaryKeys as string[]
result = pass(
`interfaces.digitalLink declared; ${dl.wellKnown} → ${ev!.status} ${ev!.contentType}, valid against GS1's published description-file schema ` +
`(${GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.$id}, vendored at sha256:${GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.sha256.slice(0, 12)}… fetched ${GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.fetchedAt}, never fetched at verification time); ` +
`resolverRoot ${rr} names the serving origin; supportedPrimaryKeys ${JSON.stringify(keys)}. ` +
'NOT enforced: draft-07 `format: "uri"` (annotation-only) and `contact.hasTelephone` (misplaced in GS1\'s own schema). ' +
'NOT covered: RFC 9264 linkset responses, redirect semantics — this check verifies the discovery indicator, not resolution behaviour.',
)
}
}
}
}
}
}
checks.push(check('digital-link-resolver',
'a DECLARED Digital Link interface serves a schema-valid GS1 resolver description file at its well-known', undefined,
dlEvidence,
result ?? {
verdict: 'fail',
detail: problems.slice(0, 6).join('; ') || 'the declared Digital Link interface could not be verified',
}))
}

// ── AXP structural checks (Clause 3 conneg + Clause 6 cross-linking) ──────
// Discriminating checks the AXP pinned spec binds via kind:'check':
// machine-legible-home, conneg-accept, conneg-client-class,
Expand Down Expand Up @@ -3096,6 +3229,15 @@ function looksLikeHtml(body: string): boolean {
return /^\s*(<!doctype html|<html|<head|<body)/i.test(body) || /<html[\s>]/i.test(body.slice(0, 1024))
}

/**
* Drop a single trailing slash so `https://x.example/` and `https://x.example`
* compare equal. Used only to reconcile two DECLARED resolverRoot strings —
* a trailing slash is not a disagreement worth failing a card over.
*/
function trimTrailingSlash(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url
}

function pass(detail: string): { verdict: Verdict; detail: string } {
return { verdict: 'pass', detail }
}
Expand Down
124 changes: 124 additions & 0 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { Observer, isPubliclyRoutableSameOrigin, isPublicHttpsOffOriginAllowed } from './http.js'
import { GS1_RESOLVER_WELL_KNOWN_PATH } from './gs1-resolver.js'
import { canonicalJson, sha256Hex, sampleSeeded } from './digest.js'
import type {
ClaimedEndpoint,
Expand Down Expand Up @@ -146,6 +147,16 @@ export const ROLE = {
* Same-origin SSRF-gated. Absent ⇒ the parity check SKIPs (no twin to diff).
*/
uiStreamTwin: 'probe:ui-message-stream-twin',
// ── GS1 Digital Link resolver (OPTIONAL, target-DECLARED) ────────────────
/**
* GET of the GS1 resolver description file — `/.well-known/gs1resolver` by
* default (RFC 8615, fixed by the GS1 resolver standard), or the location
* the card names in `interfaces.digitalLink.wellKnown`. Same-origin
* SSRF-gated. Recorded ONLY when the card DECLARES the interface: a card
* that omits `interfaces.digitalLink` records nothing, spends no budget,
* and the digital-link-resolver check SKIPs.
*/
gs1Resolver: 'surface:gs1resolver',
} as const

export function findEvidence(bundle: EvidenceBundle, role: string): Evidence | undefined {
Expand Down Expand Up @@ -276,6 +287,48 @@ export interface AgentsClaims {
* the target exposes no UI-message-stream face and the ui-stream-* checks SKIP.
*/
uiStream?: { url?: string; twin?: string }
/**
* The target-declared GS1 Digital Link resolver interface
* (`interfaces.digitalLink`) — an OPTIONAL third interface beside
* `interfaces.http` and `interfaces.mcp`. PRESENT ⇒ the card CLAIMS the
* surface is a Digital Link resolver and the digital-link-resolver check is
* ARMED; ABSENT ⇒ the field is undefined, the well-known is never fetched,
* and that check SKIPs. A card that simply omits the key is fully
* conforming — the interface is optional, and declaring it is what invites
* the verification.
*/
digitalLink?: DigitalLinkClaim
}

/**
* A card's `interfaces.digitalLink` declaration, as parsed off the wire.
*
* `malformed` is the load-bearing distinction: the key being PRESENT but not
* a plain object (a string, `true`, `null`, an array) is a DEFECTIVE claim,
* never an absence. Collapsing it to "not declared" would let a card claim
* the face in a shape no verifier can check and still SKIP — so it is
* surfaced and FAILED. A card meaning "no Digital Link interface" omits the
* key.
*/
export interface DigitalLinkClaim {
/** The `interfaces.digitalLink` key was present on the card. Always true. */
declared: true
/** Present but not a plain JSON object — a defective declaration. */
malformed?: boolean
/** `typeof`-style name of the malformed value, for the failure message. */
malformedAs?: string
/** Well-known location exactly as the card wrote it (relative or absolute). */
wellKnownRaw?: string
/**
* Well-known location to fetch, absolutized against the target origin.
* Defaults to the RFC 8615 path the GS1 standard fixes. An ABSOLUTE
* off-origin value is preserved verbatim (never rewritten to the target)
* so the same-origin gate can DROP it and the check can FAIL the card for
* claiming another origin's resolver.
*/
wellKnown: string
/** Card-declared `resolverRoot`, absolutized. Optional. */
resolverRoot?: string
}

export function parseAgentsJson(doc: unknown, origin: string): AgentsClaims {
Expand Down Expand Up @@ -333,6 +386,37 @@ export function parseAgentsJson(doc: unknown, origin: string): AgentsClaims {
}
}

// GS1 Digital Link resolver face (OPTIONAL third interface):
// interfaces.digitalLink.{wellKnown,resolverRoot}. PRESENCE of the key is
// the whole declaration signal — `'digitalLink' in interfaces`, not
// truthiness — so a present-but-defective value is recorded as MALFORMED and
// failed, never silently treated as absent. Both urls are card-derived and
// therefore adversarial: absolutize (a relative "/.well-known/gs1resolver"
// resolves same-origin; an absolute foreign url is PRESERVED so the
// same-origin gate downstream drops it and the check fails the card for
// claiming another origin's resolver), exactly like interfaces.mcp.url.
if (Object.prototype.hasOwnProperty.call(interfaces, 'digitalLink')) {
const dl = interfaces.digitalLink
if (dl !== null && typeof dl === 'object' && !Array.isArray(dl)) {
const d = dl as Record<string, unknown>
const wellKnownRaw = str(d.wellKnown)
const resolverRoot = str(d.resolverRoot)
out.digitalLink = {
declared: true,
...(wellKnownRaw !== undefined && { wellKnownRaw }),
wellKnown: absolutize(wellKnownRaw ?? GS1_RESOLVER_WELL_KNOWN_PATH, origin),
...(resolverRoot !== undefined && { resolverRoot: absolutize(resolverRoot, origin) }),
}
} else {
out.digitalLink = {
declared: true,
malformed: true,
malformedAs: dl === null ? 'null' : Array.isArray(dl) ? 'array' : typeof dl,
wellKnown: absolutize(GS1_RESOLVER_WELL_KNOWN_PATH, origin),
}
}
}

// Top-level server.json pointer (card-derived; absolutized, SSRF-gated at fetch).
const declaredServerJson = str(d.serverJson) ?? str(d.server_json)
if (out.serverJsonUrl === undefined && declaredServerJson !== undefined) {
Expand Down Expand Up @@ -1162,6 +1246,37 @@ async function observeUiStream(origin: string, observer: Observer): Promise<void
}
}

/**
* Observe the GS1 Digital Link resolver description file. TARGET-DECLARED and
* OPTIONAL: only when agents.json carries `interfaces.digitalLink` do we fetch
* anything — a card that omits the key records NOTHING, spends none of the
* politeness budget, and the digital-link-resolver check SKIPs. That is the
* whole reason the interface can be optional without weakening the verifier.
*
* A MALFORMED declaration (`interfaces.digitalLink` present but not an object)
* is not fetched either: there is no location to trust. The check still FAILS
* it from the card alone — the claim is defective, not absent.
*
* The well-known location is CARD-DERIVED adversarial input, so it goes
* through the SAME shared same-origin + publicly-routable gate as the openapi
* / offer / mcp.url / uiMessageStream probes. An off-origin or private
* declared location is DROPPED and never fetched; the check then fails the
* card explicitly for claiming another origin's resolver, reading the declared
* string out of the bundle's own agents.json evidence rather than needing the
* fetch to have happened.
*/
async function observeDigitalLink(origin: string, observer: Observer): Promise<void> {
const items = observer.items
const bundleView: EvidenceBundle = { target: origin, fetchedAt: '', seed: 0, items }
const agentsEv = findEvidence(bundleView, ROLE.agentsJson)
const agents = parseAgentsJson(parseJsonBody(agentsEv), origin)
const dl = agents.digitalLink
if (!dl) return // interface not declared — optional, nothing to verify
if (dl.malformed) return // defective declaration — judged from the card, never fetched
if (!isPubliclyRoutableSameOrigin(dl.wellKnown, origin)) return // off-origin/private — dropped, the check fails it
await observer.observe(ROLE.gs1Resolver, dl.wellKnown, { accept: 'application/json' })
}

export async function observeTarget(origin: string, observer: Observer, seed: number): Promise<EvidenceBundle> {
// 1. The fixed surface plan — identical for every target (no fingerprint).
const rootAgentEv = await observer.observe(ROLE.rootAgent, `${origin}/`, { accept: '*/*' })
Expand Down Expand Up @@ -1437,6 +1552,15 @@ export async function observeTarget(origin: string, observer: Observer, seed: nu
// card-derived probe (see observeUiStream).
await observeUiStream(origin, observer)

// 4e. GS1 Digital Link resolver (OPTIONAL, target-declared): when the card
// declares `interfaces.digitalLink`, GET the resolver description file so
// the digital-link-resolver judge can hold the claim to GS1's published
// description-file schema. Zero-overhead and zero-budget for the common
// case (no declaration ⇒ no probe). Ordered here, BEFORE the unbounded
// contract-diff enumeration, so a fixed high-value probe is never starved
// by an endpoint-rich target — the same priority rule as 4b/4c/4d.
await observeDigitalLink(origin, observer)

// 5. Contract-diff probing (ax-e6b.28.4): for a FULL OpenAPI<->live diff,
// fetch EVERY GET-safe candidate path once — not just the seeded keyless
// sample above — so the diff enumerates every declared operation, not a
Expand Down
Loading
Loading