SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

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.

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.txt and 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.

Terminal
npm add @sdxc/cli @sdxc/logger @sdxc/openpgp @sdxc/result @sdxc/well-known remix

Make 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:

Terminal
sdxc make command openpgp/key
app/commands/index.ts
import { 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>",
				}),
			},
		}),
	}),
});
app/commands/openpgp/key.ts
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:

Terminal
sdxc run openpgp key --user-id "Security <security@example.com>" > signing.key
npx wrangler secret put OPENPGP_SIGNING_KEY < signing.key

Sign 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:

app/initializers/router.tsx
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:

app/services/releases.ts
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:

Terminal
gpg --import security.pub.asc
gpg --verify app.tar.gz.asc app.tar.gz

Verify 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:

config/vendor-key.ts
export const VENDOR_PUBLIC_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK-----

…
-----END PGP PUBLIC KEY BLOCK-----`;
app/services/signed-download.ts
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:

app/services/signed-download.test.ts
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:

Terminal
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

Documentation

Can’t find it? Contact us