Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/__tests__/documentAttestation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,22 @@ describe("the chain", () => {
expect(result.ok).toBe(true);
});
});

describe("an untrusted key id", () => {
it("is unknown when it names a property every object inherits", () => {
// `publicKeysById` is a plain object built from request JSON, so a lookup
// by "constructor" must not find Object.prototype's function and report a
// bad signature — the key is simply not one the caller recognises.
for (const keyId of ["constructor", "toString", "__proto__"]) {
const [record] = chain(1);
const result = verifyAttestationChain(
[{ ...record!, publicKeyId: keyId }],
KEYS,
);
expect(result.ok).toBe(false);
expect(result.errors).toEqual([
`sequence 1: signed by unknown key "${keyId}".`,
]);
}
});
});
81 changes: 81 additions & 0 deletions src/__tests__/documentSignoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@/lib/documents/payload";
import {
PROOF_FORMAT,
PROOF_LIMITS,
VERIFICATION_INSTRUCTIONS,
parseProofPackage,
proofPackageProblem,
Expand Down Expand Up @@ -444,3 +445,83 @@ describe("parseProofPackage", () => {
expect(proofPackageProblem([])).toMatch(/no version or policy/i);
});
});

/**
* `verifyProof` is a public endpoint, so the gate has to stop every package the
* verifier would otherwise throw on — a wrong type in any field it reads must
* be named as a problem, never surface as a TypeError and a 500.
*/
describe("proofPackageProblem on fields of the wrong type", () => {
const valid = () => makeProof([makeReview(SIGNER_A)]);

it("passes a well-formed package, with or without the optional review fields", () => {
expect(proofPackageProblem(valid())).toBeNull();
const withParty = valid();
withParty.reviews[0] = { ...withParty.reviews[0]!, partyId: "p1", comment: "ok" };
expect(proofPackageProblem(withParty)).toBeNull();
});

it("says in a sentence what is wrong, for the paste box on /verify", () => {
const pkg = valid() as unknown as Record<string, any>;
pkg.reviews = null;
expect(proofPackageProblem(pkg)).toBe(
"That proof package is malformed: proof.reviews is missing or too long.",
);
});

it.each<[string, (p: Record<string, any>) => void, RegExp]>([
["version is a string", (p) => (p.version = "v1"), /proof\.version/],
["contentHash is a number", (p) => (p.version.contentHash = 5), /proof\.version/],
["versionNumber is a string", (p) => (p.version.versionNumber = "1"), /proof\.version/],
["document is missing", (p) => delete p.document, /proof\.document/],
["policy is a string", (p) => (p.policy = "2-of-3"), /proof\.policy/],
["requiredSigners is a string", (p) => (p.policy.requiredSigners = "2"), /proof\.policy/],
["a signer address is a number", (p) => (p.policy.signersAddresses = [1]), /proof\.policy/],
["reviews is null", (p) => (p.reviews = null), /proof\.reviews/],
["a review is null", (p) => (p.reviews = [null]), /review is malformed/],
["a review payload is an object", (p) => (p.reviews[0].payload = {}), /review is malformed/],
["a review comment is an object", (p) => (p.reviews[0].comment = { toString: 1 }), /review is malformed/],
])("names the problem when %s", (_, mutate, problem) => {
const pkg = valid() as unknown as Record<string, any>;
mutate(pkg);
expect(proofPackageProblem(pkg)).toMatch(problem);
});

it("refuses more reviews than any wallet could produce", () => {
// Each review costs a CIP-8 check on the server; the cap bounds that work.
const review = makeReview(SIGNER_A);
const padded = makeProof(
Array.from({ length: PROOF_LIMITS.reviews + 1 }, () => review),
);
expect(proofPackageProblem(padded)).toMatch(/too long/);
});
});

describe("verifyProofPackage on a signed payload of the wrong shape", () => {
// The payload string is what the signer signed, so it is attacker-chosen
// too. Valid, canonical JSON that is not a sign-off payload has to be an
// invalid review, not an exception.
it.each(["null", "42", "[]", '"approve"'])(
"reports %s as not a JSON object",
async (payload) => {
const review = { ...makeReview(SIGNER_A), payload };
const result = await verifyProofPackage(makeProof([review], 1), {
checkSignature: acceptAll,
});
expect(result.valid).toBe(false);
expect(result.reviews[0]!.errors).toContain("payload is not a JSON object");
},
);

it("fails the binding when the signed contentHash is not a string", async () => {
const good = makeReview(SIGNER_A);
const tampered = { ...JSON.parse(good.payload), contentHash: 5 };
const review = { ...good, payload: canonicalize(tampered) };
const result = await verifyProofPackage(makeProof([review], 1), {
checkSignature: acceptAll,
});
expect(result.valid).toBe(false);
expect(result.reviews[0]!.payloadBindsToVersion).toBe(false);
expect(result.reviews[0]!.errors.join(" ")).toMatch(/payload\.contentHash/);
});
});
179 changes: 179 additions & 0 deletions src/__tests__/trpc/documentVerifyEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { generateKeyPairSync } from "crypto";
import { beforeAll, describe, expect, it, jest } from "@jest/globals";
import { TRPCError } from "@trpc/server";

