UNPKG

noa-receipt

Version:

NOA Agent Action Receipt — open, offline-verifiable provenance for AI-agent actions. The governance/receipt organ only; the NOA brain is separate and proprietary.

149 lines (148 loc) • 6.88 kB
import { encInt, encBstr, encTstr, encArray, encMap, encTag, decode } from "./cbor.js"; import { signEd25519, verifyEd25519 } from "../keys.js"; import { parseVerificationKeyring } from "../verification-keyring.js"; import { bufEquals, bufToString, bufferFrom, bufferAlloc, jsonStringify } from "../intrinsics.js"; function utf8StringIfLossless(bytes) { const s = bufToString(bytes, "utf8"); return bufEquals(bufferFrom(s, "utf8"), bytes) ? s : null; } const COSE_SIGN1_TAG = 18; const HDR_ALG = 1; const HDR_CRIT = 2; const HDR_KID = 4; const ALG_ED25519 = -19; function protectedHeaderBytes(kid) { return encMap([ [encInt(HDR_ALG), encInt(ALG_ED25519)], [encInt(HDR_KID), encBstr(bufferFrom(kid, "utf8"))], ]); } function sigStructure(protectedBytes, payload) { return encArray([encTstr("Signature1"), encBstr(protectedBytes), encBstr(bufferAlloc(0)), encBstr(payload)]); } export function coseSign1(payload, signer) { const kid = signer.kid; if (typeof kid !== "string" || bufToString(bufferFrom(kid, "utf8"), "utf8") !== kid) { throw new Error("coseSign1: signer.kid does not round-trip through UTF-8 (a lone surrogate or non-well-formed string cannot be a signed identity)"); } const prot = protectedHeaderBytes(kid); const sigB64 = signEd25519(signer.privateKey, sigStructure(prot, payload)); const sig = bufferFrom(sigB64, "base64"); const unprotected = encMap([]); const body = encArray([encBstr(prot), unprotected, encBstr(payload), encBstr(sig)]); return encTag(COSE_SIGN1_TAG, body); } function validateProtectedAlg(protectedBytes) { let m; try { m = decode(protectedBytes); } catch (e) { return { ok: false, protectedKid: null, reason: `protected header CBOR: ${e.message}` }; } if (m.t !== "map") return { ok: false, protectedKid: null, reason: "protected header is not a CBOR map" }; let alg = null; let critLabels = null; let protectedKid = null; for (let mi = 0; mi < m.v.length; mi++) { const entry = m.v[mi]; const k = entry[0]; const val = entry[1]; if (k.t !== "int") continue; if (k.v === HDR_ALG) { if (val.t !== "int") return { ok: false, protectedKid: null, reason: "protected alg (label 1) must be an int" }; alg = val.v; } else if (k.v === HDR_CRIT) { critLabels = val; } else if (k.v === HDR_KID) { if (val.t !== "bstr") return { ok: false, protectedKid: null, reason: "protected kid (label 4) must be a bstr" }; protectedKid = utf8StringIfLossless(val.v); if (protectedKid === null) { return { ok: false, protectedKid: null, reason: "protected kid (label 4) is not valid UTF-8 — a lossy decode would collapse distinct signed kid byte strings onto one identity" }; } } } if (alg !== ALG_ED25519) { return { ok: false, protectedKid: null, reason: "protected header alg is not Ed25519 (-19, RFC 9864)" }; } if (critLabels !== null) { if (critLabels.t !== "array" || critLabels.v.length === 0) { return { ok: false, protectedKid: null, reason: "crit (label 2) must be a non-empty array" }; } for (let ci = 0; ci < critLabels.v.length; ci++) { const c = critLabels.v[ci]; if (!(c.t === "int" && (c.v === HDR_ALG || c.v === HDR_KID))) { return { ok: false, protectedKid: null, reason: "unprocessable critical header in crit (label 2) — fail-closed" }; } } } return { ok: true, protectedKid }; } export function coseSign1Verify(coseBytes, keyring) { const kParsed = parseVerificationKeyring(keyring, "keyring"); if (!kParsed.ok) return { ok: false, kid: null, payload: null, kidAuthenticated: false, reason: kParsed.reason }; const result = coseSign1VerifyParsed(coseBytes, kParsed.value.keyring); if (result.kid !== null && kParsed.value.retiredKids[result.kid] === true) { return { ok: false, kid: result.kid, payload: null, kidAuthenticated: false, reason: `signing key ${jsonStringify(result.kid)} is retired; signer-chosen artifact time is not an independent witness`, }; } return result; } export function coseSign1VerifyParsed(coseBytes, keyring) { let v; try { v = decode(coseBytes); } catch (e) { return { ok: false, kid: null, payload: null, kidAuthenticated: false, reason: `cbor: ${e.message}` }; } if (v.t !== "tag" || v.tag !== COSE_SIGN1_TAG) return { ok: false, kid: null, payload: null, kidAuthenticated: false, reason: "not a COSE_Sign1 (tag 18)" }; const arr = v.v; if (arr.t !== "array" || arr.v.length !== 4) return { ok: false, kid: null, payload: null, kidAuthenticated: false, reason: "COSE_Sign1 must be a 4-element array" }; const p = arr.v[0], u = arr.v[1], pl = arr.v[2], s = arr.v[3]; if (p.t !== "bstr" || u.t !== "map" || pl.t !== "bstr" || s.t !== "bstr") { return { ok: false, kid: null, payload: null, kidAuthenticated: false, reason: "COSE_Sign1 element types invalid" }; } const prot = validateProtectedAlg(p.v); if (!prot.ok) return { ok: false, kid: null, payload: null, kidAuthenticated: false, reason: prot.reason ?? "protected header is not {alg: Ed25519}" }; let kid = prot.protectedKid; if (kid === null) { for (let ui = 0; ui < u.v.length; ui++) { const entry = u.v[ui]; const k = entry[0]; const val = entry[1]; if (k.t === "int" && k.v === HDR_KID && val.t === "bstr") { const lifted = utf8StringIfLossless(val.v); if (lifted === null) { return { ok: false, kid: null, payload: null, kidAuthenticated: false, reason: "unprotected kid (label 4) is not valid UTF-8 — a lossy decode would collapse distinct kid byte strings onto one identity" }; } kid = lifted; } } } if (!kid) return { ok: false, kid: null, payload: null, kidAuthenticated: false, reason: "no kid (header label 4, protected or unprotected)" }; const pub = keyring[kid]; if (!pub) return { ok: false, kid, payload: null, kidAuthenticated: false, reason: `unknown kid "${kid}" not in keyring` }; const ok = verifyEd25519(pub, sigStructure(p.v, pl.v), bufToString(s.v, "base64")); const kidAuthenticated = prot.protectedKid !== null && kid === prot.protectedKid; return ok ? { ok: true, kid, payload: pl.v, kidAuthenticated } : { ok: false, kid, payload: null, kidAuthenticated: false, reason: "bad signature" }; }