Signing & attest
Sign a JSON report so it can later be checked for tampering, check a signed report, print a commit trailer, and write a shields.io badge.
Signing modes
Two modes, both under verify --sign. local
(the default: bare --sign or --sign=local)
uses an ed25519 keypair generated by paranoid attest
keygen and never touches the network. --sign=keyless
shells out to the cosign CLI to run Sigstore's
keyless signing flow, the same pattern this tool already uses
for git and docker: shell out to a real binary instead of
importing its SDK. There is no Sigstore Go dependency. Keyless
signing does use the network, and, outside a recognized CI OIDC
environment, needs an interactive browser login; it only runs
when you pass --sign=keyless.
--sign requires --format json and
--output: the signature covers exactly the bytes
written to --output, so there is always a file on
disk whose bytes match what was signed. It writes
<output>.sig next to it (and, for keyless,
<output>.pem, the signing certificate).
Generating a local key
$ paranoid attest keygen
wrote /home/you/.config/paranoid/attest.key and /home/you/.config/paranoid/attest.key.pub
Writes attest.key (private, hex-encoded ed25519
seed) and attest.key.pub (public), both permission
0600, to ${XDG_CONFIG_HOME:-~/.config}/paranoid.
--force overwrites an existing key; without it,
keygen refuses to run if a key already exists there, since
overwriting silently would strand every signature already made
with the old key: a later verify against the new public key
would read as tampered, not merely "signed with an old key".
Signing a report
$ paranoid verify --format json --output report.json --sign
$ ls report.json report.json.sig
For --sign=keyless, cosign also needs to be on
PATH; without it, --sign=keyless fails
with a clear error naming --sign=local as the
offline alternative.
paranoid attest verify
$ paranoid attest verify --report report.json --sig report.json.sig
valid: local ed25519, signed 2026-08-26T12:00:00Z, key fingerprint 3f2a9c1d7b4e6081
Reads the signature file to decide the mode: a local signature
is a small, self-describing JSON envelope (mode
"local"); anything else is treated as a keyless
signature, checked by shelling out to cosign
verify-blob. No flag is needed to say which mode a given
.sig file is.
| Flag | Default | Meaning |
|---|---|---|
| --report string | required | The report file to check |
| --sig string | required | The signature file written by verify --sign |
| --key string | attest.key.pub in the config directory | Public key file for a local signature |
| --cert string | the .pem file next to --sig | Certificate file for a keyless signature |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Signature is valid |
| 2 | Signature is invalid |
| 3 | Could not attempt verification (missing or unreadable file, malformed signature, cosign not on PATH for a keyless signature) |
For a local signature, exit code 2 always means the report bytes
do not match the signature and key: the report was edited after
signing, or the wrong public key was used. For a keyless
signature, exit code 2 means cosign's own
verify-blob refused it; cosign's exit code does not
distinguish a genuinely invalid signature from an operational
failure on its side (a network hiccup, a Sigstore outage), so
read the printed reason text to tell those apart.
Identity is not pinned
Keyless verification checks
--certificate-identity-regexp .* and
--certificate-oidc-issuer-regexp .*: it proves the
report was signed by someone through a Fulcio-issued
certificate logged in Sigstore's Rekor transparency log, not by
any specific person, email, or CI job. The signer info line on a
successful verification reads the certificate's own identity
(email, else URL, else subject) for display only; that is not an
access-control decision. Pinning a specific identity is left for
a future task.
--trailer
Prints Audited-by: paranoid score=NN verdict=V as
the last line on stdout, meant to go into a commit trailer:
$ paranoid verify --trailer
...
Audited-by: paranoid score=92 verdict=pass
$ git commit --trailer "$(paranoid verify --trailer | tail -n1)" -m "message"
--badge <file>
Writes a shields.io endpoint badge as JSON, deterministically: the same score and verdict always produce the same bytes.
$ paranoid verify --badge badge.json
$ cat badge.json
{
"schemaVersion": 1,
"label": "paranoid",
"message": "score 92",
"color": "brightgreen"
}
Color follows the verdict: brightgreen for pass,
yellow for warn, red for fail. Host the
file wherever your CI already publishes artifacts and point a
shields.io endpoint badge URL at it.
Where the signature lives
A signature is always a sidecar file next to the report, never
part of the report's own JSON. The report schema
(schema_version) is unchanged by signing.