Verifier Profile¶
This document defines the current public PoC behavior for the narrowed verifier flow implemented in:
- verifier.py
- narrowed_verifier_poc.py
- payload_revalidation_poc.py
- signed_schema_poc.py
- qr_artifact_poc.py
Goal¶
Define a verifier contract that is narrower and more explicit than generic "signed QR" language. The current PoC focuses on deterministic acceptance and failure behavior.
API Surface¶
Current public reference endpoints:
GET /verifier/statusGET /verifier/trust-storePOST /verifier/demo-materialsPOST /verifier/verifyPOST /verifier/verify-scannedPOST /verifier/decode-image
POST /verifier/demo-materials intentionally does not return the signing
private key. It returns only the material needed to exercise the verifier flow,
plus a trust echo (key_ref, key_state, issuer_status,
retired_key_refs) naming the key the artifact was sealed under.
GET /verifier/trust-store is the read-only view of the issuers and keys the
scanned path trusts: issuers[] (issuer_id, issuer_name, root_id,
status, issued_at, expires_at, verified_domains, allow_subdomains)
and keys[] (key_ref, issuer_id, algorithm_id, state, not_before,
not_after, revoked_at, revocation_reason). It is gated like /status
evidence: open when verifier API key auth is disabled, otherwise it requires a
management credential. Nothing on this surface writes to the store; only
demo-materials does.
The primary interactive client is now the React frontend in
frontend, which uses
the same /verifier/* API surface and can optionally be served over local
HTTPS for secure-context camera testing on iPhone Safari.
The older /certificates/*, /qrcodes/*, and /organizations/* experimental
routes have been removed from the codebase and return 404.
POST endpoints under /verifier/* are rate-limited per client. Image decode is
subject to a stricter limit than the other verifier actions because it is the
most expensive parsing path. Redis-backed coordination is used when Redis is
connected; otherwise the PoC falls back to process-local in-memory limits.
DB-backed verifier-client keys protect POST endpoints under /verifier/*.
Static VERIFIER_API_KEYS are a local-only fallback and require explicit
VERIFIER_STATIC_API_KEYS_ENABLED=true opt-in. The interactive clients remain
public so they can load, but their POST actions must include the key when the
server requires one.
Verifier client keys are managed through audited management-plane endpoints
under /admin/verifier-clients/api-keys/*. The retired
/verifier/admin/api-keys/* lab mutation surface returns 410 Gone so key
changes do not bypass scoped management credentials or audit history. The
verifier remains locked when API-key auth is enabled, even if no dynamic key is
currently active.
QR Artifact Contract¶
The QR artifact layer serializes the signed envelope as compact JSON with this shape:
claimssignaturecode_algorithm_idwhen present
Rules:
claimsmust satisfy the exact signed-claims contractsignaturemust be present in the QR payload- unsupported top-level envelope keys are rejected
- QR payloads above the configured maximum size are rejected
- QR PNG rendering is a transport layer, not a source of truth
- verification of scanned QR data uses the decoded QR payload string, not image-level metadata
- image decode fallback is available through
POST /verifier/decode-image - oversized or over-large image decode requests are rejected before QR parsing
Signed Claims Contract¶
Signed claims must contain exactly these fields, in this order:
versioncertificate_refissued_atexpires_atpayload
Rules:
- no unknown signed-claim fields
- no missing signed-claim fields
versionmust be exactly2issued_atandexpires_atmust be ISO-8601 timestamps with timezone informationexpires_atmust be later thanissued_at
The canonical claim order is defined in signed_schema_poc.py.
Envelope Identity¶
An envelope is identified by a value derived from what was signed, not by a field carried inside the claims:
envelope_id = sha256(canonical_claims + "." + signature), lowercase hex, 64 charactersenvelope_fingerprintis the first 16 hex characters ofenvelope_id, used where a short human-readable handle is wanted
POST /verifier/demo-materials returns the envelope_id of the artifact it
generated, and POST /scanner/decisions returns the envelope_id of the
envelope it evaluated. Mutating any signed claim changes envelope_id.
GET /verifier/scan-activity?envelope_id=<64 hex> reads scan activity for one
envelope.
Certificate And Algorithm Rules¶
Current PoC rules:
- the signed
certificate_refmust match the authoritative certificate record - the certificate is the authoritative source of
algorithm_id code_algorithm_idis optional and treated only as a mirror hint- if
code_algorithm_idis present and conflicts with the certificate, verification fails - the current supported certificate algorithm is
rsa-pss-sha256-v1
Verifier Chain¶
The narrowed verifier evaluates a presented code in this order:
- canonicalize signed claims
- verify signed envelope against the authoritative certificate
- enforce certificate status
- enforce time window
- revalidate the payload destination against issuer-controlled state
Every presentation of one envelope is evaluated the same way; the verifier
keeps no per-presentation state. Freshness is carried entirely by the validity
window (issued_at … expires_at) in the signed claims, and the freshness
residual family blocks past expires_at.
Issuer State Contract¶
Current issuer state fields:
verified_domains: list[str]allow_subdomains: boolcertificate_active: boolcertificate_revoked: boolcertificate_revocation_reason: str | None
Optional lifecycle fields (the trust-store record supplies them on the scanned
path; POST /verifier/verify accepts them inline so a rehearsal can express
the same state):
issuer_status: "active" | "suspended" | "revoked" | Noneissuer_record_issued_at: str | None,issuer_record_expires_at: str | Nonekey_state: "active" | "retired" | "revoked" | Nonekey_not_before: str | None,key_not_after: str | Nonekey_revocation_reason: str | None
Behavior:
certificate_revoked = truefolds ontoissuer_status = "revoked"andcertificate_active = falseontoissuer_status = "suspended"; both fail at theissuer_statusstage (causesissuer-revokedandissuer-inactive)- payload acceptance depends on the current
verified_domainsandallow_subdomainspolicy, not just the original signed payload
Key Lifecycle Contract¶
A signing key is active, retired, or revoked, and an issuer is active,
suspended, or revoked. The rules run in this order, and the first one that
fires names the stage and cause:
| Condition | stage |
cause |
|---|---|---|
issuer revoked |
issuer_status |
issuer-revoked |
issuer not active |
issuer_status |
issuer-inactive |
key revoked |
key_status |
key-revoked |
| issuer record not yet valid | issuer_status |
issuer-record-not-yet-valid |
| issuer record expired | issuer_status |
issuer-record-expired |
artifact issued_at outside the key's [not_before, not_after] |
key_status |
key-window-mismatch |
| artifact not yet valid | time_window |
not-yet-valid |
artifact past expires_at |
time_window |
object-expired |
| otherwise | accepted |
— |
Two consequences follow:
- Rotation is not revocation. A retired key still vouches for every
artifact whose
issued_atfalls inside its window, so codes sealed before a rotation keep verifying. Only an artifact sealed afternot_afterfails, withkey-window-mismatch. - Revocation is terminal. A revoked key never returns to
active; the demo issuer mints a fresh key reference instead. Everything the revoked key signed blocks withkey-revoked, whatever its window says.
The demo issuer keeps one process-stable key. POST /verifier/demo-materials
accepts rotate_key: true (mint a successor, retire the current key) and
key_state: "retired" | "revoked" (set the current key's state), and echoes the
result under trust. The store is in-memory for this cycle and resets with the
API process.
Payload Revalidation Rules¶
Destinations and policy rules are both reduced to canonical form (RFC 3986) before any comparison:
- trim surrounding whitespace; a payload without a scheme is parsed as
https://... - lowercase the scheme and the host; encode the host as IDNA/punycode
- strip a single trailing
.from the host; strip a leadingwww.for host matching - fold default ports (
:80for http,:443for https) to absent - percent-decode unreserved characters only
- resolve dot-segments (RFC 3986 section 5.2.4)
A destination that cannot be canonicalized is rejected with cause
destination-invalid: userinfo, a backslash in the authority or path,
control characters, an empty host, or a non-http(s) scheme.
Current matching rules:
- exact host match is allowed
- subdomain match is allowed only when
allow_subdomainsis true - matching is against the issuer's current verified domain set; a domain whose proof has expired no longer matches
- the destination scheme must appear in the rule's
allowed_schemes(absent meanshttpsonly) - an explicit non-default port must appear in the rule's
allowed_ports(absent means the scheme default only) - path prefixes match on segment boundaries: a
/payprefix matches/payand/pay/..., never/payments; a/prefix matches every path - query keys are checked against the rule's
query_policyandallowed_query_keyssettings - an invalid destination-policy document fails closed with cause
policy-invalid; a missing policy document falls back to host matching alone
Current PoC intentionally does not define:
- content inspection or reputation scoring
Redirect chains are not observed in this build. A redirecting destination
is reported unknown with cause redirect-unobserved. A safe
redirect-observation service is specified for a later cycle; this profile
claims no live redirect verification today.
Scan Accounting Rules¶
The verifier records scan evidence rather than gating on it. Only
POST /scanner/decisions records a scan; reading
GET /verifier/scan-activity?envelope_id=<64 hex> never does.
Per-envelope accounting bounds how much evidence one envelope may accumulate:
envelope_budget_limit,envelope_budget_remainingandenvelope_budget_window_secondsare reported on the scan-activity throttleenvelope_rate_limit_window_secondsandenvelope_rate_limit_max_requestsare reported onGET /verifier/status
Exhausting a budget bounds recording, not acceptance: a decision is still returned, and a repeat presentation inside the validity window still verifies.
Result Contract¶
Current response fields from the narrowed verifier:
allowed: boolstage: strreason: strcause: str | None— the structured cause behind a failing trust or freshness stage (see the Key Lifecycle Contract table)canonical_claims_sha256: str | Nonematched_rule: str | None
HTTP responses from the public verifier surface also include X-Request-ID
for request tracing without exposing request bodies in logs.
GET /verifier/status reports the current public runtime posture, including:
- whether verifier API key auth is enabled
- whether admin-managed API key flow is enabled
- whether Redis-backed coordination is active
- the current rate-limit window and thresholds
- whether the image decode fallback endpoint is available
Current stage values used by the PoC:
signed_schemaissuer_statuskey_statustime_windowpayload_revalidationaccepted
(certificate_status was the pre-lifecycle name for the issuer check; it is
now issuer_status, and key-level failures report as key_status.)
The scanner surface adds a residual_vector alongside the stage: six families
in the order issuer_chain, destination_policy, redirect_flow,
runtime_safety, freshness, artifact_integrity, each entry {tier, cause}.
A failing stage maps into that vector — time_window past expires_at becomes
freshness tier block, cause object-expired; a claims version this build
does not support becomes issuer_chain tier invalid-managed-claim, cause
unsupported-claims-version; issuer_status and key_status both become
issuer_chain tier revoked-issuer with the cause from the lifecycle table
(key-revoked, issuer-inactive, key-window-mismatch, …). The response also carries model_decision, with
profile, primary_state, annotations, reason_codes and attention_level.
Non-Goals In This Profile¶
This profile does not currently define:
- interoperability across multiple certificate formats
- decentralized trust roots
- multi-algorithm or post-quantum envelopes
- transport-specific behavior beyond the current machine-readable payload contract