@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/README.md +170 -0
- package/dist/index.cjs +424 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +271 -8
- package/dist/index.d.ts +271 -8
- package/dist/index.js +418 -29
- package/dist/index.js.map +1 -1
- package/package.json +37 -37
- package/src/fetch.ts +71 -0
- package/src/index.ts +273 -53
- package/src/types.ts +237 -2
- package/src/util.ts +62 -0
- package/src/webhook.ts +251 -0
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
|
+
}
|