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, from a key that could make it then? Resolve the header’s kid in the published key set, check that the token’s signing time falls inside that key’s validity window, 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.

Until publication, the canonical host does not resolve; this preview prints its own.

Terminal window
# 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:statusA token signed at iat verifies under this key when…
activeiat falls inside psn:validityWindow: at or after notBefore, and before notAfter if there is one
retiredthe same, and the window has an end — a retired key verifies only what it signed before it was retired
compromisediat 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.

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}`),
);
}

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:

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 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, sys
from datetime import datetime
def 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, 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
# 5. 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?The verification page prints
kid matches psn-sandbox-*TEST CERTIFICATE — not a production credential, deliberately absent from the logNoTest certificate — not a real certificate
No record under this identifierNO CERTIFICATE WITH THIS IDNoNo certificate with this ID
kid not in the published key setINVALID — unknown signing keyYesNot verified — do not rely on it
Key set unreachableCANNOT VALIDATE THE SIGNATURE RIGHT NOWNoCould not check — the published keys could not be read
Any other fetch failureCANNOT VALIDATE RIGHT NOWNoCould not check right now
A status-only record, and the token its holder shows does not hash to itINVALID — not the certificate this record describesYesNot verified — do not rely on it
Signature does not verifyINVALID — signature does not verifyYesNot verified — do not rely on it
No kid in the token’s headerINVALID — the token names no signing keyYesNot verified — do not rely on it
Header alg other than ES256INVALID — not an ES256 signatureYesNot verified — do not rely on it
No iat in the tokenINVALID — the token does not say when it was signedYesNot verified — do not rely on it
Signed outside the key’s validity windowINVALID — signed outside its key’s validity windowYesNot verified — do not rely on it
Compromised key, signed at or after its window’s endINVALID — signed after its compromised key’s window closedYesNot verified — do not rely on it
Key entry states no usable status or windowCANNOT VALIDATE — the key set does not settle the signing keyNoCould not check — the key set does not settle the signing key
Offline check only: a token whose typ no contract datesCANNOT VALIDATE — not a token type this page can dateNoOffline mode only, in the matrix’s own words
A status-only record, no token shownSTATUS RECORD — the signed certificate is with its holderNoStatus record only — this page cannot confirm whose certificate this is (or Revoked on {date} ({reason class}) when the record says revoked)
Signature not yet checkedSIGNATURE NOT CHECKEDNoCould not check right now
Signature good, hash absent from the logUNVERIFIED — not in the transparency logYesNot verified — do not rely on it
Signature good, log unreadableTRANSPARENCY-LOG INCLUSION UNKNOWNNoCould not check the public log
Signature good, logged, status: validVALIDYesGenuine certificate
Signature good, logged, status: expiredWAS VALID for the period shownYesVERIFIED — attests participation in {year} for a participation certificate; WAS VALID for the period shown for a company certificate
Signature good, logged, status: revokedREVOKED — prospective; the historical window stoodYesRevoked on {date} ({reason class}), or Revoked when the record carries no date
Signature good, logged, status: supersededSUPERSEDED — follow the successor linkYesSuperseded — a newer certificate replaces this one
Signature good, logged, unrecognised statusSTATUS NOT PUBLISHEDNoCould 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 endThe same verdict, — its signing key was later compromisedAs that verdictThe 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:

  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
typThe frozen enum supporter · license-status · contributor · steward · topup. Only the first two are issuable at this phase — see the certificate policy
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.

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.org or 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.
  • 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.