Skip to content

Verify a certificate offline

A certificate is only worth something if you can check it without asking us whether it is good. This page is the procedure, with no step that requires trusting this site.

A compact JWS: three base64url segments separated by dots, signed with ES256 (ECDSA over P-256 with SHA-256).

eyJhbGciOiJFUzI1NiIsImtpZCI6InBzbi1wcm9kLTIwMjYtMSJ9.eyJjaWQiOiJjZXJ0XzAxai4uLiJ9.MEUCIQ…
└──────────── header ────────────┘ └───────── payload ─────────┘ └─ signature ─┘

The PDF is a rendering; the JWS is the credential. The transparency log hashes the compact JWS, never the PDF, so re-rendering a certificate cannot change whether it verifies.

Verification is three independent questions, and the order matters because a later answer cannot rescue an earlier failure:

  1. Is the signature good? Resolve the header’s kid in the published key set and verify the signature over header.payload.
  2. Is the hash in the transparency log? Compute SHA-256 of the whole compact JWS string and look for it in a log segment. A validly signed certificate that is not logged is unverified — that is the property the log exists for: it makes rogue issuance detectable.
  3. What does the status record say? Valid, expired, revoked, or superseded. Expiry is not revocation, and revocation is prospective.
Terminal window
curl -s https://purposesource.org/jwks.json -o jwks.json
# The same document is served from the edge, and committed to the spec repository:
curl -s https://api.purposesource.org/jwks.json | diff - jwks.json && echo "identical"

The key set carries every key ever used, current and retired, each with a status and a validity window — so a certificate signed under a retired key still verifies. Key ids follow psn-{env}-{yyyy}-{n}; the production key is psn-prod-2026-1.

A third copy is committed, with signed commits, to the public specification repository. Compare the three: substituting a key on the website alone cannot forge trust.

This is what the verification page does, in your own browser, with no server in the trust path:

const b64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
async function verify(compactJws, jwks) {
const [h, p, s] = compactJws.split('.');
const header = JSON.parse(new TextDecoder().decode(b64u(h)));
if (header.alg !== 'ES256') throw new Error('unexpected alg: ' + header.alg);
const jwk = jwks.keys.find((k) => k.kid === header.kid);
if (!jwk) throw new Error('kid not in the published key set: ' + header.kid);
const key = await crypto.subtle.importKey(
'jwk',
{ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify'],
);
// JWS ES256 signatures are raw r||s (64 bytes), which is what WebCrypto expects.
return crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
b64u(s),
new TextEncoder().encode(`${h}.${p}`),
);
}

OpenSSL wants a DER-encoded signature and a PEM public key, so two conversions are needed. The JWK’s x and y are the raw P-256 coordinates:

