I asked Codex (GPT-5.4, OpenAI) to audit the Oversight codebase. It found nine security issues. Some were subtle, some were embarrassing, and all of them shared a common pattern: the code chose to continue rather than stop when something went wrong. This post documents each finding, the fix, and the principle that ties them together.
Transparency about vulnerabilities is part of Oversight's identity. A provenance protocol that hides its own security bugs has no business asking anyone to trust it. Every issue described here has been fixed in v0.4.4, and the commits are public. This review was conducted by Codex (GPT-5.4, OpenAI), working alongside Claude Opus 4.6 and 4.7 (Anthropic). The findings are theirs; the fixes and editorial judgment are mine.
The max_opens TOCTOU race
The max_opens field in a sealed container limits how many times a recipient
can open it. The intent is a self-destructing document: after N successful opens, the
content becomes permanently inaccessible. The bug was in when the counter incremented.
Prior to v0.4.4, the open count was consumed at the start of the decryption attempt,
before the key was validated. A failed decryption (wrong key, corrupted ciphertext,
anything) still burned one of the recipient's allowed opens.
The attack is straightforward. An adversary who knows the recipient's sealed file (but not their private key) sends repeated open requests with garbage keys. Each attempt fails decryption but succeeds at consuming an open count. After N attempts, the legitimate recipient is locked out permanently, having never accessed the content. The adversary never reads a single byte but achieves a denial-of-service against the recipient's access rights.
This is a textbook time-of-check-to-time-of-use (TOCTOU) error. The check ("is the open count below max_opens?") and the use ("increment the counter") happened at the wrong point in the sequence. The fix moves the counter increment to after successful decryption and MAC verification. If decryption fails, the counter does not change. Only a genuine, authenticated open consumes a count.
Windows policy counter: POSIX assumptions and silent fallbacks
Oversight's policy engine tracks state (open counts, expiration, revocation status) in
a local counter file. LOCAL_ONLY mode protects concurrent access to this
file using POSIX fcntl advisory locking. The problem: fcntl
does not exist on Windows. The Python fcntl module raises
ImportError on Windows, and the original code caught this exception
silently, proceeding without any file lock at all. On Windows, two concurrent processes
could both read the counter, both decide they had opens remaining, and both increment,
resulting in more opens than max_opens should permit.
The fix replaces the POSIX-specific locking with a cross-platform approach: msvcrt.locking()
on Windows, fcntl.flock() on POSIX. The abstraction lives in a single
lock-acquisition function that dispatches based on sys.platform, so the
rest of the policy engine never thinks about platform differences.
The second issue in this area was worse. REGISTRY and HYBRID
policy modes are supposed to check a remote registry server for revocation and open
counts, providing centralized control. When the registry was unreachable (network error,
DNS failure, server down), both modes silently fell back to local state, behaving
identically to LOCAL_ONLY. This is exactly wrong. If an administrator
revokes a document via the registry and the recipient's machine cannot reach the
registry, the old code would happily open the revoked document using stale local state.
The fallback converted a security control into a suggestion.
Now, REGISTRY mode fails closed: if the registry is unreachable, the open
is denied. HYBRID mode uses local state as a cache but refuses to open if
the local state is older than a configurable staleness threshold, defaulting to 24 hours.
If you cannot confirm that the document has not been revoked, you do not get to open it.
Rekor digest verification: checking the envelope but not the contents
Oversight logs sealed documents to a Sigstore Rekor transparency log using DSSE (Dead
Simple Signing Envelope) format. The verify_inclusion_offline function
verified that a valid DSSE envelope existed in the Rekor log, that the envelope's
signature was valid, and that the Rekor inclusion proof checked out against the log root.
What it did not verify was whether the DSSE envelope's subject digest matched the hash
of the file being verified.
The attack: an adversary takes a legitimately signed DSSE envelope for File A and presents it as the transparency proof for File B. The envelope is real, the signature is valid, the inclusion proof is genuine. But it has nothing to do with File B. The old code accepted this because it never compared the digest inside the envelope against the expected content hash of the file under verification.
The fix adds an explicit digest comparison step. After verifying the envelope's signature and inclusion proof, the code extracts the subject digest from the DSSE payload and compares it against the SHA-256 hash of the file being verified. If they do not match, verification fails with a clear error identifying both digests. A valid proof for the wrong file is not a valid proof.
Registry sidecar validation: trusting unverified input
The Oversight registry accepts sidecar data at the /register endpoint:
beacon URLs, watermark metadata, and attribution hints that are stored alongside the
signed manifest. The manifest itself is signed by the sealer's private key, but the
sidecar data was accepted as-is from the HTTP request body without checking whether it
matched the signed manifest's contents.
An attacker who could reach the /register endpoint (which is authenticated
but not exclusively so in all deployment configurations) could register a valid manifest
with sidecar data pointing to beacons on attacker-controlled infrastructure. When an
auditor later queried the registry for beacon URLs, they would receive URLs that the
attacker had chosen, potentially redirecting them to a server that logs their queries,
serves forged beacon responses, or simply wastes their time.
The fix validates all sidecar fields against the signed manifest before accepting the registration. Beacon URLs must match the beacon configuration in the manifest. Watermark metadata must be consistent with the manifest's declared watermark layers and parameters. Any mismatch between sidecar data and the signed manifest causes the registration to be rejected with a 400 response, and the discrepancy is logged for auditing.
The seal_multi() closure
seal_multi() was an experimental function for sealing a document to multiple
recipients in a single operation. The implementation used a shared data encryption key
(DEK) for all recipients, encrypting the DEK separately under each recipient's public
key. The problem was in the manifest: it bound only one recipient's public key hash to
the content hash, regardless of how many recipients were specified.
The consequence is that any recipient could verify the manifest, extract the DEK, and decrypt any other recipient's copy. The per-recipient isolation that the manifest is supposed to enforce did not exist. In a leak investigation, the manifest would identify only one recipient, making attribution ambiguous for multi-recipient seals. Worse, a recipient could plausibly deny being the leaker by pointing out that other recipients had the same DEK.
The correct fix requires a manifest format that honestly represents multiple recipients,
binding each recipient's key hash to the content hash independently and logging each
binding separately to the transparency log. That is a nontrivial format change that
touches the manifest schema, the Rekor logging path, and the verification logic. Rather
than ship a partial fix that might introduce new bugs, I disabled seal_multi()
entirely. Calling it raises NotImplementedError with a message pointing to
the tracking issue. Single-recipient sealing (called in a loop for multiple recipients)
remains the supported path. Each recipient gets their own DEK, their own manifest, and
their own Rekor entry.
Text format adapter ordering and the empty tlog root
Two smaller findings round out the nine. The text format adapter, which handles conversion between Oversight's internal representation and various output formats (plain text, HTML, Markdown), had a processing order dependency. Applying L1 watermarks before format conversion could produce different results than applying them after, because format conversion might strip or alter the zero-width Unicode characters that L1 uses. The fix enforces a deterministic ordering: format conversion first, then L1 embedding, so the watermark is always applied to the final-form text.
The empty tlog root fix addresses an edge case in the RFC 6962 Merkle tree
implementation. When the transparency log contains zero entries, the Merkle root should
be the hash of the empty string, per RFC 6962 Section 2.1. The old code returned a
zero-filled byte array, which is not the same thing and would cause verification failures
if anyone ever checked a root computed over an empty log against the RFC specification.
The fix returns SHA-256("") for the empty tree, matching the RFC.
The pattern: fail open is fail
Every one of these bugs follows the same shape. The code encounters an error condition (wrong key, missing module, unreachable server, empty tree) and instead of stopping, it continues with degraded behavior. In application software, this is often the right choice: a photo editor that crashes when it cannot load a thumbnail is worse than one that shows a placeholder. In security software, the calculus is reversed. A security control that silently degrades is worse than one that loudly refuses, because the degraded state looks identical to the working state from the outside. The user believes they are protected when they are not.
The principle is simple: security mechanisms must fail closed. If you cannot verify, deny. If you cannot lock, refuse to proceed. If you cannot reach the authority that knows whether a document is revoked, treat it as revoked. The cost of a false denial (the user has to retry or fix their network) is categorically lower than the cost of a false permission (the user opens a revoked document or an attacker exhausts their open count).
I should have caught these myself. Several of them (the fcntl import, the
registry fallback) are patterns I know to watch for. The value of an external review,
even an automated one, is that it does not share your blind spots. Codex found what I
had normalized. The nine findings are fixed, the tests cover the new behavior, and the
codebase is measurably harder to exploit than it was last week. That is the point of
the exercise.