Signed Proofs
A plain attestation proves access: someone who could post to that URL claims
the handle. A signed attestation proves key possession as well - the posted
text carries a JWS the claimant signed with a private key they never sent us.
The difference shows up as signature_verified on every proof.
Opt in at challenge time by supplying a PEM-encoded ECDSA P-256 public key. Once a key is attached to the seed, the signature becomes mandatory: a proof posted without one is rejected rather than downgraded.
Generate a key pair
openssl ecparam -name prime256v1 -genkey -noout -out ec_priv.pem
openssl ec -in ec_priv.pem -pubout -out ec_pub.pemRequest a signed challenge
snug --output json attestation generate-text \
--platform github --identity alice --public-key="$(cat ec_pub.pem)"{
"nonce": "ca19f-de9f-3fe4f",
"post_instructions": "Sign the proof text with your private key (ES256) and post the text plus a 'SnugSig:<jws>' line publicly on your github account.",
"proof_text": "I am proving my identity on github. I am the user 'docs-wave' on Snug, and I am claiming the handle 'docs4-signed'. This is proof ID: ca19f-de9f-3fe4f."
}Note --public-key= with an equals sign. A PEM begins with -----BEGIN, and
the space-separated form (--public-key "$(cat ec_pub.pem)") makes the
argument parser read those dashes as a flag and fail with
unexpected argument '-----BEGIN PUBLIC KEY-----'.
Sign and post
The signature is a compact ES256 JWS whose payload binds three fields - the
issued nonce, your Snug user_id, and the identity you are claiming. All
three must match the challenge or verification fails.
import jwt # PyJWT
token = jwt.encode(
{"nonce": nonce, "user_id": "docs-wave", "identity": "docs4-signed"},
open("ec_priv.pem").read(),
algorithm="ES256",
)
posted = f"{proof_text}\nSnugSig:{token}\n"Publish posted at a public URL. The server scans the fetched body line by
line for the first one beginning SnugSig:, so the marker must sit on its own
line; surrounding prose is fine.
Verify
snug attestation verify --platform github --identity alice \
--proof-url https://gist.github.com/alice/abc123{
"proof_id": "proof_9878f87357834a328bab814a083063ca",
"verified": true,
"platform": "github",
"identity": "docs4-signed",
"signature_verified": true,
"attestation_url": "/api/v1/proofs/proof_9878f87357834a328bab814a083063ca",
"verified_at": "2026-08-28T22:57:11.308307Z"
}signature_verified: true is the only difference in shape from an unsigned
proof, and it is what a consumer should gate on when access alone is not enough
trust. The public key is retained on the attestation record but is not returned
by get, list, or resolve.
Failure modes
Both are 400 invalid_signature:
{
"status": 400,
"msg": "Cryptographic signature verification failed: a public key was claimed but the posted proof carries no SnugSig signature",
"error": "invalid_signature"
}{
"status": 400,
"msg": "Cryptographic signature verification failed: signature did not verify: InvalidSignature",
"error": "invalid_signature"
}The first is a challenge that requested a key with a proof posted without one;
the second is a SnugSig line signed by a different key than the one attached
to the challenge. A payload whose nonce, user_id, or identity disagrees
with the challenge fails the same way. Nothing is written to the ledger in
either case - regenerate the challenge and post again.
Signing is orthogonal to everything else: signed attestations claim the handle, expire, revoke, and resolve exactly like unsigned ones. See the service overview for that lifecycle.