# RealStamp Verification Protocol

**Version:** 1.2
**Last updated:** 2026-05-23
**Maintainer:** Skillecta Inc.
**Canonical URL:** `https://realstamp.app/VERIFICATION.md`

This document describes, in implementation-ready detail, how an independent third party can verify a RealStamp credential end-to-end — without trusting the RealStamp web application. Every claim the system makes about cryptographic verifiability rests on the procedures below. Where the procedures can be run against live endpoints, URLs are given; where reference code is helpful, it is provided in runnable form.

This document is stable under a single versioning scheme. Material changes to the verification protocol will be announced by publishing a new version with a new `Last updated` date and a corresponding entry in the Change Log at the bottom. Verifiers are expected to consult the `Last updated` date when evaluating a credential against older or newer procedures.

---

## 1. What a RealStamp credential is

A RealStamp **credential** is an SD-JWT (Selective-Disclosure JWT, per `draft-ietf-oauth-selective-disclosure-jwt`) that attests to the following claims about a piece of content:

| Claim | Disclosable | Meaning |
|---|---|---|
| `stamp_id` | always | UUID primary key; stable identifier |
| `iss` | always | Issuer, always `https://realstamp.app` for the production platform |
| `iat`, `exp` | always | Issued-at and expiry (Unix seconds) |
| `is_verified` | always | Set at issuance; `true` when the liveness check passed |
| `status` | always | `active`, `disputed`, `resolved_valid`, `resolved_invalid`, `withdrawn` |
| `attestation_state` | always | `single_signed`, `pending_cosigners`, `fully_signed`, `partially_signed` |
| `cosigner_count_signed`, `require_cosigners` | always | Multi-signer chain state |
| `content_hash` | always | `sha256:<hex>` of the stamped content |
| `_sd_alg` | always | Hash algorithm for disclosures, always `sha-256` |
| `_sd` | always | Sorted array of base64url-encoded SHA-256 hashes of each disclosure |
| `ai_percentage` | selective | Estimated AI-generated fraction (bucketed to nearest 10) |
| `liveness_method` | selective | `camera`, `voice`, `movement`, `phrase` |
| `trust_score` | selective | Creator's longitudinal trust score |
| `chain_index` | selective | Monotonic append-only index; ties the credential to the Merkle log |
| `content_type` | selective | `text`, `email`, `photo`, `video`, `post`, `ai_drafted` |
| `anchor_digest` | selective | SHA-256 of the canonical anchor payload that contained this stamp's leaf |
| `rekor_log_index` | selective | Sigstore Rekor log index where the anchor was published |
| `creator_display_name`, `creator_user_id` | selective | Identity metadata |
| `c2pa_manifest_url` | selective | If present, a C2PA manifest that co-attests this credential |

Selective disclosure means the holder of a credential can choose which selectively-disclosable claims to reveal to a verifier. The verifier sees only the disclosed values; hashes of the undisclosed values appear in `_sd` but cannot be reversed.

A RealStamp credential is not a statement about the truthfulness of the content. It is a statement about:

1. **Human origination.** At the time of issuance, a live human presented themselves to a liveness check and bound that liveness event to this specific content hash.
2. **Cryptographic integrity.** The credential cannot be altered after issuance without invalidating the signature.
3. **Transparency.** The credential has been anchored to a public, append-only log (Sigstore Rekor) within a batched Merkle tree; inclusion can be independently confirmed.
4. **Accountability.** The credential is subject to a public dispute protocol, and revocations are published in a signed list.

Every downstream claim about a RealStamp credential is derivable from those four properties. The verification procedure below proves all four.

---

## 1.5 Permanence vs. envelope expiry

A RealStamp credential has two truths, and they have very different lifetimes:

1. **Permanent truth.** The underlying ES256 signature, the disclosed claim values (`stamp_id`, `content_hash`, `chain_index`, …), the Sigstore Rekor anchor, and the create-time WebAuthn liveness attestation. These never expire. The credential remains a binding statement about the moment the content was stamped for as long as the JWKS publishes the signing key (see §7.3) and the credential has not been revoked (§8).

