216 lines
7.1 KiB
JavaScript
216 lines
7.1 KiB
JavaScript
/**
|
|
* sharing.js — ECDH P-256 cryptography for zero-knowledge item sharing
|
|
*
|
|
* Each user has a P-256 keypair:
|
|
* - Public key : stored on server as base64 raw bytes (65-byte uncompressed point)
|
|
* - Private key : stored on server as JWK, AES-256-GCM encrypted with the user's vault key
|
|
*
|
|
* When Alice shares with Bob:
|
|
* 1. Alice fetches Bob's public key from the server
|
|
* 2. Alice derives an ECDH shared secret: ECDH(Alice_priv, Bob_pub)
|
|
* 3. Alice imports that shared secret as an AES-256-GCM key
|
|
* 4. Alice encrypts the item's plaintext → (enc_data, iv)
|
|
* 5. Alice POST /api/sharing with the ciphertext — server stores the blob
|
|
*
|
|
* When Bob decrypts:
|
|
* 1. Bob fetches Alice's public key from the server (returned in inbox response)
|
|
* 2. Bob derives the same ECDH shared secret: ECDH(Bob_priv, Alice_pub) ← commutative!
|
|
* 3. Bob decrypts enc_data with the derived key
|
|
*
|
|
* The server only ever sees ciphertext. Zero-knowledge.
|
|
*/
|
|
|
|
const SharingCrypto = (() => {
|
|
const subtle = window.crypto.subtle;
|
|
|
|
// ── Helpers (same encoding as crypto.js) ─────────────────────────────────
|
|
|
|
function base64ToBytes(b64) {
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
function bytesToBase64(bytes) {
|
|
let bin = '';
|
|
bytes.forEach(b => (bin += String.fromCharCode(b)));
|
|
return btoa(bin);
|
|
}
|
|
|
|
// ── Key generation ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate a fresh ECDH P-256 keypair.
|
|
* Both keys are extractable so they can be exported/stored.
|
|
*/
|
|
async function generateKeyPair() {
|
|
return subtle.generateKey(
|
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
true,
|
|
['deriveBits'],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Export the public key as raw bytes (uncompressed point, 65 bytes) → base64.
|
|
* This is what gets stored on the server and shared with other users.
|
|
*/
|
|
async function exportPublicKey(publicKey) {
|
|
const raw = await subtle.exportKey('raw', publicKey);
|
|
return bytesToBase64(new Uint8Array(raw));
|
|
}
|
|
|
|
/**
|
|
* Encrypt the private key JWK with the user's vault key (AES-256-GCM).
|
|
* The resulting ciphertext is stored on the server — only the user can decrypt it.
|
|
*/
|
|
async function encryptPrivateKey(vaultKey, privateKey) {
|
|
const jwk = await subtle.exportKey('jwk', privateKey);
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
const plaintext = new TextEncoder().encode(JSON.stringify(jwk));
|
|
const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, vaultKey, plaintext);
|
|
return {
|
|
private_key_enc: bytesToBase64(new Uint8Array(ciphertext)),
|
|
private_key_iv: bytesToBase64(iv),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decrypt the private key JWK (fetched from server) using the user's vault key.
|
|
* Returns a CryptoKey usable for ECDH deriveBits.
|
|
*/
|
|
async function decryptPrivateKey(vaultKey, private_key_enc, private_key_iv) {
|
|
const plaintext = await subtle.decrypt(
|
|
{ name: 'AES-GCM', iv: base64ToBytes(private_key_iv) },
|
|
vaultKey,
|
|
base64ToBytes(private_key_enc),
|
|
);
|
|
const jwk = JSON.parse(new TextDecoder().decode(plaintext));
|
|
return subtle.importKey(
|
|
'jwk',
|
|
jwk,
|
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
false,
|
|
['deriveBits'],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Import a remote user's public key from its base64 raw representation.
|
|
*/
|
|
async function importPublicKey(base64Raw) {
|
|
return subtle.importKey(
|
|
'raw',
|
|
base64ToBytes(base64Raw),
|
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
false,
|
|
[], // public keys have no usages in WebCrypto ECDH
|
|
);
|
|
}
|
|
|
|
// ── Shared-secret derivation ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Derive an AES-256-GCM CryptoKey from the ECDH shared secret.
|
|
* ECDH is commutative: ECDH(A_priv, B_pub) === ECDH(B_priv, A_pub).
|
|
*/
|
|
async function deriveSharedKey(myPrivateKey, theirPublicKey) {
|
|
const bits = await subtle.deriveBits(
|
|
{ name: 'ECDH', public: theirPublicKey },
|
|
myPrivateKey,
|
|
256,
|
|
);
|
|
return subtle.importKey(
|
|
'raw',
|
|
bits,
|
|
{ name: 'AES-GCM' },
|
|
false,
|
|
['encrypt', 'decrypt'],
|
|
);
|
|
}
|
|
|
|
// ── Encrypt / Decrypt with shared key ────────────────────────────────────
|
|
|
|
async function encryptForShare(sharedKey, plaintextObject) {
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
const plaintext = new TextEncoder().encode(JSON.stringify(plaintextObject));
|
|
const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, plaintext);
|
|
return {
|
|
enc_data: bytesToBase64(new Uint8Array(ciphertext)),
|
|
iv: bytesToBase64(iv),
|
|
};
|
|
}
|
|
|
|
async function decryptShare(sharedKey, enc_data, iv) {
|
|
const plaintext = await subtle.decrypt(
|
|
{ name: 'AES-GCM', iv: base64ToBytes(iv) },
|
|
sharedKey,
|
|
base64ToBytes(enc_data),
|
|
);
|
|
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
}
|
|
|
|
/**
|
|
* Encrypt a display name string with the ECDH shared key.
|
|
* Uses the same AES-256-GCM primitive as encryptForShare but operates on
|
|
* a plain string rather than a JSON-serialised object.
|
|
*/
|
|
async function encryptName(sharedKey, name) {
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
const plaintext = new TextEncoder().encode(name);
|
|
const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, plaintext);
|
|
return {
|
|
enc_name: bytesToBase64(new Uint8Array(ciphertext)),
|
|
iv_name: bytesToBase64(iv),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decrypt an encrypted display name.
|
|
* Returns null on failure (e.g. legacy share with no enc_name).
|
|
*/
|
|
async function decryptName(sharedKey, enc_name, iv_name) {
|
|
try {
|
|
const plaintext = await subtle.decrypt(
|
|
{ name: 'AES-GCM', iv: base64ToBytes(iv_name) },
|
|
sharedKey,
|
|
base64ToBytes(enc_name),
|
|
);
|
|
return new TextDecoder().decode(plaintext);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
|
|
return {
|
|
generateKeyPair,
|
|
exportPublicKey,
|
|
encryptPrivateKey,
|
|
decryptPrivateKey,
|
|
importPublicKey,
|
|
deriveSharedKey,
|
|
encryptForShare,
|
|
decryptShare,
|
|
encryptName,
|
|
decryptName,
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* SharingSession — holds the decrypted ECDH private key for the tab lifetime.
|
|
* Similar to VaultSession; cleared on sign-out.
|
|
*/
|
|
const SharingSession = (() => {
|
|
let _privateKey = null;
|
|
|
|
function setKey(key) { _privateKey = key; }
|
|
function getKey() { return _privateKey; }
|
|
function clear() { _privateKey = null; }
|
|
function isReady() { return _privateKey !== null; }
|
|
|
|
return { setKey, getKey, clear, isReady };
|
|
})();
|