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.
86 lines (85 loc) • 4.34 kB
JavaScript
import { parseDocument } from "./bytes.js";
import { arrayIncludes, dateParse, isArray, isNaNValue, jsonStringify, objectCreateNull, objectGetOwnPropertyNames, } from "./intrinsics.js";
import { isRfc3339Instant } from "./scan.js";
export const SIGNING_KEY_LIFECYCLE_SPEC = "noa.signing-key-lifecycle/0.1";
export function parseVerificationKeyring(document, label = "keyring") {
const parsed = parseDocument(document, label);
if (!parsed.ok)
return parsed;
const value = parsed.value;
if (typeof value !== "object" || value === null || isArray(value)) {
return { ok: false, reason: `${label} must be an object (static kid map or atomic signing key lifecycle)` };
}
const top = value;
const topNames = objectGetOwnPropertyNames(top);
const hasLifecycleSpec = arrayIncludes(topNames, "spec") && top.spec === SIGNING_KEY_LIFECYCLE_SPEC;
const hasStructuredKeys = arrayIncludes(topNames, "keys") && typeof top.keys !== "string";
const looksLikeLifecycle = hasLifecycleSpec || hasStructuredKeys;
const keyring = objectCreateNull();
const retiredKids = objectCreateNull();
if (!looksLikeLifecycle) {
for (let i = 0; i < topNames.length; i++) {
const kid = topNames[i];
const publicKey = top[kid];
if (typeof publicKey !== "string" || publicKey.length === 0) {
return { ok: false, reason: `${label} public key for signing key ${jsonStringify(kid)} must be a non-empty string` };
}
keyring[kid] = publicKey;
}
return { ok: true, value: { keyring, retiredKids, lifecycle: false } };
}
if (top.spec !== SIGNING_KEY_LIFECYCLE_SPEC
|| topNames.length !== 2
|| !arrayIncludes(topNames, "spec")
|| !arrayIncludes(topNames, "keys")) {
return { ok: false, reason: "malformed signing key lifecycle" };
}
const entries = top.keys;
if (typeof entries !== "object" || entries === null || isArray(entries)) {
return { ok: false, reason: "signing key lifecycle keys must be an object" };
}
const kids = objectGetOwnPropertyNames(entries);
if (kids.length === 0)
return { ok: false, reason: "signing key lifecycle must contain at least one key" };
for (let i = 0; i < kids.length; i++) {
const kid = kids[i];
const entry = entries[kid];
if (typeof entry !== "object" || entry === null || isArray(entry)) {
return { ok: false, reason: `lifecycle entry for signing key ${jsonStringify(kid)} must be an object` };
}
const fields = objectGetOwnPropertyNames(entry);
if (fields.length !== 2
|| !arrayIncludes(fields, "publicKey")
|| !arrayIncludes(fields, "retiredAt")) {
return { ok: false, reason: `lifecycle entry for signing key ${jsonStringify(kid)} must contain exactly publicKey + retiredAt` };
}
const publicKey = entry.publicKey;
const retiredAt = entry.retiredAt;
if (typeof publicKey !== "string" || publicKey.length === 0) {
return { ok: false, reason: `lifecycle publicKey for signing key ${jsonStringify(kid)} must be a non-empty string` };
}
if (retiredAt !== null
&& (typeof retiredAt !== "string" || !isRfc3339Instant(retiredAt) || isNaNValue(dateParse(retiredAt)))) {
return { ok: false, reason: `lifecycle retiredAt for signing key ${jsonStringify(kid)} must be null or a parseable RFC 3339 instant` };
}
keyring[kid] = publicKey;
if (retiredAt !== null)
retiredKids[kid] = true;
}
return { ok: true, value: { keyring, retiredKids, lifecycle: true } };
}
export function resolveVerificationKey(document, kid) {
const parsed = parseVerificationKeyring(document);
if (!parsed.ok)
return parsed;
if (parsed.value.retiredKids[kid] === true) {
return {
ok: false,
reason: `signing key ${jsonStringify(kid)} is retired; signer-chosen artifact time is not an independent witness`,
};
}
const publicKey = parsed.value.keyring[kid];
if (!publicKey)
return { ok: false, reason: `signing key ${jsonStringify(kid)} not in keyring` };
return { ok: true, publicKey, lifecycle: parsed.value.lifecycle };
}