2. **Presentation envelope.** The SD-JWT that wraps the permanent truth — header, payload, signature, and disclosures — has a 30-day `exp` claim. The envelope is renewed automatically: every successful call to `api-verify` returns a freshly-issued envelope in the `current_sd_jwt` response field, and a background job re-issues stale envelopes every six hours. Fresh envelopes contain the same disclosed values and the same `stamp_id` / `content_hash` / `chain_index`, but use new disclosure salts and a new `iat` / `exp` pair, and are signed independently.

**Implication for verifiers.** A credential whose envelope `exp` has passed is **not** invalid. Per §4 Step 8 (v1.2+), `exp <= now` is a `warning` (`envelope_expired_but_signature_valid`), not a rejection — as long as the signature still verifies and the credential is not revoked. Verifiers SHOULD treat such credentials as valid and SHOULD ask the issuer for a fresh envelope by calling `api-verify` with the stale token; the response carries `current_sd_jwt`.

---

## 2. Trust roots

A verifier establishes trust by fetching four public artifacts:

### 2.1 JWKS (JSON Web Key Set)

**URL:** `https://realstamp.app/.well-known/jwks.json`

Returns a JSON document of shape `{ "keys": [...] }` containing all currently-active public keys. Each key has shape:

```json
{
  "kty": "EC",
  "crv": "P-256",
  "x": "<base64url>",
  "y": "<base64url>",
  "use": "sig",
  "alg": "ES256",
  "kid": "<key-id>"
}
```

As of this document's publication, the JWKS contains two keys:

- `realstamp-platform-2026` — signs SD-JWT credentials and the signed revocation list.
- `realstamp-anchor-2026` — signs Rekor anchor payloads. Verifiers confirming Rekor entries against RealStamp's operator identity use this key.

### 2.2 OpenID Connect Discovery

**URL:** `https://realstamp.app/.well-known/openid-configuration`

Standard OIDC discovery metadata. Relevant fields:

- `issuer: "https://realstamp.app"`
- `jwks_uri` — points at the JWKS above
- `id_token_signing_alg_values_supported: ["ES256"]`
- `claims_supported` — enumerates the RealStamp-specific claims listed in §1
- `kid_active` — advisory: the currently-preferred platform signing kid

### 2.3 Signed revocation list

**URL:** `https://realstamp.app/.well-known/revocation-list/current.jws`

A compact JWS signed with the `realstamp-platform-2026` key. Its payload is:

```json
{
  "issuer": "https://realstamp.app",
  "generated_at": "<ISO-8601 timestamp>",
  "version": <monotonic integer>,
  "entries": [
    { "stamp_id": "<uuid>", "status": "resolved_invalid" | "withdrawn", "chain_index": <int> }
  ]
}
```

The list is regenerated and re-anchored to Rekor every six hours. Version numbers are strictly monotonic. A stamp appearing in any published version is considered revoked for all verifications after that version's `generated_at`.

### 2.4 Sigstore Rekor

**URL:** `https://rekor.sigstore.dev`

A public, append-only, cryptographically signed transparency log operated by the Sigstore project (Linux Foundation). RealStamp anchors Merkle roots and revocation-list hashes to Rekor. Verifiers use Rekor's public API to independently confirm inclusion.

Rekor is operated externally. RealStamp makes no claims about Rekor's security; its operator is the Sigstore project and its security model is documented at `https://docs.sigstore.dev/logging/overview/`.

---

## 3. SD-JWT format and disclosure construction

A RealStamp SD-JWT presentation has the form:

```
<header>.<payload>.<signature>~<disclosure-1>~<disclosure-2>~...~
```

Concatenation with `~` separators, terminated with a trailing `~`. Each `<disclosure-k>` is a base64url-encoded JSON array.

**Header** (base64url-encoded JSON):
```json
{ "alg": "ES256", "typ": "sd-jwt", "kid": "realstamp-platform-2026" }
```

**Payload** (base64url-encoded JSON): contains all always-disclosed claims plus `_sd` (sorted array of disclosure hashes) and `_sd_alg`.

