conductor-remote 1.30.4 → 1.31.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/README.md +39 -3
- package/dist/assets/index-B-CPj6V0.js +41 -0
- package/dist/assets/index-DzZRiYLb.css +1 -0
- package/dist/index.html +2 -2
- package/dist/push-sw.js +64 -0
- package/dist/sw.js +1 -1
- package/dist-node/src/notify.js +319 -0
- package/dist-node/src/reads.js +61 -0
- package/dist-node/src/server.js +45 -0
- package/dist-node/src/webpush.js +146 -0
- package/package.json +1 -1
- package/dist/assets/index-7eBQvvzX.js +0 -41
- package/dist/assets/index-8aCnDSQJ.css +0 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Push, written out of `node:crypto` — VAPID (RFC 8292) plus `aes128gcm`
|
|
3
|
+
* payload encryption (RFC 8291 over RFC 8188).
|
|
4
|
+
*
|
|
5
|
+
* Why by hand rather than the `web-push` package: the tarball ships **zero
|
|
6
|
+
* runtime deps** (see CLAUDE.md ▸ traps) and that is worth keeping — the whole
|
|
7
|
+
* protocol is one ECDH, three HKDF expansions and an AES-GCM record, all of
|
|
8
|
+
* which `node:crypto` already has. Nothing here talks to Conductor; it is a
|
|
9
|
+
* pure encoder plus one `fetch`.
|
|
10
|
+
*
|
|
11
|
+
* Two rules the push services enforce and that a hand-rolled encoder gets to
|
|
12
|
+
* discover the hard way:
|
|
13
|
+
* - **The keypair must be stable.** `applicationServerKey` is baked into the
|
|
14
|
+
* browser's subscription at subscribe time, and a push signed by a *different*
|
|
15
|
+
* VAPID key is rejected (403) forever after. So the keys are persisted
|
|
16
|
+
* (src/notify.ts) and never regenerated behind a live subscription.
|
|
17
|
+
* - **A push must carry a notification the SW actually shows.** Silent pushes
|
|
18
|
+
* get a subscription dropped on iOS, so the caller always sends a payload and
|
|
19
|
+
* `public/push-sw.js` always calls `showNotification`.
|
|
20
|
+
*/
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
const b64u = (b) => b.toString('base64url');
|
|
23
|
+
const unb64u = (s) => Buffer.from(s, 'base64url');
|
|
24
|
+
/** `0x04 || x || y` — the uncompressed point form both VAPID and ECDH speak on the wire. */
|
|
25
|
+
function rawPublicKey(jwk) {
|
|
26
|
+
if (!jwk.x || !jwk.y)
|
|
27
|
+
throw new Error('EC JWK missing coordinates');
|
|
28
|
+
return Buffer.concat([Buffer.from([4]), unb64u(jwk.x), unb64u(jwk.y)]);
|
|
29
|
+
}
|
|
30
|
+
export function generateVapidKeys() {
|
|
31
|
+
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
32
|
+
const privateJwk = privateKey.export({ format: 'jwk' });
|
|
33
|
+
return { publicKey: b64u(rawPublicKey(privateJwk)), privateJwk };
|
|
34
|
+
}
|
|
35
|
+
/** HKDF-Expand for a single 32-byte block — every length we need is ≤ 32, so one HMAC covers it. */
|
|
36
|
+
function hkdf(prk, info, length) {
|
|
37
|
+
const block = crypto
|
|
38
|
+
.createHmac('sha256', prk)
|
|
39
|
+
.update(info)
|
|
40
|
+
.update(Buffer.from([1]))
|
|
41
|
+
.digest();
|
|
42
|
+
return block.subarray(0, length);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `Authorization: vapid t=<JWT>, k=<public key>` for one push endpoint.
|
|
46
|
+
*
|
|
47
|
+
* The JWT's audience is the endpoint's *origin* (not the full URL) and it is
|
|
48
|
+
* signed ES256 — which for JOSE means the raw `r||s` pair, hence
|
|
49
|
+
* `dsaEncoding: 'ieee-p1363'`; Node's default DER encoding validates as a
|
|
50
|
+
* signature but is rejected by every push service.
|
|
51
|
+
*/
|
|
52
|
+
export function vapidHeader(endpoint, keys, subject) {
|
|
53
|
+
const header = b64u(Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256' })));
|
|
54
|
+
const claims = b64u(Buffer.from(JSON.stringify({
|
|
55
|
+
aud: new URL(endpoint).origin,
|
|
56
|
+
// Push services cap this at 24h; 12 keeps a clock skew of hours harmless.
|
|
57
|
+
exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,
|
|
58
|
+
sub: subject
|
|
59
|
+
})));
|
|
60
|
+
const signingInput = `${header}.${claims}`;
|
|
61
|
+
const key = crypto.createPrivateKey({ key: keys.privateJwk, format: 'jwk' });
|
|
62
|
+
const signature = crypto.sign('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' });
|
|
63
|
+
return `vapid t=${signingInput}.${b64u(signature)}, k=${keys.publicKey}`;
|
|
64
|
+
}
|
|
65
|
+
/** Record size we advertise. One record, so it only has to exceed the body; push services cap the POST near 4 KB. */
|
|
66
|
+
const RECORD_SIZE = 4096;
|
|
67
|
+
/** Ciphertext overhead per record: the `0x02` last-record delimiter plus the GCM tag. */
|
|
68
|
+
const OVERHEAD = 1 + 16;
|
|
69
|
+
/** Header: 16-byte salt + 4-byte record size + 1-byte key length + the 65-byte key. */
|
|
70
|
+
const HEADER_SIZE = 16 + 4 + 1 + 65;
|
|
71
|
+
/** Longest plaintext that still fits one `RECORD_SIZE` record — callers clip to it rather than get a 413. */
|
|
72
|
+
export const MAX_PAYLOAD_BYTES = RECORD_SIZE - HEADER_SIZE - OVERHEAD;
|
|
73
|
+
/**
|
|
74
|
+
* Encrypt one `aes128gcm` body for a subscription (RFC 8291 §3.4 key schedule).
|
|
75
|
+
*
|
|
76
|
+
* The whole derivation hangs off two secrets the browser gave us — the UA's
|
|
77
|
+
* public key (`p256dh`) and a 16-byte `auth` secret — combined with a fresh
|
|
78
|
+
* ephemeral keypair per message, so no two pushes share a key or nonce.
|
|
79
|
+
*/
|
|
80
|
+
export function encryptPayload(sub, payload) {
|
|
81
|
+
if (payload.length > MAX_PAYLOAD_BYTES)
|
|
82
|
+
throw new Error(`payload too large (${payload.length} bytes)`);
|
|
83
|
+
const uaPublic = unb64u(sub.keys.p256dh);
|
|
84
|
+
const authSecret = unb64u(sub.keys.auth);
|
|
85
|
+
const ecdh = crypto.createECDH('prime256v1');
|
|
86
|
+
ecdh.generateKeys();
|
|
87
|
+
const asPublic = ecdh.getPublicKey();
|
|
88
|
+
const sharedSecret = ecdh.computeSecret(uaPublic);
|
|
89
|
+
// Extract with the auth secret, then bind the derived key material to *both*
|
|
90
|
+
// public keys so a captured record can't be replayed against another subscription.
|
|
91
|
+
const prkKey = crypto.createHmac('sha256', authSecret).update(sharedSecret).digest();
|
|
92
|
+
const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0'), uaPublic, asPublic]);
|
|
93
|
+
const ikm = hkdf(prkKey, keyInfo, 32);
|
|
94
|
+
const salt = crypto.randomBytes(16);
|
|
95
|
+
const prk = crypto.createHmac('sha256', salt).update(ikm).digest();
|
|
96
|
+
const cek = hkdf(prk, Buffer.from('Content-Encoding: aes128gcm\0'), 16);
|
|
97
|
+
const nonce = hkdf(prk, Buffer.from('Content-Encoding: nonce\0'), 12);
|
|
98
|
+
const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce);
|
|
99
|
+
// 0x02 is the delimiter marking this as the *last* record; 0x01 would promise another.
|
|
100
|
+
const ciphertext = Buffer.concat([cipher.update(payload), cipher.update(Buffer.from([2])), cipher.final()]);
|
|
101
|
+
const recordSize = Buffer.alloc(4);
|
|
102
|
+
recordSize.writeUInt32BE(RECORD_SIZE);
|
|
103
|
+
return Buffer.concat([salt, recordSize, Buffer.from([asPublic.length]), asPublic, ciphertext, cipher.getAuthTag()]);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* POST one notification. Resolves with the outcome instead of throwing: a dead
|
|
107
|
+
* subscription (`gone`) is a routine fact the caller prunes on, not an error,
|
|
108
|
+
* and a phone that is merely offline is the push service's problem to hold.
|
|
109
|
+
*/
|
|
110
|
+
export async function sendPush(sub, keys, subject, payload, ttlSeconds) {
|
|
111
|
+
let body;
|
|
112
|
+
try {
|
|
113
|
+
body = encryptPayload(sub, payload);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch(sub.endpoint, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: {
|
|
122
|
+
authorization: vapidHeader(sub.endpoint, keys, subject),
|
|
123
|
+
'content-encoding': 'aes128gcm',
|
|
124
|
+
'content-type': 'application/octet-stream',
|
|
125
|
+
ttl: String(ttlSeconds),
|
|
126
|
+
urgency: 'normal'
|
|
127
|
+
},
|
|
128
|
+
body: new Uint8Array(body),
|
|
129
|
+
signal: AbortSignal.timeout(10_000)
|
|
130
|
+
});
|
|
131
|
+
if (res.ok)
|
|
132
|
+
return { ok: true, status: res.status };
|
|
133
|
+
// The body carries the service's own reason ("VAPID credentials mismatch", …) —
|
|
134
|
+
// far more use than the status alone when a push silently stops arriving.
|
|
135
|
+
const detail = await res.text().catch(() => '');
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
status: res.status,
|
|
139
|
+
gone: res.status === 404 || res.status === 410,
|
|
140
|
+
error: `HTTP ${res.status}${detail ? `: ${detail.slice(0, 200).trim()}` : ''}`
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
|
145
|
+
}
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|