@serve.zone/dcrouter 17.7.1 → 17.8.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.
Files changed (77) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +410 -410
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +2 -0
  5. package/dist_ts/classes.dcrouter.js +23 -1
  6. package/dist_ts/config/classes.gateway-client-manager.js +3 -1
  7. package/dist_ts/db/documents/classes.cached.email.d.ts +7 -0
  8. package/dist_ts/db/documents/classes.cached.email.js +52 -2
  9. package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
  10. package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
  11. package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
  12. package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
  13. package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
  14. package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
  15. package/dist_ts/db/documents/index.d.ts +3 -0
  16. package/dist_ts/db/documents/index.js +5 -1
  17. package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
  18. package/dist_ts/email/classes.accepted-email-spool.js +28 -3
  19. package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
  20. package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
  21. package/dist_ts/index.d.ts +1 -0
  22. package/dist_ts/index.js +3 -1
  23. package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
  24. package/dist_ts/opsserver/classes.opsserver.js +5 -1
  25. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +11 -5
  26. package/dist_ts/opsserver/handlers/index.d.ts +2 -0
  27. package/dist_ts/opsserver/handlers/index.js +3 -1
  28. package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
  29. package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
  30. package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
  31. package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
  32. package/dist_ts/plugins.d.ts +5 -2
  33. package/dist_ts/plugins.js +6 -3
  34. package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
  35. package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
  36. package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
  37. package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
  38. package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
  39. package/dist_ts/webpush/classes.webpush-transport.js +161 -0
  40. package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
  41. package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
  42. package/dist_ts/webpush/index.d.ts +4 -0
  43. package/dist_ts/webpush/index.js +5 -0
  44. package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
  45. package/dist_ts/webpush/webpush-indexes.js +123 -0
  46. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  47. package/dist_ts_interfaces/data/route-management.js +1 -1
  48. package/dist_ts_migrations/index.js +221 -11
  49. package/dist_ts_web/00_commitinfo_data.js +1 -1
  50. package/package.json +12 -8
  51. package/readme.md +27 -0
  52. package/third-party-notices.md +37 -0
  53. package/ts/00_commitinfo_data.ts +1 -1
  54. package/ts/classes.dcrouter.ts +29 -0
  55. package/ts/config/classes.gateway-client-manager.ts +2 -0
  56. package/ts/db/documents/classes.cached.email.ts +47 -0
  57. package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
  58. package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
  59. package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
  60. package/ts/db/documents/index.ts +5 -0
  61. package/ts/email/classes.accepted-email-spool.ts +41 -2
  62. package/ts/email/classes.workapp-mail-manager.ts +97 -10
  63. package/ts/index.ts +3 -0
  64. package/ts/opsserver/classes.opsserver.ts +4 -0
  65. package/ts/opsserver/handlers/gatewayclient.handler.ts +10 -3
  66. package/ts/opsserver/handlers/index.ts +2 -0
  67. package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
  68. package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
  69. package/ts/plugins.ts +6 -0
  70. package/ts/webpush/classes.webpush-crypto.ts +306 -0
  71. package/ts/webpush/classes.webpush-manager.ts +3173 -0
  72. package/ts/webpush/classes.webpush-transport.ts +241 -0
  73. package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
  74. package/ts/webpush/index.ts +4 -0
  75. package/ts/webpush/webpush-indexes.ts +141 -0
  76. package/ts_web/00_commitinfo_data.ts +1 -1
  77. package/readme.plan.md +0 -22
