A Year of Hardening

It is my birthday, so I let myself do the thing I usually avoid: stop and look back. Not at features. At the security round that sits underneath them. A birthday is a year marker, and the honest way to mark a year on a project like this is to ask whether it lies less than it did before. Oversight is a protocol for proving where data came from and noticing when it leaks. If it cannot be trusted when storage, parsing, logs, and release pipelines go sideways, then the diagram on the homepage is decoration. The work this round was about removing decoration.

This note collects that round in one place: what changed across the protocol core and the mobile verifier, and why each piece mattered. A lot of it is unglamorous. That is the point. A security protocol earns trust by refusing to fail quietly, and most of the engineering that buys that refusal is small, specific, and boring.

The parser stopped trusting its own arithmetic

The sealed-container format is the first thing an attacker touches. Every byte of a bundle is hostile until proven otherwise, and the code that walks those bytes has to assume the length fields are lies. Two fixes this round came from exactly that posture.

Earlier in the round the ciphertext size ceiling, MAX_CIPHERTEXT_BYTES, was written as a literal that overflowed at const-eval time on 32-bit targets, which quietly blocked 32-bit Android and iOS builds. That got split by pointer width so the bound is 4 GiB on 64-bit and capped at usize::MAX on 32-bit, where it lands just under 4 GiB anyway. The sealed-parser follow-up tightened the surrounding length checks in the same spirit.

The most recent fix is the one I like best, because it is the kind of bug that hides in a function everyone has already read. The container reader advanced its cursor with *at + n and sliced with &buf[*at..*at + n], where n comes straight from an attacker-controlled u32 length. On a 64-bit host that addition cannot overflow, so the code looked correct under every test that ran on a normal machine. On a 32-bit target it can wrap, and a wrapped end offset slips past the bounds check and then panics on the slice. The mobile verifier ships a 32-bit ARM build, and it parses bundles handed to it by other people. So this was a real crash path reachable from a malicious file on a real device, invisible on the machine where the tests pass. The reader now computes the end offset with checked addition and treats a wrap as truncation, which is what a wrap is. Valid bundles behave exactly as before, the seventeen container tests still pass, and the cross-language conformance run still shows Python and Rust producing byte-identical output.

That last sentence is the whole discipline in one line. A parser fix that changes the wire format is not a fix, it is a new bug with a confident commit message. Conformance is the gate.

The registry learned to fail closed

The Rust registry was the long thread of the round. It moved from serving the v1 surface to surviving the operational reality of an evidence service: migration from the original Python registry, corruption, and partial writes. The shape that emerged has three interlocking rules.

First, append before store. Register, HTTP beacon, DNS beacon, OCSP-style beacon, and license-style beacon writes now fail if the local transparency log cannot append the matching leaf. Before that, a service could write a database row while failing to write its audit leaf, which is a split-brain evidence state: a row that looks real and cannot be proven. Returning an internal error is the correct outcome.

Second, rows must point at matching leaves. The database validator checks the relationships that matter after a migration, then goes further than existence: an event row's transparency-log index must point at a leaf whose payload actually matches that row. An in-range index is not enough. If row 24 claims a beacon fired for token A but leaf 24 records a beacon for token B, the database is not clean, whatever the cause. The validator compares event kind, token, file and recipient bindings, source IP, user agent, timestamp, and the DNS sidecar fields against the indexed leaf.

Third, recovered logs must verify against themselves. The transparency log used to recover permissively at startup, skipping malformed lines or bad hashes. That is the wrong posture for an append-only log. Recovery now rejects malformed records, non-contiguous indexes, bad hash lengths, and leaf-hash mismatches. New leaf records carry leaf_data_hex, the exact bytes used to compute SHA-256(0x00 || leaf_bytes), so a monitor can recompute the RFC 6962 leaf hash from exact bytes instead of trusting a lossy display field. The range reads on top of the log were hardened too, and the error envelopes across the registry were aligned so that a client cannot read internal structure out of the way a request failed.

