Sign files and security.txt with OpenPGP
Make an OpenPGP signing key, serve a cleartext-signed security.txt, publish detached signatures beside your downloads, and verify a file someone else signed.
Last updated
After reading this guide, you will know
- How to make an OpenPGP signing key GnuPG can verify against
- How to serve a cleartext-signed
security.txtand sign files with detached signatures - How to verify a detached signature against a pinned certificate before trusting a file
A checksum beside a download proves the bytes arrived intact; it proves nothing about who put
them there, since whoever can replace the file can replace the checksum. An OpenPGP signature
proves the file came from the holder of one private key, and anyone with gpg and your public
key can check it without talking to your server. RFC 9116 recommends the same for
security.txt, so a researcher can trust the contact address a compromised page could
otherwise rewrite.
This guide makes a signing key, serves a signed security.txt, signs the files your app
publishes, and verifies a file a vendor signed before your app uses it.
@sdxc/openpgp implements RFC 9580 signing and verification on Web Crypto, so
it runs unchanged in a Worker. @sdxc/well-known serves security.txt
and signs it through @sdxc/openpgp, and every call answers a Result from
@sdxc/result.
npm add @sdxc/cli @sdxc/logger @sdxc/openpgp @sdxc/result @sdxc/well-known remixMake a signing key
generateKey makes an Ed25519 signing key with its self-signatures. The default is a version 6
key, the RFC 9580 format; GnuPG 2.2 and other RFC 4880 tools read only version 4 keys, so pass
version: 4 whenever people will verify with gpg. Generate it from a command, which runs on
your machine rather than in the Worker:
sdxc make command openpgp/keyimport { command, commands, flag } from "@sdxc/cli";
export default commands({
openpgp: commands({
key: command({
description: "Print a new OpenPGP signing key, armored",
flags: {
userId: flag.string("user-id", {
description: "Who the key belongs to, as Name <email>",
}),
},
}),
}),
});import { createCommand, failure, success } from "@sdxc/cli";
import { generateKey } from "@sdxc/openpgp";
import { isFailure } from "@sdxc/result";
import commandset from "~/app/commands";
export default createCommand(commandset.openpgp.key, async (ctx) => {
let key = await generateKey({ userId: ctx.flags.userId, version: 4 });
if (isFailure(key)) return failure(key.error);
ctx.stdout(key.data.armor());
ctx.stderr(`Fingerprint: ${key.data.fingerprint}\n\n`);
ctx.stderr(key.data.publicKey.armor());
return success();
});The private key goes to standard output, so it lands in the secret without touching the terminal, and the fingerprint and public key go to standard error for you to publish:
sdxc run openpgp key --user-id "Security <security@example.com>" > signing.key
npx wrangler secret put OPENPGP_SIGNING_KEY < signing.keySign security.txt
cleartextSigner from @sdxc/well-known/security-txt is a body transform for serve: it
wraps the file in an OpenPGP cleartext signature, so the text stays readable and the signature
follows it:
import { currentLog } from "@sdxc/logger";
import { serve, wellKnown } from "@sdxc/well-known/middleware";
import { cleartextSigner, securityTxt } from "@sdxc/well-known/security-txt";
import { env } from "cloudflare:workers";
import { createRouter } from "remix/router";
import { SECURITY_TXT } from "~/config/security-txt";
export function createWebRouter() {
return createRouter({
middleware: [
wellKnown({
"security.txt": serve(securityTxt, () => SECURITY_TXT, {
transform: cleartextSigner({
key: () => env.OPENPGP_SIGNING_KEY,
onError: (error) =>
currentLog()?.warn("security_txt.unsigned", {
code: error.code,
}),
}),
}),
}),
],
});
}Without a key, or with one it cannot read or use, the file is served unsigned and onError
says why, so a missing secret in a preview environment never takes the contact address
offline. Security headers and CSP
writes the SECURITY_TXT document itself.
Publish detached signatures beside your files
A detached signature travels in its own file, conventionally the file's name plus .asc, so
the signed file stays byte for byte what it was. signDetached answers both the binary
packet and the armored PGP SIGNATURE block; the armored one is what gpg --verify reads:
import { readPrivateKey, signDetached } from "@sdxc/openpgp";
import { isFailure, success } from "@sdxc/result";
import { env } from "cloudflare:workers";
export async function publishRelease(name: string, bytes: Uint8Array) {
let key = await readPrivateKey(env.OPENPGP_SIGNING_KEY);
if (isFailure(key)) return key;
let signature = await signDetached(bytes, key.data);
if (isFailure(signature)) return signature;
await env.RELEASES.put(name, bytes);
await env.RELEASES.put(`${name}.asc`, signature.data.armored, {
httpMetadata: { contentType: "application/pgp-signature" },
});
return success({ name, fingerprint: key.data.fingerprint });
}Whoever downloads app.tar.gz and app.tar.gz.asc checks them with your public key:
gpg --import security.pub.asc
gpg --verify app.tar.gz.asc app.tar.gzVerify a file someone else signed
The other direction is a file your app consumes: a vendor's data export, a blocklist, a firmware image. Pin the vendor's public key in your configuration, the way you would pin a certificate, and accept the file only once a signature from that key verifies:
export const VENDOR_PUBLIC_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK-----
…
-----END PGP PUBLIC KEY BLOCK-----`;import type { PublicKey } from "@sdxc/openpgp";
import { verifyDetached } from "@sdxc/openpgp";
import { isFailure, success, wrap } from "@sdxc/result";
export async function signedDownload(url: string, certificate: PublicKey) {
let file = await wrap(() =>
fetch(url).then((response) => response.arrayBuffer()),
);
if (isFailure(file)) return file;
let signature = await wrap(() => fetch(`${url}.asc`).then((r) => r.text()));
if (isFailure(signature)) return signature;
let bytes = new Uint8Array(file.data);
let verified = await verifyDetached(bytes, signature.data, certificate);
if (isFailure(verified)) return verified;
return success({ bytes, signedBy: verified.data.signingKey });
}The caller reads the pinned key once with readPublicKey(VENDOR_PUBLIC_KEY) and passes it in.
verifyDetached accepts one certificate or a list, so a key rotation is a second entry in the
list until the old key retires. A signature by a revoked or expired key answers
invalid-signature, and one from a key you did not pass answers unknown-signer.
verifyCleartext(text, certificate) checks a cleartext message, such as another site's
security.txt. Only the text it returns is covered by the signature, so read the fields from
that.
Test signing and verifying
A key generated in the test signs and verifies end to end. MSW serves the vendor's file and its signature, so the download path runs as it does in production:
import {
generateKey,
readPublicKey,
signDetached,
verifyCleartext,
} from "@sdxc/openpgp";
import { isFailure, isSuccess, unwrap } from "@sdxc/result";
import { cleartextSigner, stringify } from "@sdxc/well-known/security-txt";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { signedDownload } from "~/app/services/signed-download";
import { SECURITY_TXT } from "~/config/security-txt";
const SERVER = setupServer();
const BLOCKLIST_URL = "https://vendor.example.com/blocklist.json";
beforeAll(() => SERVER.listen({ onUnhandledRequest: "error" }));
afterEach(() => SERVER.resetHandlers());
afterAll(() => SERVER.close());
test("accepts the vendor's file and refuses a changed one", async () => {
let key = unwrap(
await generateKey({ userId: "Vendor <ops@vendor.example.com>" }),
);
let certificate = unwrap(await readPublicKey(key.publicKey.armor()));
let file = new TextEncoder().encode('["203.0.113.7"]');
let signature = unwrap(await signDetached(file, key));
SERVER.use(
http.get(BLOCKLIST_URL, () => HttpResponse.text('["203.0.113.8"]')),
http.get(`${BLOCKLIST_URL}.asc`, () => HttpResponse.text(signature.armored)),
);
let changed = await signedDownload(BLOCKLIST_URL, certificate);
expect(isFailure(changed) && changed.error.code).toBe("invalid-signature");
SERVER.use(http.get(BLOCKLIST_URL, () => HttpResponse.arrayBuffer(file.buffer)));
let accepted = await signedDownload(BLOCKLIST_URL, certificate);
expect(isSuccess(accepted) && accepted.data.signedBy).toBe(key.fingerprint);
});
test("security.txt verifies against the published key", async () => {
let key = unwrap(await generateKey({ version: 4 }));
let sign = cleartextSigner({ key: () => key.armor() });
let signed = await sign(stringify(SECURITY_TXT));
let certificate = unwrap(await readPublicKey(key.publicKey.armor()));
let verified = unwrap(await verifyCleartext(signed, certificate));
expect(verified.text).toBe(stringify(SECURITY_TXT));
});Run it from the terminal
@sdxc/openpgp makes the key, signs a release and checks a vendor's file from a terminal too,
for the times you sign by hand:
sdxc pkg openpgp keygen --key-version 4 --user-id 'Security <security@example.com>' \
--public-out public.asc > signing.key
sdxc pkg openpgp inspect public.asc
sdxc pkg openpgp sign release.tar.gz --detach --key-file signing.key > release.tar.gz.asc
sdxc pkg openpgp verify blocklist.json --signature blocklist.json.asc --key-file vendor.asc--key-version 4 writes the key GnuPG 2.2 reads. verify exits 1 when the signature is bad
or made by another key, and a protected key's passphrase comes from --passphrase-file or
--passphrase-env. Use a package from the
terminal covers what every package command
line shares.
Where to go next
Publish transparent statements with SCITT — a public, append-only record of what you signed, for releases that need more than a key.
Security headers and CSP — the
security.txtdocument this guide signs, and the headers it sits beside.@sdxc/openpgp— key generation options,verifyKey, armor and packets.