Repository navigation
Open sealed embargo bundles on the recipient side and stop the seal from eating user data - #14
Merged
Merged
Conversation
…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
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
Plaintext and refusal paths
Smaller
My own tests were part of the problemThree symlink assertions passed because the temp directory was named 243 tests green, clippy |
`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
force-pushed
the
feat/embargo-verify-handoff
branch
from
September 27, 2026 08:58
0030cf2 to
536c514
Compare
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR made by space-bunny from opencode on behalf of Kriday.
embargo-bundlecould seal a maintainer bundle, but there was no way to open one.embargo-verifydecrypts, requires VALIDSIG plus DECRYPTION_OKAY, pins the signer, unpacks and reports the MANIFEST. Oot still never sends: the operator moves the.tar.gpgout of band.Five review rounds then turned up real breakage, three of it in code added alongside it:
notes/erased it and its contents, then reported a clean refusal. The guard now arms only on paths it has proven absent.sealedand exited 0 with the unfiltered history on disk. Cleanup now propagates, and a failed cleanup removes the artifact too.--expect-signerwith the primary fingerprint fromgpg --fingerprintfailed for any key that signs through a subkey. Both fingerprints now parse and either satisfies a pin.Summary:
oot embargo-verify: open, verify and report a sealed bundleVerification: 235 tests across 21 suites,
cargo clippy --all-targets --all-features -D warningsclean,cargo fmt --checkclean. 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.