Signing and verification internals¶
This page explains how claims are built, signed, encoded, and verified. The user-facing workflow is in Signing and verification; this page focuses on the code.
Modules involved¶
| Module | Responsibility |
|---|---|
claims.py |
Build unsigned claim dicts; encode/decode proof tokens |
signing.py |
Load/generate minisign keys; sign a claim |
verifier.py |
Fetch external URLs; extract tokens; verify signatures |
What a claim is¶
A claim is a JSON object that says: "the entity who controls PyPI package X also controls the
URL Y, as of time T, signed with key K."
There are two formats: full and compact. Full is more readable; compact is shorter (about 360 chars vs about 1000 chars) for platforms like Mastodon with character limits.
Full claim structure¶
from pypi_profile.claims import build_claim
claim = build_claim(
profile_package="pypi-profile-jane-smith",
pypi_username="jane-smith",
claim_type="controls-url",
subject_url="https://github.com/jane-smith",
key_id="AABBCCDDEEFF0011",
signature_backend="minisign",
)
This produces:
{
"profile_package": "pypi-profile-jane-smith",
"pypi_username": "jane-smith",
"claim": "controls-url",
"subject": "https://github.com/jane-smith",
"issued_at": "2026-05-16T10:00:00Z",
"expires_at": "2027-05-16T10:00:00Z",
"nonce": "550e8400-e29b-41d4-a716-446655440000",
"key_id": "AABBCCDDEEFF0011",
"signature_backend": "minisign"
}
The nonce is a random UUID generated by secrets.token_bytes(16) on every call, preventing
replay attacks. The default expiry is one year from now.
Compact claim structure¶
from pypi_profile.claims import build_compact_claim
claim = build_compact_claim(
profile_package="pypi-profile-jane-smith",
pypi_username="jane-smith",
subject_url="https://github.com/jane-smith",
)
This produces the same data with short keys and Unix timestamps instead of ISO strings:
{
"p": "pypi-profile-jane-smith",
"u": "jane-smith",
"s": "https://github.com/jane-smith",
"i": 1747389600,
"e": 1778925600,
"n": "dGVzdC1ub25jZQ"
}
The is_compact_claim(claim) function detects the format by checking for the "p" key.
How signing works¶
After building an unsigned claim, the signing code:
- Serialises the dict to compact JSON bytes (
separators=(",", ":"), no spaces). - Calls
minisign.sign(message_bytes, secret_key)which applies the Ed25519 algorithm. - Takes the resulting signature bytes and encodes them as base64.
- Adds the
"signature"field to the claim dict (or"g"in compact format). - Serialises the whole signed claim to base64url and prepends
"pypi-profile-proof: ".
from pypi_profile.signing import sign_controls_url
token = sign_controls_url(
profile_package="pypi-profile-jane-smith",
pypi_username="jane-smith",
subject_url="https://github.com/jane-smith",
)
# token is a string like: "pypi-profile-proof: eyJ..."
print(token)
Key storage¶
signing.py manages two storage locations for the secret key:
- Disk:
~/.pypi_profile/minisign.key(always written bykeygen) - Keyring: system keyring under service
"pypi-profile", username fromPYPI_PROFILE_KEYRING_USERNAMEenv var (defaults to"default")
When signing, the code tries the keyring first and falls back to disk. When keygen runs, it
writes to disk and attempts to store in the keyring if one is available.
from pypi_profile.signing import read_public_key_b64
pub_b64 = read_public_key_b64() # reads ~/.pypi_profile/minisign.pub
print(pub_b64) # "RWRCcrgceTEEEv..."
Encoding and decoding tokens¶
from pypi_profile.claims import encode_claim, decode_claim
# Encode: dict → "pypi-profile-proof: <base64url>"
token = encode_claim({"foo": "bar", "signature": "abc123"})
print(token) # pypi-profile-proof: eyJmb28iOiJiYXIiLCJzaWduYXR1cmUiOiJhYmMxMjMifQ
# Decode: "pypi-profile-proof: <base64url>" → dict
claim = decode_claim(token)
print(claim) # {'foo': 'bar', 'signature': 'abc123'}
encode_claim and decode_claim are symmetric. The only special handling is base64url padding:
the encoder strips = padding characters and the decoder adds them back before decoding.
How verification works¶
The verifier in verifier.py does these steps for each [[profiles]] entry:
Step 1: Fetch the page¶
from pypi_profile.verifier import fetch_page
html = fetch_page("https://github.com/jane-smith")
fetch_page makes a plain HTTP GET using urllib.request (part of the Python standard library;
no third-party HTTP client needed). It follows redirects and uses a 15-second timeout. The
function checks that the URL scheme is http or https and raises ValueError for anything
else.
Some domains (linkedin.com, twitter.com/x.com, instagram.com, facebook.com) are
skipped because they actively block plain HTTP scrapers. These are listed in
SCRAPER_HOSTILE_DOMAINS in verifier.py.
Step 2: Extract tokens¶
from pypi_profile.verifier import find_proof_tokens
tokens = find_proof_tokens(html)
# ["pypi-profile-proof: eyJ...", ...]
find_proof_tokens uses a regular expression to scan the full HTML text for any occurrence of
pypi-profile-proof: followed by a base64url string. The token can appear anywhere — in a
paragraph, a code block, a comment, or an HTML attribute — as long as it is in the raw HTML.
Step 3: Check the claim fields¶
For each token, the verifier:
- Decodes it with
decode_claim. - Checks that
subject(or"s"in compact format) matches the URL being verified. - Checks that
pypi_username(or"u") matches the profile'sidentity.pypi_username.
Step 4: Check expiry¶
from pypi_profile.claims import is_expired
if is_expired(claim):
# token has passed its expires_at / "e" timestamp
...
is_expired handles both ISO-string ("expires_at") and Unix timestamp ("e") formats.
Step 5: Verify the signature¶
from pypi_profile.verifier import verify_claim_signature
ok = verify_claim_signature(claim, public_key_b64="RWRCcrgc...")
verify_claim_signature extracts the "signature" (or "g") field from the claim, strips it
out to reconstruct the unsigned payload, and calls minisign.verify(message_bytes, signature_bytes, public_key). The minisign package is py-minisign, which wraps the Ed25519 operations from the
cryptography library.
Status values¶
After all checks, each [[profiles]] entry gets one of these statuses:
| Status | Meaning |
|---|---|
verified |
Signature valid, not expired, subject and username match |
expired |
Signature valid but past expires_at |
invalid |
Signature did not verify, or fields did not match |
self_asserted |
No proof token found on the page |
unverified |
Could not fetch the page (network error, timeout) |
Round-trip example¶
This is a complete signing and verification cycle without running the CLI:
from pathlib import Path
from pypi_profile.loader import load_profile
from pypi_profile.signing import sign_controls_url, generate_keypair
from pypi_profile.verifier import verify_claim_signature
from pypi_profile.claims import decode_claim
# Generate a keypair (saves to disk at ~/.pypi_profile/)
generate_keypair()
# Load a profile
profile = load_profile(Path("pypi_profile.toml"))
# Sign a claim for a URL
token = sign_controls_url(
profile_package="pypi-profile-jane-smith",
pypi_username=profile.identity.pypi_username,
subject_url="https://github.com/jane-smith",
)
print("Token:", token[:60], "...")
# Decode and verify without hitting the network
claim = decode_claim(token)
ok = verify_claim_signature(claim, profile.verification.public_key)
print("Signature valid:", ok)