OBO Audit Receipt — portable, cryptographically signed delegation receipts for AI agents and services acting on behalf of (OBO) a human principal.
When an agent acts on your behalf, there is usually no tamper-proof artifact proving who authorized it, what was delegated, when, and what action was actually taken. This library captures that full delegation context at action time and seals it into a signed, portable JSON-LD receipt that any verifier can check statelessly — no callback to the issuer required.
- Zero runtime dependencies. Only Node's built-in node:crypto/ Web Crypto.
- Ed25519 / JCS (RFC 8785) / Base58btc signing, W3C Verifiable Credential–shaped receipts.
- Stateless verification with in-process replay protection.
See the API surface table below for the full export list, and the sections that follow for how the pieces fit together.
- Node.js ≥ 18 (Web Crypto Ed25519 needs ≥ 19 in practice; tested on 22).
npm install @scopetrail/coreBuilding from source (this repo):
npm install # installs dev deps (typescript, @types/node)
npm run build # compiles src/ → dist/
npm test # build + run the test suite (74 tests)The full round trip — mint a receipt, publish it to an AT Protocol repo, and verify it back with
nothing but the at:// URI and a public JWKS URL (no keys shipped to the verifier, no auth):
import { mintReceipt } from '@scopetrail/core';
import {
AtpClient, FetchTransport, AppPasswordAuth, createPlcDidResolver,
publishReceipt, verifyFromUri,
} from '@scopetrail/core/atproto';
// 1. Mint (issuer side — see "Build the delegation context" below for `context`)
const receipt = await mintReceipt(context, privateKey, 'key-1', 'did:plc:<issuer>');
// 2. Publish
const client = new AtpClient({
auth: new AppPasswordAuth({ service: process.env.ATP_PDS!, identifier: process.env.ATP_IDENTIFIER!, appPassword: process.env.ATP_APP_PASSWORD! }),
transport: new FetchTransport(),
didResolver: createPlcDidResolver(),
});
const uri = await publishReceipt(receipt, client, 'did:plc:<issuer>');
// → at://did:plc:<issuer>/dev.scopetrail.auditReceipt/<rkey>
// 3. Verify — keyless, from anywhere, using the live hosted JWKS
const result = await verifyFromUri(uri, client, {
jwksUrl: 'https://scopetrail.github.io/.well-known/jwks.json',
});
// → { valid: true, errors: [], receipt, render }Or from the command line with nothing installed and nothing configured — this verifies the live demo receipt against the hosted public key, and works on a machine that has never seen this project:
curl -sO https://scopetrail.github.io/.well-known/jwks.json
npx -y --package=@scopetrail/core view-receipt \
at://did:plc:bty3gmskhla7rwblq5zl5jm5/dev.scopetrail.auditReceipt/00MSQDN0UT747ADDB2C4BB25420974 \
--jwks jwks.jsonRun that from outside a clone of this repo. This package is
@scopetrail/core, so inside the source treenpxresolves--package=@scopetrail/coreto the local project instead of fetching it, and npm does not link a root package's own bin — you getview-receipt: command not found. From within a clone, build first and invoke the CLI directly:npm ci && npm run build curl -s https://scopetrail.github.io/.well-known/jwks.json -o jwks.json node dist/cli/view-receipt.js "at://did:plc:…/dev.scopetrail.auditReceipt/<rkey>" --jwks jwks.json
The rest of this README walks each step in detail.
RawContextInput ──extractContext()──► OBOTokenContext ──mintReceipt()──► OBOAuditReceipt (signed)
│
verifyReceipt() ◄───┘
renderReceipt() (human-readable view)
extractContext() takes raw token input, digests each token (SHA-256 — the raw token is never stored), sorts the delegation chain chronologically, and validates every structural rule. It throws a typed ContextValidationError (with a per-field error list) if anything is wrong.
import { extractContext } from '@scopetrail/core';
const now = Date.now();
const iso = (ms: number) => new Date(ms).toISOString();
const context = extractContext({
rootPrincipal: { id: 'did:web:example.com:users:jim', type: 'human', displayName: 'Jim' },
actingPrincipal: { id: 'did:web:example.com:agents:summarizer', type: 'agent', displayName: 'Summarizer' },
// Ordered delegation hops: human → service → agent
hops: [
{
delegator: { id: 'did:web:example.com:users:jim', type: 'human' },
delegate: { id: 'did:web:example.com:services:orchestrator', type: 'service' },
scopeAtHop: ['read:docs', 'invoke:summarize'],
rawToken: '<jwt-or-opaque-token>',
tokenType: 'jwt',
authorizedAt: iso(now - 2000),
},
{
delegator: { id: 'did:web:example.com:services:orchestrator', type: 'service' },
delegate: { id: 'did:web:example.com:agents:summarizer', type: 'agent' },
scopeAtHop: ['read:docs', 'invoke:summarize'],
rawToken: '<leaf-token>',
tokenType: 'jwt',
authorizedAt: iso(now - 1000),
},
],
rawUpstreamToken: '<leaf-token>', // the token directly authorizing the actor
upstreamTokenType: 'jwt',
grantedScopes: ['read:docs', 'invoke:summarize'],
audience: ['https://api.org/docs'],
action: {
verb: 'invoke',
resourceUri: 'https://api.org/docs/789/summarize',
parameters: { format: 'bullets' }, // sanitized — never put secrets here
},
issuedAt: iso(now),
expiresAt: iso(now + 3_600_000), // 1 hour TTL
});Validation enforced for you: chain linkage (each hop's delegate = next hop's delegator, leaf = actor), scope subset at each hop, max chain depth of 5, clock-skew tolerance (5 s), and identifier conformance on every principal (see below). Catch failures via the typed error:
import { ContextValidationError } from '@scopetrail/core';
try {
extractContext(badInput);
} catch (e) {
if (e instanceof ContextValidationError) {
console.error(e.errors); // [{ field: 'hops[1].delegator', rule: '...' }, ...]
}
}Every principal identifier is validated: rootPrincipal.id, actingPrincipal.id, and both ends of
every hop. Each must be either a conformant W3C DID or an absolute URI with a scheme.
The DID check follows the did-core ABNF, which means path segments
are colon-delimited and / is not a legal DID character anywhere:
did:web:example.com:users:jim ✅ conformant
did:web:example.com ✅ conformant (bare domain)
did:plc:bty3gmskhla7rwblq5zl5jm5 ✅ conformant
https://example.com/users/jim ✅ absolute URI
did:web:example.com/users/jim ❌ "/" is not a DID character
did:web: ❌ empty method-specific-id
did:WEB:example.com:jim ❌ method names are lowercase
The slash form is a common mistake because a did:web resolves to a URL where the colons become
slashes — did:web:example.com:users:jim → https://example.com/users/jim/did.json. The slashes
belong to the resolved URL, not to the identifier.
Failures arrive as ContextValidationError with one entry per offending field, so a context with
several bad identifiers reports all of them in a single pass rather than one per run.
Behavior change in 0.2.0. Before 0.2.0 only
rootPrincipal.idwas checked, and only for shape (starts withdid:, or contains://). Identifiers that previously produced a signed receipt may now throw. That is deliberate — an identifier that reaches a signed receipt unvalidated is one nothing will ever check again — but it is a breaking change for callers passing non-conformant DIDs.
import { generateKeyPair, mintReceipt, verifyReceipt } from '@scopetrail/core';
// In production the private key lives in an HSM/KMS; this is for local use/testing.
const { privateKey, publicKey } = await generateKeyPair();
const receipt = await mintReceipt(
context,
privateKey,
'key-1', // kid — appears in proof.verificationMethod
'did:web:receipts.your-org.example',
);
// → signed OBOAuditReceipt (JSON-LD). Serialize with JSON.stringify and store/ship it.
const result = await verifyReceipt(receipt, publicKey);
console.log(result); // { valid: true, errors: [] }verifyReceipt() is stateless apart from an in-process nonce store for replay protection. It checks, in order: nonce replay, Ed25519 signature over the JCS canonical form, expiry, clock skew, chain integrity, and scope subset. Any single-byte mutation to a non-proof field flips valid to false:
receipt.action.verb = 'delete'; // tamper
await verifyReceipt(receipt, publicKey); // { valid: false, errors: ['SIGNATURE_INVALID'] }The default store is a singleton. For tests or isolated verifiers, pass your own:
import { NonceStore } from '@scopetrail/core';
const store = new NonceStore();
await verifyReceipt(receipt, publicKey, store); // first call → valid
await verifyReceipt(receipt, publicKey, store); // second call → { valid: false, errors: ['NONCE_REPLAY'] }For multi-replica deployments, back replay protection with a shared store (Redis/DB). See PRD_ARCH §5 open question Q3.
Verifiers fetch your public keys to check signatures. buildJwks() produces a JWKS document ready to serve at /.well-known/jwks.json:
import { buildJwks } from '@scopetrail/core';
const jwks = await buildJwks([
{ kid: 'key-1', publicKey }, // active
{ kid: 'key-0', publicKey: retired }, // keep retired keys until their window closes
]);
// Serve as application/json at https://receipts.your-org.example/.well-known/jwks.json
// { "keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "key-1", "use": "sig", "x": "..." }, ...] }Renders the delegation chain as an ASCII flow diagram, an action box, proof metadata, and a verification banner.
# From a file
node dist/cli/view-receipt.js receipt.json
# Piped via stdin
cat receipt.json | node dist/cli/view-receipt.js
# Markdown output (fenced, paste-into-docs friendly)
node dist/cli/view-receipt.js receipt.json --markdown
# Supply a public key to actually verify the signature
node dist/cli/view-receipt.js receipt.json --jwks ./jwks.jsonExit codes: 0 = valid or unverified, 1 = invalid signature / structural error. Without --jwks the signature is not checked and the banner shows [ UNVERIFIED — no key supplied ].
Example output:
═══ OBO AUDIT RECEIPT ═══
ROOT did:web:example.com:users:jim [human]
│ authorizedAt: 2026-06-18T14:20:00Z
│ token: sha256:845e3044… (jwt / expires 16:00:00Z)
▼ scopes: read:docs, invoke:summarize
HOP 1 did:web:example.com:services:orchestrator [service]
│ authorizedAt: 2026-06-18T14:22:00Z
│ token: sha256:7d1b4186… (opaque)
▼ scopes: read:docs, invoke:summarize
ACTOR did:web:example.com:agents:summarizer [agent]
┌─ ACTION ─────────────────────────────────────────┐
│ verb: invoke │
│ resource: https://api.org/docs/789/summarize│
└──────────────────────────────────────────────────┘
[ VERIFIED ]
═════════════════════════
import { renderReceipt, verifyReceipt } from '@scopetrail/core';
const result = await verifyReceipt(receipt, publicKey);
const ascii = renderReceipt(receipt, result, 'ascii');
const markdown = renderReceipt(receipt, result, 'markdown');
// Pass null as the result to render an "unverified" view.Hosted / self-managed issuer — coming (ScopeTrail ops).
A minted receipt can be published as a record in an AT Protocol repository, giving it a public, addressable, tamper-evident home. Any third party can then verify it from its at:// URI with no keys shipped and no auth — the existing Ed25519 verification runs unchanged on fetch. See PRD_ARCH §8 for the design.
Key design points:
- Verbatim storage. The record wrapper (dev.scopetrail.auditReceipt, lexicon inlexicons/) stores the complete signed receipt as an opaque JSON string (receiptJson), so DAG-CBOR re-serialization can never break the JCS signature. Indexed mirror fields (issuer,subject, dates,summary) exist for discovery only and are cross-checked against the payload on read.
- Two identities. The atproto did:plcsays where the receipt lives; the issuer'skey-1(JWKS) says what proves it authentic. Publication adds discovery, not a new trust model.
- Record keys. receipt.idisurn:obo-receipt:<id>; colons aren't allowed in atproto rkeys, so the bare id (rkeyFor(receipt)) is the rkey andatUriFor()builds the matching URI.
import { mintReceipt } from '@scopetrail/core';
import {
AtpClient, FetchTransport, AppPasswordAuth, createPlcDidResolver,
publishReceipt, verifyFromUri,
} from '@scopetrail/core/atproto';
// Publish (issuer side — needs an app password on the PDS account)
const client = new AtpClient({
auth: new AppPasswordAuth({ service: process.env.ATP_PDS!, identifier: process.env.ATP_IDENTIFIER!, appPassword: process.env.ATP_APP_PASSWORD! }),
transport: new FetchTransport(),
didResolver: createPlcDidResolver(),
});
const uri = await publishReceipt(receipt, client, 'did:plc:<issuer>');
// → at://did:plc:<issuer>/dev.scopetrail.auditReceipt/<rkey>
// Verify (anyone, anywhere — no auth; only the issuer's public JWKS)
const result = await verifyFromUri(uri, client, jwks);
// → { valid, errors, receipt, render }verifyFromUri checks, in order: URI shape and collection (MALFORMED_URI / WRONG_COLLECTION), record existence (RECORD_NOT_FOUND), mirror integrity (MIRROR_MISMATCH), then hands the verbatim payload to the unchanged verifyReceipt() (SIGNATURE_INVALID, RECEIPT_EXPIRED, NONCE_REPLAY, …). One flat error vocabulary across both layers.
Sprint 03's live publish still had to hand verifyFromUri the issuer's public CryptoKey in-process, because there was nowhere to fetch it from. That gap is closed: verifyFromUri's third argument now also accepts a JWKS URL (a JwksUrlRef) instead of an in-process key/JWKS. A verifier with zero key material — only the at:// URI and a URL — can now do the whole thing:
import { verifyFromUri } from '@scopetrail/core/atproto';
// Nothing but the at:// URI and the hosted JWKS URL — no CryptoKey, no JsonWebKeySet, no auth.
const result = await verifyFromUri(uri, client, {
jwksUrl: 'https://scopetrail.example/.well-known/jwks.json',
});
// → { valid, errors, receipt, render }jwksUrl is fetched with fetch (injectable via an optional fetchFn, defaulting to globalThis.fetch — tests supply a stub so no real network is ever touched), the key matching the receipt's verificationMethod kid is selected and imported (crypto.subtle.importKey('jwk', jwk, 'Ed25519', false, ['verify'])), and the rest of the pipeline — mirror check, verifyReceipt() — runs unchanged. A bad URL, unreachable host, or a JWKS with the wrong/missing kid never throws; it comes back as the same typed KEY_IMPORT_FAILED result used everywhere else in this module.
Producing the JWKS artifact. scripts/emit-jwks.mjs calls the existing buildJwks() (src/signer.ts, untouched) and writes its output verbatim to .well-known/jwks.json — a static file, servable with no server running (gh-pages, S3, the scopetrail domain, …):
npm run build
node scripts/emit-jwks.mjs --jwk-file ./issuer-public-jwk.json --kid key-1 --out .well-known/jwks.jsonThe .well-known/jwks.json committed in this repo is an EXAMPLE, generated from a throwaway demo keypair (node scripts/emit-jwks.mjs with no --jwk-file) — it does not correspond to any real receipt or issuer. Jim regenerates it from the real issuer public key at deploy time and points jwksUrl at wherever it's actually hosted.
view-receipt accepts an at:// URI in place of a file; --jwks is required in this mode:
node dist/cli/view-receipt.js "at://did:plc:…/dev.scopetrail.auditReceipt/<rkey>" --jwks ./jwks.jsonThe full pipeline is exercised against an in-memory mock PDS (MockPds) implementing the real putRecord/getRecord XRPC shapes — CI needs no live account. The CLI mock mode: ATP_MOCK=1 ATP_MOCK_STORE=<seed.json>. A single live publish against a real PDS is a deliberate manual step (sprint 03 Task 5); swapping mock→real is pure config (FetchTransport + createPlcDidResolver + AppPasswordAuth).
- Receipts embed only a SHA-256 digest of each token — never the raw token.
- action.parametersmust be sanitized before minting (no bearer tokens, passwords, or PII).
- Default design is revocation-free: short TTLs are the revocation. See PRD_ARCH §5.7 for optional revocation mechanisms.
- Verifiers should maintain a trusted-issuer allowlist — a mathematically valid signature from an unknown issuer should still be rejected.
Core library, HTTP issuer service design, AT Protocol publication + verify-from-at:// (mock-PDS
tested), and fetch-by-URL JWKS resolution (true no-keys verification) are complete — 74 tests
green. The public JWKS is live at https://scopetrail.github.io/.well-known/jwks.json, and a real
receipt has been published under the dev.scopetrail.auditReceipt collection and verified keyless
against that URL. Deferred: Redis-backed nonce store, key rotation/persistence, a published atproto
lexicon schema record, OAuth replacing app-password auth.
See also: the launch post and positioning page (linked here once published) for how ScopeTrail
compares to adjacent projects, and docs/positioning.md for the short
version.
Apache License 2.0 — see LICENSE and NOTICE.
"ScopeTrail" (name and logo) is a trademark of the ScopeTrail project; the license covers the code,
not the name. See TRADEMARK.md before using the ScopeTrail name for a fork,
distribution, or hosted service. Contributions: see CONTRIBUTING.md (DCO
sign-off, no CLA).