**Signature**: ECDSA P-256 over `SHA-256(<header>.<payload>)`, in IEEE P1363 format `r || s`, base64url-encoded. **Not ASN.1 DER.** This matches the JWS ES256 specification.

**Each disclosure** is constructed as:

1. Generate 16 bytes of cryptographically secure randomness; call this `salt_bytes`.
2. `salt = base64url(salt_bytes)` (no padding).
3. `disclosure_json = canonical_json_stringify([salt, key, value])` where canonicalization sorts object keys lexicographically and strips whitespace (a subset of RFC 8785; sufficient for integer/null/string/boolean payloads).
4. `b64 = base64url(UTF-8(disclosure_json))`.
5. `hash = base64url(SHA-256(ASCII(b64)))`. The hash is over the base64url string itself, as ASCII bytes — not over the decoded JSON.
6. The `hash` value is what appears in `_sd`.

This exactly matches the SD-JWT draft specification. A verifier who receives `b64` can independently compute `hash` and confirm it appears in `_sd`.

---

## 4. Step-by-step verification procedure

Given an SD-JWT presentation string and a verifier willing to run the procedure:

**Step 1 — Parse.** Split on `~`. The first element is the JWT (`header.payload.signature`). Remaining non-empty elements are disclosure `b64` strings. Reject if structure does not match.

**Step 2 — Parse header.** Base64url-decode to JSON. Assert `alg === "ES256"`, `typ === "sd-jwt"`, and `kid` is a non-empty string. **Reject** `alg: none` or any other value; failing this check before fetching JWKS prevents algorithm-confusion attacks.

**Step 3 — Parse payload.** Base64url-decode to JSON.

**Step 4 — Fetch JWKS.** Retrieve the JWKS from §2.1. A production-grade verifier caches the result with a 1-hour TTL and uses `Cache-Control` / `ETag`. On network failure with no cached entry, **fail closed** — do not accept any credential. On network failure with a cached entry, serve the cache (stale-on-failure).

**Step 5 — Locate the key.** Find the JWK whose `kid` equals the header's `kid`. If none, reject.

**Step 6 — Import the key.** Using WebCrypto (or an equivalent library):

```javascript
const pubKey = await crypto.subtle.importKey(
  'jwk', jwk,
  { name: 'ECDSA', namedCurve: 'P-256' },
  false, ['verify']
);
```

**Step 7 — Verify the signature.** Reconstruct the signing input and verify.

```javascript
const encoder = new TextEncoder();
const signingInput = encoder.encode(`${headerB64}.${payloadB64}`);
const sigBytes = base64UrlDecode(signatureB64);  // 64 bytes, P1363 format
const ok = await crypto.subtle.verify(
  { name: 'ECDSA', hash: 'SHA-256' },
  pubKey, sigBytes, signingInput
);
if (!ok) reject('bad_signature');
```

**Step 8 — Check claims.**

- `iss === 'https://realstamp.app'`
- `iat <= now + 60` (60-second clock skew allowance)
- `_sd_alg === 'sha-256'`

A mismatch on any of the above rejects the credential.

**`exp` is an envelope freshness indicator (v1.2+), not a hard validity boundary.** Compute `envelope_status`:

- `exp > now` → `envelope_status = "fresh"`.
- `exp <= now` → `envelope_status = "stale"`. Continue verification. If the signature still verifies (Step 7), the disclosures still match (Step 9), and the credential is not revoked (Step 11), the credential is **valid** with `warning = "envelope_expired_but_signature_valid"`. Verifiers SHOULD then call `api-verify` to obtain a fresh envelope (`current_sd_jwt`) for redistribution. See §1.5 and §12.

A missing or non-numeric `exp` is still a hard rejection.


**Step 9 — Verify disclosures.** For each received disclosure `b64`:

1. Compute `hash = base64url(SHA-256(ASCII(b64)))`.
2. Confirm `hash` appears in `payload._sd`. If not, reject with `disclosure_hash_mismatch`.
3. Base64url-decode `b64`, parse as JSON. It must be a 3-element array `[salt, key, value]`. The `value` is the disclosed claim.

