138 lines
3.9 KiB
TypeScript
138 lines
3.9 KiB
TypeScript
import { generateKeyPairSync, sign } from "node:crypto";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import {
|
|
type ApprovalPayload,
|
|
parseApprovalEnvelopeJson,
|
|
verifyApprovalEnvelope,
|
|
} from "../../src/security/approval-envelope.ts";
|
|
import {
|
|
canonicalJsonDigest,
|
|
canonicalValueBytes,
|
|
} from "../../src/security/canonical-digest.ts";
|
|
|
|
const hash = (character: string) => character.repeat(64);
|
|
const payload = (): ApprovalPayload => ({
|
|
schema: "portfolio-approval-payload/v1",
|
|
keyId: "approval-2026",
|
|
issuerEpoch: 3,
|
|
authorizedRole: "portfolio-approver",
|
|
evidenceFingerprints: [hash("a")],
|
|
recordClass: "content",
|
|
recordType: "profile",
|
|
publicRecordIdOrAssetId: "profile-main",
|
|
approvedPublicRecordHashOrManifestRecordHash: hash("b"),
|
|
sanitizedOutputHashOrNull: null,
|
|
decision: "approve",
|
|
issuedAt: "2026-09-01T08:00:00Z",
|
|
expiresAt: "2026-10-01T08:00:00Z",
|
|
previousLedgerHeadHash: hash("0"),
|
|
ledgerRevision: 1,
|
|
policyVersion: "approval-policy-1",
|
|
});
|
|
|
|
function signedEnvelope(overrides: Partial<ApprovalPayload> = {}) {
|
|
const keys = generateKeyPairSync("ed25519");
|
|
const signedPayload = { ...payload(), ...overrides };
|
|
const signature = sign(
|
|
null,
|
|
canonicalValueBytes(signedPayload),
|
|
keys.privateKey,
|
|
).toString("base64url");
|
|
return {
|
|
envelope: {
|
|
schema: "portfolio-approval-envelope/v1" as const,
|
|
payload: signedPayload,
|
|
signature: {
|
|
algorithm: "Ed25519" as const,
|
|
encoding: "base64url-nopad" as const,
|
|
value: signature,
|
|
},
|
|
},
|
|
publicKey: keys.publicKey,
|
|
};
|
|
}
|
|
|
|
function trustFor(
|
|
envelope: ReturnType<typeof signedEnvelope>["envelope"],
|
|
publicKey: ReturnType<typeof signedEnvelope>["publicKey"],
|
|
) {
|
|
const envelopeDigest = canonicalJsonDigest(
|
|
"portfolio-approval-envelope-digest/v1",
|
|
envelope,
|
|
);
|
|
const latestLedgerHeadHash = canonicalJsonDigest(
|
|
"portfolio-approval-ledger-entry/v1",
|
|
{
|
|
previousLedgerHeadHash: hash("0"),
|
|
ledgerRevision: 1,
|
|
envelopeDigest,
|
|
},
|
|
);
|
|
return {
|
|
now: "2026-09-02T00:00:00Z",
|
|
latestLedgerHeadHash,
|
|
latestLedgerRevision: 1,
|
|
trustedKeys: [
|
|
{
|
|
keyId: "approval-2026",
|
|
issuerEpoch: 3,
|
|
scope: "portfolio-approval-decision/v1" as const,
|
|
authorizedRoles: ["portfolio-approver"],
|
|
publicKey,
|
|
},
|
|
],
|
|
ledgerProof: {
|
|
schema: "portfolio-approval-ledger-proof/v1" as const,
|
|
genesisHeadHash: hash("0"),
|
|
entries: [envelope],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("approval envelopes", () => {
|
|
test("verifies an exact, current, scoped Ed25519 approval", () => {
|
|
const { envelope, publicKey } = signedEnvelope();
|
|
const verified = verifyApprovalEnvelope(
|
|
envelope,
|
|
trustFor(envelope, publicKey),
|
|
);
|
|
expect(verified.publicRecordIdOrAssetId).toBe("profile-main");
|
|
});
|
|
|
|
test.each([
|
|
["expired", { expiresAt: "2026-09-01T09:00:00Z" }],
|
|
["non-UTC timestamp", { issuedAt: "2026-09-01T10:00:00+02:00" }],
|
|
["wrong epoch", { issuerEpoch: 2 }],
|
|
["rejection", { decision: "reject" }],
|
|
] as const)("rejects a %s envelope", (_label, override) => {
|
|
const { envelope, publicKey } = signedEnvelope(override);
|
|
expect(() =>
|
|
verifyApprovalEnvelope(envelope, trustFor(envelope, publicKey)),
|
|
).toThrow();
|
|
});
|
|
|
|
test("rejects unknown fields, duplicate keys, and alternate signature encodings at ingest", () => {
|
|
const { envelope } = signedEnvelope();
|
|
expect(() =>
|
|
parseApprovalEnvelopeJson(JSON.stringify({ ...envelope, extra: true })),
|
|
).toThrow();
|
|
expect(() =>
|
|
parseApprovalEnvelopeJson(
|
|
'{"schema":"portfolio-approval-envelope/v1","schema":"portfolio-approval-envelope/v1"}',
|
|
),
|
|
).toThrow();
|
|
expect(() =>
|
|
parseApprovalEnvelopeJson(
|
|
JSON.stringify({
|
|
...envelope,
|
|
signature: {
|
|
...envelope.signature,
|
|
value: `${envelope.signature.value}=`,
|
|
},
|
|
}),
|
|
),
|
|
).toThrow();
|
|
});
|
|
});
|