# Verifying offline

> Check receipts, evidence bundles, checkpoints and signed reports with nothing but the public keys you kept, without calling Immiscible and without trusting us.

Source: https://immiscible.fly.dev/docs/security/verifying-offline

Everything Immiscible signs is signed with Ed25519, and every public key is published as a JWKS. Verification needs only those keys, so it works offline, years later, and after we are gone.

## 1. Keep the keys

Fetch the JWKS when you start relying on Immiscible, and store it somewhere we cannot write:

```bash
curl -fsS https://immiscible.fly.dev/.well-known/immiscible-keys.json -o keys-$(date +%F).json
```

```json
{ "keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "k_2026_09", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" }] }
```

Keys rotate. Fetch again when you meet a `kid` you do not know, and keep every file: an old key verifies old signatures for good.

## Receipts

A receipt is a compact JWS (`typ: assay-receipt+jwt`). The whole of verification in Node 22, using only `node:crypto`:

verify-receipt.mjs:

```js
import { createPublicKey, verify } from 'node:crypto';

const ISSUER = 'https://immiscible.fly.dev';
const b64url = (s) => Buffer.from(s, 'base64url');

export function verifyReceipt(receipt, { jwks, now = Date.now() }) {
  const parts = String(receipt).split('.');
  if (parts.length !== 3) return { valid: false, reason: 'not a compact JWS' };
  const [h, p, s] = parts;
  let header;
  try { header = JSON.parse(b64url(h).toString('utf8')); } catch { return { valid: false, reason: 'not a compact JWS' }; }
  if (header.alg !== 'EdDSA' || header.typ !== 'assay-receipt+jwt') return { valid: false, reason: 'unexpected alg or typ' };

  const jwk = jwks.keys.find((k) => k.kid === header.kid && k.kty === 'OKP' && k.crv === 'Ed25519');
  if (!jwk) return { valid: false, reason: `unknown key ${header.kid}` };

  const key = createPublicKey({ key: jwk, format: 'jwk' });
  if (!verify(null, Buffer.from(`${h}.${p}`), key, b64url(s))) return { valid: false, reason: 'bad signature' };

  const claims = JSON.parse(b64url(p).toString('utf8'));
  const t = Math.floor(now / 1000);
  if (claims.iss !== ISSUER) return { valid: false, reason: 'wrong issuer', claims };
  if (t >= claims.exp) return { valid: false, reason: 'expired', claims };
  if (claims.iat > t + 60) return { valid: false, reason: 'issued in the future', claims };
  return { valid: true, claims };
}
```

Use it with the keys you saved, on the receipt the agent hands you (save the function as `verify-receipt.mjs`):

```js
import { readFileSync } from 'node:fs';
import { verifyReceipt } from './verify-receipt.mjs';

const jwks = JSON.parse(readFileSync('keys-2026-09-01.json', 'utf8'));   // the file you saved when you pinned the keys
const r = verifyReceipt(process.argv[2], { jwks });
if (!r.valid) {
  console.error(`refuse the order: ${r.reason}`);
  process.exit(1);
}
const { mer, amt, cur, typ, jti } = r.claims;
console.log(`receipt ${jti}: ${typ} of ${amt} ${cur} at ${mer}`);
```

```bash
node check.mjs "eyJhbGciOiJFZERTQSIs..."
```

Then check the claims against what is in front of you: `mer` is your domain, `amt` and `cur` cover the basket, `typ` is `payment`. Offline verification cannot see replays: keep the `jti` values seen in the last five minutes, or call [`POST /v1/verify`](https://immiscible.fly.dev/docs/api/post-v1-verify.md) once per order. The JavaScript and Python [SDKs](https://immiscible.fly.dev/docs/sdks.md) ship the same verifier.

## Evidence bundles

Download the bundle (an owner, admin or auditor in the console, or a service token with `evidence:read`):

Console session:

```bash
curl "https://immiscible.fly.dev/api/w/$IMMISCIBLE_WORKSPACE/evidence/bundle" \
  -H "cookie: __Host-sid=$IMMISCIBLE_SESSION" -o bundle.json
```

Service token:

```bash
curl "https://immiscible.fly.dev/v1/admin/evidence/bundle" \
  -H "authorization: Bearer $IMMISCIBLE_SERVICE_TOKEN" -o bundle.json
```

Then verify it with the reference verifier, passing the keys you kept:

```bash
node scripts/verify-evidence.mjs bundle.json --keys keys-2026-09-01.json     # the file you saved when you pinned the keys
```

The verifier recomputes every record's hash from its canonical form, checks each `prev` link and that `seq` has no gaps, verifies every checkpoint's signature against the trusted keys, checks each checkpoint's `head` against the chain, and checks the checkpoints themselves form an unbroken sequence. Exit code `0` means all of that held; otherwise it names the first record that broke and every checkpoint that no longer holds.

> **Important**
> Without `--keys` or `--checkpoint`, a bundle proves only that it is consistent with itself. Whoever produced it could in principle rewrite history, mint a new key and re-sign. Always pass keys you kept, or a checkpoint you kept, or both.

## Checkpoints you kept

Ask for a checkpoint whenever it matters (month end, before an audit, after an incident) and keep the token outside Immiscible:

```bash
curl -X POST "https://immiscible.fly.dev/api/w/$IMMISCIBLE_WORKSPACE/evidence/checkpoints" \
  -H "cookie: __Host-sid=$IMMISCIBLE_SESSION" -H "x-immiscible-csrf: 1"
```

Later, check any bundle against it:

```bash
node scripts/verify-evidence.mjs bundle.json --checkpoint eyJhbGciOiJFZERTQSIs...
```

No change of key can make rewritten history match a checkpoint you held. Records written after your checkpoint rest on the bundle's own keys, so the verifier says so and exits `3`; pass `--keys` as well to cover them and get `0`. Exit `1` means something did not verify; `2` means no bundle was named.

## Other signed artefacts

The same keys sign everything else a person might need to show someone:

| Artefact | `typ` | From |
|---|---|---|
| Access profile | signed profile document | [`GET .../agents/:aid/profile`](https://immiscible.fly.dev/docs/api/get-api-w-wid-agents-aid-profile.md) |
| Zero trust scorecard | `immiscible-scorecard+jwt` | [`GET .../governance/scorecard`](https://immiscible.fly.dev/docs/api/get-api-w-wid-governance-scorecard.md) |
| Drill report | `immiscible-drill+jwt` | [`POST .../drills`](https://immiscible.fly.dev/docs/api/post-api-w-wid-drills.md) |
| Review and certification | attestation tokens in the chain | [reviews](https://immiscible.fly.dev/docs/guides/traces-and-reviews.md) |

Each is a compact JWS; check it the same way as a receipt, with its own `typ`.