A verifier who accepts the credential with only a subset of disclosures gains assurance about the disclosed values only. Undisclosed claims remain cryptographically committed but unrevealed.

**Step 10 — Optional: Rekor inclusion check.** If the credential disclosed `anchor_digest` and `rekor_log_index` (or the caller otherwise knows them):

1. `GET https://rekor.sigstore.dev/api/v1/log/entries?logIndex=<n>`
2. Confirm the returned entry's hashedrekord body has `spec.data.hash.value` equal to the disclosed `anchor_digest` (with `algorithm: "sha256"`).
3. Verify the entry's signature was made by the `realstamp-anchor-2026` key from the JWKS (§2.1). The key is inlined in the Rekor entry's `spec.signature.publicKey.content` field (base64 of the PEM) — compare it against the published JWKS to confirm origin.

**Step 11 — Optional: revocation check.** Fetch the signed revocation list (§2.3). Verify its JWS signature against the JWKS. Parse the payload. If the verified payload `stamp_id` appears in `entries`, reject with `revoked`.

A credential that passes Steps 1–9 is **cryptographically valid**: it was signed by RealStamp's published key for the stated content hash, at the stated issuance time, and has not been tampered with. A credential that additionally passes Steps 10–11 is **externally confirmed**: its anchor is publicly logged and it has not been revoked.

---

## 5. Reference code

The canonical reference implementation is in the RealStamp repository at `supabase/functions/_shared/verifier.ts`. A minimal Node/TypeScript sketch that a verifier can port directly:

```typescript
async function verifyRealStampCredential(sdJwt: string, opts?: { now?: () => number }) {
  const [jwt, ...rest] = sdJwt.split('~');
  const disclosures = rest.filter(s => s.length > 0);
  const [headerB64, payloadB64, sigB64] = jwt.split('.');

  const header = JSON.parse(new TextDecoder().decode(b64uDecode(headerB64)));
  if (header.alg !== 'ES256' || header.typ !== 'sd-jwt' || !header.kid) {
    return { valid: false, reason: 'bad_header' };
  }
  const payload = JSON.parse(new TextDecoder().decode(b64uDecode(payloadB64)));

  const jwks = await (await fetch('https://realstamp.app/.well-known/jwks.json')).json();
  const jwk = jwks.keys.find((k: any) => k.kid === header.kid);
  if (!jwk) return { valid: false, reason: 'unknown_kid' };

  const key = await crypto.subtle.importKey(
    'jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']
  );
  const ok = await crypto.subtle.verify(
    { name: 'ECDSA', hash: 'SHA-256' }, key,
    b64uDecode(sigB64),
    new TextEncoder().encode(`${headerB64}.${payloadB64}`)
  );
  if (!ok) return { valid: false, reason: 'bad_signature' };

  const now = Math.floor((opts?.now ?? Date.now)() / 1000);
  if (payload.iss !== 'https://realstamp.app') return { valid: false, reason: 'wrong_iss' };
  if (payload.iat > now + 60) return { valid: false, reason: 'future_iat' };
  // v1.2: exp is an envelope freshness indicator, not a rejection.
  let envelopeStatus: 'fresh' | 'stale' = 'fresh';
  let warning: string | undefined;
  if (typeof payload.exp !== 'number') return { valid: false, reason: 'expired' };
  if (payload.exp <= now) { envelopeStatus = 'stale'; warning = 'envelope_expired_but_signature_valid'; }
  if (payload._sd_alg !== 'sha-256') return { valid: false, reason: 'bad_sd_alg' };

  const parsedDisclosures = [];
  for (const d of disclosures) {
    const hashBytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(d));
    const hash = b64uEncode(new Uint8Array(hashBytes));
    if (!payload._sd.includes(hash)) return { valid: false, reason: 'disclosure_hash_mismatch' };
    const [salt, claimKey, claimValue] = JSON.parse(new TextDecoder().decode(b64uDecode(d)));
    parsedDisclosures.push({ key: claimKey, value: claimValue, salt, b64: d, hash });
  }

  return { valid: true, payload, disclosures: parsedDisclosures, envelope_status: envelopeStatus, warning };
}

function b64uDecode(s: string): Uint8Array {
  s = s.replace(/-/g, '+').replace(/_/g, '/');
  while (s.length % 4) s += '=';
  return Uint8Array.from(atob(s), c => c.charCodeAt(0));
}
function b64uEncode(b: Uint8Array): string {
  return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
```

