Candidates refresh (server + mobile + desktop clients):
- GET /api/client-auth/connection/candidates returns the server's current
LAN URLs, relay candidate, and serverId for already-paired devices
- /health and /api/version expose serverId so clients can verify a learned
address belongs to the expected server before sending their bearer token
- mobile: refresh saved candidates over the live transport after every
connect/wake, hot-switch relay->LAN when a fresh address is reachable;
serverId gate on direct probes; token no longer sent to /health
- desktop: refresh stored host apiUrl after a relay connect and hot-switch
back to direct; electron probe verifies serverId before authenticated fetch
Fixes found while debugging a dead pairing:
- settings: strict reader that throws on corrupt/unreadable file instead of
returning {}; relay signing/encryption key generation is now gated on it,
so a swallowed read failure can no longer mint a new server identity and
orphan every paired device (loud log when a keypair IS generated)
- SessionAuthGate: bounded auto-retry for transient session-check failures
(initial request racing the relay tunnel's first WS attempt, startup 5xx)
74 lines
3.7 KiB
JavaScript
74 lines
3.7 KiB
JavaScript
// Per-server relay signing identity (ECDSA P-256), extracted from
|
|
// lib/notifications/apns-runtime.js so both the push relay and the private
|
|
// relay share the SAME keypair and thus the SAME serverId
|
|
// (base64url(SHA-256(canonical public JWK))). Storage format is unchanged:
|
|
// `settings.relaySigningKey = { privateJwk, publicJwk }` — existing installs'
|
|
// serverId must stay stable because push token binding depends on it.
|
|
|
|
/**
|
|
* @param {{
|
|
* crypto: typeof import('node:crypto'),
|
|
* readSettingsFromDiskMigrated: () => Promise<object>,
|
|
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
|
* readSettingsStrict?: () => Promise<object>,
|
|
* }} deps
|
|
* @returns {Promise<{ privateKey: import('node:crypto').KeyObject, publicJwk: JsonWebKey }>}
|
|
*/
|
|
export const getOrCreateRelaySigningKeypair = async ({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict }) => {
|
|
const toKeypair = (stored) => ({
|
|
privateKey: crypto.createPrivateKey({ key: stored.privateJwk, format: 'jwk' }),
|
|
publicJwk: stored.publicJwk,
|
|
});
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
const existing = settings?.relaySigningKey;
|
|
if (existing && existing.privateJwk && existing.publicJwk) {
|
|
return toKeypair(existing);
|
|
}
|
|
// Regeneration gate: the lenient settings reader maps read failures to `{}`,
|
|
// indistinguishable from "first run". Minting a new keypair changes serverId,
|
|
// which orphans every paired device and push binding AND the write below would
|
|
// clobber the settings file with the empty spread. Re-verify with the strict
|
|
// reader (throws on corrupt/unreadable) before generating; if it finds the
|
|
// key the lenient read lost, use it and generate nothing.
|
|
let verifiedSettings = settings;
|
|
if (readSettingsStrict) {
|
|
verifiedSettings = await readSettingsStrict();
|
|
const verified = verifiedSettings?.relaySigningKey;
|
|
if (verified && verified.privateJwk && verified.publicJwk) {
|
|
return toKeypair(verified);
|
|
}
|
|
}
|
|
// Loud on purpose: a new signing key means a new serverId — every previously
|
|
// paired device and push binding is orphaned. Expected exactly once, on first run.
|
|
console.warn('[relay-identity] Generating NEW relay signing keypair (serverId changes; previously paired devices must re-pair)');
|
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
const privateJwk = privateKey.export({ format: 'jwk' });
|
|
const publicJwk = publicKey.export({ format: 'jwk' });
|
|
await writeSettingsToDisk({ ...settings, ...(verifiedSettings || {}), relaySigningKey: { privateJwk, publicJwk } });
|
|
return { privateKey, publicJwk };
|
|
};
|
|
|
|
// Fixed key order so the hash is stable regardless of stored JSON field order.
|
|
// Byte-for-byte mirror of canonicalJwk in openchamber-website apps/api relay-auth.ts.
|
|
/** @param {JsonWebKey} jwk */
|
|
export const canonicalPublicJwkString = (jwk) =>
|
|
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
|
|
|
/**
|
|
* serverId = base64url(SHA-256(canonical public JWK)). Must match the push
|
|
* relay's deriveServerId — this id is the routing key for both relays.
|
|
* @param {{ crypto: typeof import('node:crypto') }} deps
|
|
* @param {JsonWebKey} publicJwk
|
|
*/
|
|
export const deriveServerId = ({ crypto }, publicJwk) =>
|
|
crypto.createHash('sha256').update(canonicalPublicJwkString(publicJwk)).digest('base64url');
|
|
|
|
/**
|
|
* ECDSA-SHA256, IEEE P1363 (raw r||s) signature — the form WebCrypto verifies.
|
|
* @param {{ crypto: typeof import('node:crypto') }} deps
|
|
* @param {import('node:crypto').KeyObject} privateKey
|
|
* @param {string} message
|
|
*/
|
|
export const signRelayMessage = ({ crypto }, privateKey, message) =>
|
|
crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|