From 163589f37c3bcf971633cc3f4f0418eb4cd8d516 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 24 Oct 2025 13:25:00 -0400 Subject: [PATCH] Converted validate module. --- src/constants.ts | 11 +++ src/crypto_init.ts | 11 +++ src/errors.ts | 40 ++++++++ src/keys.test.ts | 3 +- src/sign.ts | 5 +- src/validate.test.ts | 226 ++++++++++++++++++++++++++++++++++++++++++- src/validate.ts | 101 +++++++++++++++++++ 7 files changed, 389 insertions(+), 8 deletions(-) create mode 100644 src/constants.ts create mode 100644 src/crypto_init.ts diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..0bf192d --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,11 @@ +/** + * Matches 64-character lowercase hexadecimal strings. + * Used for validating event IDs and cryptographic keys. + */ +export const HEX_64_PATTERN = /^[a-f0-9]{64}$/; + +/** + * Matches 128-character lowercase hexadecimal strings. + * Used for validating signatures. + */ +export const HEX_128_PATTERN = /^[a-f0-9]{128}$/; diff --git a/src/crypto_init.ts b/src/crypto_init.ts new file mode 100644 index 0000000..2127eb0 --- /dev/null +++ b/src/crypto_init.ts @@ -0,0 +1,11 @@ +/** + * Configures @noble/secp256k1 to use synchronous hash functions. + * Required for Schnorr signing and verification operations. + * Must be imported before any secp256k1 operations execute. + */ +import { hmac } from "@noble/hashes/hmac.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { hashes as secp_hashes } from "@noble/secp256k1"; + +secp_hashes.hmacSha256 = (key, msg) => hmac(sha256, key, msg); +secp_hashes.sha256 = sha256; diff --git a/src/errors.ts b/src/errors.ts index a9c1880..6818dfb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,43 @@ +/** + * Public key is not 64 lowercase hex characters. + */ +export class MalformedPubKeyError extends Error { + constructor() { + super("public key must be 64 lowercase hex characters"); + this.name = "MalformedPubKeyError"; + } +} + +/** + * Private key is not 64 lowercase hex characters. + */ +export class MalformedPrivKeyError extends Error { + constructor() { + super("private key must be 64 lowercase hex characters"); + this.name = "MalformedPrivKeyError"; + } +} + +/** + * Event ID is not 64 hex characters. + */ +export class MalformedIDError extends Error { + constructor() { + super("event id must be 64 hex characters"); + this.name = "MalformedIDError"; + } +} + +/** + * Event signature is not 128 hex characters. + */ +export class MalformedSigError extends Error { + constructor() { + super("event signature must be 128 hex characters"); + this.name = "MalformedSigError"; + } +} + /** * Event tag contains fewer than two elements. */ diff --git a/src/keys.test.ts b/src/keys.test.ts index 195b5b8..7aaafbf 100644 --- a/src/keys.test.ts +++ b/src/keys.test.ts @@ -1,10 +1,9 @@ import { describe, expect, test } from "vitest"; +import { HEX_64_PATTERN } from "./constants"; import { Keys } from "./keys"; import { testPK, testSK } from "./util.test"; -const HEX_64_PATTERN = /^[a-f0-9]{64}$/; - describe("Keys.generatePrivate", () => { test("returns 64 hex characters", () => { const privateKey = Keys.generatePrivateKey(); diff --git a/src/sign.ts b/src/sign.ts index 2304535..0089ea2 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -1,11 +1,8 @@ -import { hmac } from "@noble/hashes/hmac.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; -import { hashes as secp_hashes } from "@noble/secp256k1"; import { schnorr } from "@noble/secp256k1"; -secp_hashes.hmacSha256 = (key, msg) => hmac(sha256, key, msg); -secp_hashes.sha256 = sha256; +import "./crypto_init"; /** * Generates a Schnorr signature for the given event ID using the provided private key. diff --git a/src/validate.test.ts b/src/validate.test.ts index 31d1451..4d52d55 100644 --- a/src/validate.test.ts +++ b/src/validate.test.ts @@ -1,3 +1,225 @@ -import { test } from "vitest"; +import { describe, expect, test } from "vitest"; -test("placeholder", () => {}); +import type { EventData } from "./types"; +import { testEvent, testPK } from "./util.test"; +import { EventValidation } from "./validate"; + +interface ValidateEventTestCase { + name: string; + event: EventData; + expectedError: string; +} + +const structureTestCases: ValidateEventTestCase[] = [ + { + name: "empty pubkey", + event: { + ...testEvent, + pubkey: "", + }, + expectedError: "public key must be 64 lowercase hex characters", + }, + { + name: "short pubkey", + event: { + ...testEvent, + pubkey: "abc123", + }, + expectedError: "public key must be 64 lowercase hex characters", + }, + { + name: "long pubkey", + event: { + ...testEvent, + pubkey: + "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48adabc", + }, + expectedError: "public key must be 64 lowercase hex characters", + }, + { + name: "non-hex pubkey", + event: { + ...testEvent, + pubkey: + "zyx-!2e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad", + }, + expectedError: "public key must be 64 lowercase hex characters", + }, + { + name: "uppercase pubkey", + event: { + ...testEvent, + pubkey: + "C7A702E6158744CA03508BBB4C90F9DBB0D6E88FEFBFAA511D5AB24B4E3C48AD", + }, + expectedError: "public key must be 64 lowercase hex characters", + }, + { + name: "empty id", + event: { + ...testEvent, + id: "", + }, + expectedError: "id must be 64 hex characters", + }, + { + name: "short id", + event: { + ...testEvent, + id: "abc123", + }, + expectedError: "id must be 64 hex characters", + }, + { + name: "empty signature", + event: { + ...testEvent, + sig: "", + }, + expectedError: "signature must be 128 hex characters", + }, + { + name: "short signature", + event: { + ...testEvent, + sig: "abc123", + }, + expectedError: "signature must be 128 hex characters", + }, + { + name: "empty tag", + event: { + ...testEvent, + tags: [[]], + }, + expectedError: "tags must contain at least two elements", + }, + { + name: "single element tag", + event: { + ...testEvent, + tags: [["a"]], + }, + expectedError: "tags must contain at least two elements", + }, + { + name: "one good tag, one single element tag", + event: { + ...testEvent, + tags: [["a", "value"], ["b"]], + }, + expectedError: "tags must contain at least two elements", + }, +]; + +describe("EventValidation.validateStructure", () => { + test.each(structureTestCases)("$name", ({ event, expectedError }) => { + expect(() => EventValidation.validateStructure(event)).toThrow( + expectedError, + ); + }); +}); + +describe("EventValidation.validateID", () => { + test("detects ID mismatch", () => { + const event: EventData = { + ...testEvent, + id: "7f661c2a3c1ed67dc959d6cd968d743d5e6e334313df44724bca939e2aa42c9e", + }; + expect(() => EventValidation.validateID(event)).toThrow( + "does not match computed id", + ); + }); +}); + +describe("EventValidation.validateSignature", () => { + test("accepts valid signature", () => { + expect(() => EventValidation.validateSignature(testEvent)).not.toThrow(); + }); + + test("rejects invalid signature", () => { + const event: EventData = { + ...testEvent, + sig: "9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482", + }; + expect(() => EventValidation.validateSignature(event)).toThrow( + "event signature is invalid", + ); + }); +}); + +interface ValidateSignatureTestCase { + name: string; + id: string; + sig: string; + pubkey: string; + expectedError: string | RegExp; +} + +const validateSignatureTestCases: ValidateSignatureTestCase[] = [ + { + name: "bad event id", + id: "badeventid", + sig: testEvent.sig, + pubkey: testEvent.pubkey, + expectedError: /hex string expected.*/, + }, + { + name: "bad event signature", + id: testEvent.id, + sig: "badeventsignature", + pubkey: testEvent.pubkey, + expectedError: /hex string expected.*/, + }, + { + name: "bad public key", + id: testEvent.id, + sig: testEvent.sig, + pubkey: "badpublickey", + expectedError: /hex string expected.*/, + }, + { + name: "malformed event signature", + id: testEvent.id, + sig: "abc123", + pubkey: testEvent.pubkey, + expectedError: /"signature" expected.*/, + }, + { + name: "malformed public key", + id: testEvent.id, + sig: testEvent.sig, + pubkey: "abc123", + expectedError: /"publicKey" expected.*/, + }, +]; + +describe("EventValidation.validateSignature - malformed inputs", () => { + test.each(validateSignatureTestCases)( + "$name", + ({ id, sig, pubkey, expectedError }) => { + const event: EventData = { ...testEvent, id, sig, pubkey }; + expect(() => EventValidation.validateSignature(event)).toThrow( + expectedError, + ); + }, + ); +}); + +describe("EventValidation.validate", () => { + test("validates complete event", () => { + const event: EventData = { + id: "c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400", + pubkey: testPK, + created_at: testEvent.created_at, + kind: testEvent.kind, + tags: [ + ["a", "value"], + ["b", "value", "optional"], + ], + content: "valid event", + sig: "668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14", + }; + expect(() => EventValidation.validate(event)).not.toThrow(); + }); +}); diff --git a/src/validate.ts b/src/validate.ts index e69de29..8b69e4b 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -0,0 +1,101 @@ +import { hexToBytes } from "@noble/hashes/utils.js"; +import { schnorr } from "@noble/secp256k1"; + +import { HEX_64_PATTERN, HEX_128_PATTERN } from "./constants"; +import "./crypto_init"; +import { + FailedIDCompError, + InvalidSigError, + MalformedIDError, + MalformedPubKeyError, + MalformedSigError, + MalformedTagError, + NoEventIDError, +} from "./errors"; +import { EventID } from "./id"; +import type { EventData } from "./types"; + +/** + * Checks event field formats and lengths conform to protocol specification. + * @throws {MalformedPubKeyError} If pubkey is not 64 lowercase hex characters + * @throws {MalformedIDError} If id is not 64 hex characters + * @throws {MalformedSigError} If sig is not 128 hex characters + * @throws {MalformedTagError} If any tag has fewer than 2 elements + */ +function validateStructure(event: EventData): void { + if (!HEX_64_PATTERN.test(event.pubkey)) { + throw new MalformedPubKeyError(); + } + + if (!HEX_64_PATTERN.test(event.id)) { + throw new MalformedIDError(); + } + + if (!HEX_128_PATTERN.test(event.sig)) { + throw new MalformedSigError(); + } + + for (const tag of event.tags) { + if (tag.length < 2) { + throw new MalformedTagError(); + } + } +} + +/** + * Verifies the event ID matches the computed hash of the serialized event. + * @throws {FailedIDCompError} If ID computation fails + * @throws {NoEventIDError} If event.id is empty + * @throws {Error} If computed ID does not match stored ID + */ +function validateID(event: EventData): void { + let computedID: string; + try { + computedID = EventID.getID(event); + } catch (err) { + throw new FailedIDCompError(); + } + + if (event.id === "") { + throw new NoEventIDError(); + } + + if (computedID !== event.id) { + throw new Error( + `event id "${event.id}" does not match computed id "${computedID}"`, + ); + } +} + +/** + * Verifies the cryptographic signature using Schnorr verification. + * @throws {InvalidSigError} If signature verification fails + */ +function validateSignature(event: EventData): void { + const idBytes = hexToBytes(event.id); + const sigBytes = hexToBytes(event.sig); + const pubkeyBytes = hexToBytes(event.pubkey); + + const isValid = schnorr.verify(sigBytes, idBytes, pubkeyBytes); + + if (!isValid) { + throw new InvalidSigError(); + } +} + +/** + * Performs complete event validation: structure, ID, and signature. + * @throws First validation error encountered + */ +function validate(event: EventData): void { + validateStructure(event); + validateID(event); + validateSignature(event); +} + +export const EventValidation = { + validate, + validateStructure, + validateID, + validateSignature, +};