Files
aios/plugins/aios-tenant-boundary/provenance.js
T

142 lines
5.8 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
export const ALLOWED_PATHS = Object.freeze([
".agent/claim.json",
".agent/heartbeat.json",
".agent/progress.json",
".agent/status.json",
".agent/blockers.json",
".agent/result.json",
".agent/credential-status.json",
]);
export const ALLOWED_OPERATIONS = Object.freeze([
"create",
"update",
"git add",
"git commit",
"normal push current task branch",
]);
const PAYLOAD_FIELDS = Object.freeze([
"schema_version", "provenance_id", "run_id", "sender_id", "task_id",
"executor", "executor_instance", "claim_oid", "candidate_oid", "task_branch",
"allowed_paths", "allowed_operations", "issued_by", "issued_at", "expires_at",
]);
const OID_RE = /^[0-9a-f]{40}$/;
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const MAX_LIFETIME_MS = 15 * 60 * 1000;
function normalized(value) {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(normalized);
const result = {};
for (const key of Object.keys(value).sort()) {
if (value[key] !== undefined) result[key] = normalized(value[key]);
}
return result;
}
export function canonicalize(value) {
return JSON.stringify(normalized(value));
}
function exactStringSet(actual, expected) {
if (!Array.isArray(actual) || actual.some((v) => typeof v !== "string")) return false;
if (actual.length !== expected.length || new Set(actual).size !== expected.length) return false;
const set = new Set(actual);
return expected.every((value) => set.has(value));
}
function failure(code) {
return { ok: false, code };
}
export function validatePayload(payload, now = Date.now()) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return failure("payload_not_object");
const keys = Object.keys(payload).sort();
if (keys.length !== PAYLOAD_FIELDS.length || PAYLOAD_FIELDS.some((key) => !keys.includes(key))) {
return failure("payload_schema_mismatch");
}
if (payload.schema_version !== "aios.run-provenance/v1") return failure("schema_version_mismatch");
for (const key of ["run_id", "sender_id", "task_id", "executor", "executor_instance", "task_branch", "issued_by"]) {
if (typeof payload[key] !== "string" || !payload[key].trim()) return failure(`invalid_${key}`);
}
if (!ID_RE.test(payload.provenance_id)) return failure("invalid_provenance_id");
if (!OID_RE.test(payload.claim_oid) || !OID_RE.test(payload.candidate_oid)) return failure("invalid_oid");
if (!exactStringSet(payload.allowed_paths, ALLOWED_PATHS)) return failure("allowed_paths_mismatch");
if (!exactStringSet(payload.allowed_operations, ALLOWED_OPERATIONS)) return failure("allowed_operations_mismatch");
if (!Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)) return failure("invalid_time");
if (payload.expires_at <= payload.issued_at || payload.expires_at - payload.issued_at > MAX_LIFETIME_MS) {
return failure("invalid_lifetime");
}
if (now < payload.issued_at) return failure("not_yet_valid");
if (now >= payload.expires_at) return failure("expired");
return { ok: true, payload };
}
export function privateKeyFromMaterial(material) {
if (!Buffer.isBuffer(material)) material = Buffer.from(material);
if (material.length === 32) {
const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
return crypto.createPrivateKey({ key: Buffer.concat([pkcs8Prefix, material]), format: "der", type: "pkcs8" });
}
if (material.subarray(0, 5).toString() === "-----") return crypto.createPrivateKey(material);
return crypto.createPrivateKey({ key: material, format: "der", type: "pkcs8" });
}
export function signRecord(payload, privateMaterial, keyId) {
const signature = crypto.sign(null, Buffer.from(canonicalize(payload)), privateKeyFromMaterial(privateMaterial));
return {
payload,
signature: { algorithm: "Ed25519", key_id: keyId, value: signature.toString("base64url") },
};
}
export function verifyRecord(record, publicKeyPem, expectedKeyId) {
if (!record || typeof record !== "object" || Array.isArray(record)) return failure("record_not_object");
const signature = record.signature;
if (!signature || signature.algorithm !== "Ed25519" || signature.key_id !== expectedKeyId || typeof signature.value !== "string") {
return failure("signature_contract_mismatch");
}
try {
const ok = crypto.verify(
null,
Buffer.from(canonicalize(record.payload)),
crypto.createPublicKey(publicKeyPem),
Buffer.from(signature.value, "base64url"),
);
return ok ? { ok: true } : failure("signature_invalid");
} catch {
return failure("signature_invalid");
}
}
function safeRegularFile(filePath) {
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_provenance_file");
if (process.platform !== "win32" && (stat.mode & 0o022) !== 0) throw new Error("unsafe_provenance_file");
}
export function loadStoredRecord(storeDir, provenanceId) {
if (!path.isAbsolute(storeDir) || !ID_RE.test(provenanceId)) throw new Error("invalid_provenance_location");
const storeReal = fs.realpathSync(storeDir);
const target = path.join(storeReal, `${provenanceId}.json`);
safeRegularFile(target);
const targetReal = fs.realpathSync(target);
if (path.dirname(targetReal) !== storeReal || path.basename(targetReal) !== `${provenanceId}.json`) {
throw new Error("unsafe_provenance_file");
}
const raw = fs.readFileSync(target, "utf8");
if (Buffer.byteLength(raw) > 128 * 1024) throw new Error("provenance_record_too_large");
return JSON.parse(raw);
}
export function readPublicKey(publicKeyPath) {
if (!path.isAbsolute(publicKeyPath)) throw new Error("invalid_public_key_path");
safeRegularFile(publicKeyPath);
return fs.readFileSync(publicKeyPath, "utf8");
}