import { makeAnonymousCtx } from "./helpers";

/**
* The public verify endpoints — `verifyProof` and `verifyAttestationChain` —
* need no account and read no database, so anyone can send them anything. The
* contract under test: input the verifier cannot read is a 400 that says so,
* never a TypeError surfacing as a 500, and an honest chain still verifies.
*
* No database is touched, so these run with or without DATABASE_URL.
*/

jest.mock("superjson", () => ({
__esModule: true,
default: {
serialize: (value: unknown) => value,
deserialize: (value: unknown) => value,
},
}));

jest.mock("@/server/auth", () => ({
__esModule: true,
getServerAuthSession: jest.fn(),
}));

let createCaller: typeof import("@/server/api/root").createCaller;
let attestation: typeof import("@/lib/documents/attestation");
let AUDIT_LIMITS: typeof import("@/lib/documents/audit").AUDIT_LIMITS;
let canonicalize: typeof import("@/lib/documents/payload").canonicalize;

beforeAll(async () => {
({ createCaller } = await import("@/server/api/root"));
attestation = await import("@/lib/documents/attestation");
({ AUDIT_LIMITS } = await import("@/lib/documents/audit"));
({ canonicalize } = await import("@/lib/documents/payload"));
});

// A fresh IP per caller keeps the public rate limiter out of the picture.
const documents = () => createCaller(makeAnonymousCtx() as never).document;

async function codeOf(call: Promise<unknown>): Promise<string> {
try {
await call;
return "OK";
} catch (error) {
return error instanceof TRPCError ? error.code : "THREW";
}
}

function signedChain(n: number) {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const spkiHex = publicKey
.export({ type: "spki", format: "der" })
.toString("hex");
const keyId = attestation.attestationKeyId(spkiHex);
const chain = [];
let prev = attestation.GENESIS_PREV;
for (let i = 1; i <= n; i++) {
const payload = attestation.buildAttestationPayload({
attestedAt: new Date(Date.UTC(2026, 0, i, 12)),
contentHash: "a".repeat(64),
documentId: "doc_1",
prevAttestationHash: prev,
sequence: i,
versionId: `ver_${i}`,
versionNumber: i,
walletId: "wallet_1",
});
chain.push({
payload,
signature: attestation.signAttestation(payload, privateKey),
publicKeyId: keyId,
});
prev = attestation.attestationHash(payload);
}
return { chain, publicKeys: { [keyId]: spkiHex } };
}

describe("verifyAttestationChain", () => {
it("verifies an honest chain", async () => {
const { chain, publicKeys } = signedChain(2);
const result = await documents().verifyAttestationChain({
chain,
publicKeys,
});
expect(result.errors).toEqual([]);
expect(result.ok).toBe(true);
});

it("does not trim an extra payload field back into a valid signature", async () => {
// The payload reaches the verifier exactly as sent: a field the platform
// never signed must break the signature, not be stripped by validation.
const { chain, publicKeys } = signedChain(1);
const padded = [{ ...chain[0]!, payload: { ...chain[0]!.payload, note: "x" } }];
const result = await documents().verifyAttestationChain({
chain: padded,
publicKeys,
});
expect(result.ok).toBe(false);
expect(result.errors).toContain("sequence 1: signature does not verify.");
});

it.each<[string, unknown]>([
["a null payload", null],
["a string payload", "payload"],
["an array payload", []],
["a missing payload", undefined],
])("answers %s with BAD_REQUEST", async (_, payload) => {
const record = { payload, signature: "", publicKeyId: "" };
expect(
await codeOf(
documents().verifyAttestationChain({ chain: [record] } as never),
),
).toBe("BAD_REQUEST");
});

it("refuses a chain longer than an audit package may carry", async () => {
// Every record costs an Ed25519 verify on a route with no account behind it.
const { chain } = signedChain(1);
const long = Array.from({ length: AUDIT_LIMITS.chain + 1 }, () => chain[0]!);
expect(
await codeOf(documents().verifyAttestationChain({ chain: long })),
).toBe("BAD_REQUEST");
});
});

