@ismaelsoilet/synapsejs 1.8.0 → 1.9.0
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 +95 -4
- package/bin/synapse.ts +217 -239
- package/package.json +1 -1
- package/src/compiler/context-bench.ts +7 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +114 -5
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +47 -3
- package/src/core/action-context.ts +51 -14
- package/src/core/config.ts +88 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +3 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-queue.ts +25 -7
- package/src/core/session-token.ts +108 -7
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +60 -217
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +47 -0
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/server.ts +876 -180
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/tsconfig.json +1 -0
|
@@ -24,21 +24,81 @@ export interface SessionClaims {
|
|
|
24
24
|
|
|
25
25
|
export type SessionTokenError = 'INVALID_TOKEN' | 'EXPIRED_TOKEN' | 'TOKEN_REVOKED';
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/** Default bounds for the in-process revocation set. Both are configurable by env. */
|
|
28
|
+
export const DEFAULT_REVOCATION_MAX_ENTRIES = 10_000;
|
|
29
|
+
export const DEFAULT_REVOCATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/** Insertion-ordered signature -> revocation instant. Bounded by cap and TTL. */
|
|
32
|
+
const inMemoryRevokedSignatures = new Map<string, number>();
|
|
33
|
+
|
|
34
|
+
function revocationMaxEntries(): number {
|
|
35
|
+
const raw = Number(process.env.SYNAPSE_REVOCATION_MAX_ENTRIES);
|
|
36
|
+
|
|
37
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_REVOCATION_MAX_ENTRIES;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function revocationTtlMs(): number {
|
|
41
|
+
const raw = Number(process.env.SYNAPSE_REVOCATION_TTL_MS);
|
|
42
|
+
|
|
43
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_REVOCATION_TTL_MS;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drops expired entries and enforces the entry cap. Returns how many were pruned.
|
|
48
|
+
* Called on every write and read, so neither store grows without bound.
|
|
49
|
+
*/
|
|
50
|
+
export function pruneSessionTokenRevocations(now = Date.now()): number {
|
|
51
|
+
const ttl = revocationTtlMs();
|
|
52
|
+
let pruned = 0;
|
|
53
|
+
|
|
54
|
+
if (ttl > 0) {
|
|
55
|
+
for (const [signature, revokedAt] of inMemoryRevokedSignatures) {
|
|
56
|
+
if (now - revokedAt > ttl) {
|
|
57
|
+
inMemoryRevokedSignatures.delete(signature);
|
|
58
|
+
pruned++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const cap = revocationMaxEntries();
|
|
64
|
+
while (inMemoryRevokedSignatures.size > cap) {
|
|
65
|
+
const oldest = inMemoryRevokedSignatures.keys().next().value;
|
|
66
|
+
|
|
67
|
+
if (oldest === undefined) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
inMemoryRevokedSignatures.delete(oldest);
|
|
72
|
+
pruned++;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return pruned;
|
|
76
|
+
}
|
|
28
77
|
|
|
29
78
|
export function revokeSessionToken(token: string): boolean {
|
|
30
79
|
const [, signature] = token.split('.');
|
|
31
80
|
if (!signature) return false;
|
|
32
|
-
inMemoryRevokedSignatures.
|
|
81
|
+
inMemoryRevokedSignatures.set(signature, Date.now());
|
|
82
|
+
pruneSessionTokenRevocations();
|
|
33
83
|
return true;
|
|
34
84
|
}
|
|
35
85
|
|
|
36
86
|
export function isSessionTokenRevoked(token: string): boolean {
|
|
37
87
|
const [, signature] = token.split('.');
|
|
38
88
|
if (!signature) return true;
|
|
89
|
+
|
|
90
|
+
pruneSessionTokenRevocations();
|
|
91
|
+
|
|
39
92
|
return inMemoryRevokedSignatures.has(signature);
|
|
40
93
|
}
|
|
41
94
|
|
|
95
|
+
/** Test/tooling helper: how many signatures the in-process set currently holds. */
|
|
96
|
+
export function sessionTokenRevocationCount(): number {
|
|
97
|
+
pruneSessionTokenRevocations();
|
|
98
|
+
|
|
99
|
+
return inMemoryRevokedSignatures.size;
|
|
100
|
+
}
|
|
101
|
+
|
|
42
102
|
export function clearSessionTokenRevocations(): void {
|
|
43
103
|
inMemoryRevokedSignatures.clear();
|
|
44
104
|
}
|
|
@@ -53,24 +113,64 @@ export async function revokeSessionTokenInDb(db: DatabaseClient, token: string):
|
|
|
53
113
|
await db.query(`
|
|
54
114
|
CREATE TABLE IF NOT EXISTS _synapse_session_blacklist (
|
|
55
115
|
token_signature TEXT PRIMARY KEY,
|
|
56
|
-
revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
116
|
+
revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
117
|
+
revoked_at_ms INTEGER
|
|
57
118
|
);
|
|
58
119
|
`);
|
|
59
120
|
|
|
121
|
+
try {
|
|
122
|
+
await db.query('ALTER TABLE _synapse_session_blacklist ADD COLUMN revoked_at_ms INTEGER;');
|
|
123
|
+
} catch {
|
|
124
|
+
// Column already present, or the engine refuses the ALTER: either way the
|
|
125
|
+
// table carries the revocation instant it needs for pruning.
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const revokedAtMs = Date.now();
|
|
129
|
+
|
|
60
130
|
await db
|
|
61
|
-
.query(
|
|
131
|
+
.query(
|
|
132
|
+
'INSERT INTO _synapse_session_blacklist (token_signature, revoked_at_ms) VALUES ($1, $2) ON CONFLICT DO NOTHING;',
|
|
133
|
+
[signature, revokedAtMs]
|
|
134
|
+
)
|
|
62
135
|
.catch(async () => {
|
|
63
136
|
// SQLite fallback without ON CONFLICT DO NOTHING if needed
|
|
64
137
|
await db
|
|
65
|
-
.query('INSERT OR IGNORE INTO _synapse_session_blacklist (token_signature) VALUES ($1);', [
|
|
138
|
+
.query('INSERT OR IGNORE INTO _synapse_session_blacklist (token_signature, revoked_at_ms) VALUES ($1, $2);', [
|
|
139
|
+
signature,
|
|
140
|
+
revokedAtMs
|
|
141
|
+
])
|
|
66
142
|
.catch(() => {});
|
|
67
143
|
});
|
|
68
144
|
|
|
69
145
|
// Also sync in-memory for instant local lookup
|
|
70
|
-
inMemoryRevokedSignatures.
|
|
146
|
+
inMemoryRevokedSignatures.set(signature, revokedAtMs);
|
|
147
|
+
pruneSessionTokenRevocations();
|
|
71
148
|
return true;
|
|
72
149
|
}
|
|
73
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Deletes persisted revocations older than the retention window. Returns how many
|
|
153
|
+
* rows were removed; SQLite and PostgreSQL both compare the epoch-millisecond
|
|
154
|
+
* column, so pruning does not depend on either dialect's date functions.
|
|
155
|
+
*/
|
|
156
|
+
export async function pruneSessionTokenRevocationsInDb(db: DatabaseClient, ttlMs = revocationTtlMs()): Promise<number> {
|
|
157
|
+
if (ttlMs <= 0) {
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const cutoff = Date.now() - ttlMs;
|
|
163
|
+
const rows = await db.query<{ token_signature: string }>(
|
|
164
|
+
'DELETE FROM _synapse_session_blacklist WHERE revoked_at_ms IS NOT NULL AND revoked_at_ms < $1 RETURNING token_signature;',
|
|
165
|
+
[cutoff]
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return Array.isArray(rows) ? rows.length : 0;
|
|
169
|
+
} catch {
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
74
174
|
/**
|
|
75
175
|
* Checks whether a token signature exists in the database blacklist table.
|
|
76
176
|
*/
|
|
@@ -85,7 +185,8 @@ export async function isSessionTokenRevokedInDb(db: DatabaseClient, token: strin
|
|
|
85
185
|
signature
|
|
86
186
|
]);
|
|
87
187
|
if (rows && rows.length > 0) {
|
|
88
|
-
inMemoryRevokedSignatures.
|
|
188
|
+
inMemoryRevokedSignatures.set(signature, Date.now());
|
|
189
|
+
pruneSessionTokenRevocations();
|
|
89
190
|
return true;
|
|
90
191
|
}
|
|
91
192
|
} catch {
|
package/src/core/storage.ts
CHANGED
|
@@ -169,6 +169,43 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
169
169
|
return `${this.endpoint}/${cleanKey}`;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Builds the SigV4 `Authorization` header for one request. Shared by every
|
|
174
|
+
* mutating operation — upload, delete — so no method reaches the service
|
|
175
|
+
* unauthenticated because its own signing was forgotten.
|
|
176
|
+
*/
|
|
177
|
+
private buildAuthorization(
|
|
178
|
+
method: string,
|
|
179
|
+
url: URL,
|
|
180
|
+
headers: Record<string, string>,
|
|
181
|
+
payloadHash: string,
|
|
182
|
+
service = 's3'
|
|
183
|
+
): string | null {
|
|
184
|
+
if (!this.accessKeyId || !this.secretAccessKey) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const amzDate = headers['x-amz-date'];
|
|
189
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
190
|
+
const sortedKeys = Object.keys(headers).sort();
|
|
191
|
+
const canonicalHeaders = sortedKeys.map((k) => `${k.toLowerCase()}:${headers[k].trim()}\n`).join('');
|
|
192
|
+
const signedHeaders = sortedKeys.map((k) => k.toLowerCase()).join(';');
|
|
193
|
+
|
|
194
|
+
const canonicalRequest = [method, url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n');
|
|
195
|
+
|
|
196
|
+
const credentialScope = `${dateStamp}/${this.region}/${service}/aws4_request`;
|
|
197
|
+
const hashedCanonical = crypto.createHash('sha256').update(canonicalRequest).digest('hex');
|
|
198
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, hashedCanonical].join('\n');
|
|
199
|
+
|
|
200
|
+
const kDate = crypto.createHmac('sha256', `AWS4${this.secretAccessKey}`).update(dateStamp).digest();
|
|
201
|
+
const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest();
|
|
202
|
+
const kService = crypto.createHmac('sha256', kRegion).update(service).digest();
|
|
203
|
+
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
|
204
|
+
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
205
|
+
|
|
206
|
+
return `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
172
209
|
async upload(
|
|
173
210
|
key: string,
|
|
174
211
|
data: Uint8Array | ArrayBuffer | string,
|
|
@@ -181,7 +218,6 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
181
218
|
|
|
182
219
|
const now = new Date();
|
|
183
220
|
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
184
|
-
const dateStamp = amzDate.slice(0, 8);
|
|
185
221
|
const contentSha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
186
222
|
|
|
187
223
|
const headers: Record<string, string> = {
|
|
@@ -192,24 +228,10 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
192
228
|
host: url.host
|
|
193
229
|
};
|
|
194
230
|
|
|
195
|
-
|
|
196
|
-
const sortedKeys = Object.keys(headers).sort();
|
|
197
|
-
const canonicalHeaders = sortedKeys.map((k) => `${k.toLowerCase()}:${headers[k].trim()}\n`).join('');
|
|
198
|
-
const signedHeaders = sortedKeys.map((k) => k.toLowerCase()).join(';');
|
|
199
|
-
|
|
200
|
-
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, contentSha256].join('\n');
|
|
231
|
+
const authorization = this.buildAuthorization('PUT', url, headers, contentSha256);
|
|
201
232
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, hashedCanonical].join('\n');
|
|
205
|
-
|
|
206
|
-
const kDate = crypto.createHmac('sha256', `AWS4${this.secretAccessKey}`).update(dateStamp).digest();
|
|
207
|
-
const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest();
|
|
208
|
-
const kService = crypto.createHmac('sha256', kRegion).update('s3').digest();
|
|
209
|
-
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
|
210
|
-
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
211
|
-
|
|
212
|
-
headers.Authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
233
|
+
if (authorization) {
|
|
234
|
+
headers.Authorization = authorization;
|
|
213
235
|
}
|
|
214
236
|
|
|
215
237
|
const response = await fetch(targetUrl, {
|
|
@@ -281,7 +303,26 @@ export class S3StorageAdapter implements StorageClient {
|
|
|
281
303
|
try {
|
|
282
304
|
const cleanKey = sanitizeStorageKey(key);
|
|
283
305
|
const targetUrl = this.getUrl(cleanKey);
|
|
284
|
-
const
|
|
306
|
+
const url = new URL(targetUrl);
|
|
307
|
+
|
|
308
|
+
// Signed with the same routine as upload(): an unauthenticated DELETE is
|
|
309
|
+
// either silently rejected by the service or, against a public-write bucket,
|
|
310
|
+
// an anonymous destructive request.
|
|
311
|
+
const headers: Record<string, string> = { host: url.host };
|
|
312
|
+
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
313
|
+
const payloadHash = crypto.createHash('sha256').update('').digest('hex');
|
|
314
|
+
|
|
315
|
+
headers['x-amz-content-sha256'] = payloadHash;
|
|
316
|
+
headers['x-amz-date'] = amzDate;
|
|
317
|
+
|
|
318
|
+
const authorization = this.buildAuthorization('DELETE', url, headers, payloadHash);
|
|
319
|
+
|
|
320
|
+
if (authorization) {
|
|
321
|
+
headers.Authorization = authorization;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const res = await fetch(targetUrl, { method: 'DELETE', headers });
|
|
325
|
+
|
|
285
326
|
return res.status === 200 || res.status === 204;
|
|
286
327
|
} catch {
|
|
287
328
|
return false;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Declared Realtime Topics
|
|
3
|
+
*
|
|
4
|
+
* A topic is a name a slice deliberately declares together with who may read it and
|
|
5
|
+
* which feature may publish into it. Inferring authorization from a naming convention
|
|
6
|
+
* is not a security control, so the event-stream gateway refuses any topic that was
|
|
7
|
+
* never declared — a caller cannot subscribe to a name merely because it can spell it.
|
|
8
|
+
*
|
|
9
|
+
* Topics are namespaced by tenant: two tenants that use the same name are two
|
|
10
|
+
* independent topics that never observe each other's broadcasts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface TopicDeclaration {
|
|
14
|
+
/** The topic name as it appears in the URL. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** The slice key (`<domain>/<name>`) permitted to publish into this topic. */
|
|
17
|
+
owner: string;
|
|
18
|
+
/** When set, the declaration exists only inside that tenant. */
|
|
19
|
+
tenantId?: string;
|
|
20
|
+
/** Roles permitted to subscribe. Empty means "any authenticated session". */
|
|
21
|
+
readRoles?: string[];
|
|
22
|
+
/** A deliberately public topic: reachable without a session. */
|
|
23
|
+
public?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The complete set of realtime rejection codes and the status each is returned with.
|
|
28
|
+
* Both transports draw from this table and the machine contract enumerates it, so a
|
|
29
|
+
* code cannot be invented at a refusal site.
|
|
30
|
+
*/
|
|
31
|
+
export const REALTIME_REJECTIONS = {
|
|
32
|
+
UNAUTHENTICATED: 401,
|
|
33
|
+
TOPIC_NOT_FOUND: 404,
|
|
34
|
+
TOPIC_FORBIDDEN: 403,
|
|
35
|
+
RATE_LIMIT_EXCEEDED: 429,
|
|
36
|
+
REALTIME_LIMIT_EXCEEDED: 429,
|
|
37
|
+
ORIGIN_NOT_ALLOWED: 403
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
export type RealtimeRejectionCode = keyof typeof REALTIME_REJECTIONS;
|
|
41
|
+
|
|
42
|
+
const registry = new Map<string, TopicDeclaration>();
|
|
43
|
+
|
|
44
|
+
function registryKey(name: string, tenantId?: string): string {
|
|
45
|
+
return `${tenantId ?? '*'}:${name}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Declares a topic. Call it at module scope in the slice that owns the topic.
|
|
50
|
+
*/
|
|
51
|
+
export function defineTopic(declaration: TopicDeclaration): TopicDeclaration {
|
|
52
|
+
registry.set(registryKey(declaration.name, declaration.tenantId), declaration);
|
|
53
|
+
|
|
54
|
+
return declaration;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The declaration that governs `name` for a session in `tenantId`: an exact
|
|
59
|
+
* tenant-scoped declaration wins over a global one. A name declared only for another
|
|
60
|
+
* tenant resolves to nothing here, so the refusal cannot disclose that it exists.
|
|
61
|
+
*/
|
|
62
|
+
export function declaredTopic(name: string, tenantId?: string): TopicDeclaration | undefined {
|
|
63
|
+
const scoped = registry.get(registryKey(name, tenantId));
|
|
64
|
+
|
|
65
|
+
if (scoped) {
|
|
66
|
+
return scoped;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return registry.get(registryKey(name));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function declaredTopics(): TopicDeclaration[] {
|
|
73
|
+
return Array.from(registry.values());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The effective, tenant-scoped hub key for a topic. */
|
|
77
|
+
export function scopedTopic(name: string, tenantId?: string): string {
|
|
78
|
+
return `${tenantId || 'global'}:${name}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Test helper: drops every declaration. */
|
|
82
|
+
export function resetDeclaredTopics(): void {
|
|
83
|
+
registry.clear();
|
|
84
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Webhook Signature Verification
|
|
3
|
+
*
|
|
4
|
+
* A webhook endpoint is a public write surface, so the framework ships the two
|
|
5
|
+
* primitives a receiver needs to treat it as one: a constant-time comparison of the
|
|
6
|
+
* provider's signature over the raw body, and a bounded replay window so a captured
|
|
7
|
+
* delivery cannot be replayed. Verifying a signature is not enough on its own — a
|
|
8
|
+
* valid signature stays valid forever unless the receiver also tracks what it has
|
|
9
|
+
* already accepted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as crypto from 'crypto';
|
|
13
|
+
import { Err, Ok, type Result } from './machine-types';
|
|
14
|
+
|
|
15
|
+
export type WebhookSignatureError = 'INVALID_SIGNATURE' | 'TIMESTAMP_OUT_OF_WINDOW' | 'REPLAYED_DELIVERY';
|
|
16
|
+
|
|
17
|
+
/** Compares two strings without leaking their difference through timing. */
|
|
18
|
+
export function constantTimeEquals(left: string, right: string): boolean {
|
|
19
|
+
const a = Buffer.from(left, 'utf-8');
|
|
20
|
+
const b = Buffer.from(right, 'utf-8');
|
|
21
|
+
|
|
22
|
+
if (a.length !== b.length) {
|
|
23
|
+
// Still perform a comparison of equal length so the failure is not timing-visible.
|
|
24
|
+
const padded = Buffer.alloc(a.length);
|
|
25
|
+
crypto.timingSafeEqual(a, padded);
|
|
26
|
+
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return crypto.timingSafeEqual(a, b);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The canonical signed value: `<timestamp>.<raw body>`. */
|
|
34
|
+
export function signedPayload(timestampSeconds: number, payload: Uint8Array): Buffer {
|
|
35
|
+
return Buffer.concat([Buffer.from(`${timestampSeconds}.`, 'utf-8'), Buffer.from(payload)]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** HMAC-SHA256 of `<timestamp>.<body>`, hex encoded — the shape providers send. */
|
|
39
|
+
export function signWebhookPayload(secret: string, timestampSeconds: number, payload: Uint8Array): string {
|
|
40
|
+
return crypto.createHmac('sha256', secret).update(signedPayload(timestampSeconds, payload)).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface VerifyWebhookOptions {
|
|
44
|
+
/** The raw request body, byte for byte — a re-serialized JSON body will not verify. */
|
|
45
|
+
payload: Uint8Array;
|
|
46
|
+
/** The signature the provider sent (hex, `sha256=...` accepted). */
|
|
47
|
+
signature: string;
|
|
48
|
+
secret: string;
|
|
49
|
+
/** The provider's delivery timestamp, in seconds. */
|
|
50
|
+
timestamp: number;
|
|
51
|
+
/** How far from now a delivery may be, in seconds (default: 300). */
|
|
52
|
+
toleranceSeconds?: number;
|
|
53
|
+
/** Injected for tests. */
|
|
54
|
+
nowSeconds?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Verifies the signature and the delivery window. A signature that matches but whose
|
|
59
|
+
* timestamp is outside the tolerance is refused: otherwise a captured delivery stays
|
|
60
|
+
* valid forever.
|
|
61
|
+
*/
|
|
62
|
+
export function verifyWebhookSignature(options: VerifyWebhookOptions): Result<true, WebhookSignatureError> {
|
|
63
|
+
const tolerance = options.toleranceSeconds ?? 300;
|
|
64
|
+
const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
65
|
+
const provided = options.signature.replace(/^sha256=/i, '').trim();
|
|
66
|
+
|
|
67
|
+
if (!Number.isFinite(options.timestamp) || Math.abs(now - options.timestamp) > tolerance) {
|
|
68
|
+
return Err('TIMESTAMP_OUT_OF_WINDOW');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const expected = signWebhookPayload(options.secret, options.timestamp, options.payload);
|
|
72
|
+
|
|
73
|
+
return constantTimeEquals(expected, provided) ? Ok(true) : Err('INVALID_SIGNATURE');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ReplayGuardOptions {
|
|
77
|
+
/** How long a signature stays in the window, in seconds (default: 300). */
|
|
78
|
+
ttlSeconds?: number;
|
|
79
|
+
/** Maximum tracked signatures (default: 10,000). */
|
|
80
|
+
maxEntries?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Remembers which signatures have already been accepted, bounded by a time-to-live
|
|
85
|
+
* and an entry cap so a flood of deliveries cannot grow the store without bound.
|
|
86
|
+
*/
|
|
87
|
+
export class WebhookReplayGuard {
|
|
88
|
+
private readonly ttlSeconds: number;
|
|
89
|
+
private readonly maxEntries: number;
|
|
90
|
+
private accepted = new Map<string, number>();
|
|
91
|
+
|
|
92
|
+
constructor(options: ReplayGuardOptions = {}) {
|
|
93
|
+
this.ttlSeconds = options.ttlSeconds ?? 300;
|
|
94
|
+
this.maxEntries = options.maxEntries ?? 10_000;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when this signature has not been accepted inside the window. */
|
|
98
|
+
isFresh(signature: string, nowSeconds?: number): boolean {
|
|
99
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
100
|
+
this.prune(now);
|
|
101
|
+
|
|
102
|
+
return !this.accepted.has(signature);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Records a signature as accepted. Returns false when it was already inside the window. */
|
|
106
|
+
accept(signature: string, nowSeconds?: number): boolean {
|
|
107
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
108
|
+
this.prune(now);
|
|
109
|
+
|
|
110
|
+
if (this.accepted.has(signature)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
while (this.accepted.size >= this.maxEntries) {
|
|
115
|
+
const oldest = this.accepted.keys().next().value;
|
|
116
|
+
|
|
117
|
+
if (oldest === undefined) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.accepted.delete(oldest);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.accepted.set(signature, now);
|
|
125
|
+
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Drops entries older than the window. Returns how many were removed. */
|
|
130
|
+
prune(nowSeconds?: number): number {
|
|
131
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
132
|
+
let pruned = 0;
|
|
133
|
+
|
|
134
|
+
for (const [signature, acceptedAt] of this.accepted) {
|
|
135
|
+
if (now - acceptedAt > this.ttlSeconds) {
|
|
136
|
+
this.accepted.delete(signature);
|
|
137
|
+
pruned++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return pruned;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get size(): number {
|
|
145
|
+
return this.accepted.size;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
clear(): void {
|
|
149
|
+
this.accepted.clear();
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/index.ts
CHANGED