309 lines
9.4 KiB
TypeScript
309 lines
9.4 KiB
TypeScript
import crypto from 'node:crypto';
|
|
|
|
export interface VapidKeys {
|
|
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
|
privateKey: string; // base64url scalar D (32 bytes) or PEM
|
|
subject: string;
|
|
}
|
|
|
|
export interface PushSubscriptionKeys {
|
|
p256dh: string; // base64url
|
|
auth: string; // base64url
|
|
}
|
|
|
|
export interface PushSubscriptionTarget {
|
|
endpoint: string;
|
|
keys: PushSubscriptionKeys;
|
|
}
|
|
|
|
export interface WebPushSendResult {
|
|
statusCode: number;
|
|
successful: boolean;
|
|
shouldRemoveSubscription: boolean; // 404 Not Found or 410 Gone
|
|
error?: string | undefined;
|
|
}
|
|
|
|
export interface WebPushOptions {
|
|
fetchFn?: typeof fetch | undefined;
|
|
ttl?: number | undefined;
|
|
urgency?: ('very-low' | 'low' | 'normal' | 'high') | undefined;
|
|
}
|
|
|
|
let cachedEphemeralKeys: VapidKeys | null = null;
|
|
|
|
/**
|
|
* Generate a fresh ECDSA P-256 key pair suitable for WebPush VAPID.
|
|
*/
|
|
export function generateVapidKeys(subject = 'mailto:admin@krona.local'): VapidKeys {
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
|
namedCurve: 'prime256v1',
|
|
});
|
|
const pubJwk = publicKey.export({ format: 'jwk' });
|
|
const privJwk = privateKey.export({ format: 'jwk' });
|
|
|
|
if (!pubJwk.x || !pubJwk.y || !privJwk.d) {
|
|
throw new Error('Failed to generate EC P-256 key pair');
|
|
}
|
|
|
|
const rawPub = Buffer.concat([
|
|
Buffer.from([0x04]),
|
|
Buffer.from(pubJwk.x, 'base64url'),
|
|
Buffer.from(pubJwk.y, 'base64url'),
|
|
]);
|
|
|
|
return {
|
|
publicKey: rawPub.toString('base64url'),
|
|
privateKey: privJwk.d,
|
|
subject,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load VAPID keys from config or environment, or fallback to an in-memory ephemeral key pair.
|
|
*/
|
|
export function resolveVapidKeys(config: {
|
|
VAPID_PUBLIC_KEY?: string | undefined;
|
|
VAPID_PRIVATE_KEY?: string | undefined;
|
|
VAPID_SUBJECT?: string | undefined;
|
|
}): VapidKeys {
|
|
const subject = config.VAPID_SUBJECT || 'mailto:admin@krona.local';
|
|
|
|
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY) {
|
|
return {
|
|
publicKey: config.VAPID_PUBLIC_KEY,
|
|
privateKey: config.VAPID_PRIVATE_KEY,
|
|
subject,
|
|
};
|
|
}
|
|
|
|
if (Boolean(config.VAPID_PUBLIC_KEY) !== Boolean(config.VAPID_PRIVATE_KEY)) {
|
|
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
|
}
|
|
|
|
// Safe ephemeral fallback in memory for dev/test
|
|
if (!cachedEphemeralKeys) {
|
|
cachedEphemeralKeys = generateVapidKeys(subject);
|
|
}
|
|
return cachedEphemeralKeys;
|
|
}
|
|
|
|
/**
|
|
* Convert VAPID private key string to a KeyObject.
|
|
*/
|
|
function createVapidPrivateKeyObject(vapidKeys: VapidKeys): crypto.KeyObject {
|
|
const priv = vapidKeys.privateKey.trim();
|
|
if (priv.startsWith('-----BEGIN')) {
|
|
return crypto.createPrivateKey({ key: priv, format: 'pem' });
|
|
}
|
|
|
|
// Raw 32-byte base64url scalar D
|
|
const pubBuf = Buffer.from(vapidKeys.publicKey, 'base64url');
|
|
if (pubBuf.length !== 65 || pubBuf[0] !== 0x04) {
|
|
throw new Error('Invalid VAPID public key length or format');
|
|
}
|
|
const x = pubBuf.subarray(1, 33).toString('base64url');
|
|
const y = pubBuf.subarray(33, 65).toString('base64url');
|
|
|
|
return crypto.createPrivateKey({
|
|
key: {
|
|
kty: 'EC',
|
|
crv: 'P-256',
|
|
x,
|
|
y,
|
|
d: vapidKeys.privateKey,
|
|
},
|
|
format: 'jwk',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a VAPID Authorization header (RFC 8292).
|
|
*/
|
|
export function createVapidAuthorizationHeader(
|
|
endpoint: string,
|
|
vapidKeys: VapidKeys,
|
|
expirationSeconds = 12 * 3600,
|
|
): string {
|
|
const url = new URL(endpoint);
|
|
const audience = `${url.protocol}//${url.host}`;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const exp = now + expirationSeconds;
|
|
|
|
const header = { typ: 'JWT', alg: 'ES256' };
|
|
const payload = { aud: audience, exp, sub: vapidKeys.subject };
|
|
|
|
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
|
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
const signingInput = `${headerB64}.${payloadB64}`;
|
|
|
|
const privateKey = createVapidPrivateKeyObject(vapidKeys);
|
|
const signature = crypto.sign('SHA256', Buffer.from(signingInput, 'utf8'), {
|
|
key: privateKey,
|
|
dsaEncoding: 'ieee-p1363',
|
|
});
|
|
|
|
const jwt = `${signingInput}.${signature.toString('base64url')}`;
|
|
return `vapid t=${jwt}, k=${vapidKeys.publicKey}`;
|
|
}
|
|
|
|
/**
|
|
* Encrypt a payload using AES-128-GCM content encoding (RFC 8291 / RFC 8188).
|
|
*/
|
|
export function encryptPushPayload(payload: string | Buffer, keys: PushSubscriptionKeys): Buffer {
|
|
const plaintext = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
|
|
const recipientP256dh = Buffer.from(keys.p256dh, 'base64url');
|
|
const recipientAuth = Buffer.from(keys.auth, 'base64url');
|
|
|
|
if (recipientP256dh.length !== 65 || recipientP256dh[0] !== 0x04) {
|
|
throw new Error('Invalid recipient p256dh key: expected 65-byte uncompressed point');
|
|
}
|
|
if (recipientAuth.length !== 16) {
|
|
throw new Error('Invalid recipient auth secret: expected 16 bytes');
|
|
}
|
|
|
|
// 1. Generate ephemeral sender key pair
|
|
const sender = crypto.createECDH('prime256v1');
|
|
sender.generateKeys();
|
|
const senderPublicKey = sender.getPublicKey(); // 65 bytes
|
|
const sharedSecret = sender.computeSecret(recipientP256dh);
|
|
|
|
// 2. Derive PRK via HKDF-Extract & Expand (RFC 8291 Section 3.2)
|
|
const keyInfo = Buffer.concat([
|
|
Buffer.from('WebPush: info\0', 'utf8'),
|
|
recipientP256dh,
|
|
senderPublicKey,
|
|
]);
|
|
const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32));
|
|
|
|
// 3. Generate 16-byte random salt
|
|
const salt = crypto.randomBytes(16);
|
|
|
|
// 4. Derive CEK and NONCE (RFC 8291 Section 3.3)
|
|
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
|
|
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16));
|
|
|
|
const nonceInfo = Buffer.from('Content-Encoding: nonce\0', 'utf8');
|
|
const nonce = Buffer.from(crypto.hkdfSync('sha256', prk, salt, nonceInfo, 12));
|
|
|
|
// 5. Append padding delimiter (0x02 for the final/only record in RFC 8188)
|
|
const record = Buffer.concat([plaintext, Buffer.from([0x02])]);
|
|
|
|
// 6. Encrypt with AES-128-GCM
|
|
const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce);
|
|
const ciphertext = Buffer.concat([cipher.update(record), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
|
|
// 7. Build RFC 8188 header:
|
|
// salt (16) + rs (4, uint32 BE = 4096) + idlen (1, uint8 = 65) + keyid (65, senderPublicKey)
|
|
const rs = 4096;
|
|
const rsBuf = Buffer.alloc(4);
|
|
rsBuf.writeUInt32BE(rs, 0);
|
|
|
|
const header = Buffer.concat([
|
|
salt,
|
|
rsBuf,
|
|
Buffer.from([senderPublicKey.length]),
|
|
senderPublicKey,
|
|
]);
|
|
|
|
return Buffer.concat([header, ciphertext, tag]);
|
|
}
|
|
|
|
/**
|
|
* Send an encrypted WebPush notification over HTTPS.
|
|
*/
|
|
export function isPrivateOrLoopbackHost(host: string): boolean {
|
|
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
|
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
|
const ipv4Match = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
if (ipv4Match) {
|
|
const parts = ipv4Match.slice(1).map(Number);
|
|
const [o1, o2] = parts;
|
|
if (o1 === 127 || o1 === 10 || o1 === 0) return true;
|
|
if (o1 === 172 && o2! >= 16 && o2! <= 31) return true;
|
|
if (o1 === 192 && o2! === 168) return true;
|
|
if (o1 === 169 && o2! === 254) return true;
|
|
}
|
|
if (
|
|
h === '::1' ||
|
|
h === '::' ||
|
|
h.startsWith('fc') ||
|
|
h.startsWith('fd') ||
|
|
h.startsWith('fe80') ||
|
|
h.includes('::ffff:127.') ||
|
|
h.includes('::ffff:10.') ||
|
|
h.includes('::ffff:192.168.')
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function sendWebPush(
|
|
target: PushSubscriptionTarget,
|
|
payload: string,
|
|
vapidKeys: VapidKeys,
|
|
options?: WebPushOptions,
|
|
): Promise<WebPushSendResult> {
|
|
if (!target.endpoint.startsWith('https://')) {
|
|
throw new Error('Subscription endpoint must use HTTPS');
|
|
}
|
|
|
|
let targetUrl: URL;
|
|
try {
|
|
targetUrl = new URL(target.endpoint);
|
|
} catch {
|
|
throw new Error('Invalid endpoint URL');
|
|
}
|
|
|
|
if (isPrivateOrLoopbackHost(targetUrl.hostname)) {
|
|
return {
|
|
statusCode: 400,
|
|
successful: false,
|
|
shouldRemoveSubscription: true,
|
|
error: 'Cannot dispatch push to internal or private IP address',
|
|
};
|
|
}
|
|
|
|
const encryptedBody = encryptPushPayload(payload, target.keys);
|
|
const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
|
|
const fetchImpl = options?.fetchFn ?? fetch;
|
|
|
|
try {
|
|
const response = await fetchImpl(target.endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: authHeader,
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Encoding': 'aes128gcm',
|
|
TTL: String(options?.ttl ?? 86400),
|
|
Urgency: options?.urgency ?? 'normal',
|
|
},
|
|
body: new Uint8Array(encryptedBody),
|
|
redirect: 'manual',
|
|
});
|
|
|
|
|
|
const statusCode = response.status;
|
|
if (statusCode >= 200 && statusCode < 300) {
|
|
return { statusCode, successful: true, shouldRemoveSubscription: false };
|
|
}
|
|
|
|
const shouldRemoveSubscription = statusCode === 404 || statusCode === 410;
|
|
const bodyText = await response.text().catch(() => '');
|
|
return {
|
|
statusCode,
|
|
successful: false,
|
|
shouldRemoveSubscription,
|
|
error: `Push service rejected notification: HTTP ${statusCode}${bodyText ? ` (${bodyText.slice(0, 200)})` : ''}`,
|
|
};
|
|
} catch (err) {
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
statusCode: 0,
|
|
successful: false,
|
|
shouldRemoveSubscription: false,
|
|
error: `Network error delivering push: ${errorMsg}`,
|
|
};
|
|
}
|
|
}
|