Skip to content

File formats

Byte-level descriptions of everything the module reads from or writes to disk and the network.

The minisign signature file

Four lines, conventionally written next to the artefact as <artefact>.minisig:

untrusted comment: <text>
base64( "ED" || key_id[8] || signature[64] )
trusted comment: <text>
base64( global_signature[64] )
  • The first base64 body decodes to exactly 74 bytes: a two-byte algorithm tag, an eight-byte key identifier, and a raw 64-byte Ed25519 signature over the artefact's BLAKE2b-512 digest.
  • The second decodes to exactly 64 bytes: the global signature.
  • global_signature = ed25519(signature || trusted_comment). The message is the 64 signature bytes followed by the bytes of the trusted comment after trusted comment:, with no trailing newline. Getting this wrong yields a file that parses cleanly and fails only at the global-signature check.
  • The untrusted comment is covered by nothing. Never make a trust decision on its contents.
  • The trusted comment is covered by the global signature, so it is tamper-evident.

The parser is tolerant about layout: it trims trailing newlines, accepts CRLF line endings, skips blank lines, and identifies the comment lines by their prefixes. Any other non-blank line is treated as a base64 body, and exactly two must be present.

The "ED" and "Ed" tags

Tag Meaning
"Ed" Legacy, "pure": ed25519(<whole file bytes>). This module never produces it and refuses to verify it.
"ED" Prehashed: ed25519(BLAKE2b-512(<whole file bytes>)). The only variant produced or accepted here.

Note the asymmetry: a public-key file carries "Ed" even though the signatures it verifies carry "ED". The tag describes what was signed, which is a property of the signature, not of the key.

"ED" is not Ed25519ph / HashEdDSA. It is a plain hash-then-sign construction: the caller hashes, and an ordinary Ed25519 signing operation signs that digest as its message. Driving a KMS with its ED25519_PH_SHA_512 algorithm produces something neither Rust consumer can verify.

The default trusted comment

With Options.TrustedComment empty, the comment is built as tab-separated fields:

timestamp:<unix seconds>    file:<base name>    hashed

and, when Options.Project is set, two further fields:

timestamp:<unix>    file:<base> hashed  project:<project>   key:<KEYID hex>

The base form is exactly what minisign -S writes; the trailing hashed marks the prehashed variant. The two extra fields keep the same tab-separated style, so anything parsing the conventional format still finds what it expects. The key: field duplicates the identifier already in the signature body — the body is authoritative, and the copy exists only so a signature file is greppable under review. Both are covered by the same signature, so they cannot disagree without verification failing.

The minisign public-key file

Two lines:

untrusted comment: <text>
base64( "Ed" || key_id[8] || public_key[32] )

The base64 body decodes to exactly 42 bytes. That single base64 string — without the comment line — is what cargo-binstall pins as pubkey in a crate's [package.metadata.binstall.signing] table, and ParsePublicKey accepts either form.

How the key identifier is derived

key_id = SHA-256(public_key)[:8]

The reference minisign implementation generates this identifier randomly at key-generation time and stores it in the key files it writes. A KMS-held key offers no such step to hook, and keeping local state alongside an HSM key would be one more thing to lose, so it is derived instead. The derivation is deterministic and stateless: a rebuilt signing host recomputes an identical identifier, and a published public key can be re-derived and audited from the KMS public half alone.

The identifier is opaque. Verification rests on the Ed25519 key itself, so a hash prefix is entirely adequate. The bytes are used in the order returned, with no endianness reinterpretation — minisign's C implementation prints the identifier byte-reversed in its human-readable comments, but that affects only comment text; on the wire both sides compare raw bytes.

The WKD directory layout

openpgpkey.WriteWKDTree writes a complete tree under an output directory. For the advanced method (the default), with domain example.com:

<outDir>/.well-known/openpgpkey/example.com/
├── policy                  (zero bytes, required by the specification)
├── submission-address      (only when Options.SubmissionAddress is set)
└── hu/<z-base-32 hash>     (one per unique email)

The direct method drops the <domain>/ level:

<outDir>/.well-known/openpgpkey/
├── policy
├── submission-address?
└── hu/<z-base-32 hash>

Files are written 0644 and directories 0755 — a Web Key Directory is served publicly, so world-readable is correct. WriteWKDTree refuses to operate outside <outDir>/.well-known/openpgpkey, which is what the domain validation protects.

It returns the relative paths of every file written, in write order: metadata first, then the hu/ files in lexicographic hash order.

What goes inside a hu/ file

Binary OpenPGP packets — not ASCII armour. Input keys may be either form and are dearmoured as needed. Where several keys share an address, their binary streams are concatenated in ascending order of uppercase-hex primary fingerprint, so the same key set produces a byte-identical file regardless of the order it was passed in.

The hu/ filename

hu_filename = z-base-32( SHA-1( lowercase(local_part) ) )

SHA-1 is fixed by the WKD wire format, not chosen here. It is a bucket identifier, not a security primitive: authenticity comes from OpenPGP signature verification, never from the filename.

z-base-32 is Zooko's human-oriented base-32 alphabet, ybndrfg8ejkmcpqxot1uwisza345h769, mandated by the specification. There is no = padding. A SHA-1 digest is 20 bytes, which encodes to exactly 32 characters.

openpgpkey.WKDHash(email) computes it from a full address; openpgpkey.WKDLocalPartHash(localPart) from an already-split local part. Both route through one implementation, shared with the resolver, so the publish and fetch sides cannot drift apart.

The URLs a consumer fetches

For release@example.com:

Method URL
Advanced https://openpgpkey.example.com/.well-known/openpgpkey/example.com/hu/<hash>?l=release
Direct https://example.com/.well-known/openpgpkey/hu/<hash>?l=release

verify.WKDURLs(email) returns both plus the canonical advanced host, so you can print exactly what will be requested. The domain is lower-cased when the URLs are built; the ?l= local part is passed through url.QueryEscape as supplied, while the hash always uses the lower-cased form.

The resolver requests the advanced URL first and falls back to the direct URL only on HTTP 404.

What a resolver accepts back

The response is parsed as a binary keyring, then filtered to entities carrying a User ID whose email matches the requested address, compared case-insensitively. Keys for any other address are discarded, and a response containing none for the requested address is treated as unavailable.

The filter matters because a WKD endpoint controls the bytes it returns. It closes the gap on the key_source=external path, where there is no second anchor to cross-check against: a directory cannot smuggle an unrelated key into the trust set.

The OpenPGP artefacts

Artefact Produced by Shape
Armoured public key openpgpkey.ArmoredPublicKey, WriteArmoredPublicKey A -----BEGIN PGP PUBLIC KEY BLOCK----- envelope holding one v4 RSA key, a User ID, and a positive-certification self-signature
Detached signature openpgpkey.DetachSign A -----BEGIN PGP SIGNATURE----- envelope over the manifest bytes
Checksums manifest your build Opaque bytes to this module — it signs and verifies them without parsing

The public key deliberately carries a User ID and self-signature: a bare public-key packet is rejected by the verifier as a "v4 entity without any identities". Producing that self-signature requires signing, which is why minting a key needs the private half — and why an opaque KMS-backed crypto.Signer works, since go-crypto drives RSA signing through the crypto.Signer interface.

Signatures are reproducible when you pin the times. DetachSign truncates the signature creation time to whole seconds — an OpenPGP v4 signature packet stores it as uint32 seconds since the epoch — and disables go-crypto's random-salt notation, which would otherwise make each run differ.