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.
What a certificate is
Section titled “What a certificate is”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.
The three checks, in order
Section titled “The three checks, in order”Verification is three independent questions, and the order matters because a later answer cannot rescue an earlier failure:
- Is the signature good, from a key that could make it then? Resolve the header’s
kidin the published key set, check that the token’s signing time falls inside that key’s validity window, and verify the signature overheader.payload. - Is the hash in the transparency log? Compute
SHA-256of 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. - What does the status record say? Valid, expired, revoked, or superseded. Expiry is not revocation, and revocation is prospective.
Step 1 — get the key set
Section titled “Step 1 — get the key set”Until publication, the canonical host does not resolve; this preview prints its own.
# This site's own copy, written by the Pages build from the recorded public coordinates.# Save the whole document, under this name: Step 2 reads it back.curl -s https://purposesource.pages.dev/artifacts/jwks.json -o jwks.json# Compare the KEY MATERIAL, not the whole document: each copy carries its own build# metadata, and a build timestamp is not a key.jq -S .keys < jwks.json > site-keys.json# The edge's copy, served from the Worker bundle:curl -s https://api.purposesource.org/jwks.json | jq -S .keys | diff - site-keys.json && echo "same keys"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, as long as it was
signed inside that window. Key ids follow psn-{env}-{yyyy}-{n}; the production key is
psn-prod-2026-1.
psn:status | A token signed at iat verifies under this key when… |
|---|---|
active | iat falls inside psn:validityWindow: at or after notBefore, and before notAfter if there is one |
retired | the same, and the window has an end — a retired key verifies only what it signed before it was retired |
compromised | iat falls inside the window, and the window has an end. On a compromised key notAfter is where acceptance ends: the earlier of its retirement and the earliest moment the key may have been used by someone else, never moved later. The verdict then carries a warning: whoever holds a compromised key can backdate iat, so the transparency log and the incident notice decide whether the token was really issued then |
A key whose entry states no status or no window is not accepted at all. The one exception is
a test key (psn-dev-*, psn-sandbox-*) published with neither member, as the sample plane
publishes its fixture keys; nothing a test key signs is a credential.
The apex https://purposesource.org/jwks.json is the edge’s copy as well — the apex machine
endpoints are Worker routes — which is why the recipe reads this site’s copy from
/artifacts/jwks.json, the path the site itself publishes.
Two copies exist today, written by two separate deployments: the Pages build writes this site’s, the Worker bundle carries the edge’s. Both are built from the same committed file, so the diff above proves that neither deployment substituted or mangled the key on its way out — it does not prove the two were sourced independently. The copy that would, committed with signed commits to a public repository, is not published yet: it publishes when the out-of-band mirror repository is named.
Step 2 — verify the signature
Section titled “Step 2 — verify the signature”In a browser, with WebCrypto
Section titled “In a browser, with WebCrypto”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);
// A kid that resolves is not yet a key that could sign THEN. The token's `iat` must fall in // the key's window: notBefore included, notAfter excluded. On a retired or compromised key // notAfter is where acceptance ends. Anything missing or unreadable refuses; none passes. const { iat } = JSON.parse(new TextDecoder().decode(b64u(p))); const status = jwk['psn:status']; const validity = jwk['psn:validityWindow']; if (!['active', 'retired', 'compromised'].includes(status) || !validity) { throw new Error('the key set does not state this key\'s status and window'); } const notBefore = Date.parse(validity.notBefore); const notAfter = validity.notAfter == null ? null : Date.parse(validity.notAfter); if (!Number.isFinite(notBefore) || (notAfter !== null && !Number.isFinite(notAfter))) { throw new Error('the key\'s validity window is not a pair of instants'); } if (status !== 'active' && notAfter === null) throw new Error(`a ${status} key must state when its window closed`); if (typeof iat !== 'number' || !Number.isFinite(iat)) throw new Error('the token does not say when it was signed'); const signedAt = iat * 1000; if (signedAt < notBefore) throw new Error('signed before the key was in use'); if (notAfter !== null && signedAt >= notAfter) throw new Error('signed after the key\'s window closed'); if (status === 'compromised') console.warn('its key is published as compromised: check the log and the incident notice');
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}`), );}On the command line, with OpenSSL
Section titled “On the command line, with OpenSSL”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, taken from the jwks.json Step 1 saved:
JWS=$(cat certificate.jws) # the compact JWS, one line, no whitespaceH=${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 signedprintf '%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, Integerraw = 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 key could sign it THEN: the token's iat inside the key's validity window# (notBefore included, notAfter excluded). Anything missing or unreadable refuses.python3 - "$H" "$P" <<'PY'import base64, json, math, sysfrom datetime import datetimedef seg(v): return json.loads(base64.urlsafe_b64decode(v + '=' * (-len(v) % 4)))def at(s): return datetime.fromisoformat(s.replace('Z', '+00:00')).timestamp()def refuse(why): sys.exit('REFUSED: ' + why)header, payload = seg(sys.argv[1]), seg(sys.argv[2])jwk = next(k for k in json.load(open('jwks.json'))['keys'] if k['kid'] == header['kid'])status, window, iat = jwk.get('psn:status'), jwk.get('psn:validityWindow'), payload.get('iat')if status not in ('active', 'retired', 'compromised') or not isinstance(window, dict): refuse("the key set does not state this key's status and window")not_before = at(window['notBefore'])not_after = None if window.get('notAfter') is None else at(window['notAfter'])if status != 'active' and not_after is None: refuse(f'a {status} key must state when its window closed')if type(iat) not in (int, float) or not math.isfinite(iat): refuse('the token does not say when it was signed')if iat < not_before: refuse('signed before the key was in use')if not_after is not None and iat >= not_after: refuse("signed after the key's window closed")print(f'{status} key, iat inside its window' + (' - published as compromised: check the log' if status == 'compromised' else ''))PY
# 4. 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, sysfrom cryptography.hazmat.primitives.asymmetric import ecfrom cryptography.hazmat.primitives import serializationdef 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
# 5. verifyopenssl dgst -sha256 -verify key.pem -signature sig-der.bin signing-input.bin# -> Verified OKStep 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:
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 9f2cThe 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.
Step 4 — read the status record
Section titled “Step 4 — read the status record”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 verdict matrix
Section titled “The verdict matrix”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:
| Condition | Verdict | Asserts anything? | The verification page prints |
|---|---|---|---|
kid matches psn-sandbox-* | TEST CERTIFICATE — not a production credential, deliberately absent from the log | No | Test certificate — not a real certificate |
| No record under this identifier | NO CERTIFICATE WITH THIS ID | No | No certificate with this ID |
kid not in the published key set | INVALID — unknown signing key | Yes | Not verified — do not rely on it |
| Key set unreachable | CANNOT VALIDATE THE SIGNATURE RIGHT NOW | No | Could not check — the published keys could not be read |
| Any other fetch failure | CANNOT VALIDATE RIGHT NOW | No | Could not check right now |
| A status-only record, and the token its holder shows does not hash to it | INVALID — not the certificate this record describes | Yes | Not verified — do not rely on it |
| Signature does not verify | INVALID — signature does not verify | Yes | Not verified — do not rely on it |
No kid in the token’s header | INVALID — the token names no signing key | Yes | Not verified — do not rely on it |
Header alg other than ES256 | INVALID — not an ES256 signature | Yes | Not verified — do not rely on it |
No iat in the token | INVALID — the token does not say when it was signed | Yes | Not verified — do not rely on it |
| Signed outside the key’s validity window | INVALID — signed outside its key’s validity window | Yes | Not verified — do not rely on it |
| Compromised key, signed at or after its window’s end | INVALID — signed after its compromised key’s window closed | Yes | Not verified — do not rely on it |
| Key entry states no usable status or window | CANNOT VALIDATE — the key set does not settle the signing key | No | Could not check — the key set does not settle the signing key |
Offline check only: a token whose typ no contract dates | CANNOT VALIDATE — not a token type this page can date | No | Offline mode only, in the matrix’s own words |
| A status-only record, no token shown | STATUS RECORD — the signed certificate is with its holder | No | Status record only — this page cannot confirm whose certificate this is (or Revoked on {date} ({reason class}) when the record says revoked) |
| Signature not yet checked | SIGNATURE NOT CHECKED | No | Could not check right now |
| Signature good, hash absent from the log | UNVERIFIED — not in the transparency log | Yes | Not verified — do not rely on it |
| Signature good, log unreadable | TRANSPARENCY-LOG INCLUSION UNKNOWN | No | Could not check the public log |
Signature good, logged, status: valid | VALID | Yes | Genuine certificate |
Signature good, logged, status: expired | WAS VALID for the period shown | Yes | VERIFIED — attests participation in {year} for a participation certificate; WAS VALID for the period shown for a company certificate |
Signature good, logged, status: revoked | REVOKED — prospective; the historical window stood | Yes | Revoked on {date} ({reason class}), or Revoked when the record carries no date |
Signature good, logged, status: superseded | SUPERSEDED — follow the successor link | Yes | Superseded — a newer certificate replaces this one |
| Signature good, logged, unrecognised status | STATUS NOT PUBLISHED | No | Could not check — the record carries no status this page recognises |
| Any of the verdicts above, from a key published as compromised and a token dated before its window’s end | The same verdict, — its signing key was later compromised | As that verdict | The same words, followed by “its signing key was later compromised” |
The rows, their order and their verdicts are the matrix’s, unchanged (src/lib/verify-verdict.mjs
in the website’s source, a fixture per row). The last column is what the
verification page prints for each row since its second version: the same outcome,
worded for the reader. Where no network answered at all, a row that asserts nothing prints
“Could not check — you seem to be offline” instead. While the log is still being read, the page
prints “Signature verified in your browser”, with “Checking the public log…” on a line of its
own, and nothing final. Offline mode — paste a token keeps the matrix’s own words.
Three properties the order protects:
- 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.
- A certificate absent from the log renders unverified even with a perfect signature. The log is not decoration.
- 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.
Reading the payload
Section titled “Reading the payload”| Claim | Meaning |
|---|---|
cid | Certificate identifier — the value in the verification URL and the QR code |
typ | The frozen enum supporter · license-status · contributor · steward · topup. Only the first two are issuable at this phase — see the certificate policy |
variant | For example entitlement, waiver, under-threshold |
sub | The subject as it elected to be named. Never an email address |
scope | { kind, repos } — pass covers every registered repository |
band | Present on organisation certificates; absent where there is no price |
period | validFrom / validUntil — the window the certificate attests |
kid | The 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.
How a certificate reaches its holder — and why that does not matter here
Section titled “How a certificate reaches its holder — and why that does not matter here”A holder receives their certificate as one message: the PDF, the verification link, the URL of their signed coverage record. None of that is part of the trust path, and the order the steps happen in is why: the hash reaches the transparency log before anything is emitted or delivered, so the certificate is checkable by everyone the moment it exists, and the message is a convenience rather than a credential.
Two consequences for anyone checking one:
- Verify from the address above, not from a link you were sent. Every step on this page
starts from
purposesource.orgor the published key set; nothing needs the message, and a message is the easiest thing to forge. The mail we send carries no click tracking, so the address printed in it is the address it links to — but that is a property of ours, not something you should have to rely on. - A certificate that was never delivered still verifies. Delivery can fail, be retried, or be lost; the record, the token and the log entry are already public. So “the holder has no copy” and “the credential is not real” are unrelated statements, and Step 3 answers the second one without reference to the first.
If something does not check out
Section titled “If something does not check out”- 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 per class is the certificate policy, written for a programme office in the OSPO and legal pack.
Related
Section titled “Related”- The verification page — the same three checks, in your browser
- Certificate policy — the two issuable classes and what a holder may claim
- Keys and the transparency log — key custody, the ceremony, rotation
- Public API reference — the endpoints used above, with cache and rate limits
- Security — how to report what you find