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 aftertrusted 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:
and, when Options.Project is set, two further fields:
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:
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¶
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:
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¶
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.