fix: replace X25519 with ECDH P-256 for Electron compatibility
Build & Release / Docker / backend (push) Has been cancelled
Build & Release / Docker / frontend (push) Has been cancelled
Build & Release / Desktop / Linux & Windows (push) Has been cancelled
Build & Release / Deploy to server (push) Has been cancelled
Build & Release / Create release (push) Has been cancelled

X25519 Web Crypto support is not available in Electron's Chromium build
despite being in Chrome 113+. P-256 ECDH is universally supported across
all browsers and Electron versions with equivalent security.

- Replace { name: 'X25519' } with { name: 'ECDH', namedCurve: 'P-256' }
  throughout the keypair generation, sealing, and opening functions
- Wire format updated: ephemeral pub key grows from 32 to 65 bytes
  (uncompressed P-256 point: 0x04 || x || y)
- Existing accounts with X25519 keys must re-register

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-28 11:41:11 -04:00
1 parent 84ff218a30
commit 7351f9048a
2 files changed
+23 -38

No files matched your search

+22 -37
View File
@@ -50,24 +50,23 @@ export async function decryptMasterKey(encB64: string, derived: CryptoKey): Prom
]);
}
// ── X25519 ECDH keypair ──────────────────────────────────────────────────────
// ── ECDH P-256 keypair ───────────────────────────────────────────────────────
// P-256 is universally supported across all browsers and Electron versions.
// Raw public key is 65 bytes (uncompressed point: 0x04 || x || y).
const ECDH_ALGO = { name: 'ECDH', namedCurve: 'P-256' } as const;
const PUB_KEY_BYTES = 65; // uncompressed P-256 point
export async function generateX25519Keypair(): Promise<{
publicKeyB64: string;
privateKey: CryptoKey;
pkcs8Bytes: Uint8Array<ArrayBuffer>;
}> {
const kp = await crypto.subtle.generateKey({ name: 'X25519' } as any, true, ['deriveBits']);
const kp = await crypto.subtle.generateKey(ECDH_ALGO, true, ['deriveBits']);
const publicRaw = await crypto.subtle.exportKey('raw', (kp as CryptoKeyPair).publicKey);
const pkcs8 = await crypto.subtle.exportKey('pkcs8', (kp as CryptoKeyPair).privateKey);
const privateKey = await crypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: 'X25519' } as any,
false,
['deriveBits']
);
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, ECDH_ALGO, false, ['deriveBits']);
return {
publicKeyB64: bufToB64(publicRaw),
@@ -85,12 +84,12 @@ export async function decryptPrivateKeyToCryptoKey(
master: CryptoKey
): Promise<CryptoKey> {
const pkcs8 = await aesDecryptRaw(encB64, master);
return crypto.subtle.importKey('pkcs8', pkcs8, { name: 'X25519' } as any, false, ['deriveBits']);
return crypto.subtle.importKey('pkcs8', pkcs8, ECDH_ALGO, false, ['deriveBits']);
}
// ── Conversation key exchange (ephemeral X25519 ECDH sealed box) ─────────────
// ── Conversation key exchange (ephemeral ECDH P-256 sealed box) ───────────────
//
// Wire format: ephemeral_pub_key(32) | nonce(12) | ciphertext
// Wire format: ephemeral_pub_key(65) | nonce(12) | ciphertext
export function generateConvKeyRaw(): Uint8Array<ArrayBuffer> {
return crypto.getRandomValues(new Uint8Array(32));
@@ -100,23 +99,15 @@ export async function sealConvKey(
keyRaw: Uint8Array<ArrayBuffer>,
recipientPublicKeyB64: string
): Promise<string> {
const recipientPubKey = await crypto.subtle.importKey(
'raw',
b64ToBuf(recipientPublicKeyB64),
{ name: 'X25519' } as any,
false,
[]
);
const recipientPubKey = await crypto.subtle.importKey('raw', b64ToBuf(recipientPublicKeyB64), ECDH_ALGO, false, []);
const ephemeral = await crypto.subtle.generateKey({ name: 'X25519' } as any, true, [
'deriveBits',
]);
const ephemeral = await crypto.subtle.generateKey(ECDH_ALGO, true, ['deriveBits']);
const ephemeralPubRaw = new Uint8Array(
await crypto.subtle.exportKey('raw', (ephemeral as CryptoKeyPair).publicKey)
);
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'X25519', public: recipientPubKey } as any,
{ name: 'ECDH', public: recipientPubKey },
(ephemeral as CryptoKeyPair).privateKey,
256
);
@@ -126,10 +117,10 @@ export async function sealConvKey(
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, keyRaw);
const out = new Uint8Array(32 + 12 + ct.byteLength);
const out = new Uint8Array(PUB_KEY_BYTES + 12 + ct.byteLength);
out.set(ephemeralPubRaw, 0);
out.set(nonce, 32);
out.set(new Uint8Array(ct), 44);
out.set(nonce, PUB_KEY_BYTES);
out.set(new Uint8Array(ct), PUB_KEY_BYTES + 12);
return bufToB64(out);
}
@@ -138,20 +129,14 @@ export async function openSealedConvKey(
myPrivateKey: CryptoKey
): Promise<Uint8Array<ArrayBuffer>> {
const data = b64ToBuf(sealedB64);
const ephemeralPubRaw = u8(data, 0, 32);
const nonce = u8(data, 32, 44);
const ct = u8(data, 44);
const ephemeralPubRaw = u8(data, 0, PUB_KEY_BYTES);
const nonce = u8(data, PUB_KEY_BYTES, PUB_KEY_BYTES + 12);
const ct = u8(data, PUB_KEY_BYTES + 12);
const ephemeralPubKey = await crypto.subtle.importKey(
'raw',
ephemeralPubRaw,
{ name: 'X25519' } as any,
false,
[]
);
const ephemeralPubKey = await crypto.subtle.importKey('raw', ephemeralPubRaw, ECDH_ALGO, false, []);
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'X25519', public: ephemeralPubKey } as any,
{ name: 'ECDH', public: ephemeralPubKey },
myPrivateKey,
256
);
+1 -1
View File
@@ -60,7 +60,7 @@ export default function RegisterPage() {
socket.connect(accessToken);
navigate('/messages');
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed');
setError(err.response?.data?.error || err.message || 'Registration failed');
} finally {
setLoading(false);
}