Skip to content

Security Model

github-actions[bot] edited this page Jul 24, 2026 · 7 revisions

Security model

openvc verifies Verifiable Credentials, handles signing keys, and dereferences issuer-named URLs over the network. This page states what it defends, against whom, and how — the reference an auditor (or an integrator) starts from. The per-control hardening notes are in SECURITY.md; the code-cited auditor annex (per-parser tables, the fail-closed invariants catalog, the residual-risk register, and the fuzz-coverage / adversarial-review history) is the external-audit pack.

Assets

  • The verification decision. The accept/reject output of verify_credential (and verify_ebsi_badge) is the asset everything else protects: a wrong accept (a forged/tampered/expired/revoked credential accepted) is the primary harm.
  • Signing keys. Private key material. openvc never requires it in-process on the signing path — signing goes through the SigningKey protocol, so an HSM/Vault/KMS backend keeps keys out of the process (see Keys & HSM backends).
  • Trust anchors. The roots a verifier trusts: X.509 x5c trust anchors, the EBSI RootTAO, the DID documents a resolver returns. Compromise of an anchor is out of scope (it is the operator's root of trust) but openvc must not widen it.

On the issuer side. The OpenID4VCI key-proof verifier (ADR-0007) adds the mirror attacker — one who requests a credential rather than presenting one — as a row in the table below. The asset is unchanged: it is still a verification decision over attacker-controlled bytes. The issuance decision is not openvc's; the protocol layer and single-use enforcement of c_nonce live in the consuming application, injected as a required callable, so a caller whose store is not atomic re-opens a replay window openvc cannot close for them.

Trust boundaries

Untrusted input crosses into openvc at:

  1. The credential itself — fully attacker-controlled bytes (a JWS/SD-JWT string or a JSON document). Every field is untrusted until the signature verifies.
  2. Network dereferencesdid:web, /.well-known/jwt-vc-issuer, status-list and credentialSchema URLs. The issuer names these URLs and (for status/schema) controls the bytes returned. All of these are attacker-influenced.
  3. The SigningKey / key-agreement backend — an out-of-process boundary (HSM, Vault, KMS). openvc trusts it to sign/decrypt but not to hold key material for it.
  4. Injected resolversresolver, resolve_status_list*, resolve_credential_schema, *_fetch. openvc's guarantees hold only for what these return; a custom resolver that skips verification or the SSRF guard opts out of the corresponding control (hence the blessed defaults in openvc.resolvers).

Attacker capabilities & controls

Attacker capability Threat Control
Present a forged / tampered credential Wrong accept Signature verification through the matching suite; the {ES256, ES384, EdDSA, Ed25519} allow-list runs before any crypto (rejects alg:none, RS*, HS* — alg-confusion defence); unknown JWS crit extensions are rejected on every JOSE lane (RFC 7515 §4.1.11, parity with the COSE and JWE paths); JWS is R‖S, never DER
Name an arbitrary issuer but sign with own key Impersonation Issuer binding — a Data Integrity proof's verificationMethod must be controlled by the credential's issuer DID; VC-JWT reconciles the JWT envelope with the embedded credential; x5c binds the leaf SAN to iss
Serve a malicious document at a fetched URL SSRF (reach internal hosts / cloud metadata) openvc.fetch: https-only, blocks private/loopback/link-local/reserved/multicast IPs, refuses redirects, pins the connection to the validated IP (closes DNS-rebinding). Status/schema fetches use the same guard via the blessed openvc.resolvers defaults
Ship a tiny highly-compressible status list Decompression bomb (OOM DoS) Status decode caps the decompressed output at 16 MiB and fails closed (StatusListError), reading incrementally so a bomb is never materialised
Point credentialSchema at a schema with a catastrophic pattern ReDoS (CPU DoS) Schema validation is opt-in; remote $ref is off (no SSRF via $ref). Residual pattern-ReDoS is a documented limitation (mitigation tracked) — point the schema resolver at trusted hosts
Swap / replay a status list or presentation Stale-status / replay accept Status-list token sub must equal the fetched URI (anti-swap); presentations bind aud + one-time nonce/challenge; a fetched status list is verified before it is trusted
Backdate / post-date validity Expired/not-yet-valid accept Temporal checks on validFrom/validUntil (+ VCDM 1.1 aliases) and proof expires; a present-but-unparseable timestamp fails closed, never silently skipped
Withhold a selectively-disclosed status/schema Skip a fail-closed gate Documented caveat: an issuer that needs status/schema enforced must make the pointer non-selectively-disclosable (mandatory for ecdsa-sd, outside disclosable for SD-JWT)
MITM a fetch Tamper in transit TLS with certificate validation and SNI on the pinned connection
Present a forged or replayed key proof to an issuer, to obtain a credential bound to a key they do not control Wrong issue openvc.openid4vci: the openid4vci-proof+jwt typ pin (no replaying a KB-JWT / VP-JWT / status-list token as a key proof), the allow-list before any crypto, exactly one of the jwk/kid/x5c header key parameters (two lets an attacker pair an honest kid with their own jwk), the key↔alg (kty, crv) binding, aud pinned to this Credential Issuer with a multi-valued aud rejected, and iat freshness both ways (a future-dated proof would otherwise never expire). Nonce single-use is the caller's, injected as a required callable and consumed once, after every signature verifies

Design posture

  • Fail closed. Ambiguity, an unresolvable key, a malformed timestamp, an unrecognised status/schema shape, or a missing opted-in resolver all reject rather than accept.
  • Least authority on the network. Every dereference is https-only and SSRF-guarded by default; nothing is fetched from an allow-list openvc did not vet.
  • HSM-friendly. Key material need never enter the process.

Observability

openvc is silent and dependency-free by default: it attaches a NullHandler to the openvc logger and uses a no-op span hook. Two opt-ins let an operator see why a verification accepted or rejected a credential, without pulling in a tracing dependency.

import logging
from openvc.observability import set_span_hook

# 1. Structured logs: the "openvc" logger is silent until you attach a handler.
logging.getLogger("openvc").setLevel(logging.DEBUG)
logging.basicConfig()

# 2. Spans: bridge to your tracer (OpenTelemetry, ...); a no-op until set.
set_span_hook(lambda name, attrs: tracer.start_as_current_span(name, attributes=attrs))

Neither logs nor spans carry credential contents or private key material — only format, DIDs and outcomes — so enabling them does not widen the attack surface. See the Observability API.

Out of scope

  • Compromise of a trusted anchor, of the host, or of the SigningKey backend.
  • Availability of remote issuers / status lists (openvc bounds its own resource use — response size, decompressed size, recursion — but cannot guarantee a third party is reachable).
  • Anything an injected resolver does after openvc hands it a URL, if the caller supplies a custom one instead of the guarded default.
  • Side-channels in the underlying cryptography / pyjwt primitives.

Clone this wiki locally