This file covers architecture details, server internals, devcontainer infrastructure, and development workflows not in @README.md
content → attestension (JS) → gpg-attest → gpg → signed attestation → attestension → gpg-attest-server
- attestension: Captures content, provides UI for key and server selection, submits signed attestations; backend-agnostic (works with gpg-attest-server, EAS, or others)
- gpg-attest: Minimal program that calls
gpgfor signing; keeps private keys in gpg's control - gpg-attest-server: Custom transparency log — stores attestations, indexes by artifact hash, timestamps entries with its own key
Signing is intentionally done in the native helper (not OpenPGP.js) so that:
- Private keys never transit through the browser
gpg-agenthandles passphrase prompting/caching- Hardware tokens (YubiKey, etc.) work transparently
The custom log server replaces Rekor. It is deliberately minimal: it does not verify submitted signatures. That is the client's responsibility. The server enforces input validation (hash format, field size limits) and rate limiting (global 50 req/s + per-IP 5 req/s) but its role is:
- Append-only storage — entries are never modified or deleted (Trillian Merkle tree)
- Authoritative timestamps — the server signs each entry with its own key, preventing back-dating. A signer whose PGP key is later revoked cannot fabricate past verdicts.
- Hash-indexed lookup — entries are queryable by artifact SHA-256
- Public key transparency — the server publishes its own public key so clients can verify that a timestamp was genuinely issued by the log
Rekor enforces server-side signature verification and only accepts x509/PKIX public keys (the Sigstore/Fulcio ecosystem). This project uses PGP web-of-trust as its trust model, which is incompatible with Rekor's identity assumptions. Rekor also requires uploading full artifact content for hash-indexed lookup, which is impractical for large digital content. The custom server avoids all of these mismatches.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/entries |
Submit a verdict entry |
GET |
/api/v1/entries?hash=sha256:<hex> |
Retrieve all entries for an artifact hash |
GET |
/api/v1/entries/<uuid> |
Retrieve a single entry by UUID |
GET |
/api/v1/publickey |
Server's public key (for timestamp verification) |
GET |
/api/v1/loginfo |
Current tree size and root hash |
{
"artifact_hash": "sha256:<hex>",
"category": "authenticity",
"verdict": "authentic",
"signer_keyid": "<pgp key fingerprint>",
"signature": "<base64-encoded PGP detached signature>"
}Request body is limited to 100 KB (verdict may evolve into arbitrary nested JSON).
artifact_hash must be algorithm:<hex> with correct length (currently sha256: + 64 hex
chars; add new algorithms to hashLengths in handler.go). signature is capped at 8 KB
(sufficient for all classical and ML-DSA post-quantum signatures). signer_keyid is capped
at 256 bytes.
The category field specifies which verdict dimension is being attested. Valid
category/verdict combinations:
| Category | Allowed verdicts | Type |
|---|---|---|
authorship |
my-work, revoke |
Toggle — "I created this" |
method |
ai-generated, revoke |
Toggle — "AI was used" |
authenticity |
authentic, satire, misleading, revoke |
Exclusive scale |
Each category is independently signable. A signer can submit multiple entries for
the same artifact (one per category). The revoke verdict withdraws a previous
claim in that category, returning to silence.
The server adds uuid, log_index, server_timestamp, and server_signature to the stored
entry and returns them in the response.
The signer signs the canonical JSON serialisation of the five fields above (keys sorted, no
extra whitespace). Any verifier can reconstruct the signed payload from the entry fields and
check the PGP signature against the claimed signer_keyid — no separate payload field is
needed.
The server signs each entry with its own GPG key, providing an authoritative timestamp that prevents back-dating. The extension verifies both the server's timestamp signature and the signer's attestation signature before displaying a badge.
Self-revocation (key owner revokes their own key):
- Alice signs verdicts throughout 2025; the log timestamps each one at submission time.
- In 2026, Alice revokes her own key (e.g., she lost control of it).
- Her verdicts from 2025 remain valid — the server timestamps prove they predate the revocation.
- Nobody can fabricate a new verdict under Alice's key and claim it was signed in 2025, because the server would timestamp it in 2026, after the revocation.
Trust withdrawal (user revokes their certification on a signer's key):
- Bob certified Alice's key in 2024 (signed it with
gpg --sign-key). - In 2026, Bob decides Alice is no longer reliable and revokes his certification
(
gpg --edit-key alice revsig). The revocation has a timestamp. - Alice's verdicts from 2025 (timestamped before Bob's revocation) remain valid for Bob.
- Alice's verdicts timestamped after 2026 are dropped for Bob.
- This is a per-user action — Carol's trust in Alice is unaffected unless Carol also revokes.
TODO: Third-party revocation monitoring
A separate advisory tool could watch keyservers for revocations on keys the user currently trusts and alert them: "Carol revoked her certification on Alice — you still trust Alice independently." The user would then decide whether to revoke their own certification. This is independent of the verification pipeline and does not gate badge display.
Releases are triggered by pushing a v* tag. CI builds installation packages for each platform
— no raw binaries are released (except Windows, which has no native installer format).
| Platform | Format | GPG dependency |
|---|---|---|
| Debian/Ubuntu | .deb |
gnupg (declared in Depends:) |
| Fedora/RHEL | .rpm |
gnupg2 (declared in Requires:) |
| macOS | .pkg |
user must install via Homebrew (brew install gnupg) |
| Windows | raw .exe |
user must install Gpg4win |
Firefox Snap (Ubuntu default since 22.04) runs in a sandbox that cannot see system-wide
extension paths (/usr/lib/mozilla/extensions/). Bundling the .xpi into the deb would only
work for deb-installed Firefox, making it fragile and confusing. Instead, all packages print
a post-install message with links to install the extension from the browser stores.
Placeholder URLs: TODO_FIREFOX_ADDON_URL and TODO_CHROME_WEBSTORE_URL appear in
build-deb.sh, build-rpm.sh, build-pkg.sh, and README.md. Replace them once the
extensions are publicly listed on AMO and Chrome Web Store.
The RPM spec uses Requires: gnupg2 because that is the package name on Fedora/RHEL.
Debian/Ubuntu use gnupg instead. Both provide the gpg binary.
The devcontainer runs the backing services automatically on every container start.
| Service | Purpose | Port |
|---|---|---|
| MariaDB | Trillian backing store | 3306 |
| Trillian log server | Append-only Merkle tree (gRPC) | 8090 |
| Trillian log signer | Batches and signs tree heads | — |
| Redis | Search index (artifact hash → UUIDs) | 6379 |
| gpg-attest-server | Custom log API | 8081 |
| Caddy | HTTPS reverse proxy (TLS termination) | 443 |
The Trillian tree ID is written to ~/.gpg-attest/tree_id on first start and reused on subsequent
restarts.
postStartCommand runs init-gpg.sh && start-caddy.sh && start-services.sh (all in .devcontainer/). Each script is idempotent.
curl http://localhost:8081/api/v1/loginfo
curl -k https://gpg-attest.org/api/v1/loginfo # -k for self-signed cert in dev~/.gpg-attest/logs/startup.log
~/.gpg-attest/logs/gpg-attest-server.log
~/.gpg-attest/logs/trillian-log-server.log
~/.gpg-attest/logs/trillian-log-signer.log
~/.gpg-attest/logs/redis.log
~/.gpg-attest/logs/caddy.log
/workspace/.devcontainer/start-services.shSet LOG_SERVER=https://gpg-attest.org in your .env file (copy from .env.example).
VS Code's Dev Containers extension injects host GPG keys and relay sockets into the container
at attach time (pubring.kbx, private-keys-v1.d/*.key, and four S.gpg-agent* sockets).
For a signing project this is a risk: gpg --sign could operate on host private keys, and
S.gpg-agent.ssh exposes host SSH keys.
.devcontainer/init-gpg.sh runs at all three lifecycle hooks (postCreateCommand,
postStartCommand, postAttachCommand) to defend against this. On each attach it:
- Checks whether any non-test key is present in the keyring
- If yes (or if the test key is missing): kills the relay agent, wipes all injected key
material and relay sockets, starts a fresh container-local
gpg-agent, creates the test key - Otherwise: no-op
The postAttachCommand hook is the critical one — it fires after VS Code has fully attached
and injected keys, so it always runs last.
A complete end-to-end test using Firefox and the local test page:
cd /workspace/client && make installThis compiles the Go binary and writes the native messaging manifests for Firefox
(~/.mozilla/native-messaging-hosts/) and Chromium (~/.config/chromium/NativeMessagingHosts/).
The service worker uses fetch(), which is blocked on file:// origins.
cd /workspace/testpage && python3 -m http.server 8080Leave this running in a separate terminal.
Load the extension as a temporary add-on (see Installation in README).
Navigate to http://localhost:8080 in Firefox.
Open DevTools (F12) and switch to the Console tab.
- Right-click any image → click Attest... in the context menu
- The attestation dialog opens with key selector, checkboxes for I created this (authorship) and AI-generated (method), and radio buttons for Authentic / Satire / Misleading (authenticity)
- Select one or more verdicts → click Sign → the Console prints the sha256 hash and PGP signature for each category
After editing extension JS/CSS, click Reload on the about:debugging page.
After editing the native host, re-run cd /workspace/client && make install and reload
the extension.
Verdicts use three independent categories. Each is independently signable and revocable.
| Category | Type | Verdicts | Meaning |
|---|---|---|---|
| Authorship | Toggle (checkbox) | my-work |
"I created this" |
| Method | Toggle (checkbox) | ai-generated |
"AI was used to produce this" |
| Authenticity | Exclusive scale (radio) | authentic, satire, misleading |
Deception/intent spectrum |
A signer can select any combination (e.g., my-work + ai-generated + authentic).
No selection in a category means no claim about that dimension. The revoke verdict
withdraws a previous claim in a category, returning to silence.
The extension queries the log for all entries matching an artifact hash, filters to trusted signers (per the user's GPG web-of-trust), keeps the latest entry per (signer, category) pair, verifies both server-timestamp and signer signatures, then aggregates per category using plurality vote. Up to three badges are displayed per image (one per category), stacked horizontally using 16px icons.
SVG source and pre-rendered PNGs live in extension/icons/. Each icon is a 32×32 symbol
with white stroke outline, drawn as SVG paths/polygons (no fonts). Area shapes (star,
triangle) use solid fill + white stroke. Line shapes (checkmark, tilde, times) use a
two-layer stroke: thicker white underneath, thinner colored on top.
| File prefix | Shape | Color | Hex |
|---|---|---|---|
authorship-my-work |
★ star | Green | #2E7D32 |
method-ai-generated |
△ triangle | Blue | #1565C0 |
authenticity-authentic |
✓ check | Green | #2E7D32 |
authenticity-satire |
~ tilde | Amber | #F57F17 |
authenticity-misleading |
✕ times | Red | #C62828 |
PNGs are generated from the SVGs at sizes 16, 24, 32, 64, and 128 px using rsvg-convert:
for svg in extension/icons/authorship-*.svg extension/icons/method-*.svg extension/icons/authenticity-*.svg; do
base="${svg%.svg}"
for size in 16 24 32 64 128; do
rsvg-convert -w $size -h $size "$svg" -o "${base}-${size}.png"
done
done