@tracescale/verifier
v0.3.0
Published
Apache-2.0 reference verifier for Tracescale norp v1.0 receipts. Strict, async, single-chain verification with structured AUD-01 reports and a terminal renderer.
Downloads
292
Readme
@tracescale/verifier
Apache-2.0 reference verifier for the norp v1.0 wire format.
Strict, async, single-chain verification producing structured AUD-01
reports plus a terminal renderer. No @nonsudo/* dependency required.
Install
pnpm add @tracescale/verifierVersion posture
The current release is 0.3.0, a coordinated republish with
@tracescale/[email protected] and @tracescale/[email protected] that widens the
accepted canonical vocabulary; verification logic is unchanged from
0.2.1 (see CHANGELOG.md). The 0.x stream is published and externally
consumable; a clean npm install performs end-to-end receipt
verification from a fresh Node container.
The schema-pack registry public API (see "Schema-pack registry" below)
ships since 0.2.1 and is semver-governed from that release forward. The
registry is inert at 0.3.0: registered packs do not affect verification.
Supported algorithms
| Kind | Identifier (wire format) | Status at 0.3.0 |
|---|---|---|
| Signature | ed25519 | Registered; verification active |
| Signature | ml-dsa-65 | Identifier shape supported in the registry; handler not yet registered |
| Hash | sha256 | Registered |
The verifier reads signature.alg and signature_pq.alg from receipts and
dispatches through the algorithm registry. Unknown algorithms produce a
finding rather than a silent accept. getSupportedAlgorithms() returns
the list of currently registered signature algorithms.
Verification modes
The verifier defaults to strict mode: every signature is cryptographically verified, the hash chain is checked, and the timestamp profile is evaluated. There is no permissive mode, no demo mode, no compatibility mode for older spec versions, and no multi-chain bundle mode.
One opt-in exception exists. A caller MAY enable integrity-only mode
(skipSignatureVerification in the library, --skip-signature on the CLI),
which checks chain integrity and schema but bypasses signature verification.
Integrity-only mode is not cryptographic verification. It emits the
signature_not_evaluated finding, sets checks.signature to SKIPPED, and
resolves the chain to PARTIAL rather than VALID. A result produced in this
mode must not be described as "verified". See the Integrity-only mode section
below.
Integrity-only mode (skip signature verification)
Some callers need to check chain integrity and schema without access to signing public keys, for example when triaging an exported chain before keys are resolvable. For that case the verifier offers an opt-in integrity-only mode.
- Library: pass
skipSignatureVerification: true. - CLI: pass
--skip-signature.
In this mode the per-receipt signature loop is bypassed. The verifier still
checks the hash chain, timestamp profile, and schema. It emits a single
chain-level signature_not_evaluated finding (severity: warning, payload
{ reason: "skipped_by_caller", mode: "integrity_only", signature_count }),
sets checks.signature to SKIPPED, and resolves verification_result to
PARTIAL when every other evaluated check passes.
Integrity-only mode is not cryptographic verification. A result produced in this mode must not be reported as "verified". The CLI prints an explicit banner to this effect.
Trust boundary
The verifier proves three things:
- Cryptographic linkage. Each receipt's signature verifies under the
public key resolved by the caller's
PublicKeyResolver. - Structural integrity. Each receipt is well-formed per norp v1.0 schema, enums, compositions, emission rules, and hash formats.
- Chain integrity. Receipts link via
prev_hash,sequence_numbervalues are monotonic without gaps or duplicates, andchain_idis consistent.
The verifier does not prove:
- That the resolved public key belongs to a legitimate emitter (key registry / trust anchor problem).
- That the emitter was authorized to act on behalf of any user (mandate / delegation problem).
- That the deployment running the proxy was trustworthy (deployment manifest / attestation problem).
- That the policy that produced these decisions was correct (policy review problem).
PublicKeyResolver is the trust boundary. A caller supplying a resolver
backed by a trusted key registry (for example, a signed deployment
manifest's key list) establishes trust. A caller supplying a resolver
that returns any public key without verification proves cryptographic
linkage but not authorization.
Usage
import { verifyChain, renderAudMini } from "@tracescale/verifier";
const report = await verifyChain(receipts, {
publicKeyResolver: async (key_id, alg) => {
/* alg is the byte-identical string carried in receipt.signature.alg
* (today: "ed25519"; future: "ml-dsa-65" when post-quantum lands).
* Resolvers that do not differentiate by algorithm can accept
* `_alg` and ignore it. */
return registry.get(key_id, alg) ?? null;
},
});
if (report.result === "INVALID") {
process.stderr.write(renderAudMini(report));
process.exit(1);
}Migrating from 0.1.0
The PublicKeyResolver signature changed in 0.2.0 from
(key_id, receipt) => ... to (key_id, alg) => .... Existing resolvers
that ignored the second parameter need no functional change beyond
renaming or accepting _alg. Resolvers that read the receipt body should
move that logic into the caller and use the algorithm string instead.
NDJSON input
import { parseNdjsonReceipts, verifyChain } from "@tracescale/verifier";
const text = await readFileAsString(path); // caller's responsibility
const parsed = parseNdjsonReceipts(text);
for (const e of parsed.parse_errors) {
console.warn(`line ${e.line_number}: ${e.message}`);
}
const report = await verifyChain([...parsed.receipts], { publicKeyResolver });parseNdjsonReceipts is pure and synchronous and does not read files.
File I/O belongs to CLI/app wrappers. It skips empty and #-comment
lines, and inserts null placeholders for malformed JSON lines so that
input_index references stay aligned with the original line numbering.
Optional report self-signing
import { signReport } from "@tracescale/verifier";
const signed = await signReport(report, verifierPrivateKey, "verifier-key-01");The verifier reuses the same canonicalization and Ed25519 primitives as
@tracescale/norp. report_hash is computed over the canonical body
excluding report_hash and report_signature; report_signature is
computed over the canonical body including report_hash but excluding
report_signature.
Determinism
Same input + same options.now() value produces identical report bytes
after JCS canonicalization. Inject now for reproducible reports:
const report = await verifyChain(receipts, {
publicKeyResolver,
now: () => "2026-05-22T10:35:12.000Z",
});TSA verification
TsaVerifier is an interface with the implementation supplied by the
caller. The default behavior when no TsaVerifier is supplied is
tsa_status: NOT_CHECKED and an INFO-severity TSA_TOKEN_NOT_CHECKED
finding for each receipt that carries TSA fields. The verifier does not
flag receipts as INVALID simply because no TSA verifier was supplied.
Schema-pack registry
The package exports a schema-pack registry whose API is stable from
0.2.0 forward. Packs are inert at 0.3.0: registering a pack does not
affect any verification result, finding, status field, or emitted report.
import {
registerSchemaPack,
listSchemaPacks,
getSchemaPack,
type SchemaPackManifest,
} from "@tracescale/verifier";
registerSchemaPack(manifest);
const registered = listSchemaPacks();
const one = getSchemaPack("example.namespace.v1");The SchemaPackManifest interface admits two pack tiers:
- Open extension packs declared by anyone, additive-only on receipt fields and named checks per the norp namespace-isolation rule.
- Verified packs carrying a
signed_manifestvalue. Validation ofsigned_manifestis not implemented at0.3.0and lands when the first verified pack ships.
The interface is semver-governed: future versions may add optional fields;
renaming or removing existing fields requires a major version bump of
@tracescale/verifier.
API
async function verifyChain(receipts: unknown[], options: VerifyOptions): Promise<VerifierReport>;
function parseNdjsonReceipts(input: string): ParseNdjsonResult;
function renderAudMini(report: VerifierReport): string;
async function signReport(report: VerifierReport, privateKey: Uint8Array, keyId: string): Promise<VerifierReport>;
async function verifyReportManifest(manifest: unknown, options: ReportManifestVerificationOptions): Promise<ReportManifestVerificationResult>;
// Crypto agility (0.2.0)
function getSupportedAlgorithms(): readonly string[]; // signature algorithms
function getSupportedHashAlgorithms(): readonly string[]; // hash algorithms
class AlgorithmRegistry { /* register / get / getSupportedAlgorithms */ }
class HashRegistry { /* register / get / getSupportedHashAlgorithms */ }
type PublicKeyResolver = (key_id: string, alg: string) => Uint8Array | null | Promise<Uint8Array | null>;See src/types.ts and src/crypto/ for the full type surface.
Observe-mode disclosure
When a chain contains one or more observe-mode receipts
(mode === "observe"), the verifier emits a structured
observe_mode_disclosure field on the report:
"observe_mode_disclosure": {
"counterfactual_count": 3,
"would_have_block_rate": 0.3333333333333333,
"would_have_step_up_rate": 0.3333333333333333,
"actual_execution_rate": 1
}Pure enforce-mode chains have no observe_mode_disclosure field. The
disclosure is informational. Its presence does not by itself make a chain
INVALID.
The verifier validates the observe-mode internal-consistency rules per
receipt. A top-level decision that does not equal
actual_execution_decision, or a counterfactual that is not true when
actual differs from would-have, raises an ERROR finding with category
OBSERVE_MODE_DECISION_INVARIANT_VIOLATED. Missing required observe-mode
fields raise SCHEMA_VIOLATION findings. No new AUD-01 named check is
added; only the one finding category.
See ../../../docs/spec/observe-mode.md
for the full normative shape.
Signed report manifest verification
Separate from verifyChain (which verifies NORP receipt chains), the
package exports verifyReportManifest for verifying signed RPT-01 report
manifests produced by @tracescale/reports.signRptManifest and the
@nonsudo/reports generators (Blocked Actions, Executive Summary, FINRA
2210, FINRA 3110).
import {
verifyReportManifest,
type ReportManifestVerificationResult,
} from "@tracescale/verifier";
const result = await verifyReportManifest(manifest, {
expectedPublicKey: pubKey,
sourceReceipts: receipts, // optional
verifySourceChain: true, // requires sourceReceipts + sourceChainPublicKeyResolver
sourceChainPublicKeyResolver: (kid, _alg) => keystore.get(kid),
});Result envelope (flat: no overall_verdict or fully_verified field)
interface ReportManifestVerificationResult {
manifest_valid: boolean;
signature_valid: boolean;
source_references_valid: boolean | "NOT_RUN";
source_chain_verification: "VERIFIED" | "FAILED" | "NOT_RUN" | "INSUFFICIENT_DATA";
evidence_context: "demo" | "test" | "production";
report_type_code: string;
source_receipt_count: number;
findings: ReportVerificationFinding[];
}source_receipt_count is locked to manifest.source_receipt_hashes.length.
The verifier never reports sourceReceipts.length here. The field reflects
what the signed manifest declares, not what the caller supplied.
Locked verification rule (consumer-side; lives in spec, not on result)
fully_verified =
result.manifest_valid === true
&& result.signature_valid === true
&& result.source_references_valid === true
&& result.source_chain_verification === "VERIFIED"Strict-equality is intentional. "NOT_RUN" is a first-class result
state, distinct from true and from a failure. The rule's === true and
=== "VERIFIED" checks mechanically prevent "NOT_RUN" from silently
aggregating into a "verified" verdict. A signature-only verification
(source_references_valid: "NOT_RUN") is explicitly NOT fully verified,
and fully_verified resolves to false. Rendering layers (the CLI
renderer, dashboards, downstream consumers) choose how to surface partial
state to humans; that derivation is a rendering concern, not a verifier
concern.
fully_verified is not a field on the result envelope. Keeping the
rule in the spec rather than in a derived field prevents the field from
drifting away from the underlying flags.
Verification stages
- RPT-01 schema. Delegated to
@tracescale/reports.validateUnsignedRptManifest. Failure short-circuits: subsequent checks returnNOT_RUN/sentinel and a singleREPORT_MANIFEST_SCHEMA_INVALIDfinding is emitted. - Ed25519 signature. Delegated to
@tracescale/reports.verifyRptManifestSignature. Public key is resolved fromexpectedPublicKey(preferred) orkeyResolver. Failure emitsREPORT_MANIFEST_SIGNATURE_INVALID. - Source receipt hash references. Delegated to
@tracescale/reports.verifySourceReceiptReferenceswhensourceReceiptsis supplied. Reuses the three canonical codesREPORT_SOURCE_RECEIPT_MISSING,REPORT_SOURCE_RECEIPT_DUPLICATE,REPORT_SOURCE_RECEIPT_HASH_MALFORMED. When receipts are not supplied,source_references_validis"NOT_RUN"and no reference-related findings are emitted. - Evidence-context consistency. When source receipts are supplied
and carry
evidence_context, mismatches againstmanifest.evidence_contextemitREPORT_EVIDENCE_CONTEXT_INCONSISTENT. This produces only a finding; it does not affectsource_references_valid. - Optional source chain AUD-01 verification. When
sourceReceiptsis supplied ANDverifySourceChain === true, the existingverifyChainruns against the chain. Verdict mapping:VALIDbecomes"VERIFIED",INVALIDbecomes"FAILED". Without asourceChainPublicKeyResolver, the result is"INSUFFICIENT_DATA". Chain-verify findings are merged into the result'sfindingsarray under their original category codes.
Finding codes (all severity ERROR)
| Code | Origin | Emitted when |
|---|---|---|
| REPORT_MANIFEST_SCHEMA_INVALID | local | RPT-01 schema validation fails |
| REPORT_MANIFEST_SIGNATURE_INVALID | local | Signature does not verify, or no public key is resolvable for key_id |
| REPORT_SOURCE_RECEIPT_MISSING | reused from @tracescale/reports | A declared hash does not resolve to a supplied receipt |
| REPORT_SOURCE_RECEIPT_DUPLICATE | reused from @tracescale/reports | source_receipt_hashes carries duplicates (also caught at schema layer) |
| REPORT_SOURCE_RECEIPT_HASH_MALFORMED | reused from @tracescale/reports | A declared hash is not sha256:<64-hex> (also caught at schema layer) |
| REPORT_EVIDENCE_CONTEXT_INCONSISTENT | local | A source receipt's evidence_context differs from the manifest's |
Findings have severity ERROR because each code directly breaks the
evidentiary meaning of the bundle. Reducing
REPORT_EVIDENCE_CONTEXT_INCONSISTENT to a warning, for example, would
let evidence_context: production manifests over demo receipts
disappear in consumers that filter findings by severity.
Out of scope
- CLI rendering of
verifyReportManifestresults lands in a follow-on slice. - Dashboard reports rendering is a separate workstream.
- Modifications to
verifyChainor AUD-01 named-check semantics.
What verifyReportManifest does NOT verify
- That the resolved public key belongs to a legitimate issuer (same trust
boundary as
verifyChain). - Cross-chain provenance. The verifier operates on a single signed manifest plus at most one supplied source chain.
- That the manifest's
report_contentbody is correct with respect to the source chain. It verifies that hashes resolve, not that generator logic was sound.
License
Apache-2.0. See LICENSE.
