Registry threat model
The registry publishes server metadata and accepts updates from the credential
that owns each public identity. It is the standalone C++ service in relay/,
backed by rqlite. PHP Discovery registration and pairing use local application
persistence and do not call this service; their boundaries belong to the
application threat model.
This model describes source controls and the cases exercised by the local native contracts. Operators should start with the Registry chapter for applicability, health, credentials and recovery boundaries. The service reference describes the runners, artifact locations and release acceptance limits. Execution results apply to the checkout and toolchain recorded by each run; test descriptions below do not claim a current passing run. Container startup qualification is prior evidence unless that container gate is explicitly rerun; local native tests do not renew it or qualify a regional deployment.
Assets and actors
| Asset | Authority and exposure |
|---|---|
Registration apiKey |
A client-generated secret encoding 32 random bytes as 64 lowercase hex characters. Possession authorizes updates to every identity owned by its SHA-256 digest. |
| Enrollment capability and HMAC key | A capability binds one identity, credential digest and expiry. The shared key lets an operator or API process mint capabilities; it is not a public verification key. |
| Stored ownership, metadata and revision | rqlite stores the credential digest and the public URL, name, version and heartbeat timestamps. Public lookup intentionally discloses current metadata, revision and heartbeat/update timestamps. |
| Database and TLS credentials | Basic authentication credentials, client keys and trusted CAs grant database or peer access. The API certificate authenticates the public listener. |
| Voter state and backups | Persisted membership, schema checksum, SQLite data and backup files carry ownership and metadata across restarts and recovery. A backup contains credential digests. |
| Availability and logs | API sessions, database slots and quorum are bounded resources. Logs must not become a second credential store. |
Actors include anonymous lookup clients, enrolled owners, a different owner attempting takeover, holders of leaked credentials or enrollment capabilities, network attackers, and operators controlling configuration, keys or voter storage. There is no application tenant model here. A credential can own several public identities, and lookup is public. Cross-identity checks enforce credential ownership, not user, organization or library authorization.
Operators with database access and API processes holding query/execute credentials can alter registry state outside the registration ownership checks. Compromise of those actors or trusted voters crosses the database trust boundary; this protocol provides no per-owner database isolation.
Data flows and trust boundaries
Diagram source
flowchart LR
accTitle: Registry enrollment, storage and recovery
accDescr: Owners and anonymous readers reach the registry TLS API. Operator-issued enrollment capabilities use a shared HMAC key. Authenticated connections reach rqlite voters, and backups are encrypted for offline recovery.
Owner[Owner with apiKey] --> API[Registry TLS API]
Reader[Anonymous reader] --> API
Issuer[Operator enrollment issuer] --> Owner
Key[Shared enrollment HMAC key] --> Issuer
Key --> API
API -->|Authenticated HTTPS and mTLS| DB[(rqlite HTTP API)]
DB <-->|Raft mTLS| Peers[(Other voters)]
DB --> Backup[Backup worker with public recipient]
Backup -->|Encrypted historical objects| Store[S3-compatible storage]
Store --> Recovery[Offline recovery with private identity]
The public API exposes registration, lookup, /health and /ready. It does not
proxy rqlite query, execute, load, backup or join routes. The API's outbound
database connections use operator-configured endpoints. Voter HTTP and Raft
listeners need private network access rules even though their transports also
require certificates and authentication.
The source boundaries are registration and SQL, TLS HTTP handling, database transport and JSON parsing, and voter startup. The registry README documents configuration and reproduction commands.
Threats and evidence
Credential disclosure
Registration hashes the submitted credential with SHA-256; SQL persists the digest,
not the raw credential. Response builders select public fields explicitly.
Database errors become fixed public failures rather than upstream response bodies.
Transport exceptions do not log request bodies, credentials or authentication
headers. Responses carry Cache-Control: no-store.
relay/tests/security_test.cpp checks response field selection and malformed
database results containing secret markers. relay/tests/run_http_contract.php
checks response bodies, headers and captured API/rqlite logs for the actual raw
credential, its digest, enrollment capability, enrollment key and database password
markers. Those checks cover their fixture values and paths; they are not exhaustive
analysis of process memory, crash dumps, proxy logs or external observability.
Private key, password, enrollment-key and backup access remains an operator duty.
Cross-identity mutation and enrollment abuse
The registration transaction updates a row only when both public_id and
credential_digest match. First insertion requires valid enrollment and cannot
replace an existing row. The transaction selects the resulting revision for the
same owner before success is reported. Going offline does not free an identity.
Enrollment uses HMAC-SHA-256 over a versioned message containing the public
identity, credential digest and expiry. The verifier requires an expiry within
five minutes and compares the MAC with CRYPTO_memcmp. A capability cannot be
moved to another identity or credential. It is not consumed on use; an owner
already needs no enrollment for a heartbeat.
relay/tests/run_cluster_contract.php exercises distinct owners through different
API instances: a claimant with valid enrollment for a different credential still
gets 403, stored ownership and metadata/revision remain unchanged, a capability
cannot claim a different identity, and the owner can retry through the other API.
relay/tests/security_test.cpp checks identity, digest, key and expiry binding.
These controls cannot establish that a client generated its credential randomly
or that an enrolled owner controls the domain named by its URL.
Replay and stale metadata
The owner credential is reusable and the protocol has no signed client sequence, nonce or metadata version precondition. An old authorized registration can be submitted again: it restores its old URL, name and version while advancing the server revision and heartbeat. Monotonic timestamps and revisions record accepted writes; they do not order client intent or prevent replay.
relay/tests/run_http_contract.php updates owner metadata, then replays the exact
earlier body with the original enrollment capability and checks that the old
metadata returns with a new revision. A separate heartbeat checks that the owner
can register without enrollment. This is an accepted limitation of the current protocol. Someone who
steals the raw owner credential can keep identities online or change their public
metadata. There is no credential rotation, revocation, ownership transfer or
lost-credential recovery endpoint. Enrollment expiry does not revoke an existing
owner credential. A future freshness or recovery protocol needs its own design.
Malformed HTTP, JSON and field types
Boost.Beast bounds request headers and bodies to 8 KiB. Each TLS connection serves one request and closes, and its deadline covers handshake, reads, database work and response writes. JSON parsing bounds nesting to eight levels; field validation checks object/string types, lengths, control characters, public identity syntax, credential syntax and the HTTPS URL shape. SQL binds registration values as parameters.
The HTTP contract exercises conflicting framing, oversized fixed/chunked bodies,
pipelined requests, malformed JSON bytes and wrong field types. Its rejection
checks compare the public metadata/revision snapshot and require a fresh health
request to succeed; they do not inspect the private owner row for every case.
Native security tests exercise malformed database envelopes, row types and values.
The fuzz target in relay/tests/parser_fuzz.cpp covers bounded JSON parsing,
registration validation and enrollment parsing; its corpus includes duplicate
keys. It does not fuzz the live HTTP parser.
A focused reproduction found that pinned nlohmann/json 3.12.0 accepts a valid
object followed by a raw NUL, even when another JSON value follows that byte: its
lexer treats NUL as end-of-input. The shared bounded parser rejects raw NUL bytes
before DOM parsing, after the size check, while preserving valid escaped Unicode
such as \u0000. Native regressions in relay/tests/transport_test.cpp cover
trailing and interior NULs, hidden suffix bytes, an escaped-NUL positive control
and size-limit precedence. The malformed-owner HTTP case checks wire rejection;
execution status comes from the corresponding native and HTTP contract runs.
The same parser reads database responses, so malformed upstream bytes must fail closed
rather than becoming a successful authoritative result.
JSON object duplicate keys are not rejected explicitly. The current nlohmann parser retains the last occurrence, and validation operates on that resulting object. A duplicate-key corpus seed does not establish a rejection policy or equivalence with a proxy using a different parser. Duplicate enrollment HTTP headers are a separate case: a first claim requires exactly one such header.
Rate abuse and resource exhaustion
The API uses one token bucket per instance, fixed session capacity and deadlines.
The database pool has fixed slots and no waiting queue; saturation fails with
503. Rate exhaustion answers 429; both statuses include Retry-After: 1.
/health bypasses the request token bucket but still needs a session and TLS work.
The HTTP and transport contracts exercise rate exhaustion, pool saturation,
deadlines, slow requests and shutdown behavior.
The bucket is global to an API instance, not per owner or source address and not coordinated across instances. One client can consume another client's allowance; adding instances changes the aggregate allowance. Session admission saturation, sustained hostile load, whole-host CPU/RSS and the constrained 24-hour soak remain separate qualification work. Bounded state alone does not establish fair access or denial-of-service resistance.
Stored URLs and actual outbound connections
A registration URL must be absolute HTTPS with a hostname, without user information or a fragment. The registry stores and returns it; this path does not resolve the hostname or fetch the resource. An attacker-supplied registration URL is therefore not, by itself, a demonstrated registry SSRF path. HTTPS syntax also does not prove that the destination is public or safe for a consumer to fetch. Consumers need their own destination and rendering policy.
The API's database egress goes to configured rqlite origins. The transport requires
HTTPS, explicit CA trust, hostname verification, SNI, a client certificate/key and
Basic authentication. Optional connectAddress changes the connected IP while
retaining the configured TLS identity. Redirects are rejected, not followed.
Configuration and its DNS remain privileged inputs; this is not a public-address
allowlist. relay/tests/run_transport_contract.php checks certificates, hostname
binding, redirects, deadlines and reconnection with disposable TLS peers.
TLS or quorum downgrade
Both API and database TLS contexts disable SSLv2/3 and TLS 1.0/1.1. The transport contract probes upstream legacy TLS rejection, untrusted/expired certificates and hostname mismatch. It does not qualify inbound legacy TLS negotiation; that listener setting has source evidence until a dedicated probe is run.
Lookup and readiness request level=linearizable. Registration uses a transaction
on the unified endpoint with the same consistency level; it does not queue writes
or fall back to stale reads. Invalid results, lost quorum and uncertain upstream
outcomes produce 503; the transport evicts the connection and does not replay
the uncertain write. A confirmed absent or offline identity gets 404, which is
distinct from inability to obtain an authoritative answer.
relay/tests/run_cluster_contract.php checks partitions, minority refusal, majority
progress, loss of a quorum and recovery with persisted identities/revisions.
Local TLS forwarders emulate partitions; they do not establish regional network
or physical-failure behavior. The startup wrapper requires explicit new-node modes,
preserves persisted membership on restart and refuses manual peer recovery files.
Its container contracts are separate prior evidence unless rerun in the current
checkpoint. Quorum loss does not authorize reducing the configured voter set.
Backup disclosure and recovery
The opt-in backup worker streams snapshots into age and spools only ciphertext. It holds a public recipient, database backup credentials and S3 upload credentials. The private recovery identity stays outside the worker. Snapshot transport verifies TLS and rejects nonbinary responses, incomplete transfers and rqlite stream-error trailers before upload.
Every completed capture uses a distinct historical object key. The worker does not delete objects or enable bucket-native versioning. A failed attempt preserves the last successful capture time; a separate health invocation detects failed or stale backups. Operators must also monitor a stopped or missing worker.
The disposable backup contract uses real age and rclone against authenticated TLS object storage, then decrypts separately and reuses the existing fresh-cluster restore assertions. Wrong identities and altered ciphertext must fail before load. Encryption protects confidentiality and ciphertext integrity, but anyone holding the public recipient can create ciphertext; it does not authenticate the uploader. Destination write permissions, trusted object selection and independently retained recovery material remain operator responsibilities. The backup runbook documents these boundaries.
Remaining qualification and operational work
The shared enrollment key gives every verifier minting authority. A compromised verifier can issue capabilities for unclaimed identities; existing ownership still requires its credential unless database authority is also compromised. The current configuration loads one key at startup and has no key identifier, overlapping-key window or live rotation mechanism. Operators need a separately designed rotation procedure; no such procedure is qualified here.
Live HTTP fuzzing, parser differentials, session admission saturation and inbound legacy TLS probes remain open. Regional deployment, physical failure, constrained resource soak, actual S3 destination qualification and measured recovery objectives also remain open. Local binary and encrypted-object restore contracts do not establish those operational guarantees. Use the release acceptance requirements for deployment decisions and the corresponding run artifacts for execution evidence.