Skip to content

Security

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.

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:

Shell
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
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):

JavaScript
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}`);
Shell
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 once per order. The JavaScript and Python SDKs ship the same verifier.

#Evidence bundles

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

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

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

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

#Checkpoints you kept

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

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

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

ArtefacttypFrom
Access profilesigned profile documentGET .../agents/:aid/profile
Zero trust scorecardimmiscible-scorecard+jwtGET .../governance/scorecard
Drill reportimmiscible-drill+jwtPOST .../drills
Review and certificationattestation tokens in the chainreviews

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