| … | … | @@ -1,9 +1,102 @@ |
| 1 | 1 | import db from './db.json' with { type: 'json'} |
| 2 | 2 | import { serveDir } from 'https://deno.land/std/http/file_server.ts' |
| 3 | 3 | import { apds } from './apds.js' |
| 4 | import { encode, decode } from './lib/base64.js' |
| 4 | 5 | |
| 5 | 6 | await apds.start('apdsv1') |
| 6 | 7 | |
| 8 | const encoder = new TextEncoder() |
| 9 | |
| 10 | const subscriptionsPath = new URL('./subscriptions.json', import.meta.url) |
| 11 | const subscriptionsTmpPath = new URL('./subscriptions.json.tmp', import.meta.url) |
| 12 | |
| 13 | const vapidPath = new URL('./vapid.json', import.meta.url) |
| 14 | const vapidTmpPath = new URL('./vapid.json.tmp', import.meta.url) |
| 15 | |
| 16 | const toBase64Url = (b64) => |
| 17 | b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '') |
| 18 | |
| 19 | const fromBase64Url = (b64url) => { |
| 20 | const b64 = b64url.replaceAll('-', '+').replaceAll('_', '/') |
| 21 | const pad = '='.repeat((4 - (b64.length % 4)) % 4) |
| 22 | return b64 + pad |
| 23 | } |
| 24 | |
| 25 | const base64UrlEncodeBytes = (bytes) => toBase64Url(encode(bytes)) |
| 26 | const base64UrlDecodeBytes = (b64url) => decode(fromBase64Url(b64url)) |
| 27 | |
| 28 | const jsonHeaders = () => { |
| 29 | const header = new Headers() |
| 30 | header.append("Content-Type", "application/json") |
| 31 | header.append("Access-Control-Allow-Origin", "*") |
| 32 | return header |
| 33 | } |
| 34 | |
| 35 | const corsPreflight = () => { |
| 36 | const header = jsonHeaders() |
| 37 | header.append('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') |
| 38 | header.append('Access-Control-Allow-Headers', 'Content-Type') |
| 39 | header.append('Access-Control-Max-Age', '86400') |
| 40 | return new Response(null, { status: 204, headers: header }) |
| 41 | } |
| 42 | |
| 43 | const readSubscriptions = async () => { |
| 44 | try { |
| 45 | const txt = await Deno.readTextFile(subscriptionsPath) |
| 46 | const parsed = JSON.parse(txt) |
| 47 | if (!parsed || typeof parsed !== 'object') return { subscriptions: {} } |
| 48 | if (!parsed.subscriptions || typeof parsed.subscriptions !== 'object') return { subscriptions: {} } |
| 49 | return parsed |
| 50 | } catch { |
| 51 | return { subscriptions: {} } |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | const writeSubscriptions = async (data) => { |
| 56 | const payload = JSON.stringify(data, null, 2) |
| 57 | await Deno.writeTextFile(subscriptionsTmpPath, payload) |
| 58 | await Deno.rename(subscriptionsTmpPath, subscriptionsPath) |
| 59 | } |
| 60 | |
| 61 | const readVapid = async () => { |
| 62 | try { |
| 63 | const txt = await Deno.readTextFile(vapidPath) |
| 64 | const parsed = JSON.parse(txt) |
| 65 | if (!parsed || typeof parsed !== 'object') return undefined |
| 66 | if (typeof parsed.publicKey !== 'string') return undefined |
| 67 | if (!parsed.privateJwk || typeof parsed.privateJwk !== 'object') return undefined |
| 68 | if (typeof parsed.subject !== 'string') return undefined |
| 69 | return parsed |
| 70 | } catch { |
| 71 | return undefined |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | const writeVapid = async (data) => { |
| 76 | const payload = JSON.stringify(data, null, 2) |
| 77 | await Deno.writeTextFile(vapidTmpPath, payload) |
| 78 | await Deno.rename(vapidTmpPath, vapidPath) |
| 79 | } |
| 80 | |
| 81 | const ensureVapid = async () => { |
| 82 | const existing = await readVapid() |
| 83 | if (existing) return existing |
| 84 | |
| 85 | const subject = Deno.env.get('VAPID_SUBJECT') || 'mailto:admin@localhost' |
| 86 | const keypair = await crypto.subtle.generateKey( |
| 87 | { name: 'ECDSA', namedCurve: 'P-256' }, |
| 88 | true, |
| 89 | ['sign', 'verify'] |
| 90 | ) |
| 91 | const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', keypair.publicKey)) |
| 92 | const publicKey = base64UrlEncodeBytes(publicRaw) |
| 93 | const privateJwk = await crypto.subtle.exportKey('jwk', keypair.privateKey) |
| 94 | |
| 95 | const data = { subject, publicKey, privateJwk } |
| 96 | await writeVapid(data) |
| 97 | return data |
| 98 | } |
| 99 | |
| 7 | 100 | if (!await apds.pubkey()) { |
| 8 | 101 | const keypair = await apds.generate() |
| 9 | 102 | await apds.put('keypair', keypair) |
| … | … | @@ -12,6 +105,248 @@ if (!await apds.pubkey()) { |
| 12 | 105 | console.log('apdsbot started, your pubkey: ' + await apds.pubkey()) |
| 13 | 106 | } |
| 14 | 107 | |
| 108 | const vapid = await ensureVapid() |
| 109 | console.log('Web Push VAPID public key: ' + vapid.publicKey) |
| 110 | |
| 111 | let pendingPush = false |
| 112 | let nextAllowedPushAt = 0 |
| 113 | let flushTimer = undefined |
| 114 | const queuedPushSigs = new Set() |
| 115 | |
| 116 | const broadcastPush = async (payloadObj) => { |
| 117 | const store = await readSubscriptions() |
| 118 | const endpoints = Object.keys(store.subscriptions ?? {}) |
| 119 | if (endpoints.length === 0) return |
| 120 | |
| 121 | const dead = new Set() |
| 122 | const results = await Promise.allSettled( |
| 123 | endpoints.map(async (endpoint) => { |
| 124 | const entry = store.subscriptions[endpoint] |
| 125 | if (!entry?.subscription) return |
| 126 | try { |
| 127 | const res = await sendWebPush(entry.subscription, payloadObj) |
| 128 | if (res.status === 404 || res.status === 410) dead.add(endpoint) |
| 129 | } catch { |
| 130 | // Best-effort. |
| 131 | } |
| 132 | }) |
| 133 | ) |
| 134 | void results |
| 135 | |
| 136 | if (dead.size > 0) { |
| 137 | for (const endpoint of dead) delete store.subscriptions[endpoint] |
| 138 | store.updatedAt = Date.now() |
| 139 | await writeSubscriptions(store) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | const scheduleFlush = (delayMs = 0) => { |
| 144 | if (flushTimer !== undefined) return |
| 145 | flushTimer = setTimeout(() => { |
| 146 | flushTimer = undefined |
| 147 | void flushPushQueue() |
| 148 | }, delayMs) |
| 149 | } |
| 150 | |
| 151 | const flushPushQueue = async () => { |
| 152 | if (pendingPush) return scheduleFlush(500) |
| 153 | |
| 154 | const now = Date.now() |
| 155 | if (now < nextAllowedPushAt) return scheduleFlush(nextAllowedPushAt - now) |
| 156 | |
| 157 | const sigs = Array.from(queuedPushSigs).slice(0, 25) |
| 158 | if (sigs.length === 0) return |
| 159 | for (const sig of sigs) queuedPushSigs.delete(sig) |
| 160 | |
| 161 | pendingPush = true |
| 162 | nextAllowedPushAt = now + 2000 |
| 163 | try { |
| 164 | await broadcastPush({ type: 'anproto', sigs, ts: now }) |
| 165 | } finally { |
| 166 | pendingPush = false |
| 167 | if (queuedPushSigs.size > 0) scheduleFlush(0) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | const queuePushSig = (sig) => { |
| 172 | if (typeof sig !== 'string' || sig.length === 0) return |
| 173 | queuedPushSigs.add(sig) |
| 174 | scheduleFlush(200) |
| 175 | } |
| 176 | |
| 177 | const concatBytes = (...parts) => { |
| 178 | const total = parts.reduce((sum, p) => sum + p.length, 0) |
| 179 | const out = new Uint8Array(total) |
| 180 | let offset = 0 |
| 181 | for (const p of parts) { |
| 182 | out.set(p, offset) |
| 183 | offset += p.length |
| 184 | } |
| 185 | return out |
| 186 | } |
| 187 | |
| 188 | const hmacSha256 = async (keyBytes, dataBytes) => { |
| 189 | const key = await crypto.subtle.importKey( |
| 190 | 'raw', |
| 191 | keyBytes, |
| 192 | { name: 'HMAC', hash: 'SHA-256' }, |
| 193 | false, |
| 194 | ['sign'] |
| 195 | ) |
| 196 | const sig = await crypto.subtle.sign('HMAC', key, dataBytes) |
| 197 | return new Uint8Array(sig) |
| 198 | } |
| 199 | |
| 200 | const hkdfExtract = async (salt, ikm) => await hmacSha256(salt, ikm) |
| 201 | |
| 202 | const hkdfExpand = async (prk, info, length) => { |
| 203 | const hashLen = 32 |
| 204 | const n = Math.ceil(length / hashLen) |
| 205 | let t = new Uint8Array(0) |
| 206 | let okm = new Uint8Array(0) |
| 207 | for (let i = 1; i <= n; i++) { |
| 208 | const input = concatBytes(t, info, new Uint8Array([i])) |
| 209 | t = await hmacSha256(prk, input) |
| 210 | okm = concatBytes(okm, t) |
| 211 | } |
| 212 | return okm.slice(0, length) |
| 213 | } |
| 214 | |
| 215 | const readDerLength = (bytes, offset) => { |
| 216 | let length = bytes[offset] |
| 217 | if ((length & 0x80) === 0) { |
| 218 | return { length, read: 1 } |
| 219 | } |
| 220 | const numBytes = length & 0x7f |
| 221 | length = 0 |
| 222 | for (let i = 0; i < numBytes; i++) { |
| 223 | length = (length << 8) | bytes[offset + 1 + i] |
| 224 | } |
| 225 | return { length, read: 1 + numBytes } |
| 226 | } |
| 227 | |
| 228 | const ecdsaDerToJose = (derSignature, size = 32) => { |
| 229 | const bytes = derSignature instanceof Uint8Array ? derSignature : new Uint8Array(derSignature) |
| 230 | let offset = 0 |
| 231 | if (bytes[offset++] !== 0x30) throw new Error('Invalid DER signature (no sequence)') |
| 232 | const seqLen = readDerLength(bytes, offset) |
| 233 | offset += seqLen.read |
| 234 | if (bytes[offset++] !== 0x02) throw new Error('Invalid DER signature (no r)') |
| 235 | const rLen = readDerLength(bytes, offset) |
| 236 | offset += rLen.read |
| 237 | let r = bytes.slice(offset, offset + rLen.length) |
| 238 | offset += rLen.length |
| 239 | if (bytes[offset++] !== 0x02) throw new Error('Invalid DER signature (no s)') |
| 240 | const sLen = readDerLength(bytes, offset) |
| 241 | offset += sLen.read |
| 242 | let s = bytes.slice(offset, offset + sLen.length) |
| 243 | |
| 244 | while (r.length > 0 && r[0] === 0x00 && r.length > size) r = r.slice(1) |
| 245 | while (s.length > 0 && s[0] === 0x00 && s.length > size) s = s.slice(1) |
| 246 | if (r.length > size || s.length > size) throw new Error('Invalid DER signature (r/s too large)') |
| 247 | |
| 248 | const out = new Uint8Array(size * 2) |
| 249 | out.set(r, size - r.length) |
| 250 | out.set(s, size * 2 - s.length) |
| 251 | return out |
| 252 | } |
| 253 | |
| 254 | const createVapidJwt = async (endpoint) => { |
| 255 | const url = new URL(endpoint) |
| 256 | const aud = `${url.protocol}//${url.host}` |
| 257 | const exp = Math.floor(Date.now() / 1000) + (12 * 60 * 60) |
| 258 | const header = base64UrlEncodeBytes(encoder.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' }))) |
| 259 | const payload = base64UrlEncodeBytes(encoder.encode(JSON.stringify({ aud, exp, sub: vapid.subject }))) |
| 260 | const data = `${header}.${payload}` |
| 261 | |
| 262 | const privateKey = await crypto.subtle.importKey( |
| 263 | 'jwk', |
| 264 | vapid.privateJwk, |
| 265 | { name: 'ECDSA', namedCurve: 'P-256' }, |
| 266 | false, |
| 267 | ['sign'] |
| 268 | ) |
| 269 | const derSig = new Uint8Array( |
| 270 | await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, encoder.encode(data)) |
| 271 | ) |
| 272 | const joseSig = ecdsaDerToJose(derSig) |
| 273 | const signature = base64UrlEncodeBytes(joseSig) |
| 274 | return `${data}.${signature}` |
| 275 | } |
| 276 | |
| 277 | const encryptWebPushPayload = async (subscription, payloadBytes) => { |
| 278 | const receiverPublicKey = base64UrlDecodeBytes(subscription.keys.p256dh) |
| 279 | const authSecret = base64UrlDecodeBytes(subscription.keys.auth) |
| 280 | |
| 281 | const ephemeral = await crypto.subtle.generateKey( |
| 282 | { name: 'ECDH', namedCurve: 'P-256' }, |
| 283 | true, |
| 284 | ['deriveBits'] |
| 285 | ) |
| 286 | const senderPublicKey = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey)) |
| 287 | |
| 288 | const receiverKey = await crypto.subtle.importKey( |
| 289 | 'raw', |
| 290 | receiverPublicKey, |
| 291 | { name: 'ECDH', namedCurve: 'P-256' }, |
| 292 | false, |
| 293 | [] |
| 294 | ) |
| 295 | const sharedSecret = new Uint8Array( |
| 296 | await crypto.subtle.deriveBits({ name: 'ECDH', public: receiverKey }, ephemeral.privateKey, 256) |
| 297 | ) |
| 298 | |
| 299 | const prk = await hkdfExtract(authSecret, sharedSecret) |
| 300 | const authInfo = encoder.encode('Content-Encoding: auth\u0000') |
| 301 | const ikm = await hkdfExpand(prk, authInfo, 32) |
| 302 | |
| 303 | const salt = crypto.getRandomValues(new Uint8Array(16)) |
| 304 | const prk2 = await hkdfExtract(salt, ikm) |
| 305 | |
| 306 | const context = concatBytes( |
| 307 | new Uint8Array([0x00, 0x41]), |
| 308 | receiverPublicKey, |
| 309 | new Uint8Array([0x00, 0x41]), |
| 310 | senderPublicKey |
| 311 | ) |
| 312 | |
| 313 | const cekInfo = concatBytes(encoder.encode('Content-Encoding: aes128gcm\u0000'), context) |
| 314 | const nonceInfo = concatBytes(encoder.encode('Content-Encoding: nonce\u0000'), context) |
| 315 | const cek = await hkdfExpand(prk2, cekInfo, 16) |
| 316 | const nonce = await hkdfExpand(prk2, nonceInfo, 12) |
| 317 | |
| 318 | const key = await crypto.subtle.importKey('raw', cek, { name: 'AES-GCM' }, false, ['encrypt']) |
| 319 | const padding = new Uint8Array([0x00, 0x00]) |
| 320 | const plaintext = concatBytes(padding, payloadBytes) |
| 321 | const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, plaintext)) |
| 322 | |
| 323 | return { salt, senderPublicKey, ciphertext } |
| 324 | } |
| 325 | |
| 326 | const sendWebPush = async (subscription, payloadObj) => { |
| 327 | const payloadBytes = encoder.encode(JSON.stringify(payloadObj)) |
| 328 | const { salt, senderPublicKey, ciphertext } = await encryptWebPushPayload(subscription, payloadBytes) |
| 329 | const jwt = await createVapidJwt(subscription.endpoint) |
| 330 | |
| 331 | const header = new Headers() |
| 332 | header.set('TTL', '300') |
| 333 | header.set('Content-Type', 'application/octet-stream') |
| 334 | header.set('Content-Encoding', 'aes128gcm') |
| 335 | header.set('Encryption', `salt=${base64UrlEncodeBytes(salt)}`) |
| 336 | header.set( |
| 337 | 'Crypto-Key', |
| 338 | `dh=${base64UrlEncodeBytes(senderPublicKey)}; p256ecdsa=${vapid.publicKey}` |
| 339 | ) |
| 340 | header.set('Authorization', `vapid t=${jwt}, k=${vapid.publicKey}`) |
| 341 | |
| 342 | const res = await fetch(subscription.endpoint, { |
| 343 | method: 'POST', |
| 344 | headers: header, |
| 345 | body: ciphertext |
| 346 | }) |
| 347 | return res |
| 348 | } |
| 349 | |
| 15 | 350 | const apdsbot = async (ws) => { |
| 16 | 351 | ws.onopen = async () => { |
| 17 | 352 | setTimeout(async () => { |
| … | … | @@ -42,7 +377,7 @@ const apdsbot = async (ws) => { |
| 42 | 377 | } |
| 43 | 378 | if (m.data.length != 44) { |
| 44 | 379 | await apds.make(m.data) |
| 45 | | await apds.add(m.data) |
| 380 | const added = await apds.add(m.data) |
| 46 | 381 | const opened = await apds.open(m.data) |
| 47 | 382 | if (opened) { |
| 48 | 383 | const content = await apds.get(opened.substring(13)) |
| … | … | @@ -59,6 +394,9 @@ const apdsbot = async (ws) => { |
| 59 | 394 | ws.send(yaml.previous) |
| 60 | 395 | } |
| 61 | 396 | } |
| 397 | if (added) { |
| 398 | queuePushSig(m.data) |
| 399 | } |
| 62 | 400 | } |
| 63 | 401 | } |
| 64 | 402 | ws.onclose = () => { |
| … | … | @@ -68,10 +406,66 @@ const apdsbot = async (ws) => { |
| 68 | 406 | |
| 69 | 407 | const directory = async (r) => { |
| 70 | 408 | const url = new URL(r.url) |
| 409 | if (r.method === 'OPTIONS') { |
| 410 | return corsPreflight() |
| 411 | } |
| 412 | |
| 413 | if (url.pathname === '/push/subscribe') { |
| 414 | const header = jsonHeaders() |
| 415 | if (r.method !== 'POST') { |
| 416 | return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header }) |
| 417 | } |
| 418 | |
| 419 | let body |
| 420 | try { |
| 421 | body = await r.json() |
| 422 | } catch { |
| 423 | return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers: header }) |
| 424 | } |
| 425 | |
| 426 | const subscription = body?.subscription ?? body |
| 427 | const endpoint = subscription?.endpoint |
| 428 | const p256dh = subscription?.keys?.p256dh |
| 429 | const auth = subscription?.keys?.auth |
| 430 | |
| 431 | if (!endpoint || typeof endpoint !== 'string') { |
| 432 | return new Response(JSON.stringify({ error: 'Missing subscription.endpoint' }), { status: 400, headers: header }) |
| 433 | } |
| 434 | if (!p256dh || !auth) { |
| 435 | return new Response(JSON.stringify({ error: 'Missing subscription.keys' }), { status: 400, headers: header }) |
| 436 | } |
| 437 | |
| 438 | const store = await readSubscriptions() |
| 439 | store.subscriptions ??= {} |
| 440 | store.subscriptions[endpoint] = { |
| 441 | subscription, |
| 442 | updatedAt: Date.now() |
| 443 | } |
| 444 | store.updatedAt = Date.now() |
| 445 | await writeSubscriptions(store) |
| 446 | |
| 447 | return new Response(JSON.stringify({ ok: true }), { status: 201, headers: header }) |
| 448 | } |
| 449 | |
| 450 | if (url.pathname === '/push/vapidPublicKey') { |
| 451 | const header = jsonHeaders() |
| 452 | if (r.method !== 'GET') { |
| 453 | return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header }) |
| 454 | } |
| 455 | return new Response(JSON.stringify({ publicKey: vapid.publicKey }), { status: 200, headers: header }) |
| 456 | } |
| 457 | |
| 458 | if (url.pathname === '/push/test') { |
| 459 | const header = jsonHeaders() |
| 460 | if (r.method !== 'POST') { |
| 461 | return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header }) |
| 462 | } |
| 463 | await broadcastPush({ type: 'test', ts: Date.now() }) |
| 464 | return new Response(JSON.stringify({ ok: true }), { status: 200, headers: header }) |
| 465 | } |
| 466 | |
| 71 | 467 | const key = url.pathname.substring(1) |
| 72 | | const header = new Headers() |
| 73 | | header.append("Content-Type", "application/json") |
| 74 | | header.append("Access-Control-Allow-Origin", "*") |
| 468 | const header = jsonHeaders() |
| 75 | 469 | const q = await apds.query(key) |
| 76 | 470 | if (db[key]) { |
| 77 | 471 | const ar = db[key] |