How verification works
Check an OSINT certificate without trusting the page. Canonical JSON, SHA-256, Ed25519, salted recipient commitments, the keys endpoint, and a worked curl and openssl example.
The verification page is a convenience. The proof is the signed record, and anyone can check it with standard tools. This page explains what is signed, where the keys are, and walks through one verification by hand.
The record
GET /api/certificates/verify/{id} (also /certificates/verify/{id}.json) returns the public twin of a certificate. The part that is signed is record:
{
"v": 1,
"id": "OVC-7K3Q-M8PX-2DLA",
"issuer": { "id": "…", "slug": "osint-collective", "name": "OSINT Collective" },
"type": { "id": "…", "slug": "speaker-oct26", "title": "Speaker, Oct'26" },
"recipient": {
"name": "<sha256 commitment>",
"handle": "<sha256 commitment or null>"
},
"issued_at": "2026-10-31T00:00:00.000Z",
"expires_at": null,
"fields": { "track": "GEOINT" },
"skills": ["geoint"],
"evidence_url": "https://osintverse.com/collective/oct26",
"verify_url": "https://osintverse.com/certificates/verify/OVC-7K3Q-M8PX-2DLA"
}Everything in the record is fixed at issue time. Status (revoked, expired, superseded) is live data next to the record, not inside it, so revoking never invalidates a signature and a signature never hides a revocation.
Beside the record, the response carries:
| Field | Meaning |
|---|---|
signature.alg | Always Ed25519. |
signature.kid | The id of the key that signed this record. |
signature.hash_sha256 | SHA-256 of the canonical bytes, hex. |
signature.value | The signature over the same bytes, base64url without padding. |
signature.keys_url | Where to fetch the public keys. |
signature.verified_by_server | Whether the server re-checked the signature on this read. Treat it as a hint; check it yourself. |
recipient.name, recipient.name_salt | The disclosed name and its salt, or null when the recipient withheld the name. |
recipient.handle, recipient.handle_salt | The disclosed handle and its salt, or null. |
status, revocation, superseded_by | Live status. |
transparency_log | The certificate's position (seq) and chain hash in the public transparency log at /certificates/log, with the chain rule and the signed head URL. Null for certificates issued before the log existed and not yet backfilled. |
open_badge | Where to fetch the Open Badges 3.0 export and what secures it (see below). Null when the export is turned off. |
Canonical JSON
The signed bytes are the canonical JSON of record:
- object keys sorted by code unit at every level,
- no whitespace,
- UTF-8,
- strings and
nullonly (no numbers other than the integerv), undefinedvalues dropped.
Serialising the record this way with any library gives the same bytes, so the hash and signature are reproducible outside OSINTverse.
Keys
GET /api/certificates/keys lists every public key that ever signed a certificate, with its kid, the key as base64 DER (SubjectPublicKeyInfo) and as PEM, when it was created, and when it was retired. A key id is ovc- followed by the first 12 hex characters of the SHA-256 of the DER bytes, so you can check that a key matches its id.
Keys rotate; old ones stay published, so a certificate signed years ago still verifies against the key named in its signature.kid.
Recipient commitments
The record never carries the recipient's name or handle in plain text. Each is a salted commitment:
sha256("ovc-recipient-v1:" + kind + ":" + salt + ":" + value)where kind is name or handle, salt is 32 hex characters, and value is the exact string the issuer recorded. When the recipient discloses a field, the public JSON includes the value and its salt, and you recompute the commitment. When a field is withheld, its salt stays private, so a guessed name cannot be confirmed against the record. Either way the signature covers both commitments, so the recipient's choice never changes what was signed.
Worked example
Fetch, hash, verify, and check the recipient with curl, jq, and openssl (3.x, for raw Ed25519 verification).
Fetch the record
ID=OVC-7K3Q-M8PX-2DLA
curl -s "https://osintverse.com/api/certificates/verify/$ID" -o cert.json
jq '{status, title, issuer: .issuer.name, recipient: .recipient.label}' cert.jsonRebuild the canonical bytes and compare the hash
jq -cjS sorts keys at every level and prints compact output with no trailing newline. The result must hash to signature.hash_sha256.
jq -cjS '.record' cert.json > record.bin
sha256sum record.bin
jq -r '.signature.hash_sha256' cert.jsonFetch the key named in the signature
KID=$(jq -r '.signature.kid' cert.json)
curl -s https://osintverse.com/api/certificates/keys \
| jq -r --arg kid "$KID" '.keys[] | select(.kid == $kid) | .public_key_pem' > key.pem
cat key.pemDecode the signature and verify
The signature is base64url without padding; restore the padding before decoding.
jq -r '.signature.value' cert.json \
| tr '_-' '/+' \
| awk '{ pad = (4 - length($0) % 4) % 4; printf "%s%s", $0, substr("==", 1, pad) }' \
| base64 -d > sig.bin
openssl pkeyutl -verify -pubin -inkey key.pem -rawin -in record.bin -sigfile sig.binSignature Verified Successfully means the record was signed by that key and has not changed since.
Check a disclosed recipient field
NAME=$(jq -r '.recipient.name' cert.json)
SALT=$(jq -r '.recipient.name_salt' cert.json)
printf 'ovc-recipient-v1:name:%s:%s' "$SALT" "$NAME" | sha256sum
jq -r '.record.recipient.name' cert.jsonThe two hashes match when the disclosed name is the one the issuer recorded. Do the same with handle and handle_salt for a disclosed handle. When the recipient withheld a field, both values are null and there is nothing to check; the record still commits to it.
Read the live status
The signature says nothing about whether the certificate still stands. Read status (valid, revoked, expired, superseded), revocation, expires_at, and superseded_by from the same response, and read issuer.verified_by_osintverse to know whether OSINTverse checked who the issuer is.
The transparency log
Every certificate issued since the log started is appended to a public, hash-chained log at /certificates/log (JSON at /api/certificates/log, newest first). Each entry's chain hash is sha256('ovc-log-v1:<previous chain hash>:<record hash>:<id>'), and GET /api/certificates/log/head returns the latest position and chain hash, signed with the active key. A verifier can replay the log up to the certificate's transparency_log.seq and check that the chain hash matches, which shows the certificate was recorded in sequence and that no earlier entry was changed or removed. The log carries ids, hashes, issuer names, and titles only, never recipient data.
Open Badges 3.0 export
GET /api/certificates/{id}/openbadge.json returns the same certificate as an OpenBadgeCredential: a W3C Verifiable Credential 2.0 in JSON-LD, the format badge wallets, learner records, and credential validators read. It is built from the signed record on request and secured with a Data Integrity proof (DataIntegrityProof, cryptosuite eddsa-jcs-2022): the document and the proof options are canonicalised with the JSON Canonicalization Scheme (RFC 8785), hashed with SHA-256, concatenated, and signed with Ed25519 by the same key that signs certificate records.
issueris OSINTverse, identified asdid:web:osintverse.com, because OSINTverse holds the signing key. The organisation that awarded the certificate is the achievement'screator, which is what Open Badges means by it; a verified issuer's creator id is its directory page.- The recipient appears only as hashed
IdentityObjectentries for the fields the recipient disclosed on the public page:identityHashissha256$followed by the lowercase hex of SHA-256 over the value concatenated with thesalt, Open Badges' own form. A withheld name is absent, not guessable. validFromandvalidUntilcarry the issue and expiry dates; skills becometags; the evidence link becomescriteria.idand anevidenceentry.- The proof's
verificationMethodisdid:web:osintverse.com#<kid>. The DID document at/.well-known/did.jsonlists every signing key as aMultikey(publicKeyMultibase), retired keys included, so an export signed before a rotation still resolves. - Valid and expired certificates export. Revoked and superseded ones answer
410with the verification URL, because a credential that no longer stands must not travel as one. Checkstatuson the JSON twin before relying on an export you were handed.
To verify an export: remove proof, canonicalise the rest with JCS and hash it; take the proof without proofValue, add the document's @context, canonicalise and hash it; concatenate the proof-options hash and the document hash; decode proofValue (multibase base58btc, the leading z) and verify the Ed25519 signature over the concatenation with the key named by verificationMethod. Any library that implements eddsa-jcs-2022 does this for you.
The export can be turned off platform-wide (CERTIFICATES_OB3=false), in which case the endpoint and the links to it disappear while verification pages keep working.
What the signature does and does not prove
- It proves the record was issued through OSINTverse under the named key and has not changed since.
- It does not vouch for the issuer. Only
issuer.verified_by_osintversesays OSINTverse reviewed the issuer's identity; the page and the JSON always say which it is. - It does not say the certificate still stands. Check the live status.
- The recipient's name and handle are the issuer's statements, committed to in the record; a disclosed value is confirmed against the commitment, never against an external identity.
In code
Node.js (built-in crypto) verifies the same way:
import { createHash, createPublicKey, verify } from "node:crypto"
type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
function canonical(value: Json): string {
if (value === null || typeof value !== "object") return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort()
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`
}
const cert = await fetch(`https://osintverse.com/api/certificates/verify/${id}`).then((r) => r.json())
const bytes = Buffer.from(canonical(cert.record), "utf8")
const hashOk = createHash("sha256").update(bytes).digest("hex") === cert.signature.hash_sha256
const keys = await fetch(cert.signature.keys_url).then((r) => r.json())
const key = keys.keys.find((k: { kid: string }) => k.kid === cert.signature.kid)
const publicKey = createPublicKey({ key: Buffer.from(key.public_key_der_base64, "base64"), format: "der", type: "spki" })
const signatureOk = verify(null, bytes, publicKey, Buffer.from(cert.signature.value, "base64url"))Agents get the same record through the MCP tool verify_credential (see API).
OSINT Certificates
Signed, verifiable certificates for OSINT courses, CTFs, conferences, and communities. What a certificate is, who issues one, and how anyone checks it.
Issuing certificates
Set up an issuer, define credential types and layouts, issue from a form, a CSV, or the API, and handle claims, resends, reissues, and revocations.