validation.ts raw
1 /**
2 * Validation functions for the Distributed Directory Consensus Protocol
3 *
4 * This module provides validation matching the Go implementation in
5 * pkg/protocol/directory/validation.go
6 */
7
8 import type { IdentityTag } from './types.js';
9 import { TrustLevel, KeyPurpose, ReplicationStatus } from './types.js';
10
11 /**
12 * Validation error class
13 */
14 export class ValidationError extends Error {
15 constructor(message: string) {
16 super(message);
17 this.name = 'ValidationError';
18 }
19 }
20
21 // Regular expressions for validation
22 const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
23 const NPUB_REGEX = /^npub1[0-9a-z]+$/;
24 const WS_URL_REGEX = /^wss?:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?(?:\/.*)?$/;
25
26 /**
27 * Validates that a string is a valid 64-character hex key
28 */
29 export function validateHexKey(key: string): void {
30 if (!HEX_KEY_REGEX.test(key)) {
31 throw new ValidationError('invalid hex key format: must be 64 hex characters');
32 }
33 }
34
35 /**
36 * Validates that a string is a valid npub-encoded public key
37 */
38 export function validateNPub(npub: string): void {
39 if (!NPUB_REGEX.test(npub)) {
40 throw new ValidationError('invalid npub format');
41 }
42
43 // Additional validation would require bech32 decoding
44 // which should be handled by applesauce-core utilities
45 }
46
47 /**
48 * Validates that a string is a valid WebSocket URL
49 */
50 export function validateWebSocketURL(url: string): void {
51 if (!WS_URL_REGEX.test(url)) {
52 throw new ValidationError('invalid WebSocket URL format');
53 }
54
55 try {
56 const parsed = new URL(url);
57
58 if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
59 throw new ValidationError('URL must use ws:// or wss:// scheme');
60 }
61
62 if (!parsed.host) {
63 throw new ValidationError('URL must have a host');
64 }
65
66 // Ensure trailing slash for canonical format
67 if (!url.endsWith('/')) {
68 throw new ValidationError('Canonical WebSocket URL must end with /');
69 }
70 } catch (err) {
71 if (err instanceof ValidationError) {
72 throw err;
73 }
74 throw new ValidationError(`invalid URL: ${err instanceof Error ? err.message : String(err)}`);
75 }
76 }
77
78 /**
79 * Validates a nonce meets minimum security requirements
80 */
81 export function validateNonce(nonce: string): void {
82 const MIN_NONCE_SIZE = 16; // bytes
83
84 if (nonce.length < MIN_NONCE_SIZE * 2) { // hex encoding doubles length
85 throw new ValidationError(`nonce must be at least ${MIN_NONCE_SIZE} bytes (${MIN_NONCE_SIZE * 2} hex characters)`);
86 }
87
88 if (!/^[0-9a-fA-F]+$/.test(nonce)) {
89 throw new ValidationError('nonce must be valid hex');
90 }
91 }
92
93 /**
94 * Validates trust level value
95 */
96 export function validateTrustLevel(level: string): void {
97 if (!Object.values(TrustLevel).includes(level as TrustLevel)) {
98 throw new ValidationError(`invalid trust level: must be one of ${Object.values(TrustLevel).join(', ')}`);
99 }
100 }
101
102 /**
103 * Validates key purpose value
104 */
105 export function validateKeyPurpose(purpose: string): void {
106 if (!Object.values(KeyPurpose).includes(purpose as KeyPurpose)) {
107 throw new ValidationError(`invalid key purpose: must be one of ${Object.values(KeyPurpose).join(', ')}`);
108 }
109 }
110
111 /**
112 * Validates replication status value
113 */
114 export function validateReplicationStatus(status: string): void {
115 if (!Object.values(ReplicationStatus).includes(status as ReplicationStatus)) {
116 throw new ValidationError(`invalid replication status: must be one of ${Object.values(ReplicationStatus).join(', ')}`);
117 }
118 }
119
120 /**
121 * Validates confidence value (must be between 0.0 and 1.0)
122 */
123 export function validateConfidence(confidence: number): void {
124 if (confidence < 0.0 || confidence > 1.0) {
125 throw new ValidationError('confidence must be between 0.0 and 1.0');
126 }
127 }
128
129 /**
130 * Validates an identity tag structure
131 *
132 * Note: This performs structural validation only. Signature verification
133 * requires cryptographic operations and should be done separately.
134 */
135 export function validateIdentityTagStructure(tag: IdentityTag): void {
136 if (!tag.identity) {
137 throw new ValidationError('identity tag must have an identity field');
138 }
139
140 validateHexKey(tag.identity);
141
142 if (!tag.delegate) {
143 throw new ValidationError('identity tag must have a delegate field');
144 }
145
146 validateHexKey(tag.delegate);
147
148 if (!tag.signature) {
149 throw new ValidationError('identity tag must have a signature field');
150 }
151
152 validateHexKey(tag.signature);
153
154 if (tag.relayHint) {
155 validateWebSocketURL(tag.relayHint);
156 }
157 }
158
159 /**
160 * Validates event content is valid JSON
161 */
162 export function validateJSONContent(content: string): void {
163 if (!content || content.trim() === '') {
164 return; // Empty content is valid
165 }
166
167 try {
168 JSON.parse(content);
169 } catch (err) {
170 throw new ValidationError(`invalid JSON content: ${err instanceof Error ? err.message : String(err)}`);
171 }
172 }
173
174 /**
175 * Validates a timestamp is in the past
176 */
177 export function validatePastTimestamp(timestamp: Date | number): void {
178 const now = Date.now();
179 const ts = timestamp instanceof Date ? timestamp.getTime() : timestamp * 1000;
180
181 if (ts > now) {
182 throw new ValidationError('timestamp must be in the past');
183 }
184 }
185
186 /**
187 * Validates a timestamp is in the future
188 */
189 export function validateFutureTimestamp(timestamp: Date | number): void {
190 const now = Date.now();
191 const ts = timestamp instanceof Date ? timestamp.getTime() : timestamp * 1000;
192
193 if (ts <= now) {
194 throw new ValidationError('timestamp must be in the future');
195 }
196 }
197
198 /**
199 * Validates an expiry timestamp (must be in the future if provided)
200 */
201 export function validateExpiry(expiry?: Date | number): void {
202 if (expiry === undefined || expiry === null) {
203 return; // No expiry is valid
204 }
205
206 validateFutureTimestamp(expiry);
207 }
208
209 /**
210 * Validates a BIP32 derivation path
211 */
212 export function validateDerivationPath(path: string): void {
213 // Basic validation - should start with m/ and contain numbers/apostrophes
214 if (!/^m(\/\d+'?)*$/.test(path)) {
215 throw new ValidationError('invalid BIP32 derivation path format');
216 }
217 }
218
219 /**
220 * Validates a key index is non-negative
221 */
222 export function validateKeyIndex(index: number): void {
223 if (!Number.isInteger(index) || index < 0) {
224 throw new ValidationError('key index must be a non-negative integer');
225 }
226 }
227
228 /**
229 * Validates event kinds array is not empty
230 */
231 export function validateEventKinds(kinds: number[]): void {
232 if (!Array.isArray(kinds) || kinds.length === 0) {
233 throw new ValidationError('event kinds array must not be empty');
234 }
235
236 for (const kind of kinds) {
237 if (!Number.isInteger(kind) || kind < 0) {
238 throw new ValidationError(`invalid event kind: ${kind}`);
239 }
240 }
241 }
242
243 /**
244 * Validates authors array contains valid pubkeys
245 */
246 export function validateAuthors(authors: string[]): void {
247 if (!Array.isArray(authors)) {
248 throw new ValidationError('authors must be an array');
249 }
250
251 for (const author of authors) {
252 validateHexKey(author);
253 }
254 }
255
256 /**
257 * Validates limit is positive
258 */
259 export function validateLimit(limit: number): void {
260 if (!Number.isInteger(limit) || limit <= 0) {
261 throw new ValidationError('limit must be a positive integer');
262 }
263 }
264
265