Terminal window
JWS=$(cat certificate.jws) # the compact JWS, one line, no whitespace
H=${JWS%%.*}; REST=${JWS#*.}; P=${REST%%.*}; S=${REST#*.}
b64u() { python3 -c 'import base64,sys; d=sys.stdin.buffer.read().strip(); sys.stdout.buffer.write(base64.urlsafe_b64decode(d + b"=" * (-len(d) % 4)))'; }
# 1. the signing input, exactly as signed
printf '%s.%s' "$H" "$P" > signing-input.bin
# 2. r||s -> DER (openssl expects DER for ecdsa-with-SHA256)
printf '%s' "$S" | b64u > sig-raw.bin # 64 bytes: r (32) || s (32)
python3 - <<'PY'
from asn1crypto.core import Sequence, Integer
raw = open('sig-raw.bin','rb').read()
assert len(raw) == 64, len(raw)
r = int.from_bytes(raw[:32],'big'); s = int.from_bytes(raw[32:],'big')
class Sig(Sequence): _fields = [('r', Integer), ('s', Integer)]
open('sig-der.bin','wb').write(Sig({'r': r, 's': s}).dump())
PY
# 3. the JWK for this kid -> PEM (kid comes from the JWS header)
KID=$(printf '%s' "$H" | b64u | python3 -c 'import json,sys; print(json.load(sys.stdin)["kid"])')
python3 - "$KID" <<'PY'
import base64, json, sys
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
def d(v): return int.from_bytes(base64.urlsafe_b64decode(v + '=' * (-len(v) % 4)), 'big')
jwk = next(k for k in json.load(open('jwks.json'))['keys'] if k['kid'] == sys.argv[1])
pub = ec.EllipticCurvePublicNumbers(d(jwk['x']), d(jwk['y']), ec.SECP256R1()).public_key()
open('key.pem','wb').write(pub.public_bytes(serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo))
PY
# 4. verify
openssl dgst -sha256 -verify key.pem -signature sig-der.bin signing-input.bin
# -> Verified OK

Step 3 — check transparency-log inclusion

Section titled “Step 3 — check transparency-log inclusion”

Hash the whole compact JWS string, then look for that hash in a log segment:

Terminal window
printf '%s' "$(cat certificate.jws)" | sha256sum
# 9f2c… -
curl -s https://api.purposesource.org/ct/latest.json | grep -i 9f2c
# numbered segments, for a certificate older than the current segment:
curl -s https://api.purposesource.org/ct/11.json | grep -i 9f2c

The same segment files are committed to the ct/ tree of the public website repository and appended by pull request, with a continuous-integration guard that rejects any diff editing or deleting an existing entry. Monthly checkpoints — a signed git tag plus a checkpoint token signed with the production key — commit the log head, so an entire log cannot be replaced retroactively without the checkpoints disagreeing.

A log entry contains a hash, a type code, and a timestamp. No names, no email addresses, nothing personal: the log is safe to mirror forever, and mirroring it is the strongest check available to an outsider.

Terminal window
curl -s https://api.purposesource.org/v1/verify/cert_01j… | jq '{status, typ, variant, period, ct, revocation}'

status is one of valid, expired, revoked, superseded. ct is null for a signed-but-unlogged certificate, and a client that sees null must render it as unverified.

The verification page’s decision is a pure function, published here in full and covered by a fixture per row. Nothing in it is a judgement call:

ConditionVerdictAsserts anything?
kid matches psn-sandbox-*TEST CERTIFICATE — not a production credential, deliberately absent from the logNo
No record under this identifierNO CERTIFICATE WITH THIS IDNo
kid not in the published key setINVALID — unknown signing keyYes
Key set unreachableCANNOT VALIDATE THE SIGNATURE RIGHT NOWNo
Any other fetch failureCANNOT VALIDATE RIGHT NOWNo
Signature does not verifyINVALID — signature does not verifyYes
Signature not yet checkedSIGNATURE NOT CHECKEDNo
Signature good, hash absent from the logUNVERIFIED — not in the transparency logYes
Signature good, log unreadableTRANSPARENCY-LOG INCLUSION UNKNOWNNo
Signature good, logged, status: validVALIDYes
Signature good, logged, status: expiredWAS VALID for the period shownYes
Signature good, logged, status: revokedREVOKED — prospective; the historical window stoodYes
Signature good, logged, status: supersededSUPERSEDED — follow the successor linkYes
Signature good, logged, unrecognised statusSTATUS NOT PUBLISHEDNo

Three properties the order protects:

  1. A sandbox certificate never renders as a production credential, whatever its signature says. Sandbox keys are a disjoint set at a separate path and sandbox-signed tokens never enter the log.
  2. A certificate absent from the log renders unverified even with a perfect signature. The log is not decoration.
  3. A failure to fetch asserts nothing, in either direction. “We could not check” is a distinct answer from “invalid”, and conflating them would make the page useless in exactly the situations where it matters.
ClaimMeaning
cidCertificate identifier — the value in the verification URL and the QR code
typsupporter · admin · contributor · license-status
variantFor example entitlement, waiver, under-threshold
subThe subject as it elected to be named. Never an email address
scope{ kind, repos }pass covers every registered repository
bandPresent on organisation certificates; absent where there is no price
periodvalidFrom / validUntil — the window the certificate attests
kidThe signing key id, which decides which published key verifies it
ct{ seq, segment } — the log position, or null

A certificate never carries more subject data than it displays, and never an amount on an unfunded type — a waiver certificate has no fee, so it has no fee field.

  • Signature fails, or the key id is unknown: treat the artifact as invalid and report it to security@purposesource.org. A forged credential is a security issue, not a support question.
  • Signature verifies but the hash is not in the log: this is the interesting case, and the one the log exists to surface. Report it the same way; it means either a bug in our issuance order or an issuance that should not have happened.
  • Everything checks out but the claim on the artifact overstates it: that is a claims problem, not a cryptography problem — the abuse route, and the permitted wording is in the OSPO and legal pack.