Featured image of post One key, or two?

One key, or two?

The Rust toolkit needed to sign its own release binaries, and I had this pencilled in as an afternoon. Precompiled cargo-bin crate assets are conventionally signed with Ed25519, everything I own is signed with an RSA key that never leaves AWS KMS, and so the question was just… does Rust get its own key, or can it use the one I already have?

It was not an afternoon.

Because two keys means two places a private key lives, two rotation procedures, and two things to remember. One of which is definitely going to be the one I forget.

Why Ed25519 in the first place

Worth saying, because it never made it into the original ticket and it’s the thing that makes the rest of this make sense. Ed25519 is what the Rust ecosystem’s tooling is built around, and conventions are rather the point when the person doing the verifying isn’t you.

You can bend RSA into that shape, mind. It’s a faff, it isn’t considered best practice, and you end up standing outside every assumption the tooling makes… which is a lovely way to build something nobody can check without ringing you up first.

Three walls, stacked

They turned up one behind the other, the way these things do, and each one looked final on its own.

The backend simply refused. Our KMS signing backend parses the public key and hard-rejects anything that isn’t RSA. Which is fine, until you look at why: sitting right next to that check were two comments explaining that KMS doesn’t expose Ed25519 at all.

And that was true. Once. I picked it up somewhere years back, from a source I couldn’t name for you now, and I wrote it into our own documentation as settled fact. Then I did the thing everybody does with their own documentation, which is believe it. Meanwhile AWS got on with being a competent cloud provider and shipped Ed25519 support at the tail end of 2025, and I never went back to look. Why would you? You wrote it down.

So the guard in my code was defending against a limitation that had stopped existing, and the tidy explanation sitting next to it was the exact reason nobody ever poked at it. My own note, in my own repo, in my own words, wrong for months.

RTFM, kids! Then read it again next year, because the M keeps changing and your notes don’t.

The proof turns up the moment you mint a key and try to use it:

ERRO constructing signer: KMS key is ed25519.PublicKey:
     KMS key is not RSA; only RSA SIGN_VERIFY keys are supported

The containers disagreed. Our signing stack for Go was built around OpenPGP, and emits it armoured. The Rust side wants a raw 64-byte signature or a minisign file, and hasn’t got an OpenPGP parser anywhere in it. Same maths, completely different envelope, and no amount of goodwill gets one to read the other.

And then the size cap. KMS will sign a message of at most 4096 bytes. Release artefacts are megabytes. There’s a prehashed Ed25519 mode that looks for all the world like the way round it, and isn’t: it’s a different algorithm, not a size escape, and the two can’t be swapped for each other.

Stack all that up and the conclusion rather writes itself. KMS can’t sign the artefact, so the Rust key has to be a software key, sat somewhere less protected than the HSM everything else lives in.

That was the design. Written up, cited, and about ten minutes from agreed.

The bit I was stubborn about

Two things stopped it, and neither was clever. First, I asked for the receipt on the one claim holding the whole thing up: the 4 KB cap. Show me that in the docs. Second, and this is the one that mattered, I refused to treat everything around the problem as fixed. Was signing the binary really the only mechanism open to us? We own the updater. We can change how it works… nobody handed that down on a stone tablet.

That second question is what cracked it, because answering it meant somebody had to write down what minisign actually does, rather than what it’s for. And there it was, sat in one sentence:

The file is first hashed with BLAKE2b-512, then Ed25519 signs that 64-byte digest.

Sixty-four bytes.

Nobody signs the file. Nobody has ever signed the file! The thing that goes to the signer is a digest, and a digest is three orders of magnitude inside the limit that had spent two days doing a very convincing impression of a brick wall.

And here’s the bit that still bothers me: every fact in the way was true. The 4 KB cap is real. The prehashed variant really is a different algorithm. The artefacts really are megabytes. The error was in the join, applying a constraint to the file when the file was never the thing crossing the boundary. That’s a lot harder to spot than something invented, because every citation you chase comes back clean and you end up more convinced, not less. I’ll write that one up on its own, it deserves more room than I can give it here.

So: one key, or two? One. Go binaries, wasm modules, ONNX models and Rust crates all sign against the same root, and there’s one rotation to remember instead of two.

So, sigillum

Which left a tool to build, because none of the above helps a Rust pipeline if the only way to run it is to install a Go CLI framework first.

sigillum is a single binary that signs things and manages the keys it signs with.

sigillum sign <file>              # detached, ASCII-armoured OpenPGP signature
sigillum sign --format minisign   # ...or minisign, for the Rust side
sigillum keys generate            # a fresh Ed25519 or RSA keypair
sigillum keys mint                # an OpenPGP public key from an existing signer
sigillum keys wkd                 # a Web Key Directory tree from your public keys

The private key never leaves the KMS, the HSM or the local PEM file, and the backend registry underneath is the same one the framework has always used.

The commands didn’t move. They got a second front door.

This is the bit I’d have got wrong reading it from the outside, so let me be precise about it.

gtb sign and gtb keys still exist. Not deprecated, not relocated, not reimplemented. If you use them, nothing has changed and you can stop reading this section.

What moved is where the command definitions live. They came out of the framework and into go/signing-cli, a small module sat between go/signing (where all the real logic has always lived) and anything fancying a command surface on top of it. It exports constructors, NewCmdSign, NewCmdKeys and friends, each taking a logger and handing you back a plain cobra command.

go-tool-base then mounts them, in its own root:

setup.Wrap("", signingcli.NewCmdKeys(p.GetLogger())),
setup.Wrap("", signingcli.NewCmdSign(p.GetLogger())),

And sigillum mounts the same constructors, at the top level, as its whole reason for existing.

So these aren’t two implementations held in step by discipline and hope. They’re the same commands, compiled into two binaries, versioned as a module. Fix a flag once and both get it.

Why its own module, rather than a package tucked inside the framework? The dependency graph, basically. In gtb those were internal commands because gtb is both a library and a CLI, so it could comfortably carry the pair. sigillum is a pure CLI with shareable commands and all the real logic stays down in go/signing. Sticking the definitions in the middle is what heads off the cyclical reference nightmare every other arrangement walks straight into.

Commands feel like glue. Like the bit of your binary that isn’t really code, just wiring. Pull them out and version them and it turns out they’re a library like anything else… and once they are, the second tool costs you almost nothing.

The name

The working title was signctl. Descriptive, forgettable, and about the four hundredth thing in this space named after what it does.

Signing tools are not exactly an empty field! Between cosign, minisign, signify, notary, gpg and everything orbiting sigstore, there’s no arrangement of “sign” and a suffix left that isn’t either taken or one letter off something taken. You start reaching for the second syllable and every door’s got a name on it already.

Sigillum is Latin for a seal. It sits with the other things round here that got a name rather than a label (keryx the herald, krites the judge), and a wax seal was already the visual language every signing post on this blog had been leaning on anyway, so mostly I was catching up with my own cover art.

There is, I found out afterwards, already a sigillum on crates.io. Different ecosystem, different problem, and I’m relaxed about it. Naming is mostly the art of finding something that isn’t too taken.


sigillum is at v0.2.2, with docs at sigillum.phpboyscout.uk. It’s a rubber stamp. After a fortnight of arguing about elliptic curves, that is precisely what I wanted it to be.

Built with Hugo
Theme Stack designed by Jimmy