@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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +712 -709
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -0
- package/dist_ts/classes.dcrouter.js +23 -1
- package/dist_ts/config/classes.db-seeder.js +9 -1
- package/dist_ts/config/classes.gateway-client-manager.js +3 -1
- package/dist_ts/config/helpers.http-redirects.js +28 -1
- package/dist_ts/config/helpers.special-forwards.js +12 -9
- package/dist_ts/db/documents/classes.cached.email.d.ts +7 -0
- package/dist_ts/db/documents/classes.cached.email.js +52 -2
- package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +19 -0
- package/dist_ts/db/documents/classes.webpush-admission.doc.js +124 -0
- package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +65 -0
- package/dist_ts/db/documents/classes.webpush-binding.doc.js +205 -0
- package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +44 -0
- package/dist_ts/db/documents/classes.webpush-spool.doc.js +277 -0
- package/dist_ts/db/documents/index.d.ts +3 -0
- package/dist_ts/db/documents/index.js +5 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
- package/dist_ts/email/classes.accepted-email-spool.js +28 -3
- package/dist_ts/email/classes.email-route-builder.js +5 -1
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -1
- package/dist_ts/email/classes.workapp-mail-manager.js +87 -12
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/opsserver/classes.opsserver.d.ts +2 -0
- package/dist_ts/opsserver/classes.opsserver.js +5 -1
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +33 -6
- package/dist_ts/opsserver/handlers/index.d.ts +2 -0
- package/dist_ts/opsserver/handlers/index.js +3 -1
- package/dist_ts/opsserver/handlers/webpush-app.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/webpush-app.handler.js +66 -0
- package/dist_ts/opsserver/handlers/webpush-control.handler.d.ts +14 -0
- package/dist_ts/opsserver/handlers/webpush-control.handler.js +163 -0
- package/dist_ts/plugins.d.ts +5 -2
- package/dist_ts/plugins.js +6 -3
- package/dist_ts/remoteingress/edge-derivation-routes.js +5 -1
- package/dist_ts/security/classes.route-policy-augmenter.d.ts +10 -6
- package/dist_ts/security/classes.route-policy-augmenter.js +65 -51
- package/dist_ts/webpush/classes.webpush-crypto.d.ts +38 -0
- package/dist_ts/webpush/classes.webpush-crypto.js +231 -0
- package/dist_ts/webpush/classes.webpush-manager.d.ts +153 -0
- package/dist_ts/webpush/classes.webpush-manager.js +2374 -0
- package/dist_ts/webpush/classes.webpush-transport.d.ts +45 -0
- package/dist_ts/webpush/classes.webpush-transport.js +161 -0
- package/dist_ts/webpush/helpers.webpush-endpoint.d.ts +21 -0
- package/dist_ts/webpush/helpers.webpush-endpoint.js +111 -0
- package/dist_ts/webpush/index.d.ts +4 -0
- package/dist_ts/webpush/index.js +5 -0
- package/dist_ts/webpush/webpush-indexes.d.ts +11 -0
- package/dist_ts/webpush/webpush-indexes.js +123 -0
- package/dist_ts_interfaces/data/remoteingress.d.ts +14 -2
- package/dist_ts_interfaces/data/route-management.d.ts +2 -0
- package/dist_ts_interfaces/data/route-management.js +1 -1
- package/dist_ts_migrations/index.js +285 -11
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/network/ops-view-routes.js +41 -1
- package/dist_ts_web/elements/network/special-forward-dialog.js +2 -2
- package/package.json +14 -10
- package/readme.md +27 -0
- package/third-party-notices.md +37 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +29 -0
- package/ts/config/classes.db-seeder.ts +8 -0
- package/ts/config/classes.gateway-client-manager.ts +2 -0
- package/ts/config/helpers.http-redirects.ts +31 -1
- package/ts/config/helpers.special-forwards.ts +11 -8
- package/ts/db/documents/classes.cached.email.ts +47 -0
- package/ts/db/documents/classes.webpush-admission.doc.ts +48 -0
- package/ts/db/documents/classes.webpush-binding.doc.ts +147 -0
- package/ts/db/documents/classes.webpush-spool.doc.ts +136 -0
- package/ts/db/documents/index.ts +5 -0
- package/ts/email/classes.accepted-email-spool.ts +41 -2
- package/ts/email/classes.email-route-builder.ts +4 -0
- package/ts/email/classes.workapp-mail-manager.ts +97 -10
- package/ts/index.ts +3 -0
- package/ts/opsserver/classes.opsserver.ts +4 -0
- package/ts/opsserver/handlers/gatewayclient.handler.ts +34 -4
- package/ts/opsserver/handlers/index.ts +2 -0
- package/ts/opsserver/handlers/webpush-app.handler.ts +105 -0
- package/ts/opsserver/handlers/webpush-control.handler.ts +257 -0
- package/ts/plugins.ts +6 -0
- package/ts/remoteingress/edge-derivation-routes.ts +4 -0
- package/ts/security/classes.route-policy-augmenter.ts +70 -57
- package/ts/webpush/classes.webpush-crypto.ts +306 -0
- package/ts/webpush/classes.webpush-manager.ts +3173 -0
- package/ts/webpush/classes.webpush-transport.ts +241 -0
- package/ts/webpush/helpers.webpush-endpoint.ts +150 -0
- package/ts/webpush/index.ts +4 -0
- package/ts/webpush/webpush-indexes.ts +141 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/network/ops-view-routes.ts +38 -0
- 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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
70
|
+
inboundProxyProtocol,
|
|
64
71
|
},
|
|
65
72
|
};
|
|
66
73
|
});
|
|
@@ -92,21 +99,40 @@ export class RoutePolicyAugmenter {
|
|
|
92
99
|
};
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
private
|
|
102
|
+
private getAllowedIngressOrigins(
|
|
96
103
|
route: plugins.smartproxy.IRouteConfig,
|
|
97
|
-
):
|
|
104
|
+
): TIngressOrigin[] {
|
|
98
105
|
const dcRoute = route as IDcRouterRouteConfig;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
119
|
+
|
|
120
|
+
if (dcRoute.remoteIngress?.enabled) {
|
|
121
|
+
origins.add('trustedProxy');
|
|
108
122
|
}
|
|
109
|
-
|
|
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
|
+
}
|