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-sh/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, always your own 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 a key you generate and hold yourself, so you prove provenance of your own evidence with your 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-sh/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.