@serve.zone/dcrouter 17.7.1 → 17.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.
Files changed (94) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +712 -709
  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.db-seeder.js +9 -1
  7. package/dist_ts/config/classes.gateway-client-manager.js +3 -1
  8. package/dist_ts/config/helpers.http-redirects.js +28 -1
  9. package/dist_ts/config/helpers.special-forwards.js +12 -9
  10. package/dist_ts/db/documents/classes.cached.email.d.ts +7 -0
  11. package/dist_ts/db/documents/classes.cached.email.js +52 -2
  12. package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
  13. package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
  14. package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
  15. package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
  16. package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
  17. package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
  18. package/dist_ts/db/documents/index.d.ts +3 -0
  19. package/dist_ts/db/documents/index.js +5 -1
  20. package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
  21. package/dist_ts/email/classes.accepted-email-spool.js +28 -3
  22. package/dist_ts/email/classes.email-route-builder.js +5 -1
  23. package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
  24. package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
  25. package/dist_ts/index.d.ts +1 -0
  26. package/dist_ts/index.js +3 -1
  27. package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
  28. package/dist_ts/opsserver/classes.opsserver.js +5 -1
  29. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +33 -6
  30. package/dist_ts/opsserver/handlers/index.d.ts +2 -0
  31. package/dist_ts/opsserver/handlers/index.js +3 -1
  32. package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
  33. package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
  34. package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
  35. package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
  36. package/dist_ts/plugins.d.ts +5 -2
  37. package/dist_ts/plugins.js +6 -3
  38. package/dist_ts/remoteingress/edge-derivation-routes.js +5 -1
  39. package/dist_ts/security/classes.route-policy-augmenter.d.ts +10 -6
  40. package/dist_ts/security/classes.route-policy-augmenter.js +65 -51
  41. package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
  42. package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
  43. package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
  44. package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
  45. package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
  46. package/dist_ts/webpush/classes.webpush-transport.js +161 -0
  47. package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
  48. package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
  49. package/dist_ts/webpush/index.d.ts +4 -0
  50. package/dist_ts/webpush/index.js +5 -0
  51. package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
  52. package/dist_ts/webpush/webpush-indexes.js +123 -0
  53. package/dist_ts_interfaces/data/remoteingress.d.ts +14 -2
  54. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  55. package/dist_ts_interfaces/data/route-management.js +1 -1
  56. package/dist_ts_migrations/index.js +285 -11
  57. package/dist_ts_web/00_commitinfo_data.js +1 -1
  58. package/dist_ts_web/elements/network/ops-view-routes.js +41 -1
  59. package/dist_ts_web/elements/network/special-forward-dialog.js +2 -2
  60. package/package.json +14 -10
  61. package/readme.md +27 -0
  62. package/third-party-notices.md +37 -0
  63. package/ts/00_commitinfo_data.ts +1 -1
  64. package/ts/classes.dcrouter.ts +29 -0
  65. package/ts/config/classes.db-seeder.ts +8 -0
  66. package/ts/config/classes.gateway-client-manager.ts +2 -0
  67. package/ts/config/helpers.http-redirects.ts +31 -1
  68. package/ts/config/helpers.special-forwards.ts +11 -8
  69. package/ts/db/documents/classes.cached.email.ts +47 -0
  70. package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
  71. package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
  72. package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
  73. package/ts/db/documents/index.ts +5 -0
  74. package/ts/email/classes.accepted-email-spool.ts +41 -2
  75. package/ts/email/classes.email-route-builder.ts +4 -0
  76. package/ts/email/classes.workapp-mail-manager.ts +97 -10
  77. package/ts/index.ts +3 -0
  78. package/ts/opsserver/classes.opsserver.ts +4 -0
  79. package/ts/opsserver/handlers/gatewayclient.handler.ts +34 -4
  80. package/ts/opsserver/handlers/index.ts +2 -0
  81. package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
  82. package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
  83. package/ts/plugins.ts +6 -0
  84. package/ts/remoteingress/edge-derivation-routes.ts +4 -0
  85. package/ts/security/classes.route-policy-augmenter.ts +70 -57
  86. package/ts/webpush/classes.webpush-crypto.ts +306 -0
  87. package/ts/webpush/classes.webpush-manager.ts +3173 -0
  88. package/ts/webpush/classes.webpush-transport.ts +241 -0
  89. package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
  90. package/ts/webpush/index.ts +4 -0
  91. package/ts/webpush/webpush-indexes.ts +141 -0
  92. package/ts_web/00_commitinfo_data.ts +1 -1
  93. package/ts_web/elements/network/ops-view-routes.ts +38 -0
  94. package/ts_web/elements/network/special-forward-dialog.ts +1 -1
