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:

  1. Serialises the dict to compact JSON bytes (separators=(",", ":"), no spaces).
  2. Calls minisign.sign(message_bytes, secret_key) which applies the Ed25519 algorithm.
  3. Takes the resulting signature bytes and encodes them as base64.
  4. Adds the "signature" field to the claim dict (or "g" in compact format).
  5. 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 by keygen)
  • Keyring: system keyring under service "pypi-profile", username from PYPI_PROFILE_KEYRING_USERNAME env 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:

  1. Decodes it with decode_claim.
  2. Checks that subject (or "s" in compact format) matches the URL being verified.
  3. Checks that pypi_username (or "u") matches the profile's identity.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)