@pierre/storage 0.0.3 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/util.ts ADDED
@@ -0,0 +1,62 @@
1
+ export function timingSafeEqual(a: string | Uint8Array, b: string | Uint8Array): boolean {
2
+ const bufferA = typeof a === 'string' ? new TextEncoder().encode(a) : a;
3
+ const bufferB = typeof b === 'string' ? new TextEncoder().encode(b) : b;
4
+
5
+ if (bufferA.length !== bufferB.length) return false;
6
+
7
+ let result = 0;
8
+ for (let i = 0; i < bufferA.length; i++) {
9
+ result |= bufferA[i] ^ bufferB[i];
10
+ }
11
+ return result === 0;
12
+ }
13
+
14
+ export async function getEnvironmentCrypto() {
15
+ if (!globalThis.crypto) {
16
+ const { webcrypto } = await import('node:crypto');
17
+ return webcrypto;
18
+ }
19
+ return globalThis.crypto;
20
+ }
21
+
22
+ export async function createHmac(algorithm: string, secret: string, data: string): Promise<string> {
23
+ if (algorithm !== 'sha256') {
24
+ throw new Error('Only sha256 algorithm is supported');
25
+ }
26
+ if (!secret || secret.length === 0) {
27
+ throw new Error('Secret is required');
28
+ }
29
+
30
+ const crypto = await getEnvironmentCrypto();
31
+ const encoder = new TextEncoder();
32
+ const key = await crypto.subtle.importKey(
33
+ 'raw',
34
+ encoder.encode(secret),
35
+ { name: 'HMAC', hash: 'SHA-256' },
36
+ false,
37
+ ['sign'],
38
+ );
39
+
40
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
41
+ return Array.from(new Uint8Array(signature))
42
+ .map((b) => b.toString(16).padStart(2, '0'))
43
+ .join('');
44
+ }
45
+
46
+ // Keep the legacy async function for backward compatibility
47
+ export async function createHmacAsync(secret: string, data: string): Promise<string> {
48
+ const crypto = await getEnvironmentCrypto();
49
+ const encoder = new TextEncoder();
50
+ const key = await crypto.subtle.importKey(
51
+ 'raw',
52
+ encoder.encode(secret),
53
+ { name: 'HMAC', hash: 'SHA-256' },
54
+ false,
55
+ ['sign'],
56
+ );
57
+
58
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
59
+ return Array.from(new Uint8Array(signature))
60
+ .map((b) => b.toString(16).padStart(2, '0'))
61
+ .join('');
62
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Webhook validation utilities for Pierre Git Storage
3
+ */
4
+
5
+ import type {
6
+ ParsedWebhookSignature,
7
+ WebhookEventPayload,
8
+ WebhookValidationOptions,
9
+ WebhookValidationResult,
10
+ } from './types';
11
+
12
+ import { createHmac, timingSafeEqual } from './util';
13
+
14
+ const DEFAULT_MAX_AGE_SECONDS = 300; // 5 minutes
15
+
16
+ /**
17
+ * Parse the X-Pierre-Signature header
18
+ * Format: t=<timestamp>,sha256=<signature>
19
+ */
20
+ export function parseSignatureHeader(header: string): ParsedWebhookSignature | null {
21
+ if (!header || typeof header !== 'string') {
22
+ return null;
23
+ }
24
+
25
+ let timestamp = '';
26
+ let signature = '';
27
+
28
+ // Split by comma and parse each element
29
+ const elements = header.split(',');
30
+ for (const element of elements) {
31
+ const trimmedElement = element.trim();
32
+ const parts = trimmedElement.split('=', 2);
33
+ if (parts.length !== 2) {
34
+ continue;
35
+ }
36
+
37
+ const [key, value] = parts;
38
+ switch (key) {
39
+ case 't':
40
+ timestamp = value;
41
+ break;
42
+ case 'sha256':
43
+ signature = value;
44
+ break;
45
+ }
46
+ }
47
+
48
+ if (!timestamp || !signature) {
49
+ return null;
50
+ }
51
+
52
+ return { timestamp, signature };
53
+ }
54
+
55
+ /**
56
+ * Validate a webhook signature and timestamp
57
+ *
58
+ * @param payload - The raw webhook payload (request body)
59
+ * @param signatureHeader - The X-Pierre-Signature header value
60
+ * @param secret - The webhook secret for HMAC verification
61
+ * @param options - Validation options
62
+ * @returns Validation result with details
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * const result = await validateWebhookSignature(
67
+ * requestBody,
68
+ * request.headers['x-pierre-signature'],
69
+ * webhookSecret
70
+ * );
71
+ *
72
+ * if (!result.valid) {
73
+ * console.error('Invalid webhook:', result.error);
74
+ * return;
75
+ * }
76
+ * ```
77
+ */
78
+ export async function validateWebhookSignature(
79
+ payload: string | Buffer,
80
+ signatureHeader: string,
81
+ secret: string,
82
+ options: WebhookValidationOptions = {},
83
+ ): Promise<WebhookValidationResult> {
84
+ if (!secret || secret.length === 0) {
85
+ return {
86
+ valid: false,
87
+ error: 'Empty secret is not allowed',
88
+ };
89
+ }
90
+
91
+ // Parse the signature header
92
+ const parsed = parseSignatureHeader(signatureHeader);
93
+ if (!parsed) {
94
+ return {
95
+ valid: false,
96
+ error: 'Invalid signature header format',
97
+ };
98
+ }
99
+
100
+ // Parse timestamp
101
+ const timestamp = Number.parseInt(parsed.timestamp, 10);
102
+ if (isNaN(timestamp)) {
103
+ return {
104
+ valid: false,
105
+ error: 'Invalid timestamp in signature',
106
+ };
107
+ }
108
+
109
+ // Validate timestamp age (prevent replay attacks)
110
+ const maxAge = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
111
+ if (maxAge > 0) {
112
+ const now = Math.floor(Date.now() / 1000);
113
+ const age = now - timestamp;
114
+
115
+ if (age > maxAge) {
116
+ return {
117
+ valid: false,
118
+ error: `Webhook timestamp too old (${age} seconds)`,
119
+ timestamp,
120
+ };
121
+ }
122
+
123
+ // Also reject timestamps from the future (clock skew tolerance of 60 seconds)
124
+ if (age < -60) {
125
+ return {
126
+ valid: false,
127
+ error: 'Webhook timestamp is in the future',
128
+ timestamp,
129
+ };
130
+ }
131
+ }
132
+
133
+ // Convert payload to string if it's a Buffer
134
+ const payloadStr = typeof payload === 'string' ? payload : payload.toString('utf8');
135
+
136
+ // Compute expected signature
137
+ // Format: HMAC-SHA256(secret, timestamp + "." + payload)
138
+ const signedData = `${parsed.timestamp}.${payloadStr}`;
139
+ const expectedSignature = await createHmac('sha256', secret, signedData);
140
+
141
+ // Compare signatures using constant-time comparison
142
+ const expectedBuffer = Buffer.from(expectedSignature);
143
+ const actualBuffer = Buffer.from(parsed.signature);
144
+
145
+ // Ensure both buffers are the same length for timing-safe comparison
146
+ if (expectedBuffer.length !== actualBuffer.length) {
147
+ return {
148
+ valid: false,
149
+ error: 'Invalid signature',
150
+ timestamp,
151
+ };
152
+ }
153
+
154
+ const signaturesMatch = timingSafeEqual(expectedBuffer, actualBuffer);
155
+ if (!signaturesMatch) {
156
+ return {
157
+ valid: false,
158
+ error: 'Invalid signature',
159
+ timestamp,
160
+ };
161
+ }
162
+
163
+ return {
164
+ valid: true,
165
+ timestamp,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Validate a webhook request
171
+ *
172
+ * This is a convenience function that validates the signature and parses the payload.
173
+ *
174
+ * @param payload - The raw webhook payload (request body)
175
+ * @param headers - The request headers (must include x-pierre-signature and x-pierre-event)
176
+ * @param secret - The webhook secret for HMAC verification
177
+ * @param options - Validation options
178
+ * @returns The parsed webhook payload if valid, or validation error
179
+ *
180
+ * @example
181
+ * ```typescript
182
+ * const result = await validateWebhook(
183
+ * request.body,
184
+ * request.headers,
185
+ * process.env.WEBHOOK_SECRET
186
+ * );
187
+ *
188
+ * if (!result.valid) {
189
+ * return new Response('Invalid webhook', { status: 401 });
190
+ * }
191
+ *
192
+ * // Type-safe access to the webhook payload
193
+ * console.log('Push event:', result.payload);
194
+ * ```
195
+ */
196
+ export async function validateWebhook(
197
+ payload: string | Buffer,
198
+ headers: Record<string, string | string[] | undefined>,
199
+ secret: string,
200
+ options: WebhookValidationOptions = {},
201
+ ): Promise<WebhookValidationResult & { payload?: WebhookEventPayload }> {
202
+ // Get signature header
203
+ const signatureHeader = headers['x-pierre-signature'] || headers['X-Pierre-Signature'];
204
+ if (!signatureHeader || Array.isArray(signatureHeader)) {
205
+ return {
206
+ valid: false,
207
+ error: 'Missing or invalid X-Pierre-Signature header',
208
+ };
209
+ }
210
+
211
+ // Get event type header
212
+ const eventType = headers['x-pierre-event'] || headers['X-Pierre-Event'];
213
+ if (!eventType || Array.isArray(eventType)) {
214
+ return {
215
+ valid: false,
216
+ error: 'Missing or invalid X-Pierre-Event header',
217
+ };
218
+ }
219
+
220
+ // Validate signature
221
+ const validationResult = await validateWebhookSignature(
222
+ payload,
223
+ signatureHeader,
224
+ secret,
225
+ options,
226
+ );
227
+
228
+ if (!validationResult.valid) {
229
+ return validationResult;
230
+ }
231
+
232
+ // Parse payload
233
+ const payloadStr = typeof payload === 'string' ? payload : payload.toString('utf8');
234
+ let parsedPayload: WebhookEventPayload;
235
+ try {
236
+ parsedPayload = JSON.parse(payloadStr);
237
+ } catch {
238
+ return {
239
+ valid: false,
240
+ error: 'Invalid JSON payload',
241
+ timestamp: validationResult.timestamp,
242
+ };
243
+ }
244
+
245
+ return {
246
+ valid: true,
247
+ eventType,
248
+ timestamp: validationResult.timestamp,
249
+ payload: parsedPayload,
250
+ };
251
+ }