@@ -4,63 +4,70 @@ import type { ISecurityCompiledPolicy } from '../../ts_interfaces/data/security-
4
4
  import type { DcRouter } from '../classes.dcrouter.js';
5
5
 
6
6
  type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
7
+ type TIngressOrigin = NonNullable<
8
+ NonNullable<plugins.smartproxy.IRouteConfig['security']>['ingress']
9
+ >['allowedOrigins'][number];
7
10
 
8
11
  /**
9
12
  * Pure route-policy transformations applied before routes reach SmartProxy:
10
- * per-listener inbound PROXY protocol policies (driven by RemoteIngress and
11
- * VPN) and merging of compiled security policies.
13
+ * route-level trusted ingress authorization, listener-level inbound PROXY
14
+ * protocol policy, and merging of compiled security policies.
12
15
  */
13
16
  export class RoutePolicyAugmenter {
14
17
  constructor(private dcRouterRef: DcRouter) {}
15
18
 
16
19
  /**
17
- * Ensure every route on a shared listener carries a compatible
18
- * inboundProxyProtocol policy, deriving defaults from RemoteIngress/VPN.
20
+ * Compile each dcrouter ingress policy into SmartProxy route security, then
21
+ * derive one compatible PROXY protocol mode for every shared listener.
22
+ *
23
+ * The listener mode only controls parsing. Route security remains the
24
+ * authorization boundary, so a direct request cannot fall through to a
25
+ * wildcard route that only permits RemoteIngress or SmartVPN.
19
26
  */
20
27
  public applyInboundProxyProtocolPolicies(
21
28
  routes: plugins.smartproxy.IRouteConfig[],
22
29
  ): plugins.smartproxy.IRouteConfig[] {
23
- const policiesByListener = new Map<string, TInboundProxyProtocolPolicy>();
24
-
25
- for (const route of routes) {
26
- const policy = route.match?.inboundProxyProtocol || this.getDesiredInboundProxyProtocolPolicy(route);
27
- if (!policy) {
28
- continue;
30
+ const routesWithIngressSecurity = routes.map((route) => {
31
+ const allowedOrigins = this.getAllowedIngressOrigins(route);
32
+ if (allowedOrigins.length === 0) {
33
+ throw new Error(`Route '${route.name}' has no enabled ingress path`);
29
34
  }
35
+ return {
36
+ ...route,
37
+ security: {
38
+ ...route.security,
39
+ ingress: {
40
+ allowedOrigins,
41
+ },
42
+ },
43
+ };
44
+ });
45
+
46
+ const originsByListener = new Map<string, Set<TIngressOrigin>>();
47
+ for (const route of routesWithIngressSecurity) {
48
+ const origins = route.security!.ingress!.allowedOrigins;
30
49
  for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
31
- const mergedPolicy = this.mergeInboundProxyProtocolPolicies(
32
- policiesByListener.get(listenerKey),
33
- policy,
34
- );
35
- if (mergedPolicy) {
36
- policiesByListener.set(listenerKey, mergedPolicy);
50
+ const listenerOrigins = originsByListener.get(listenerKey) || new Set<TIngressOrigin>();
51
+ for (const origin of origins) {
52
+ listenerOrigins.add(origin);
37
53
  }
54
+ originsByListener.set(listenerKey, listenerOrigins);
38
55
  }
39
56
  }
40
57
 
41
- if (policiesByListener.size === 0) {
42
- return routes;
43
- }
44
-
45
- return routes.map((route) => {
46
- if (route.match?.inboundProxyProtocol) {
47
- return route;
48
- }
49
- let listenerPolicy: TInboundProxyProtocolPolicy | undefined;
58
+ return routesWithIngressSecurity.map((route) => {
59
+ const listenerOrigins = new Set<TIngressOrigin>();
50
60
  for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
51
- listenerPolicy = this.mergeInboundProxyProtocolPolicies(
52
- listenerPolicy,
53
- policiesByListener.get(listenerKey),
54
- );
55
- }
56
- if (!listenerPolicy) {
57
- return route;
61
+ for (const origin of originsByListener.get(listenerKey) || []) {
62
+ listenerOrigins.add(origin);
63
+ }
58
64
  }
65
+ const inboundProxyProtocol = this.getListenerProxyProtocolPolicy(listenerOrigins);
59
66
  return {
60
67
  ...route,
61
68
  match: {
62
69
  ...route.match,
63
- inboundProxyProtocol: listenerPolicy,
70
+ inboundProxyProtocol,
64
71
  },
65
72
  };
66
73
  });
@@ -92,21 +99,40 @@ export class RoutePolicyAugmenter {
92
99
  };
93
100
  }
94
101
 
95
- private getDesiredInboundProxyProtocolPolicy(
102
+ private getAllowedIngressOrigins(
96
103
  route: plugins.smartproxy.IRouteConfig,
97
- ): TInboundProxyProtocolPolicy | undefined {
104
+ ): TIngressOrigin[] {
98
105
  const dcRoute = route as IDcRouterRouteConfig;
99
- if (this.dcRouterRef.isRemoteIngressHubEnabled() && dcRoute.remoteIngress?.enabled) {
100
- const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
101
- if (ports.some((port) => port === 25 || port === 587)) {
102
- return { mode: 'required' };
103
- }
104
- return { mode: 'optional' };
106
+ const origins = new Set<TIngressOrigin>();
107
+
108
+ if (dcRoute.ingress) {
109
+ if (dcRoute.ingress.directHub) origins.add('direct');
110
+ if (dcRoute.ingress.smartVpn) origins.add('smartVpn');
111
+ } else if (dcRoute.vpnOnly) {
112
+ origins.add('smartVpn');
113
+ } else {
114
+ // Compatibility for runtime routes and producers predating the explicit
115
+ // contract. Persisted routes are backfilled by the versioned migration.
116
+ origins.add('direct');
117
+ origins.add('smartVpn');
105
118
  }
106
- if (this.dcRouterRef.options.vpnConfig?.enabled) {
107
- return { mode: 'optional' };
119
+
120
+ if (dcRoute.remoteIngress?.enabled) {
121
+ origins.add('trustedProxy');
108
122
  }
109
- return undefined;
123
+
124
+ return [...origins];
125
+ }
126
+
127
+ private getListenerProxyProtocolPolicy(
128
+ origins: Set<TIngressOrigin>,
129
+ ): TInboundProxyProtocolPolicy {
130
+ const allowsDirect = origins.has('direct');
131
+ const allowsProxied = origins.has('trustedProxy') || origins.has('smartVpn');
132
+
133
+ if (allowsDirect && allowsProxied) return { mode: 'optional' };
134
+ if (allowsProxied) return { mode: 'required' };
135
+ return { mode: 'reject' };
110
136
  }
111
137
 
112
138
  private getInboundProxyListenerKeys(route: plugins.smartproxy.IRouteConfig): string[] {
@@ -124,17 +150,4 @@ export class RoutePolicyAugmenter {
124
150
  }
125
151
  return keys;
126
152
  }
127
-
128
- private mergeInboundProxyProtocolPolicies(
129
- current?: TInboundProxyProtocolPolicy,
130
- next?: TInboundProxyProtocolPolicy,
131
- ): TInboundProxyProtocolPolicy | undefined {
132
- if (!current) return next;
133
- if (!next) return current;
134
- if (current.mode === 'required') return current;
135
- if (next.mode === 'required') return next;
136
- if (current.mode === 'optional') return current;
137
- if (next.mode === 'optional') return next;
138
- return current;
139
- }
140
153
  }
@@ -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
+ }