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
JavaScript
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" };
}