@@ -0,0 +1,306 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ export interface IWebPushKeyRing {
4
+ currentKeyId: string;
5
+ keys: ReadonlyMap<string, Buffer>;
6
+ }
7
+
8
+ export interface IWebPushEncryptedEnvelope {
9
+ version: 1;
10
+ algorithm: 'aes-256-gcm';
11
+ keyId: string;
12
+ nonce: string;
13
+ ciphertext: string;
14
+ tag: string;
15
+ }
16
+
17
+ export interface IWebPushAadContext {
18
+ gatewayClientId: string;
19
+ appInstanceId: string;
20
+ bindingId: string;
21
+ documentType: string;
22
+ documentId: string;
23
+ }
24
+
25
+ export type TWebPushHmacDomain =
26
+ | 'credential'
27
+ | 'idempotency-key'
28
+ | 'request'
29
+ | 'endpoint'
30
+ | 'topic';
31
+
32
+ const KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
33
+ const BASE64URL_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
34
+ const BASE64URL_VALUE_PATTERN = /^[A-Za-z0-9_-]+$/;
35
+ const KEY_RING_LIMIT = 8;
36
+ const AAD_SCHEMA_VERSION = 1;
37
+
38
+ function requirePlainObject(
39
+ valueArg: unknown,
40
+ labelArg: string,
41
+ ): Record<string, unknown> {
42
+ if (
43
+ valueArg === null
44
+ || typeof valueArg !== 'object'
45
+ || Array.isArray(valueArg)
46
+ || Object.getPrototypeOf(valueArg) !== Object.prototype
47
+ ) {
48
+ throw new Error(`${labelArg} must be a plain object`);
49
+ }
50
+ return valueArg as Record<string, unknown>;
51
+ }
52
+
53
+ function decodeBase64UrlExact(
54
+ valueArg: unknown,
55
+ lengthArg: number,
56
+ labelArg: string,
57
+ ): Buffer {
58
+ if (typeof valueArg !== 'string' || !BASE64URL_VALUE_PATTERN.test(valueArg)) {
59
+ throw new Error(`${labelArg} must be unpadded base64url`);
60
+ }
61
+ const decoded = Buffer.from(valueArg, 'base64url');
62
+ if (decoded.length !== lengthArg || decoded.toString('base64url') !== valueArg) {
63
+ throw new Error(`${labelArg} has an invalid encoded length`);
64
+ }
65
+ return decoded;
66
+ }
67
+
68
+ function parseKeyRingSource(rawValueArg: unknown, labelArg: string): Record<string, unknown> {
69
+ if (typeof rawValueArg === 'string') {
70
+ const trimmed = rawValueArg.trim();
71
+ if (!trimmed) {
72
+ throw new Error(`${labelArg} is empty`);
73
+ }
74
+ const jsonText = trimmed.startsWith('base64Object:')
75
+ ? Buffer.from(trimmed.slice('base64Object:'.length), 'base64').toString('utf8')
76
+ : trimmed;
77
+ try {
78
+ return requirePlainObject(JSON.parse(jsonText), labelArg);
79
+ } catch (error: unknown) {
80
+ if ((error as Error).message.startsWith(`${labelArg} `)) throw error;
81
+ throw new Error(`${labelArg} must be valid JSON`, { cause: error });
82
+ }
83
+ }
84
+ return requirePlainObject(rawValueArg, labelArg);
85
+ }
86
+
87
+ export function parseWebPushKeyRing(
88
+ rawValueArg: unknown,
89
+ labelArg: string,
90
+ ): IWebPushKeyRing {
91
+ const source = parseKeyRingSource(rawValueArg, labelArg);
92
+ const currentKeyId = source.currentKeyId;
93
+ if (typeof currentKeyId !== 'string' || !KEY_ID_PATTERN.test(currentKeyId)) {
94
+ throw new Error(`${labelArg}.currentKeyId is invalid`);
95
+ }
96
+ const rawKeys = requirePlainObject(source.keys, `${labelArg}.keys`);
97
+ const entries = Object.entries(rawKeys);
98
+ if (entries.length === 0 || entries.length > KEY_RING_LIMIT) {
99
+ throw new Error(`${labelArg}.keys must contain 1 to ${KEY_RING_LIMIT} keys`);
100
+ }
101
+ const keys = new Map<string, Buffer>();
102
+ const fingerprints = new Set<string>();
103
+ for (const [keyId, encodedKey] of entries) {
104
+ if (!KEY_ID_PATTERN.test(keyId)) {
105
+ throw new Error(`${labelArg}.keys contains an invalid key id`);
106
+ }
107
+ if (typeof encodedKey !== 'string' || !BASE64URL_KEY_PATTERN.test(encodedKey)) {
108
+ throw new Error(`${labelArg}.keys.${keyId} must be a 32-byte unpadded base64url key`);
109
+ }
110
+ const key = decodeBase64UrlExact(encodedKey, 32, `${labelArg}.keys.${keyId}`);
111
+ const fingerprint = key.toString('hex');
112
+ if (fingerprints.has(fingerprint)) {
113
+ throw new Error(`${labelArg}.keys must not contain duplicate key material`);
114
+ }
115
+ fingerprints.add(fingerprint);
116
+ keys.set(keyId, key);
117
+ }
118
+ if (!keys.has(currentKeyId)) {
119
+ throw new Error(`${labelArg}.currentKeyId does not name a configured key`);
120
+ }
121
+ return {
122
+ currentKeyId,
123
+ keys,
124
+ };
125
+ }
126
+
127
+ export function assertDistinctWebPushKeyRings(
128
+ encryptionRingArg: IWebPushKeyRing,
129
+ hmacRingArg: IWebPushKeyRing,
130
+ ): void {
131
+ const encryptionFingerprints = new Set(
132
+ [...encryptionRingArg.keys.values()].map((keyArg) => keyArg.toString('hex')),
133
+ );
134
+ if (
135
+ [...hmacRingArg.keys.values()]
136
+ .some((keyArg) => encryptionFingerprints.has(keyArg.toString('hex')))
137
+ ) {
138
+ throw new Error('Web Push encryption and HMAC key rings must use distinct key material');
139
+ }
140
+ }
141
+
142
+ export function buildWebPushAad(contextArg: IWebPushAadContext): Buffer {
143
+ for (const [name, value] of Object.entries(contextArg)) {
144
+ if (typeof value !== 'string' || !value.trim()) {
145
+ throw new Error(`Web Push AAD ${name} is missing`);
146
+ }
147
+ }
148
+ return Buffer.from(canonicalJson({
149
+ schemaVersion: AAD_SCHEMA_VERSION,
150
+ gatewayClientId: contextArg.gatewayClientId,
151
+ appInstanceId: contextArg.appInstanceId,
152
+ bindingId: contextArg.bindingId,
153
+ documentType: contextArg.documentType,
154
+ documentId: contextArg.documentId,
155
+ }), 'utf8');
156
+ }
157
+
158
+ function canonicalize(valueArg: unknown): unknown {
159
+ if (
160
+ valueArg === null
161
+ || typeof valueArg === 'string'
162
+ || typeof valueArg === 'boolean'
163
+ ) {
164
+ return valueArg;
165
+ }
166
+ if (typeof valueArg === 'number') {
167
+ if (!Number.isFinite(valueArg)) throw new Error('Canonical JSON does not support non-finite numbers');
168
+ return valueArg;
169
+ }
170
+ if (Array.isArray(valueArg)) {
171
+ return valueArg.map((entryArg) => canonicalize(entryArg));
172
+ }
173
+ const value = requirePlainObject(valueArg, 'Canonical JSON value');
174
+ const result: Record<string, unknown> = {};
175
+ for (const key of Object.keys(value).sort()) {
176
+ if (value[key] === undefined) {
177
+ throw new Error('Canonical JSON does not support undefined values');
178
+ }
179
+ result[key] = canonicalize(value[key]);
180
+ }
181
+ return result;
182
+ }
183
+
184
+ export function canonicalJson(valueArg: unknown): string {
185
+ return JSON.stringify(canonicalize(valueArg));
186
+ }
187
+
188
+ export class WebPushCrypto {
189
+ public constructor(
190
+ private readonly encryptionRing: IWebPushKeyRing,
191
+ private readonly hmacRing: IWebPushKeyRing,
192
+ ) {
193
+ assertDistinctWebPushKeyRings(encryptionRing, hmacRing);
194
+ }
195
+
196
+ public get currentEncryptionKeyId(): string {
197
+ return this.encryptionRing.currentKeyId;
198
+ }
199
+
200
+ public get currentHmacKeyId(): string {
201
+ return this.hmacRing.currentKeyId;
202
+ }
203
+
204
+ public encryptJson(
205
+ valueArg: unknown,
206
+ aadContextArg: IWebPushAadContext,
207
+ ): IWebPushEncryptedEnvelope {
208
+ const keyId = this.encryptionRing.currentKeyId;
209
+ const key = this.encryptionRing.keys.get(keyId);
210
+ if (!key) throw new Error('Current Web Push encryption key is unavailable');
211
+ const nonce = plugins.crypto.randomBytes(12);
212
+ const cipher = plugins.crypto.createCipheriv('aes-256-gcm', key, nonce);
213
+ cipher.setAAD(buildWebPushAad(aadContextArg));
214
+ const ciphertext = Buffer.concat([
215
+ cipher.update(canonicalJson(valueArg), 'utf8'),
216
+ cipher.final(),
217
+ ]);
218
+ const tag = cipher.getAuthTag();
219
+ return {
220
+ version: 1,
221
+ algorithm: 'aes-256-gcm',
222
+ keyId,
223
+ nonce: nonce.toString('base64url'),
224
+ ciphertext: ciphertext.toString('base64url'),
225
+ tag: tag.toString('base64url'),
226
+ };
227
+ }
228
+
229
+ public decryptJson<T>(
230
+ envelopeArg: IWebPushEncryptedEnvelope,
231
+ aadContextArg: IWebPushAadContext,
232
+ ): T {
233
+ if (
234
+ envelopeArg?.version !== 1
235
+ || envelopeArg.algorithm !== 'aes-256-gcm'
236
+ || typeof envelopeArg.keyId !== 'string'
237
+ || !KEY_ID_PATTERN.test(envelopeArg.keyId)
238
+ ) {
239
+ throw new Error('Web Push encrypted envelope is malformed');
240
+ }
241
+ const key = this.encryptionRing.keys.get(envelopeArg.keyId);
242
+ if (!key) throw new Error('Web Push encrypted envelope references an unavailable key');
243
+ const nonce = decodeBase64UrlExact(envelopeArg.nonce, 12, 'Web Push envelope nonce');
244
+ const tag = decodeBase64UrlExact(envelopeArg.tag, 16, 'Web Push envelope tag');
245
+ if (
246
+ typeof envelopeArg.ciphertext !== 'string'
247
+ || !envelopeArg.ciphertext
248
+ || !BASE64URL_VALUE_PATTERN.test(envelopeArg.ciphertext)
249
+ ) {
250
+ throw new Error('Web Push envelope ciphertext is malformed');
251
+ }
252
+ const ciphertext = Buffer.from(envelopeArg.ciphertext, 'base64url');
253
+ if (ciphertext.toString('base64url') !== envelopeArg.ciphertext) {
254
+ throw new Error('Web Push envelope ciphertext is malformed');
255
+ }
256
+ try {
257
+ const decipher = plugins.crypto.createDecipheriv('aes-256-gcm', key, nonce);
258
+ decipher.setAAD(buildWebPushAad(aadContextArg));
259
+ decipher.setAuthTag(tag);
260
+ const plaintext = Buffer.concat([
261
+ decipher.update(ciphertext),
262
+ decipher.final(),
263
+ ]).toString('utf8');
264
+ return JSON.parse(plaintext) as T;
265
+ } catch (error: unknown) {
266
+ throw new Error('Web Push encrypted envelope authentication failed', { cause: error });
267
+ }
268
+ }
269
+
270
+ public hmac(
271
+ domainArg: TWebPushHmacDomain,
272
+ valueArg: string,
273
+ keyIdArg = this.hmacRing.currentKeyId,
274
+ ): { keyId: string; digest: string } {
275
+ const key = this.hmacRing.keys.get(keyIdArg);
276
+ if (!key) throw new Error('Web Push HMAC key is unavailable');
277
+ const digest = plugins.crypto
278
+ .createHmac('sha256', key)
279
+ .update(`dcrouter:webpush:v1:${domainArg}\0`, 'utf8')
280
+ .update(valueArg, 'utf8')
281
+ .digest('base64url');
282
+ return {
283
+ keyId: keyIdArg,
284
+ digest,
285
+ };
286
+ }
287
+
288
+ public verifyHmac(
289
+ domainArg: TWebPushHmacDomain,
290
+ valueArg: string,
291
+ keyIdArg: string,
292
+ expectedDigestArg: string,
293
+ ): boolean {
294
+ let actualDigest: string;
295
+ try {
296
+ actualDigest = this.hmac(domainArg, valueArg, keyIdArg).digest;
297
+ } catch {
298
+ return false;
299
+ }
300
+ const actual = Buffer.from(actualDigest, 'base64url');
301
+ const expected = Buffer.from(expectedDigestArg, 'base64url');
302
+ return actual.length === expected.length
303
+ && actual.length > 0
304
+ && plugins.crypto.timingSafeEqual(actual, expected);
305
+ }
306
+ }