Converted validate module.

This commit is contained in:
Jay
2025-10-24 13:25:00 -04:00
parent 268f411633
commit 163589f37c
7 changed files with 389 additions and 8 deletions

11
src/constants.ts Normal file
View File

@@ -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}$/;

11
src/crypto_init.ts Normal file
View File

@@ -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;

View File

@@ -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. * Event tag contains fewer than two elements.
*/ */

View File

@@ -1,10 +1,9 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { HEX_64_PATTERN } from "./constants";
import { Keys } from "./keys"; import { Keys } from "./keys";
import { testPK, testSK } from "./util.test"; import { testPK, testSK } from "./util.test";
const HEX_64_PATTERN = /^[a-f0-9]{64}$/;
describe("Keys.generatePrivate", () => { describe("Keys.generatePrivate", () => {
test("returns 64 hex characters", () => { test("returns 64 hex characters", () => {
const privateKey = Keys.generatePrivateKey(); const privateKey = Keys.generatePrivateKey();

View File

@@ -1,11 +1,8 @@
import { hmac } from "@noble/hashes/hmac.js";
import { sha256 } from "@noble/hashes/sha2.js"; import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { hashes as secp_hashes } from "@noble/secp256k1";
import { schnorr } from "@noble/secp256k1"; import { schnorr } from "@noble/secp256k1";
secp_hashes.hmacSha256 = (key, msg) => hmac(sha256, key, msg); import "./crypto_init";
secp_hashes.sha256 = sha256;
/** /**
* Generates a Schnorr signature for the given event ID using the provided private key. * Generates a Schnorr signature for the given event ID using the provided private key.

View File

@@ -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();
});
});

View File

@@ -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,
};