Equivalent implementations in Python (`cryptography` + `requests`), Go (`github.com/go-jose/go-jose`), and Rust (`ring` + `serde_json`) are straightforward ports.

A future release will publish `@realstamp/verify` on npm with a vetted, cross-runtime build. Until then, the reference code above is the canonical path.

---

## 6. Merkle tree format

RealStamp batches outstanding credentials into periodic anchors and publishes each anchor's Merkle root to Rekor. The Merkle construction follows **RFC 6962** (Certificate Transparency) semantics from 2026-04-22 onward:

- **Leaf.** For stamp `i` in a batch, the leaf data is the UTF-8 string `${chain_index}:${content_hash}:${stamp_id}`, where `chain_index` is the stamp's monotonic append-only index. The leaf hash is `SHA-256(0x00 || SHA-256(leaf_data))` — first the leaf data is hashed, then prefixed with `0x00` (leaf domain separator) and re-hashed. See `supabase/functions/_shared/crypto.ts:hashLeaf`.
- **Internal node.** `SHA-256(0x01 || left_hash || right_hash)` — concatenation of the `0x01` internal-node prefix with the left and right child hashes, then SHA-256.
- **Tree construction.** For `n` leaves, find `k` = largest power of 2 strictly less than `n`. Recursively compute `MTH(leaves[0:k])` and `MTH(leaves[k:n])`, then combine. Odd leaves are **promoted**, never duplicated.
- **Empty tree** is not permitted; the anchor job rejects batches of zero.

Anchor payloads prior to 2026-04-22 used an earlier construction (odd-leaf duplication, no domain separation). The `transparency_anchors.merkle_format` column identifies each anchor's algorithm: `legacy` or `rfc6962`. Verifiers re-computing a root must check this column.

The anchor payload itself is:

```json
{
  "from_chain_index": <int>,
  "to_chain_index": <int>,
  "leaf_count": <int>,
  "merkle_root": "<hex>"
}
```

Serialized with `canonical_json_stringify` from `_shared/crypto.ts` before signing, hashing, and submission to Rekor.

---

## 7. Key rotation policy

### 7.1 Key identifier scheme

Keys are identified by `kid` strings of the form `realstamp-<role>-<year>`:

- `realstamp-platform-YYYY` — signs SD-JWT credentials and the revocation list
- `realstamp-anchor-YYYY` — signs Merkle anchor payloads submitted to Rekor

The year indicates planned rotation cadence, not a hard expiry. A key remains the authoritative signer until its successor is announced in the JWKS.

### 7.2 Rotation ceremony

Key generation occurs on a hardened, non-shared workstation. The ceremony produces:

- A private key PEM in PKCS#8 format (ECDSA P-256).
- A self-signed X.509 certificate with 730-day validity (platform key only; anchor key publishes the raw public key).
- The corresponding public key PEM.
- SHA-256 fingerprints of all public artifacts, timestamped, signed by the operator.

Ceremony records are retained in offline cold storage. Public key fingerprints are published alongside each rotation announcement.

### 7.3 Overlap period

When a rotation occurs:

1. The new key is added to the JWKS alongside the old key.
2. New credentials are signed with the new key's `kid`.
3. The old key remains in JWKS for the envelope-refresh window of the longest-lived outstanding credential (currently 30 days — see §1.5; this is the SD-JWT envelope refresh cycle, not a credential lifetime).
4. After the overlap period, the old key is removed from JWKS; credentials signed with it thereafter fail verification by design.

Rotation announcements are published in the Change Log of this document and as a signed release note.

---

## 8. Signed revocation list format

As described in §2.3. The published JWS has header:

```json
{ "alg": "ES256", "kid": "realstamp-platform-2026", "typ": "realstamp-rl+jws" }
```

To verify:

