Skip to content

Open sealed embargo bundles on the recipient side and stop the seal from eating user data - #14

Merged
kridaydave merged 18 commits into
mainfrom
feat/embargo-verify-handoff
Sep 27, 2026
Merged

kridaydave merged 18 commits into
mainfrom
feat/embargo-verify-handoff

Conversation

@kridaydave

Copy link
Copy Markdown
Contributor

PR made by space-bunny from opencode on behalf of Kriday.

embargo-bundle could seal a maintainer bundle, but there was no way to open one. embargo-verify decrypts, requires VALIDSIG plus DECRYPTION_OKAY, pins the signer, unpacks and reports the MANIFEST. Oot still never sends: the operator moves the .tar.gpg out of band.

Five review rounds then turned up real breakage, three of it in code added alongside it:

  • The plaintext guard deleted user data. Sealing next to a release dir named notes/ erased it and its contents, then reported a clean refusal. The guard now arms only on paths it has proven absent.
  • A failed cleanup reported success. It warned on stderr, then printed sealed and exited 0 with the unfiltered history on disk. Cleanup now propagates, and a failed cleanup removes the artifact too.
  • The documented signer pin did not work. It compared only the VALIDSIG signing subkey, so --expect-signer with the primary fingerprint from gpg --fingerprint failed for any key that signs through a subkey. Both fingerprints now parse and either satisfies a pin.
  • A 3 MB signed bundle could fill 3.4 GB (1023x amplification), and FIFO members landed in the tree. A raw ustar header walk caps members and declared bytes before tar parses anything; member types are limited to files, directories and symlinks.
  • An expired signing key reported itself as absent from the keyring, telling operators to import a key they already had.

Summary:

  • Added oot embargo-verify: open, verify and report a sealed bundle
  • Fixed the plaintext guard deleting pre-existing files and directories
  • Fixed a failed plaintext cleanup reporting success
  • Fixed the signer pin rejecting the primary fingerprint of a subkey signer
  • Bounded bundle members, declared bytes and member types
  • Corrected the expired-signer message and the stale "never seals" comments
  • Documented the handoff, the MANIFEST schema and the seal preconditions
  • Added symlink, fail-closed, guard and status-parsing regression tests

Verification: 235 tests across 21 suites, cargo clippy --all-targets --all-features -D warnings clean, cargo fmt --check clean. Fixes were mutation-checked (revert each one, confirm a test fails): 17 of 19 caught. The two that would need a failure injected inside gpg or the audit-log append are not covered by a test.

…rom eating user data

embargo-bundle sealed full history to named recipients but there was no way to
open one. embargo-verify decrypts, requires VALIDSIG plus DECRYPTION_OKAY, pins
the signer, unpacks and reports the MANIFEST. Oot still never sends: the
operator moves the .tar.gpg out of band.

Then five review rounds turned up real breakage, three of it in code added
alongside it:

- The plaintext guard deleted whatever sat at the derived staging and tar
  names. Sealing next to a release dir named notes/ erased it, then reported a
  clean refusal. The guard now arms only on paths it has proven absent.
- A cleanup that failed only warned, so the command printed sealed and exited 0
  with the unfiltered history on disk. Cleanup now propagates, and a failed
  cleanup takes the artifact with it.
- The signer pin compared only the VALIDSIG signing subkey, so the documented
  --expect-signer with the primary fingerprint from gpg --fingerprint failed for
  any key that signs through a subkey. Both fingerprints now parse and either
  satisfies a pin.
- A 3 MB signed bundle could fill 3.4 GB on open. A raw ustar header walk caps
  members and declared bytes before tar parses anything, and member types are
  limited to files, directories and symlinks.
- An expired signing key reported itself as absent from the keyring, telling
  operators to import a key they already had.

Verified by mutation: revert each fix, confirm a test fails. 17 of 19 caught;
the two that need a failure injected inside gpg or the audit-log append are
uncovered and say so.

Summary:
- Added oot embargo-verify: open, verify and report a sealed bundle
- Fixed the plaintext guard deleting pre-existing files and directories
- Fixed a failed plaintext cleanup reporting success
- Fixed the signer pin rejecting the primary fingerprint of a subkey signer
- Bounded bundle members, declared bytes and member types
- Corrected the expired-signer message and the stale seal comments
- Documented the handoff, MANIFEST schema and seal preconditions
- Added symlink, fail-closed and guard regression tests
@kridaydave

Copy link
Copy Markdown
Contributor Author

Follow-up to the review above: the handoff did not actually work. Three things made it impossible, and two of them were in the seal path this feature depends on.

