Signing primitive
Your evidence, signed under your own per-tenant Ed25519 key (never Caisson's) so any third party verifies it without touching your secrets.
What it is
signing-primitive produces a detached Ed25519 signature over a canonical, chain-anchored evidence manifest, bound to the WORM audit chain's tip hash and signed under a per-tenant key that is deliberately distinct from the Caisson license-issuer key. An optional RFC-3161 timestamp countersigns the signature, and a separate deployment-level Ed25519ph signer anchors receipts into Sigstore Rekor's public transparency log.
What ships in the module
Browser-safe entry point
Import @caisson/signing-primitive/browser inside a client bundle for the verify half: the signable-payload construction, verifyEvidenceSignature over the same @noble/ed25519 primitive the server signs with, and the RFC-3161 test double (Node 20.12 or later). A relying party can check your evidence pack entirely in their own browser. The signing identity stays off that entry on purpose, a tenant seed does not belong in a bundle users download, and every browser-entry export is also on the main entry.
Per-tenant Ed25519Signer, never the license key
Ed25519Signer holds a 32-byte tenant seed in a private #secretKey field, never logged or serialized; construction throws ValidationError on an empty keyId or a wrong-length key. It is deliberately distinct from Caisson's own license-issuer key, a buyer proves provenance of their own evidence with their own identity.
Detached, bound to the chain tip
evidenceSignablePayload concatenates canonicalize(manifest) with manifest.chainAnchor.tipHash before signing; signEvidencePack signs that exact payload and returns the signature BESIDE the manifest, so the canonical body stays byte-stable and golden-fixturable. Move the WORM chain tip and the same signature no longer verifies.
Fail-closed verify, never throws
verifyEvidenceSignature returns false (never throws) on an unknown algorithm, malformed hex, a wrong-length key or signature, a tampered manifest, or a moved chain tip. A forgery, a corrupt field, and a driver error all collapse to the same denial.
Optional RFC-3161 countersignature
signEvidencePack takes an optional TimestampAuthority; StubTimestampAuthority is the network-free test double shipped for CI, and timestampCountersignsSignature recomputes sha256(signature) to confirm a token actually attests to THIS signature. The live TSA transport is a documented un-wired seam, no live network call runs in CI.
Constant-time signature compare
signaturesEqual wraps @caisson/kernel's safeEqualFixed so comparing two hex signatures never leaks how many leading bytes matched, the same timing-safe discipline the kernel's secret comparisons use elsewhere.
Deployment-level Ed25519ph key for Rekor anchoring
Ed25519PhSigner.fromEnv loads a base64 32-byte seed from CAISSON_REKOR_ANCHORING_KEY (never the per-tenant key) and signs with @noble/curves' ed25519ph, the RFC-8032 §5.1 prehash variant Rekor v2's hashedrekord endpoint requires, since a pure Ed25519 signature would be handed only a digest and re-hash it.
export async function signEvidencePack(
signer: Signer,
manifest: SignableManifest,
options?: SignEvidencePackOptions,
): Promise<EvidenceSignature> {
const payload = evidenceSignablePayload(manifest);
const [publicKeyBytes, signatureBytes] = await Promise.all([
signer.publicKey(),
signer.sign(payload),
]);
if (signatureBytes.length !== ED25519_SIGNATURE_BYTES) {
throw new ValidationError(
`detached signature must be ${String(ED25519_SIGNATURE_BYTES)} bytes, got ${String(signatureBytes.length)}`,
);
}
if (publicKeyBytes.length !== ED25519_PUBLIC_BYTES) {
throw new ValidationError(
`ed25519 public key must be ${String(ED25519_PUBLIC_BYTES)} bytes, got ${String(publicKeyBytes.length)}`,
);
}
const base: EvidenceSignature = {
algorithm: signer.algorithm,
keyId: signer.keyId,
publicKey: toHex(publicKeyBytes),
signature: toHex(signatureBytes),
};
if (options?.timestampAuthority === undefined) return base;
const timestamp =
await options.timestampAuthority.countersign(signatureBytes);
return { ...base, timestamp };
}- Promise.all runs signer.publicKey() and signer.sign(payload) concurrently, the key and the signature are two independent async calls, not a serial round-trip.
- signatureBytes and publicKeyBytes are length-checked against ED25519_SIGNATURE_BYTES/ED25519_PUBLIC_BYTES before either is hex-encoded, a malformed signer output throws instead of shipping a corrupt EvidenceSignature.
- options?.timestampAuthority?.countersign only runs when a TSA was supplied, the base EvidenceSignature with no timestamp field is already a complete, valid return.