@durable-streams/server 0.3.2 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Node.js reference server implementation for Durable Streams",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
@@ -40,14 +40,14 @@
40
40
  "@neophi/sieve-cache": "^1.0.0",
41
41
  "lmdb": "^3.3.0",
42
42
  "@durable-streams/client": "0.2.4",
43
- "@durable-streams/state": "0.2.6"
43
+ "@durable-streams/state": "0.2.7"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.0.0",
47
47
  "tsdown": "^0.9.0",
48
48
  "typescript": "^5.0.0",
49
49
  "vitest": "^4.0.0",
50
- "@durable-streams/server-conformance-tests": "0.3.1"
50
+ "@durable-streams/server-conformance-tests": "0.3.2"
51
51
  },
52
52
  "files": [
53
53
  "dist",
package/src/crypto.ts ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Cryptographic utilities for webhook signatures and callback tokens.
3
+ */
4
+
5
+ import {
6
+ createHash,
7
+ createHmac,
8
+ createPublicKey,
9
+ generateKeyPairSync,
10
+ randomBytes,
11
+ sign,
12
+ timingSafeEqual,
13
+ verify as verifySignature,
14
+ } from "node:crypto"
15
+ import type { JsonWebKey as NodeJsonWebKey } from "node:crypto"
16
+
17
+ export interface WebhookPublicJwk {
18
+ kty: `OKP`
19
+ crv: `Ed25519`
20
+ x: string
21
+ kid: string
22
+ use: `sig`
23
+ alg: `EdDSA`
24
+ }
25
+
26
+ export interface WebhookJwks {
27
+ keys: Array<WebhookPublicJwk>
28
+ }
29
+
30
+ /**
31
+ * Generate a unique wake ID.
32
+ */
33
+ export function generateWakeId(): string {
34
+ return `w_${randomBytes(12).toString(`hex`)}`
35
+ }
36
+
37
+ const WEBHOOK_KEYPAIR = generateKeyPairSync(`ed25519`)
38
+ const WEBHOOK_PUBLIC_JWK = buildWebhookPublicJwk()
39
+
40
+ function buildWebhookPublicJwk(): WebhookPublicJwk {
41
+ const exported = WEBHOOK_KEYPAIR.publicKey.export({ format: `jwk` }) as {
42
+ kty?: string
43
+ crv?: string
44
+ x?: string
45
+ }
46
+
47
+ if (exported.kty !== `OKP` || exported.crv !== `Ed25519` || !exported.x) {
48
+ throw new Error(`Failed to export Ed25519 webhook signing key`)
49
+ }
50
+
51
+ const thumbprintInput = JSON.stringify({
52
+ crv: exported.crv,
53
+ kty: exported.kty,
54
+ x: exported.x,
55
+ })
56
+ const kid = `ds_${createHash(`sha256`)
57
+ .update(thumbprintInput)
58
+ .digest(`base64url`)}`
59
+
60
+ return {
61
+ kty: `OKP`,
62
+ crv: `Ed25519`,
63
+ x: exported.x,
64
+ kid,
65
+ use: `sig`,
66
+ alg: `EdDSA`,
67
+ }
68
+ }
69
+
70
+ export function getWebhookSigningKeyId(): string {
71
+ return WEBHOOK_PUBLIC_JWK.kid
72
+ }
73
+
74
+ export function getWebhookJwks(): WebhookJwks {
75
+ return {
76
+ keys: [{ ...WEBHOOK_PUBLIC_JWK }],
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Sign a webhook payload for the Webhook-Signature header.
82
+ * Format: t=<timestamp>,kid=<key_id>,ed25519=<base64url_signature>
83
+ */
84
+ export function signWebhookPayload(body: string): string {
85
+ const timestamp = Math.floor(Date.now() / 1000)
86
+ const payload = `${timestamp}.${body}`
87
+ const signature = sign(
88
+ null,
89
+ Buffer.from(payload),
90
+ WEBHOOK_KEYPAIR.privateKey
91
+ ).toString(`base64url`)
92
+ return `t=${timestamp},kid=${WEBHOOK_PUBLIC_JWK.kid},ed25519=${signature}`
93
+ }
94
+
95
+ /**
96
+ * Verify a webhook signature.
97
+ */
98
+ export function verifyWebhookSignature(
99
+ body: string,
100
+ signatureHeader: string,
101
+ jwks: WebhookJwks = getWebhookJwks(),
102
+ toleranceSeconds = 300
103
+ ): boolean {
104
+ const match = signatureHeader.match(
105
+ /^t=(\d+),kid=([^,]+),ed25519=([A-Za-z0-9_-]+)$/
106
+ )
107
+ if (!match) return false
108
+
109
+ const [, timestamp, kid, signature] = match
110
+ const ts = parseInt(timestamp!, 10)
111
+
112
+ const now = Math.floor(Date.now() / 1000)
113
+ if (Math.abs(now - ts) > toleranceSeconds) return false
114
+
115
+ const jwk = jwks.keys.find((key) => key.kid === kid)
116
+ if (!jwk) return false
117
+
118
+ try {
119
+ const publicKey = createPublicKey({
120
+ key: jwk as unknown as NodeJsonWebKey,
121
+ format: `jwk`,
122
+ })
123
+ return verifySignature(
124
+ null,
125
+ Buffer.from(`${timestamp}.${body}`),
126
+ publicKey,
127
+ Buffer.from(signature!, `base64url`)
128
+ )
129
+ } catch {
130
+ return false
131
+ }
132
+ }
133
+
134
+ // Token signing key — generated per server instance
135
+ const TOKEN_KEY = randomBytes(32)
136
+
137
+ /**
138
+ * Generate a signed callback token.
139
+ * Token format: base64url(json_payload).base64url(hmac_signature)
140
+ * Payload: { consumer_id, epoch, exp }
141
+ */
142
+ export function generateCallbackToken(
143
+ consumerId: string,
144
+ epoch: number
145
+ ): string {
146
+ const payload = {
147
+ sub: consumerId,
148
+ epoch,
149
+ exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour TTL
150
+ jti: randomBytes(8).toString(`hex`),
151
+ }
152
+ const payloadStr = Buffer.from(JSON.stringify(payload)).toString(`base64url`)
153
+ const sig = createHmac(`sha256`, TOKEN_KEY)
154
+ .update(payloadStr)
155
+ .digest(`base64url`)
156
+ return `${payloadStr}.${sig}`
157
+ }
158
+
159
+ /** Seconds before expiry at which a token should be refreshed. */
160
+ const TOKEN_REFRESH_THRESHOLD = 300 // 5 minutes
161
+
162
+ /**
163
+ * Validate a callback token. Returns the decoded payload or null.
164
+ * On success, includes `exp` (unix seconds) so callers can decide
165
+ * whether the token needs refreshing.
166
+ */
167
+ export function validateCallbackToken(
168
+ token: string,
169
+ consumerId: string
170
+ ):
171
+ | { valid: true; exp: number; epoch: number }
172
+ | { valid: false; code: `TOKEN_INVALID` | `TOKEN_EXPIRED` } {
173
+ const parts = token.split(`.`)
174
+ if (parts.length !== 2) {
175
+ return { valid: false, code: `TOKEN_INVALID` }
176
+ }
177
+
178
+ const [payloadStr, sig] = parts
179
+
180
+ const expectedSig = createHmac(`sha256`, TOKEN_KEY)
181
+ .update(payloadStr!)
182
+ .digest(`base64url`)
183
+
184
+ try {
185
+ if (!timingSafeEqual(Buffer.from(sig!), Buffer.from(expectedSig))) {
186
+ return { valid: false, code: `TOKEN_INVALID` }
187
+ }
188
+ } catch {
189
+ return { valid: false, code: `TOKEN_INVALID` }
190
+ }
191
+
192
+ let payload: { sub: string; epoch: number; exp: number }
193
+ try {
194
+ payload = JSON.parse(Buffer.from(payloadStr!, `base64url`).toString())
195
+ } catch {
196
+ return { valid: false, code: `TOKEN_INVALID` }
197
+ }
198
+
199
+ if (payload.sub !== consumerId) {
200
+ return { valid: false, code: `TOKEN_INVALID` }
201
+ }
202
+
203
+ const now = Math.floor(Date.now() / 1000)
204
+ if (now > payload.exp) {
205
+ return { valid: false, code: `TOKEN_EXPIRED` }
206
+ }
207
+
208
+ return { valid: true, exp: payload.exp, epoch: payload.epoch }
209
+ }
210
+
211
+ /**
212
+ * Check whether a token is close enough to expiry that it should be refreshed.
213
+ */
214
+ export function tokenNeedsRefresh(exp: number): boolean {
215
+ const now = Math.floor(Date.now() / 1000)
216
+ return exp - now <= TOKEN_REFRESH_THRESHOLD
217
+ }