Operator authentication on the write side was brought into parity between the Python and Rust paths during the migration work, so the write surface enforces the same token rule regardless of which backend is live.

The container and the dependency surface

Two smaller protocol-side fixes closed out the round. The registry Docker image ran as root; a process that handles signed evidence and listens on a port should not. It now runs as an unprivileged user that owns its data volume, so a registry compromise lands without root inside the container. And the DNS sidecar imported dnslib without declaring it, which is the sort of gap that works on the machine where it was written and fails on a clean install. It now has a declared optional-dependency group.

Dependencies got attention at the edges as well. The rustls-webpki bump pulled in an upstream security fix on the TLS verification path, and the mobile Rust core is pinned to a specific Oversight release tag rather than a floating branch, so the verifier on a phone is built from exactly the crates that were audited under that tag.

The mobile verifier got privacy guardrails

The mobile repo changed in ways that are deliberately invisible to a user. That is the correct kind of invisible. Recent verification history is session-only now, and the app clears the old persisted history key on boot and after verification. Issuer IDs, filenames, and content-hash summaries should not sit in a phone backup just because someone verified a bundle once.

The verifier's privacy claim is narrow and I want it to stay narrow: no accounts, no telemetry, no server-side verification requirement, all checking done on the device. A claim like that drifts the moment a well-meaning dependency adds an analytics SDK. So CI now runs scripts/privacy_guard.py on both mobile workflows, and it fails the build if a release manifest requests network, location, microphone, or contacts access, or if a telemetry or crash SDK appears in pubspec.yaml. The claim is now enforced by the pipeline, not by my memory.

Build provenance moved in the same round. Android and iOS CI emit an oversight-mobile-build-manifest.json next to each artifact, recording commit, ref, lockfile hashes, toolchain settings, and the artifact paths, sizes, and SHA-256 hashes, and a verification step checks that manifest in CI. It is not byte-for-byte reproducible builds yet. It is the bridge artifact until that lands, and it means a release carries a signed-in-spirit record of what produced it.

Secrets stopped lingering on the build runners

Signing material is the highest-value secret a mobile project handles, because a stolen signing key lets someone publish as you. The iOS job already locked its App Store key down with a tight umask, explicit chmod 600, and a scrub step. The Android job did not. On a runner with a permissive umask, the decoded upload keystore and the plaintext key.properties holding the store and key passwords were group and world readable, and both files were left on disk after the build. The Android job now matches the iOS posture: restrictive permissions on the keystore, the keystore directory, and the properties file, and an always-run step that removes both when the build finishes. The window where signing material sits readable on a shared runner is closed.

Opsec of the source itself

One thread this round was about the repository rather than the running code. An opsec scanner, a pre-commit hook, and a CI workflow now look for private IPs, workspace paths, container IDs, and credential patterns before anything reaches the public tree, and a source comment-style check keeps the committed code from carrying the kind of prose that fingerprints how and where it was written. A protocol whose whole value is provenance should not leak its own operational provenance through careless commits.

Where this leaves things

The tagged release remains v0.4.11, the hardware-key line that runs across the Python reference, the Rust core, and the browser inspector. Everything in this note lives on main after that tag, pointed at the same gate I have been describing for weeks: registry burn-in, then a wire-format stability statement, then a v1.0 only when the implementation has earned it rather than when the calendar suggests it.

If there is a theme to a year of this, it is that the interesting work was almost never the feature. It was the bounds check that only fails on the architecture nobody tests on, the database row that points at the wrong leaf, the password file with the wrong permissions, the history list that quietly survives a reboot. None of it demos well. All of it is the difference between a protocol that claims to be trustworthy and one that behaves that way when something breaks. That is a decent thing to have spent a year on, and a good thing to spend a birthday writing down.

Relevant public references: the protocol repository at oversight-protocol/oversight, the mobile verifier at oversight-protocol/oversight-mobile, docs/REGISTRY_DEPLOYMENT.md, docs/spec/registry-v1.md, and docs/ROADMAP.md.