Blockers

  • The recipient got a repo with zero git objects. replay borrows the store's odb through alternates, an absolute path to the sender's machine, so history only resolved while that machine existed:
    error: unable to normalize alternate object path: /tmp/.../proj/.oot/objects.git/objects
    fatal: bad object HEAD          count: 0   in-pack: 0
    
    The cd received/*/repo && git log that the README and CLI both advertise failed. The seal now repacks the objects in, cuts the link, and refuses to report success if the bundle has no reachable history.
  • The member check read the wrong string. It took the last field of tar's listing, which for a symlink is its target, so a repo with an ordinary -> ../shared link was refused as "unsafe member" and could never be verified, while the real name went unchecked.
  • The documented --out embargo-2099-01-01.tar.gpg failed. A bare relative name gives an empty parent and tar -C "" errors. Also now refused: --out x.tar, where the derived tar name is the artifact itself.

Plaintext and refusal paths

  • The decrypted tar was created 0666 & ~umask and chmod'd only after gpg exited, so the whole bundle was world-readable for the duration of the decrypt. Both temp files are now created 0600 up front.
  • A gpg that would not start, and a MANIFEST.json that is missing or unparseable, both returned leaving the output tree behind — plaintext survived and every retry said "output already exists".
  • remove_now bailed on the first failure, abandoning the tar when the staging dir would not delete.
  • arm and refuse_symlink read any stat error as "absent". EACCES is not absence.

Smaller

  • A co-signed bundle was pinned against only the last signature.
  • --expect-signer 0x… silently degraded into a mismatch.
  • The member/byte caps were sized for a toy and would have rejected real histories. They are now flood ceilings with injectable limits and unit tests, replacing a 100k-file fixture.
  • The expired-signer claim was wrong: gpg emits no marker for a key that expired after signing, so that bundle still opens. The doc now says what actually holds.

My own tests were part of the problem

Three symlink assertions passed because the temp directory was named oot-symlink-* and the error message quotes the path — so msg.contains("symlink") was satisfied by the path, not the reason. They now assert the production phrase. A fourth was named for the symlink refusal but never reached it (a live link trips the exists() guard first); it is split into live and dangling cases.

243 tests green, clippy -D warnings clean, fmt clean. Verified by mutation: 14 of 15 fixes are killed by reverting them; the member cap has two redundant bail sites, so only removing both kills its test.

`replay` attaches the store's object database to the exported repo through
`.git/objects/info/alternates`, an absolute path to this machine. In a bundle
that path is meaningless: the recipient's repo resolved no objects, and the
`git log` the README tells them to run died with "bad object HEAD" while
`embargo-verify` still reported success.

The seal now repacks everything reachable into the bundle's own odb and drops
the link, then checks the result still has reachable history rather than
trusting the repack to have worked.

Summary:
- Repack the bundle's objects in and delete the alternates link
- Fail the seal if the bundle has no reachable history afterwards
- Cover it by reading the received history with the store moved away
Two ways the documented commands could not work.

The member check took the last field of tar's listing, which for a symlink is
its TARGET. A repo with an ordinary `-> ../shared` link was refused as an
"unsafe member" and could never be verified, while the real member name was
never checked at all. Names now come from the name column, with the link
target split off first.

And `--out embargo-2099-01-01.tar.gpg` — exactly what the README prints — has
no directory part, so the derived staging path's parent was the empty string
and `tar -C ""` errored after the plaintext bundle was built. Also refuses
`--out x.tar`, where the derived tar name is the artifact itself; that only
fails today because gpg happens to catch it.

Summary:
- Read symlink member names from the name column, not the target
- Accept a bare relative --out instead of running tar -C ""
- Refuse an --out whose derived tar name is the artifact
`tar -cf` and `gpg --output` both create their target with 0666 & ~umask, so
the unfiltered history sat world-readable in the artifact's directory for the
whole run and was only tightened afterwards, if the run got that far. Both temp
tars are now created 0600 up front: `tar -cf` truncates an existing file and
`gpg --yes` keeps an existing file's mode, so making the file first is enough.

The same change clears a second leak: a gpg that would not start returned
before any cleanup, leaving the output dir behind, so a missing gnupg made
every retry fail with "output already exists" instead of the real problem.

Summary:
- Created the seal and verify temp tars 0600 at creation time
- Cleared the output dir when gpg cannot be spawned
- Sampled the decrypt temp tar's mode to prove it is never group/world readable
A missing MANIFEST.json, and one that is not valid JSON, both returned while
leaving the decrypted tree in `--out`. That is the worst shape a refusal can
take here: the plaintext survives on disk, and the next run fails on "output
already exists" instead of reporting the problem that is actually there.

Manifest discovery moves into `find_manifest`, which also names the directory
it was reading when a `read_dir` fails rather than surfacing a bare io error.

Summary:
- Cleared the output tree when the manifest is missing
- Cleared the output tree when the manifest is not valid JSON
- Extracted manifest discovery and named the path in its errors
`symlink_metadata` returning Err was read as "absent" in both the guard's
arming and the symlink refusal. EACCES, ELOOP and ENFILE are not absence: the
state is unknown, and unknown must not pass as "safe to write through" or
"safe to delete". On a shared or network mount that is the difference between
refusing and overwriting someone's file.

`remove_now` also bailed on its first failure, so when the staging dir would
not delete, the tar — the same plaintext, more portable — was never even
attempted. It now tries every path and reports them together.

Summary:
- Refused to arm the guard when a plaintext path cannot be stat'd
- Refused to pass a symlink check when the path cannot be stat'd
- Made cleanup attempt every guarded path and report all failures
- Covered both with a mode-000 directory, which stats as EACCES
gpg emits one VALIDSIG per signature packet, and the parser overwrote its
fields each time, so a co-signed bundle was pinned against only the last
signer. Naming the first one was refused even though the bundle is
legitimately signed by it. All signatures are kept and any of them may
satisfy the pin.

Separately, `--expect-signer` filtered the operator's input to hex digits,
which kept the `0` of a `0x` prefix and dropped the `x`. A pin copied from
`gpg --list-keys --keyid-format 0x` therefore degraded to 16 fewer bits than
asked for and then reported a "signer mismatch" against a signer that was in
fact correct. Key ids are normalized before comparison and before the length
check.

Summary:
- Kept every reported signature instead of overwriting with the last
- Allowed any signature to satisfy --expect-signer
- Normalized 0x prefixes, spaces and case before comparing
- Wrote the signers into the mismatch error instead of just the last one
A 100k member cap was wrong for what this tool ships. An embargo bundle is a
full unfiltered history, so it carries every git object in the store — about
three per commit — and a 100k-commit repository would have been refused with
no override. The caps are now flood ceilings that leave real histories alone,
and GNU tar's own refusal of traversal, absolute and hardlink members remains
the primary defence.

The limits moved into `tar_header_totals` so they can be unit tested with
tiny values. That replaces two integration tests which had to materialize a
100k-member tar to reach a limit, one of which left half a gigabyte in /tmp
when it failed.

Summary:
- Raised the member and byte ceilings to flood levels
- Moved the limits into the header walk so they are injectable
- Replaced the 100k-file fixtures with a unit test over the walk
The doc said any expiry or revocation marker refuses the open, and that "old
bundles stop opening when the signer key dies". gpg 2.4 emits no expiry
marker at all for a key that expired after it signed — verified against a key
with an expiry, verified with a faked clock — so such a bundle opens, and the
matching unit test asserted the rule against hand-written status lines rather
than anything gpg produces.

The behaviour is the OpenPGP-standard one and is fine. The claim was not. The
doc now says which markers gpg actually emits, and says plainly that a key
which expired after signing does not stop a bundle opening — with the reason
expiry is enforced on the seal side instead, where the key still has to be
usable.

Also: `sealed_staging_dir` lost its last external caller when the CLI stopped
printing the staging stem, so it is private now, and two doc comments that
had drifted are put back on the items they describe.

Summary:
- Corrected the expired-signer claim to what gpg actually does
- Explained why expiry is enforced at seal time instead
- Made sealed_staging_dir private now that nothing outside uses it
- Refixed a doc comment that had landed on the wrong item
Three of these tests asserted `msg.contains("symlink")`, and the error message
quotes the path it refused — a path inside a temp directory named
`oot-symlink-<pid>-<tag>`. The assertion was therefore satisfied by the
directory name, so it passed with the symlink check deleted. A fourth had a
`|| contains("already exists")` disjunct that defeated it the same way.

The assertions now match the phrase the code actually prints. A fifth test was
named for the symlink refusal on the temp tar but never reached it: a live
link trips the earlier `exists()` guard first, so it was really testing the
stale-verify branch. It now runs both a live and a dangling link, which is
what it claimed to cover.

Summary:
- Asserted the production phrase instead of a word the path contains
- Renamed the temp dirs so they cannot satisfy the assertion themselves
- Dropped the disjunct that let a test pass without its guard
- Split the mislabelled test into live and dangling symlink cases
The handoff example was wrong in three ways. It listed `embargo_until` and
`embargo_recipients` as sufficient, and the seal then failed on the missing
signer — which this repo's own `visibility.toml` leaves commented out, so a
fresh clone could never follow it. It ran the recipient's command in the
sender's directory, one line under "move the .tar.gpg out of band". And
`<signer-FPR>` was never explained.

The sender and recipient sides are now separate, each with its own key
requirements, and the example derives the pin from `gpg --fingerprint` rather
than leaving a placeholder. The seal preconditions are listed in the order the
checks actually fire, and `embargo_until` is "today or later" everywhere,
since the date itself counts as held.

Summary:
- Split the README handoff into sender and recipient steps
- Named the signer requirement the example was missing
- Listed the seal preconditions in the order the checks fire
- Said "today or later" instead of "future" for the embargo date
- Noted in embargo-verify --help that the recipient needs a secret key
@kridaydave
kridaydave force-pushed the feat/embargo-verify-handoff branch from 0030cf2 to 536c514 Compare September 27, 2026 08:58
…in names

Two holes in the tar pre-flight, both found by crafting archives rather than
reading the code.

A GNU sparse member ('S') puts the ARCHIVED size in the header field the walk
reads, and keeps the expanded size in a sparse map that is not read at all; a
pax record ('x'/'g') can override the size outright. So the byte cap measured
a 1.2 KB artifact as 128 GiB and the bundle opened. Those types are now
refused rather than guessed at.

And the member name was read as the fifth whitespace token, so a name
containing a space was checked only up to the first space:
`bundle/pwn /../../tmp/x` was judged on `bundle/pwn` and allowed. GNU tar
refused it anyway, but the point of the check is to refuse before unpacking
anything, and it was not doing that. The name is now everything after the
four leading columns.

Also corrected the cap doc, which still claimed the numbers refuse a disk fill.
They are a floor against floods; 64 GiB is plenty to fill a volume and the doc
should not promise otherwise.

Summary:
- Refused GNU sparse and pax members in the header walk
- Read the whole member name instead of its first word
- Corrected the cap comment to stop promising disk-exhaustion safety
…ner pinned

`--expect-signer` normalized by filtering everything non-hex out, so a pin
with junk in it was quietly compared as whatever survived. A 39-character pin
whose middle was noise passed as a 16-hex pin, while the comment beside it
claimed normalization could not shorten a pin. It now returns None for
anything that is not hex, whitespace and an optional 0x, and the pin is
refused with a message that says so.

`detach_alternates` proved self-containment with `git rev-list --all`, which
walks commits and never looks at a tree or a blob. Deleting a blob from a
bundle left it passing that check. It now runs
`rev-list --objects --all --missing=error` first, which does.

And on a co-signed bundle the report printed the first signature while the pin
was satisfied by any of them, so pinning signer two opened the bundle and
then named signer one. The report is now the signature that matched.

Summary:
- Rejected a pin containing anything but hex, whitespace or a 0x prefix
- Checked every reachable object, not just commits, before sealing
- Reported the signature that satisfied the pin
The previous commit claimed the first two of these and the edits had not
landed — the script that applied them aborted on a formatting mismatch before
writing. Reapplying them properly, and amending what that commit said.

`--expect-signer` normalized by filtering everything non-hex out, so a pin
with junk in it was compared as whatever survived. A 39-character pin whose
middle was noise passed as a 16-hex pin, while the comment beside it claimed
normalization could not shorten a pin. It now refuses anything that is not
hex, whitespace and an optional 0x, and says so.

On a co-signed bundle the report printed the first signature while the pin was
satisfied by any of them, so pinning signer two opened the bundle and then
named signer one. The report is now the signature that matched the pin.

The rest is the small stuff the review turned up: a dangling `alternates` link
would have been tarred into the bundle, because that one check still used
`exists`; a failed chmod on the output dir left the directory behind and made
every retry report "output already exists"; two error strings said the embargo
date must be "in the future" when today is accepted; a comment referred to a
chmod that no longer exists; the README's pin example picked whichever key was
first in the keyring rather than the sender's; and the plaintext-permissions
test polled a fixed 100,000 times, costing 10s whatever the decrypt took.

Summary:
- Refused a pin containing anything but hex, whitespace or a 0x prefix
- Reported the signature that satisfied the pin, not the first one
- Removed the dangling-alternates case with symlink_metadata
- Cleared the output dir when its chmod fails
- Corrected the embargo-date error strings and a stale comment
- Pinned the README example to a named signer instead of the first key
- Bounded the permissions watcher by the verify, not a fixed count
Two of the three things I said I was leaving. Both were avoidable.

Every authenticity claim in the embargo flow reduces to gpg: the signature
check, the key pre-flight, the seal, the open. Resolving it from PATH means
anything earlier on PATH can answer for it, and a shim that prints a VALIDSIG
line unpacks whatever it likes. That needs local control of the environment,
which is the same access as editing `.oot/` directly — but handing this to a
maintainer should not mean trusting PATH as well. `OOT_GPG` and `OOT_TAR` pin
absolute paths; unset, behaviour is unchanged, so the trust boundary is now a
choice the operator makes rather than something the code assumes.

The three embargo suites each carried their own copy of bin/git/oot/keyring
setup, and two had already drifted. They now share one module.

I also need to correct my own summary: I listed "pax size= overrides are still
unread" as an open item. It was already fixed, one commit earlier, in the same
change that refused GNU sparse members — pax records carry typeflag 'x' and
the walk refuses those. Verified end to end: a pax bundle is rejected with
"unsupported type" and leaves no tree. Two of my three "left unfixed" items
were one, and the third was a note where a fix belonged.

Summary:
- Added OOT_GPG and OOT_TAR to pin the tools the embargo flow trusts
- Shared the test scaffolding across the three embargo suites
…ards

A gate review found a blocker: `embargo-verify` printed "verified … N changes"
and exited 0 for a bundle whose git objects had been stripped, and the README's
next line then died with "bad object HEAD". The seal side proves the bundle
stands alone before shipping, but nothing checked on the way in, so a partial
bundle was reported as a good one with a change count read from the manifest.
Verify now runs the same `rev-list --objects --all --missing=error` over the
received repo and requires every sha the manifest names to resolve.

It also found that my own `ef0459e` had made the documentation wrong. I
"corrected" the claim that an expired signer cannot open a bundle on the
strength of a review agent asserting gpg emits no expiry marker — which I never
ran. gpg 2.4.9 does emit EXPKEYSIG, Oot does refuse, and the operator finds
out at the worst moment. The doc now says what gpg does, with a test that
proves it: sign with a key that outlives the embargo, or expect to re-seal.

The rest, from the same review:

- The recipient pre-flight only read `pub:` records, but gpg encrypts to a
  SUBKEY, so a live primary with a dead encryption subkey built the full
  plaintext history before failing. Subkeys are checked now.
- The seal event recorded the policy STRINGS for signer and recipients, which
  a changed keyring cannot resolve later, and nothing tied it to the artifact
  or the policy in force. It now carries resolved fingerprints, a digest of
  the artifact, and a policy key.
- Opening without `--expect-signer` is a silent downgrade on a governance
  tool; it now says so on stderr.
- `OOT_GPG` set to an empty string reported "install gnupg", sending the
  operator to reinstall a package that was already there.
- Tightened three tests: the partial-artifact case now requires a PRE-FLIGHT
  refusal and fails on a late one, the self-containment case reads `--all` so a
  dropped branch cannot pass, and the symlink case asserts the links survived
  rather than only that verify succeeded.
- The shared keyring teardown left an empty directory in /tmp on every run.

Summary:
- Refused a bundle whose objects or manifest shas do not resolve
- Restored the expiry documentation and added a test for it
- Checked encryption subkeys in the recipient pre-flight
- Recorded resolved fingerprints, an artifact digest and a policy key
- Warned when a bundle is opened without a signer pin
- Tightened three weakened assertions and stopped the keyring dir leak
`embargo-verify` wrote nothing. For a governance tool, "who opened this
bundle, when, and was the signer pinned" is the question a maintainer gets
asked later, and there was no way to answer it — the seal side has an audit
trail and the recipient side had none.

Verify is now wrapped so both outcomes are recorded: a refusal is exactly the
event worth keeping, and a successful open records the signer gpg actually
verified, whether a pin was supplied, the embargo date, the declared
recipients, the change count, and where the tree landed. Each line carries a
digest of the artifact, so a record can be checked against the file it claims
to describe rather than trusted on its own.

The log defaults to `oot-verify-log.jsonl` beside `--out` and moves with
`--audit-log`. It is a local record written by the recipient, and the docs say
so: evidence that someone ran this, not proof from the sender. Only the
artifact digest is verifiable after the fact, and that is what it is for.

A failed write is reported but never turns a good open into a refusal: by then
the bundle is already verified, and refusing would leave the recipient with
nothing.

Summary:
- Appended a record for every open and every refusal
- Recorded the verified signer, whether it was pinned, and the manifest fields
- Bound each record to the artifact with a digest
- Added --audit-log to move the record off the default path
@kridaydave
kridaydave merged commit d941036 into main Sep 27, 2026
5 checks passed
@kridaydave
kridaydave deleted the feat/embargo-verify-handoff branch September 27, 2026 10:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant