Skip to content

Make the package signature verification actually work - #39

Open
WhiteStarSupreme wants to merge 4 commits into
mainfrom
WhiteStarSupreme/fix-gpg-verification
Open

Make the package signature verification actually work#39
WhiteStarSupreme wants to merge 4 commits into
mainfrom
WhiteStarSupreme/fix-gpg-verification

Conversation

@WhiteStarSupreme

Copy link
Copy Markdown
Collaborator

Cyril asked me to test main before tagging v1.4.2. The tests and the round trip pass, but the release does not build. This fixes that.

The Docker image has not been buildable since v1.4.1

Adding packages/CHECKSUMS.sha256.asc on 2026-07-15 switched on a verification branch in the Dockerfile that had never once executed, because it is guarded by [ -f CHECKSUMS.sha256.asc ]. It failed immediately: python:3.12.11-slim ships no gpg.

/bin/sh: 1: gpg: not found
ERROR: Invalid GPG signature on packages

Building the v1.4.1 tag today reproduces it exactly, so this is inherited, not introduced by 1.4.2.

The native path was broken too

bootstrap-linux.sh and bootstrap-macos.sh call gpg --verify without ever importing the public key. On any machine that does not already have it in its keyring, gpg exits 2 with No public key and the script runs its || exit 1, aborting the install. That is almost every user.

The published key could not be imported

fractum-signing-key.asc on the docs site is missing the blank line the OpenPGP armor format requires between the header line and the base64 body, so gpg rejects it with invalid armor header. Anyone following the verification guide would have failed. The corrected key is added to this repo.

What this changes

The Dockerfile installs gnupg, imports the key, verifies, then purges gnupg in the same layer so it never reaches the final image.

The bootstrap scripts import the bundled key. When gpg is absent they now warn explicitly about what was not checked and fall through to the SHA-256 verification instead of aborting. That is a deliberate call: this tool is built to run on air-gapped machines where installing gnupg may be impossible, and refusing to install there would fail in precisely the case the tool exists for. The hashes are still checked in every path.

Verified

  • Build prints gpg: Good signature from "S.A.S.U. KATVIO (Fractum Release Signing) <contact@katvio.com>"
  • Final image is 260 MB and gpg is absent from it
  • Encrypt then decrypt through the official image with --network=none, using shares 1, 3 and 5: identical SHA-256
  • Two shares below a threshold of three still fail to reconstruct
  • verify_packages exits 0 both with gpg (signature valid) and without it (warning, hashes checked)
  • Test suite: 107 tests, 8/8 files
  • The pinned pyenv-installer hash still matches upstream, checked separately

The Docker image has not been buildable since v1.4.1. Adding
packages/CHECKSUMS.sha256.asc on 2026-07-15 switched on a verification branch
in the Dockerfile that had never once executed, and it failed immediately:
python:3.12.11-slim ships no gpg. Building v1.4.1 today reproduces it exactly.

The native path was broken too, for a different reason. bootstrap-linux.sh and
bootstrap-macos.sh call gpg --verify without ever importing the public key, so
on any machine that does not already have it in its keyring gpg exits 2 with
"No public key" and the script aborts the install. That is almost every user.

The public key is now in the repo. The copy published on the docs site could
not be imported at all: it was missing the blank line the armor format requires
between the header and the base64 body, so gpg rejected it as an invalid armor
header. That is fixed here.

The Dockerfile installs gnupg, imports the key, verifies, then purges gnupg in
the same layer so it does not reach the final image. The bootstrap scripts
import the bundled key, and when gpg is absent they now warn loudly and fall
through to the SHA-256 check instead of aborting: this tool is meant to run on
air-gapped machines where installing gnupg may be impossible, and refusing to
install there would fail in exactly the case it exists for.

Verified: the build prints Good signature from S.A.S.U. KATVIO (Fractum Release
Signing), the image is 260 MB with gpg absent from it, an encrypt/decrypt round
trip through the image with --network=none returns an identical SHA-256, two
shares below the threshold still fail, and the suite is 107 tests, 8/8 files.
Two findings from a full read of src/, both reproduced before fixing.

Extracted share archives were left on disk whenever anything went wrong. The
rmtree sat at the end of the loop body, so it was skipped both by the `raise`
on an invalid share and by the `continue` on an unreadable one. A single failed
decrypt left /tmp/fractum_share_* holding share_N.txt and, when the archive had
been built with --bundle-encrypted, the ciphertext beside it. Accumulate enough
of those and the leftovers alone are enough to reconstruct. Cleanup moves into
a finally.

Nothing set permissions on any output, so the process umask decided them: the
share archives, the .enc file and the reconstructed plaintext all landed as
-rw-r--r--, readable by every user on the machine. They are now created 0600.

Verified: a share with corrupt JSON now leaves zero temp directories, the three
outputs are -rw-------, the encrypt/decrypt round trip through the official
image with --network=none returns an identical SHA-256, and the suite is still
107 tests, 8/8 files.
Neither is fixed here, and the second is the reason why.

secure_clear cannot clear a bytes or a str, both immutable: it copies, wipes
the copy, and leaves the original in memory. The four call sites in commands.py
pass bytearray(share_bytes), so today they wipe nothing at all.

Making that real is a two-line change, decode the share into a bytearray and
clear the buffer itself. Tried it: the wipe becomes effective and 14 tests fail
with "MAC check failed", because the shares are read again after being cleared.
The inert call was load-bearing. Fixing it properly means moving the clear past
the last use of each share, which is a change to key lifetime and belongs in
its own commit with its own tests, not in a release fix. The trap is written
into the docstring so the next person does not repeat the attempt blind.

get_enhanced_random_bytes mixes no entropy of its own any more, it just calls
os.urandom, which is the correct behaviour. Renaming it touches src/utils
exports and about fifty call sites across seven test files for no behavioural
gain, so the name stays and the docstring now says what it does and why.
The release workflow zips a source package, and the file list did not include
fractum-signing-key.asc. Without this the published archive would be worse than
before: its Dockerfile now does COPY fractum-signing-key.asc, so the build
would fail on a missing file, and bootstrap would fall through to gpg without a
key and abort on "No public key".

Verified by zipping the same file list by hand, unpacking it into a clean
directory and building the image from it: the build succeeds.

Note for whoever publishes: the release notes tell users to fetch the key from
https://fractum.katvio.com/fractum-signing-key.asc. That URL answers 200 today
but serves a file gpg refuses with "invalid armor header". fractum-frontend#17
fixes it, and should be merged before the tag or those instructions stay broken.
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