# 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.

URL: https://osintverse.com/docs/certificates/verification

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`:

```json title="record (version 1)"
{
  "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 `null` only (no numbers other than the integer `v`),
* `undefined` values 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

```bash
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.json
```

### Rebuild 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`.

```bash
jq -cjS '.record' cert.json > record.bin
sha256sum record.bin
jq -r '.signature.hash_sha256' cert.json
```

### Fetch the key named in the signature

```bash
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.pem
```

### Decode the signature and verify

The signature is base64url without padding; restore the padding before decoding.

```bash
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.bin
```

`Signature Verified Successfully` means the record was signed by that key and has not changed since.

### Check a disclosed recipient field

```bash
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.json
```

The 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.

* `issuer` is OSINTverse, identified as `did:web:osintverse.com`, because OSINTverse holds the signing key. The organisation that awarded the certificate is the achievement's `creator`, which is what Open Badges means by it; a verified issuer's creator id is its [directory page](https://osintverse.com/certificates/issuers).
* The recipient appears only as hashed `IdentityObject` entries for the fields the recipient disclosed on the public page: `identityHash` is `sha256$` followed by the lowercase hex of SHA-256 over the value concatenated with the `salt`, Open Badges' own form. A withheld name is absent, not guessable.
* `validFrom` and `validUntil` carry the issue and expiry dates; skills become `tag`s; the evidence link becomes `criteria.id` and an `evidence` entry.
* The proof's `verificationMethod` is `did:web:osintverse.com#<kid>`. The DID document at [`/.well-known/did.json`](https://osintverse.com/.well-known/did.json) lists every signing key as a `Multikey` (`publicKeyMultibase`), retired keys included, so an export signed before a rotation still resolves.
* Valid and expired certificates export. Revoked and superseded ones answer `410` with the verification URL, because a credential that no longer stands must not travel as one. Check `status` on 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_osintverse` says 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:

```ts title="verify.ts"
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](https://osintverse.com/docs/certificates/api)).
