@serve.zone/dcrouter 20.2.0 → 32.0.1
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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +2 -3
- package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +0 -1
- package/dist_ts/db/documents/classes.webpush-spool.doc.js +2 -8
- package/dist_ts/email/classes.coremail-gateway.manager.js +2 -3
- package/dist_ts/email/coremail-gateway-authority.d.ts +1 -1
- package/dist_ts/email/coremail-gateway-authority.js +1 -1
- package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +0 -1
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +13 -34
- package/dist_ts/opsserver/helpers/gateway-client-route-key.d.ts +26 -0
- package/dist_ts/opsserver/helpers/gateway-client-route-key.js +51 -0
- package/dist_ts/webpush/classes.webpush-crypto.d.ts +9 -1
- package/dist_ts/webpush/classes.webpush-crypto.js +69 -56
- package/dist_ts/webpush/classes.webpush-manager.js +3 -4
- package/dist_ts_migrations/index.d.ts +18 -9
- package/dist_ts_migrations/index.js +602 -1776
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +5 -5
- package/readme.md +22 -7
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +2 -4
- package/ts/db/documents/classes.webpush-spool.doc.ts +0 -3
- package/ts/email/classes.coremail-gateway.manager.ts +1 -2
- package/ts/email/coremail-gateway-authority.ts +1 -1
- package/ts/opsserver/handlers/gatewayclient.handler.ts +19 -36
- package/ts/opsserver/helpers/gateway-client-route-key.ts +64 -0
- package/ts/webpush/classes.webpush-crypto.ts +80 -57
- package/ts/webpush/classes.webpush-manager.ts +2 -3
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@serve.zone/dcrouter',
|
|
6
|
-
version: '
|
|
6
|
+
version: '32.0.1',
|
|
7
7
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
|
8
8
|
};
|
|
9
9
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHNfd2ViLzAwX2NvbW1pdGluZm9fZGF0YS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLFVBQVUsR0FBRztJQUN4QixJQUFJLEVBQUUsc0JBQXNCO0lBQzVCLE9BQU8sRUFBRSxRQUFRO0lBQ2pCLFdBQVcsRUFBRSwwRUFBMEU7Q0FDeEYsQ0FBQSJ9
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serve.zone/dcrouter",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "32.0.1",
|
|
5
5
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"author": "Task Venture Capital GmbH",
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"devDependencies": {
|
|
18
|
-
"@git.zone/tsbuild": "^4.
|
|
18
|
+
"@git.zone/tsbuild": "^4.5.0",
|
|
19
19
|
"@git.zone/tsbundle": "^2.13.0",
|
|
20
20
|
"@git.zone/tsdeno": "^1.8.0",
|
|
21
21
|
"@git.zone/tsdocker": "^3.5.8",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@git.zone/tswatch": "^3.3.5",
|
|
25
25
|
"@push.rocks/smartdb-v2": "npm:@push.rocks/smartdb@2.14.6",
|
|
26
26
|
"@push.rocks/smartdb-v5.2": "npm:@push.rocks/smartdb@5.2.2",
|
|
27
|
-
"@types/node": "26.5.
|
|
27
|
+
"@types/node": "26.5.1",
|
|
28
28
|
"@types/web-push": "3.6.4"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@push.rocks/smartlog": "^3.2.2",
|
|
52
52
|
"@push.rocks/smartmcp": "^0.3.0",
|
|
53
53
|
"@push.rocks/smartmetrics": "^3.0.3",
|
|
54
|
-
"@push.rocks/smartmigration": "1.
|
|
54
|
+
"@push.rocks/smartmigration": "1.7.0",
|
|
55
55
|
"@push.rocks/smartmta": "^9.5.0",
|
|
56
56
|
"@push.rocks/smartnetwork": "^4.10.1",
|
|
57
57
|
"@push.rocks/smartpath": "^6.0.0",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@push.rocks/smartvpn": "1.22.0",
|
|
67
67
|
"@push.rocks/taskbuffer": "^9.0.2",
|
|
68
68
|
"@serve.zone/catalog": "^4.0.0",
|
|
69
|
-
"@serve.zone/interfaces": "
|
|
69
|
+
"@serve.zone/interfaces": "^32.0.0",
|
|
70
70
|
"@serve.zone/remoteingress": "^5.2.5",
|
|
71
71
|
"@tsclass/tsclass": "^9.5.1",
|
|
72
72
|
"@types/qrcode": "^1.5.6",
|
package/readme.md
CHANGED
|
@@ -324,13 +324,14 @@ continue occupying quota and reservations. A changed payload cannot reuse an
|
|
|
324
324
|
idempotency key. Retrying the same payload/key never intentionally creates a
|
|
325
325
|
second operation.
|
|
326
326
|
|
|
327
|
-
Schema migration `
|
|
328
|
-
|
|
327
|
+
Schema migration `testing-access-and-exact-certificate-identities` (`18.8.0` to
|
|
328
|
+
`19.1.0`) installs the indexes and backfills legacy certificate identities
|
|
329
|
+
without rewriting PEM data. Rollout and staging-CA validation are
|
|
329
330
|
separate operational steps; isolated tests do not issue public certificates.
|
|
330
331
|
|
|
331
332
|
## Web Push Provider
|
|
332
333
|
|
|
333
|
-
Web Push is disabled by default and does not read its secret configuration while disabled. It requires DB-backed persistence and the `
|
|
334
|
+
Web Push is disabled by default and does not read its secret configuration while disabled. It requires DB-backed persistence and the `web-push-provider-schema` migration. Enable it only after all three secret values are present:
|
|
334
335
|
|
|
335
336
|
| Environment variable | Value |
|
|
336
337
|
| --- | --- |
|
|
@@ -349,6 +350,7 @@ Operational behavior:
|
|
|
349
350
|
- Payloads are limited to 3,500 UTF-8 bytes, TTL must be from 1 through 86,400 seconds, delivery makes at most eight attempts, and each retry sends the remaining TTL. Terminal rows retain only non-secret delivery metadata for seven days.
|
|
350
351
|
- Subscription endpoints must be public HTTPS hostnames on port 443. DNS is bounded and pinned for the request; IP literals, credentials, fragments, and any answer containing a non-global address are rejected.
|
|
351
352
|
- App credential rotation has a 15-minute overlap. VAPID rotation retains retiring keys for 90 days and, if necessary, until old queued items have terminally drained, with four VAPID keys maximum. Rotate before reaching that bound.
|
|
353
|
+
- Upgrading to 32.0.0 reseals every stored Web Push envelope under new associated data, so `DCROUTER_WEB_PUSH_MASTER_KEY_RING` must hold the key each existing envelope names before the first 32.0.0 start; otherwise the `webpush-encryption-context` migration refuses by name and dcrouter does not start. The push payload also loses its `schemaVersion` member with `@serve.zone/interfaces` 32, so a service worker that reads it must stop expecting the field.
|
|
352
354
|
- For a key-ring rotation, add new unique key material, set `currentKeyId`, restart, and keep old encryption keys until every VAPID, delivery, and credential-recovery envelope using them has been rotated or purged. Keep each old HMAC key only until no current or overlap credential references its key id; terminal and idempotency retention does not require the old HMAC key. Remove old keys only after those conditions are true.
|
|
353
355
|
- `deleteWebPushBinding` is a destructive tombstone operation: it revokes and removes all credential hashes, prior credentials, credential-recovery envelopes, and encrypted VAPID private keys; cancels and scrubs queued work; and clears admission state. Queued work is terminalized immediately, but an already sending request remains `sending` until the remote response wins or its lease expires. An expired lease after cancellation is recorded as a failed delivery with an unknown remote outcome, never as a confirmed cancellation. Delete/disable reports success only after that lifecycle drains; a bounded drain timeout returns a retryable failure, and the same controller operation resumes cleanup. Re-enabling the same owner creates a new lifecycle, credential, and VAPID key. Clients must recreate browser push subscriptions and must not reuse deleted credentials.
|
|
354
356
|
- The worker performs bounded maintenance every cycle and at startup, repeatedly removing expired credential overlap/recovery state, drained retiring or retired VAPID private keys, stale admission state, and incomplete disabled/deleted lifecycle cleanup without materializing an unbounded collection.
|
|
@@ -368,6 +370,15 @@ dcrouter keeps generated and operator-created routes separate so automation can
|
|
|
368
370
|
|
|
369
371
|
System routes are persisted with stable `systemKey` values. Ordinary operator-created API routes are editable through generic route CRUD. Routes carrying managed ownership metadata, including Special Forwards and gateway-client routes, reject generic structural updates and deletion so their owning workflow keeps canonical match, priority, source-policy, and ownership fields intact.
|
|
370
372
|
|
|
373
|
+
A gateway-client route is keyed by `metadata.externalKey`, spelled
|
|
374
|
+
`<gatewayClientType>:<gatewayClientId>:<appId>:<routeKey>`. The route key is a bare hostname, or
|
|
375
|
+
`route:<routeRef>` when the client owns a route reference without a hostname, or
|
|
376
|
+
`host-route:<base64url([hostname, routeRef])>` when it owns one hostname under a specific route
|
|
377
|
+
reference. dcrouter composes and matches the key itself: a gateway client sends ownership fields
|
|
378
|
+
and never an `externalKey`. Upgrading to 32.0.1 rewrites stored keys from the released
|
|
379
|
+
`v2:host-route:` spelling through the `host-route-key` migration step; a key that still spells
|
|
380
|
+
the old prefix afterwards is treated as malformed and listed with its route hostname only.
|
|
381
|
+
|
|
371
382
|
## DNS Authority
|
|
372
383
|
|
|
373
384
|
Which zones the embedded DNS server may answer for authoritatively is database
|
|
@@ -666,6 +677,8 @@ await route.toggle(true);
|
|
|
666
677
|
|
|
667
678
|
Use `@serve.zone/dcrouter/interfaces` or `@serve.zone/dcrouter-interfaces` when you want dcrouter-local raw TypedRequest contracts instead of resource managers. Use `@serve.zone/interfaces` for the canonical machine-facing gateway client route, DNS, domain, and mail contracts shared with Onebox and Cloudly.
|
|
668
679
|
|
|
680
|
+
dcrouter 32.0.0 builds against `@serve.zone/interfaces` 32.0.0: the installed interfaces release is the contract version, there is no version field on the wire and no per-shape version suffix. dcrouter serves no session that carries the 32 protocol handshake and opens none — its gateway-client, mail, Web Push and CoreMail gateway contracts are unchanged in 32.0.0 and carry no protocol offer — so no caller has to send one.
|
|
681
|
+
|
|
669
682
|
Gateway-client mail contracts let Cloudly or Onebox claim exact app addresses, attach inbound `smtpForward` targets, enable managed outbound SMTP credentials, rotate those credentials, enqueue service mail, and query delivery status through TypedRequest. `getGatewayClientMailDomainCount` requires an authenticated, enabled gateway-client credential with the `gateway-clients:read` scope and `readMail` capability. It returns only that credential owner's distinct domain count; it does not accept a caller-selected owner and does not load bindings, DNS details, or recent mail. Managed SMTP users can only send as their exact claimed address; mismatched envelope or header `From` values are rejected before relay. Typed submissions may set `replyTo` to one bare printable-ASCII mailbox address. Dcrouter validates it authoritatively and renders one `Reply-To` header; custom headers remain unable to override `Reply-To` or any other protected MIME field. Delivery status queries return the dcrouter spool item state, including deferred SmartMTA errors and next retry timestamps when outbound delivery is temporarily delayed.
|
|
670
683
|
|
|
671
684
|
### Email Operations Views
|
|
@@ -765,7 +778,6 @@ The peer carries verifier material only:
|
|
|
765
778
|
|
|
766
779
|
```json
|
|
767
780
|
{
|
|
768
|
-
"schemaVersion": 1,
|
|
769
781
|
"coreMailServiceId": "coremail-prod",
|
|
770
782
|
"transferOrigin": "https://coremail.example.com",
|
|
771
783
|
"credentials": [
|
|
@@ -773,7 +785,7 @@ The peer carries verifier material only:
|
|
|
773
785
|
"credentialId": "coremail-gateway",
|
|
774
786
|
"version": 2,
|
|
775
787
|
"state": "current",
|
|
776
|
-
"format": "argon2id
|
|
788
|
+
"format": "argon2id",
|
|
777
789
|
"verificationHash": "$argon2id$v=19$m=65536,t=3,p=1$<salt>$<digest>"
|
|
778
790
|
}
|
|
779
791
|
]
|
|
@@ -858,7 +870,9 @@ Supported environment overrides include:
|
|
|
858
870
|
|
|
859
871
|
## Docker Image
|
|
860
872
|
|
|
861
|
-
Release builds publish a multi-arch OCI image
|
|
873
|
+
Release builds publish a multi-arch OCI image for `linux/amd64` and `linux/arm64`. The image sets `DCROUTER_MODE=OCI_CONTAINER` and starts `node ./cli.js`.
|
|
874
|
+
|
|
875
|
+
From 32.0.0 on the image is built from `Dockerfile_##version##`, so `tsdocker` publishes it under the release version tag only — `code.foss.global/serve.zone/dcrouter:32.0.0` for this release — and dcrouter publishes no `latest` tag again. The existing `dcrouter:latest` stays frozen at the 20.2.0 build for consumers that still pull it by that name, including Onebox's managed dcrouter, so pin dcrouter by version.
|
|
862
876
|
|
|
863
877
|
```bash
|
|
864
878
|
docker run --rm --name dcrouter \
|
|
@@ -866,7 +880,7 @@ docker run --rm --name dcrouter \
|
|
|
866
880
|
-v dcrouter-data:/data \
|
|
867
881
|
-e DCROUTER_BASE_DIR=/data \
|
|
868
882
|
-e DCROUTER_TLS_EMAIL=ops@example.com \
|
|
869
|
-
code.foss.global/serve.zone/dcrouter:
|
|
883
|
+
code.foss.global/serve.zone/dcrouter:32.0.0
|
|
870
884
|
```
|
|
871
885
|
|
|
872
886
|
Host networking is the simplest container mode for a gateway that owns HTTP/S, SMTP, DNS, RADIUS, remote ingress, and dynamic proxy ports. For narrower deployments, publish only the ports you enable in `IDcRouterOptions` or via the `DCROUTER_*` environment overrides.
|
|
@@ -900,6 +914,7 @@ Useful source entry points:
|
|
|
900
914
|
- `ts/opsserver/classes.opsserver.ts` wires the dashboard server and TypedRequest handlers.
|
|
901
915
|
- `ts/remoteingress/` integrates `@serve.zone/remoteingress` with stored edge registrations.
|
|
902
916
|
- `ts_migrations/index.ts` contains all DB schema migration steps.
|
|
917
|
+
- `Dockerfile_##version##` builds the service image; the file name is the published image tag.
|
|
903
918
|
|
|
904
919
|
## License and Legal Information
|
|
905
920
|
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -1339,11 +1339,9 @@ export class DcRouter {
|
|
|
1339
1339
|
const migration = await createApplicationMigrationRunner(this.dcRouterDb.getDb(), {
|
|
1340
1340
|
remoteIngressHubSettings: this.getRemoteIngressHubSettingsMigrationSeed(),
|
|
1341
1341
|
emailServerSettings: this.getEmailSettingsMigrationSeed(),
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
'smartmta-storage',
|
|
1342
|
+
webPushMasterKeyRing: await this.qenv.getEnvVarOnDemand(
|
|
1343
|
+
'DCROUTER_WEB_PUSH_MASTER_KEY_RING',
|
|
1345
1344
|
),
|
|
1346
|
-
legacyRawMessageBlobStorage: this.smartMtaBlobStorageManager,
|
|
1347
1345
|
});
|
|
1348
1346
|
const migrationResult = await migration.run();
|
|
1349
1347
|
if (migrationResult.stepsApplied.length > 0) {
|
|
@@ -210,7 +210,7 @@ export class CoreMailGatewayManager {
|
|
|
210
210
|
): Promise<{ success: boolean; peer?: TPeerDesiredState; message?: string }> {
|
|
211
211
|
let record: ICoreMailGatewayPeerRecord;
|
|
212
212
|
try {
|
|
213
|
-
// The normalizer is the contract gate: argon2id
|
|
213
|
+
// The normalizer is the contract gate: argon2id verifier form, exactly
|
|
214
214
|
// one current credential, and the current version greater than every
|
|
215
215
|
// retiring one. It is never relaxed for a producer's convenience.
|
|
216
216
|
record = normalizeCoreMailGatewayPeerRecord(ownerArg, peerArg);
|
|
@@ -259,7 +259,6 @@ export class CoreMailGatewayManager {
|
|
|
259
259
|
|
|
260
260
|
private toPeerDesiredState(peerArg: ICoreMailGatewayPeerRecord): TPeerDesiredState {
|
|
261
261
|
return data().normalizeCoreMailGatewayPeerDesiredState({
|
|
262
|
-
schemaVersion: 1,
|
|
263
262
|
coreMailServiceId: peerArg.coreMailServiceId,
|
|
264
263
|
transferOrigin: peerArg.transferOrigin,
|
|
265
264
|
credentials: peerArg.credentials.map((credentialArg) => ({ ...credentialArg })),
|
|
@@ -52,7 +52,7 @@ export function createCoreMailGatewayPeerId(
|
|
|
52
52
|
/**
|
|
53
53
|
* Normalize one peer record through the shared contract. The desired-state
|
|
54
54
|
* payload is validated by the interfaces normalizer, which is also what
|
|
55
|
-
* rejects plaintext credential material: only argon2id
|
|
55
|
+
* rejects plaintext credential material: only argon2id verifier strings
|
|
56
56
|
* survive it.
|
|
57
57
|
*/
|
|
58
58
|
export function normalizeCoreMailGatewayPeerRecord(
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
normalizeGatewayTargetHost,
|
|
12
12
|
requireGatewayMachineAuth,
|
|
13
13
|
} from '../helpers/gateway-client-auth.js';
|
|
14
|
+
import {
|
|
15
|
+
buildGatewayClientHostRouteKey,
|
|
16
|
+
buildGatewayClientRouteName,
|
|
17
|
+
parseGatewayClientHostRouteKey,
|
|
18
|
+
} from '../helpers/gateway-client-route-key.js';
|
|
19
|
+
import { logger } from '../../logger.js';
|
|
14
20
|
|
|
15
21
|
type TResolvedGatewayClientOwnership = Required<Pick<plugins.servezoneInterfaces.data.IGatewayClientOwnership,
|
|
16
22
|
'gatewayClientType' | 'gatewayClientId' | 'appId'
|
|
@@ -1171,15 +1177,22 @@ export class GatewayClientHandler {
|
|
|
1171
1177
|
const routeKey = metadata.externalKey?.startsWith(prefix)
|
|
1172
1178
|
? metadata.externalKey.slice(prefix.length)
|
|
1173
1179
|
: '';
|
|
1174
|
-
const combinedOwnership =
|
|
1180
|
+
const combinedOwnership = parseGatewayClientHostRouteKey(routeKey);
|
|
1175
1181
|
if (combinedOwnership) {
|
|
1176
1182
|
ownership.hostname = combinedOwnership.hostname;
|
|
1177
1183
|
ownership.routeRef = combinedOwnership.routeRef;
|
|
1178
|
-
} else if (routeKey.startsWith('v2:host-route:')) {
|
|
1179
|
-
// A malformed versioned key must not be exposed as a hostname.
|
|
1180
|
-
ownership.hostname = this.getRouteHostnames(routeArg.route)[0];
|
|
1181
1184
|
} else if (routeKey.startsWith('route:')) {
|
|
1182
1185
|
ownership.routeRef = routeKey.slice('route:'.length);
|
|
1186
|
+
} else if (routeKey.includes(':')) {
|
|
1187
|
+
// A hostname carries no colon, so a colon-bearing key that parses as neither of the two
|
|
1188
|
+
// other forms states ownership this router cannot prove. Report what the route actually
|
|
1189
|
+
// serves rather than the key, and say so instead of dropping the key silently.
|
|
1190
|
+
logger.log(
|
|
1191
|
+
'warn',
|
|
1192
|
+
`Gateway client route ${routeArg.id} carries a malformed route key '${routeKey}'; listing it with its route hostname only.`,
|
|
1193
|
+
{ routeId: routeArg.id },
|
|
1194
|
+
);
|
|
1195
|
+
ownership.hostname = this.getRouteHostnames(routeArg.route)[0];
|
|
1183
1196
|
} else if (routeKey) {
|
|
1184
1197
|
ownership.hostname = routeKey;
|
|
1185
1198
|
} else {
|
|
@@ -1659,8 +1672,7 @@ export class GatewayClientHandler {
|
|
|
1659
1672
|
const routeRef = ownership.routeRef?.trim();
|
|
1660
1673
|
let routeKey: string;
|
|
1661
1674
|
if (hostname && routeRef) {
|
|
1662
|
-
|
|
1663
|
-
routeKey = `v2:host-route:${payload}`;
|
|
1675
|
+
routeKey = buildGatewayClientHostRouteKey(hostname, routeRef);
|
|
1664
1676
|
} else if (hostname) {
|
|
1665
1677
|
routeKey = hostname;
|
|
1666
1678
|
} else {
|
|
@@ -1674,35 +1686,6 @@ export class GatewayClientHandler {
|
|
|
1674
1686
|
].map((part) => part.trim()).join(':');
|
|
1675
1687
|
}
|
|
1676
1688
|
|
|
1677
|
-
private parseCombinedGatewayClientRouteKey(
|
|
1678
|
-
routeKeyArg: string,
|
|
1679
|
-
): { hostname: string; routeRef: string } | undefined {
|
|
1680
|
-
const prefix = 'v2:host-route:';
|
|
1681
|
-
if (!routeKeyArg.startsWith(prefix)) {
|
|
1682
|
-
return undefined;
|
|
1683
|
-
}
|
|
1684
|
-
const payload = routeKeyArg.slice(prefix.length);
|
|
1685
|
-
if (!payload || !/^[A-Za-z0-9_-]+$/.test(payload)) {
|
|
1686
|
-
return undefined;
|
|
1687
|
-
}
|
|
1688
|
-
try {
|
|
1689
|
-
const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
1690
|
-
if (
|
|
1691
|
-
!Array.isArray(parsed)
|
|
1692
|
-
|| parsed.length !== 2
|
|
1693
|
-
|| parsed.some((value) => typeof value !== 'string' || !value.trim())
|
|
1694
|
-
) {
|
|
1695
|
-
return undefined;
|
|
1696
|
-
}
|
|
1697
|
-
return {
|
|
1698
|
-
hostname: parsed[0].trim().toLowerCase(),
|
|
1699
|
-
routeRef: parsed[1].trim(),
|
|
1700
|
-
};
|
|
1701
|
-
} catch {
|
|
1702
|
-
return undefined;
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
1689
|
private validateGatewayRouteConfig(
|
|
1707
1690
|
routeArg: unknown,
|
|
1708
1691
|
): plugins.servezoneInterfaces.data.IGatewayRouteConfig {
|
|
@@ -1882,7 +1865,7 @@ export class GatewayClientHandler {
|
|
|
1882
1865
|
} as any;
|
|
1883
1866
|
}
|
|
1884
1867
|
if (!normalizedRoute.name) {
|
|
1885
|
-
normalizedRoute.name =
|
|
1868
|
+
normalizedRoute.name = buildGatewayClientRouteName(externalKey);
|
|
1886
1869
|
}
|
|
1887
1870
|
return normalizedRoute;
|
|
1888
1871
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-key half of a gateway-client route's `externalKey`, owned here so the composer,
|
|
3
|
+
* the parser, the generated route name and the migration that rewrote stored keys all read
|
|
4
|
+
* one spelling.
|
|
5
|
+
*
|
|
6
|
+
* A stored key is `<gatewayClientType>:<gatewayClientId>:<appId>:<routeKey>`, and the route key
|
|
7
|
+
* is one of three forms: a bare hostname, `route:<routeRef>` when the client owns a route
|
|
8
|
+
* reference without a hostname, and the combined form below when it owns one hostname under a
|
|
9
|
+
* specific route reference. The combined payload is base64url so the whole key stays one opaque
|
|
10
|
+
* segment, and neither other form can be mistaken for it: a hostname carries no colon, and the
|
|
11
|
+
* route-reference form has a different prefix.
|
|
12
|
+
*/
|
|
13
|
+
export const gatewayClientHostRouteKeyPrefix = 'host-route:';
|
|
14
|
+
|
|
15
|
+
export interface IGatewayClientHostRouteKey {
|
|
16
|
+
hostname: string;
|
|
17
|
+
routeRef: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildGatewayClientHostRouteKey(
|
|
21
|
+
hostnameArg: string,
|
|
22
|
+
routeRefArg: string,
|
|
23
|
+
): string {
|
|
24
|
+
const payload = Buffer.from(JSON.stringify([hostnameArg, routeRefArg]), 'utf8').toString('base64url');
|
|
25
|
+
return `${gatewayClientHostRouteKeyPrefix}${payload}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read a combined route key back. Returns undefined for every key that is not one — including a
|
|
30
|
+
* key that carries the prefix but no decodable payload, so a malformed key can never be served
|
|
31
|
+
* as ownership it does not state.
|
|
32
|
+
*/
|
|
33
|
+
export function parseGatewayClientHostRouteKey(
|
|
34
|
+
routeKeyArg: string,
|
|
35
|
+
): IGatewayClientHostRouteKey | undefined {
|
|
36
|
+
if (!routeKeyArg.startsWith(gatewayClientHostRouteKeyPrefix)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const payload = routeKeyArg.slice(gatewayClientHostRouteKeyPrefix.length);
|
|
40
|
+
if (!payload || !/^[A-Za-z0-9_-]+$/.test(payload)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
45
|
+
if (
|
|
46
|
+
!Array.isArray(parsed)
|
|
47
|
+
|| parsed.length !== 2
|
|
48
|
+
|| parsed.some((value) => typeof value !== 'string' || !value.trim())
|
|
49
|
+
) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
hostname: parsed[0].trim().toLowerCase(),
|
|
54
|
+
routeRef: parsed[1].trim(),
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The route name generated for a gateway-client route whose producer sent none. */
|
|
62
|
+
export function buildGatewayClientRouteName(externalKeyArg: string): string {
|
|
63
|
+
return `gateway-client-${externalKeyArg.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
|
|
64
|
+
}
|
|
@@ -6,7 +6,6 @@ export interface IWebPushKeyRing {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export interface IWebPushEncryptedEnvelope {
|
|
9
|
-
version: 1;
|
|
10
9
|
algorithm: 'aes-256-gcm';
|
|
11
10
|
keyId: string;
|
|
12
11
|
nonce: string;
|
|
@@ -14,6 +13,9 @@ export interface IWebPushEncryptedEnvelope {
|
|
|
14
13
|
tag: string;
|
|
15
14
|
}
|
|
16
15
|
|
|
16
|
+
/** Exact member set of a sealed envelope; anything else is refused, never read. */
|
|
17
|
+
const ENVELOPE_KEYS = ['algorithm', 'ciphertext', 'keyId', 'nonce', 'tag'].join(',');
|
|
18
|
+
|
|
17
19
|
export interface IWebPushAadContext {
|
|
18
20
|
gatewayClientId: string;
|
|
19
21
|
appInstanceId: string;
|
|
@@ -33,7 +35,6 @@ const KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
|
33
35
|
const BASE64URL_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
34
36
|
const BASE64URL_VALUE_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
35
37
|
const KEY_RING_LIMIT = 8;
|
|
36
|
-
const AAD_SCHEMA_VERSION = 1;
|
|
37
38
|
|
|
38
39
|
function requirePlainObject(
|
|
39
40
|
valueArg: unknown,
|
|
@@ -146,7 +147,6 @@ export function buildWebPushAad(contextArg: IWebPushAadContext): Buffer {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
return Buffer.from(canonicalJson({
|
|
149
|
-
schemaVersion: AAD_SCHEMA_VERSION,
|
|
150
150
|
gatewayClientId: contextArg.gatewayClientId,
|
|
151
151
|
appInstanceId: contextArg.appInstanceId,
|
|
152
152
|
bindingId: contextArg.bindingId,
|
|
@@ -185,6 +185,81 @@ export function canonicalJson(valueArg: unknown): string {
|
|
|
185
185
|
return JSON.stringify(canonicalize(valueArg));
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Seal one value under a key ring's current key. Kept beside the reader so the
|
|
190
|
+
* two sides of an envelope cannot drift, and exposed at ring level so a caller
|
|
191
|
+
* that holds only the encryption ring — the migration that reseals stored
|
|
192
|
+
* envelopes — does not have to restate the format.
|
|
193
|
+
*/
|
|
194
|
+
export function sealWebPushEnvelope(
|
|
195
|
+
keyRingArg: IWebPushKeyRing,
|
|
196
|
+
valueArg: unknown,
|
|
197
|
+
aadContextArg: IWebPushAadContext,
|
|
198
|
+
): IWebPushEncryptedEnvelope {
|
|
199
|
+
const keyId = keyRingArg.currentKeyId;
|
|
200
|
+
const key = keyRingArg.keys.get(keyId);
|
|
201
|
+
if (!key) throw new Error('Current Web Push encryption key is unavailable');
|
|
202
|
+
const nonce = plugins.crypto.randomBytes(12);
|
|
203
|
+
const cipher = plugins.crypto.createCipheriv('aes-256-gcm', key, nonce);
|
|
204
|
+
cipher.setAAD(buildWebPushAad(aadContextArg));
|
|
205
|
+
const ciphertext = Buffer.concat([
|
|
206
|
+
cipher.update(canonicalJson(valueArg), 'utf8'),
|
|
207
|
+
cipher.final(),
|
|
208
|
+
]);
|
|
209
|
+
return {
|
|
210
|
+
algorithm: 'aes-256-gcm',
|
|
211
|
+
keyId,
|
|
212
|
+
nonce: nonce.toString('base64url'),
|
|
213
|
+
ciphertext: ciphertext.toString('base64url'),
|
|
214
|
+
tag: cipher.getAuthTag().toString('base64url'),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Open one envelope. An envelope whose member set is not exactly the sealed one is refused, never read. */
|
|
219
|
+
export function openWebPushEnvelope<T>(
|
|
220
|
+
keyRingArg: IWebPushKeyRing,
|
|
221
|
+
envelopeArg: IWebPushEncryptedEnvelope,
|
|
222
|
+
aadContextArg: IWebPushAadContext,
|
|
223
|
+
): T {
|
|
224
|
+
if (
|
|
225
|
+
envelopeArg === null
|
|
226
|
+
|| typeof envelopeArg !== 'object'
|
|
227
|
+
|| Object.keys(envelopeArg).sort().join(',') !== ENVELOPE_KEYS
|
|
228
|
+
|| envelopeArg.algorithm !== 'aes-256-gcm'
|
|
229
|
+
|| typeof envelopeArg.keyId !== 'string'
|
|
230
|
+
|| !KEY_ID_PATTERN.test(envelopeArg.keyId)
|
|
231
|
+
) {
|
|
232
|
+
throw new Error('Web Push encrypted envelope is malformed');
|
|
233
|
+
}
|
|
234
|
+
const key = keyRingArg.keys.get(envelopeArg.keyId);
|
|
235
|
+
if (!key) throw new Error('Web Push encrypted envelope references an unavailable key');
|
|
236
|
+
const nonce = decodeBase64UrlExact(envelopeArg.nonce, 12, 'Web Push envelope nonce');
|
|
237
|
+
const tag = decodeBase64UrlExact(envelopeArg.tag, 16, 'Web Push envelope tag');
|
|
238
|
+
if (
|
|
239
|
+
typeof envelopeArg.ciphertext !== 'string'
|
|
240
|
+
|| !envelopeArg.ciphertext
|
|
241
|
+
|| !BASE64URL_VALUE_PATTERN.test(envelopeArg.ciphertext)
|
|
242
|
+
) {
|
|
243
|
+
throw new Error('Web Push envelope ciphertext is malformed');
|
|
244
|
+
}
|
|
245
|
+
const ciphertext = Buffer.from(envelopeArg.ciphertext, 'base64url');
|
|
246
|
+
if (ciphertext.toString('base64url') !== envelopeArg.ciphertext) {
|
|
247
|
+
throw new Error('Web Push envelope ciphertext is malformed');
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const decipher = plugins.crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
251
|
+
decipher.setAAD(buildWebPushAad(aadContextArg));
|
|
252
|
+
decipher.setAuthTag(tag);
|
|
253
|
+
const plaintext = Buffer.concat([
|
|
254
|
+
decipher.update(ciphertext),
|
|
255
|
+
decipher.final(),
|
|
256
|
+
]).toString('utf8');
|
|
257
|
+
return JSON.parse(plaintext) as T;
|
|
258
|
+
} catch (error: unknown) {
|
|
259
|
+
throw new Error('Web Push encrypted envelope authentication failed', { cause: error });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
188
263
|
export class WebPushCrypto {
|
|
189
264
|
public constructor(
|
|
190
265
|
private readonly encryptionRing: IWebPushKeyRing,
|
|
@@ -205,66 +280,14 @@ export class WebPushCrypto {
|
|
|
205
280
|
valueArg: unknown,
|
|
206
281
|
aadContextArg: IWebPushAadContext,
|
|
207
282
|
): IWebPushEncryptedEnvelope {
|
|
208
|
-
|
|
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
|
-
};
|
|
283
|
+
return sealWebPushEnvelope(this.encryptionRing, valueArg, aadContextArg);
|
|
227
284
|
}
|
|
228
285
|
|
|
229
286
|
public decryptJson<T>(
|
|
230
287
|
envelopeArg: IWebPushEncryptedEnvelope,
|
|
231
288
|
aadContextArg: IWebPushAadContext,
|
|
232
289
|
): T {
|
|
233
|
-
|
|
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
|
-
}
|
|
290
|
+
return openWebPushEnvelope<T>(this.encryptionRing, envelopeArg, aadContextArg);
|
|
268
291
|
}
|
|
269
292
|
|
|
270
293
|
public hmac(
|
|
@@ -237,10 +237,10 @@ function normalizePayload(payloadArg: IWebPushNotificationPayload): IWebPushNoti
|
|
|
237
237
|
throw new plugins.typedrequest.TypedResponseError('Web Push payload is malformed');
|
|
238
238
|
}
|
|
239
239
|
const keys = Object.keys(payloadArg).sort();
|
|
240
|
-
if (keys.join(',') !== 'event,eventId,route
|
|
240
|
+
if (keys.join(',') !== 'event,eventId,route') {
|
|
241
241
|
throw new plugins.typedrequest.TypedResponseError('Web Push payload contains unsupported fields');
|
|
242
242
|
}
|
|
243
|
-
if (payloadArg.
|
|
243
|
+
if (payloadArg.event !== 'notificationAvailable') {
|
|
244
244
|
throw new plugins.typedrequest.TypedResponseError('Web Push payload schema is unsupported');
|
|
245
245
|
}
|
|
246
246
|
const eventId = requireOpaqueId(payloadArg.eventId, 'Web Push payload eventId');
|
|
@@ -261,7 +261,6 @@ function normalizePayload(payloadArg: IWebPushNotificationPayload): IWebPushNoti
|
|
|
261
261
|
throw new plugins.typedrequest.TypedResponseError('Web Push payload route must be same-origin');
|
|
262
262
|
}
|
|
263
263
|
const normalized = {
|
|
264
|
-
schemaVersion: 1 as const,
|
|
265
264
|
event: 'notificationAvailable' as const,
|
|
266
265
|
eventId,
|
|
267
266
|
route,
|