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:
curl -fsS https://immiscible.fly.dev/.well-known/immiscible-keys.json -o keys-$(date +%F).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:
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):
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}`);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.jsoncurl "https://immiscible.fly.dev/v1/admin/evidence/bundle" \
-H "authorization: Bearer $IMMISCIBLE_SERVICE_TOKEN" -o bundle.jsonThen verify it with the reference verifier, passing the keys you kept:
node scripts/verify-evidence.mjs bundle.json --keys keys-2026-09-01.json # the file you saved when you pinned the keysThe 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:
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:
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 |
| Zero trust scorecard | immiscible-scorecard+jwt | GET .../governance/scorecard |
| Drill report | immiscible-drill+jwt | POST .../drills |
| Review and certification | attestation tokens in the chain | reviews |
Each is a compact JWS; check it the same way as a receipt, with its own typ.