describe("verifyProof", () => {
it.each<[string, unknown]>([
["a string version", { version: "v1", policy: "p" }],
["null reviews", { version: {}, policy: {}, reviews: null }],
[
"a numeric content hash",
{
document: { id: "d", walletId: "w" },
version: { id: "v", versionNumber: 1, contentHash: 5 },
policy: { requiredSigners: 1, walletPolicyHash: "h", signersAddresses: [] },
reviews: [],
},
],
])("answers %s with BAD_REQUEST", async (_, proof) => {
expect(await codeOf(documents().verifyProof({ proof }))).toBe(
"BAD_REQUEST",
);
});

it("answers BAD_REQUEST when the verifier itself throws", async () => {
// The shape check cannot see inside the signed payload string. A field
// there that cannot even be printed in an error message makes the
// verifier throw; that must still come back as a 400.
const proof = {
document: { id: "d", walletId: "w" },
version: { id: "v", versionNumber: 1, contentHash: "a".repeat(64) },
policy: {
requiredSigners: 1,
walletPolicyHash: "h",
signersAddresses: ["addr_a"],
},
reviews: [
{
signerAddress: "addr_a",
action: "approve",
payload: canonicalize({ documentId: { toString: 1 } }),
signature: "s",
signatureKey: "k",
signedAt: "2026-01-01T00:00:00.000Z",
},
],
};
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
expect(await codeOf(documents().verifyProof({ proof }))).toBe(
"BAD_REQUEST",
);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
9 changes: 8 additions & 1 deletion src/lib/documents/attestation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,14 @@ export function verifyAttestationChain(
previousAt = attestedAt;
}

const publicKey = publicKeysById[record.publicKeyId];
// Own keys only: an id of "constructor" must read as unknown, not as
// Object.prototype's function.
const publicKey = Object.prototype.hasOwnProperty.call(
publicKeysById,
record.publicKeyId,
)
? publicKeysById[record.publicKeyId]
: undefined;
if (!publicKey) {
errors.push(`${at}: signed by unknown key "${record.publicKeyId}".`);
} else if (!verifyAttestation(payload, record.signature, publicKey)) {
Expand Down
52 changes: 5 additions & 47 deletions src/lib/documents/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import {
} from "./attestation";
import { normalizeContentHash } from "./payload";
import {
PROOF_LIMITS,
buildProofPackage,
proofShapeProblem,
verifyProofPackage,
type CheckSignatureFn,
type ProofPackage,
Expand Down Expand Up @@ -584,7 +586,7 @@ export async function verifyAuditPackage(
/** Caps for a package arriving at a public endpoint. Real documents sit far below. */
export const AUDIT_LIMITS = {
versions: 500,
reviewsPerVersion: 200,
reviewsPerVersion: PROOF_LIMITS.reviews,
chain: 500,
events: 20_000,
} as const;
Expand All @@ -596,52 +598,8 @@ const isInt = (v: unknown): v is number => Number.isInteger(v);
const isNullableString = (v: unknown) => v === null || isString(v);

function proofProblem(proof: unknown, where: string): string | null {
if (!isObject(proof)) return `${where}: proof is not an object`;
const { document, version, policy, reviews } = proof;
if (
!isObject(document) ||
!isString(document.id) ||
!isString(document.walletId)
) {
return `${where}: proof.document is malformed`;
}
if (
!isObject(version) ||
!isString(version.id) ||
!isInt(version.versionNumber) ||
!isString(version.contentHash)
) {
return `${where}: proof.version is malformed`;
}
if (
!isObject(policy) ||
!isInt(policy.requiredSigners) ||
!isString(policy.walletPolicyHash) ||
!Array.isArray(policy.signersAddresses) ||
!policy.signersAddresses.every(isString)
) {
return `${where}: proof.policy is malformed`;
}
if (
!Array.isArray(reviews) ||
reviews.length > AUDIT_LIMITS.reviewsPerVersion
) {
return `${where}: proof.reviews is missing or too long`;
}
for (const r of reviews) {
if (
!isObject(r) ||
!isString(r.signerAddress) ||
!isString(r.payload) ||
!isString(r.signature) ||
!isString(r.signatureKey) ||
!isString(r.signedAt) ||
!isString(r.action)
) {
return `${where}: a review is malformed`;
}
}
return null;
const problem = proofShapeProblem(proof);
return problem === null ? null : `${where}: ${problem}`;
}

/**
Expand Down
Loading
Loading