1. Split the JWS on `.` into three parts.
2. Base64url-decode the payload; parse as JSON.
3. Reconstruct the signing input as `${headerB64}.${payloadB64}` (ASCII bytes).
4. Verify the signature against the published JWKS using the key whose `kid` matches the header.
5. Confirm `issuer === "https://realstamp.app"`.
6. The payload `entries` array contains revoked stamps. A `stamp_id` present in any published version is revoked from `generated_at` onward.

Each published version is additionally anchored to Rekor. The `revocation_list_versions` table (readable publicly by `SELECT` against the RealStamp Supabase project) stores the mapping from `version` → Rekor log index. An external verifier can confirm the revocation list they fetched is the one that was anchored by comparing `SHA-256(JWS bytes)` against the `jws_sha256` column.

---

## 9. Long-term recovery

If `realstamp.app` becomes unreachable:

1. The JWKS is additionally committed to the public RealStamp repository. Recent commits are mirrored to archive.org snapshots.
2. Rekor entries for anchors and revocation lists remain independently accessible at `https://rekor.sigstore.dev` as long as Sigstore operates.
3. Future versions of this document will publish the JWKS SHA-256 as a DNS TXT record at `_realstamp-jwks.realstamp.app` to provide a second independent recovery channel.

A credential's verifiability survives the issuer's outage provided the verifier has (a) the JWKS from any of the above sources and (b) internet access to Rekor.

---

## 10. Security contact

Security reports: see `SECURITY.md` in the repository root for the current disclosure policy, contact address, and safe-harbor terms.

---

## 12. Auto-renewal

Every successful call to `POST /functions/v1/api-verify` returns a freshly-issued SD-JWT envelope in the response field `current_sd_jwt`. The contract:

- **Always returned** for SD-JWT-era credentials, regardless of input shape (`pulse_token`, `sd_jwt`, or `share_link_id`).
- **Refresh trigger:** when the current envelope's `exp` is within 7 days of `now` (or already past), `api-verify` calls `issueFreshEnvelope()` — re-signs with fresh disclosure salts, fresh `iat`, and fresh `exp` (= `now + 30d`) — and persists the result on the stamp row before responding. The boolean field `envelope_refreshed` indicates whether this call triggered a refresh.
- **Permanent truth is untouched.** `stamp_id`, `content_hash`, `chain_index`, the underlying disclosed values, the Rekor anchor, and the original create-time signature are all stable across reissues. Only the presentation wrapper changes.
- **Response fields (additive):** `current_sd_jwt`, `current_sd_jwt_iat`, `current_sd_jwt_exp`, `envelope_refreshed`, `envelope_status` (`"fresh" | "stale"`), and (when stale) `warning: "envelope_expired_but_signature_valid"`.
- **Out-of-band refresh.** A scheduled job sweeps stamps whose `current_sd_jwt_exp` is within 7 days of `now` and reissues them, so independent verifiers fetching credentials directly (without going through `api-verify`) generally see fresh envelopes too.

Callers MUST NOT cache `current_sd_jwt` past its `exp` for redistribution; instead, re-call `api-verify` to fetch a fresh copy. The envelope is cheap to reissue and always reflects the latest revocation/dispute state.

---

## 13. Change log

- **2026-04-22 — v1.0.** Initial version. Platform key `realstamp-platform-2026`, anchor key `realstamp-anchor-2026`. Merkle tree switched from legacy (duplicate-leaf) to RFC 6962 on this date; anchors before this date use the `legacy` format recorded in `transparency_anchors.merkle_format`.
- **2026-05-18 — v1.1.** Revocation list URL fronted under realstamp.app; protocol semantics unchanged.
- **2026-05-23 — v1.2.** Auto-renewing envelope semantics. Existing credentials remain verifiable; the `exp` claim is now an envelope freshness indicator, not a credential validity boundary. §4 Step 8 changed: `exp <= now` is a warning, not a rejection, when signature + revocation checks still pass. `api-verify` now returns `current_sd_jwt`, `envelope_status`, `envelope_refreshed`, and re-issues envelopes inline. See §1.5 and §12.

---

*End of VERIFICATION.md.*

