Files
PassKeeper/app/static/js/sharing.js
T
2026-04-16 08:27:07 -04:00

182 lines
6.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));
}
// ── Public API ────────────────────────────────────────────────────────────
return {
generateKeyPair,
exportPublicKey,
encryptPrivateKey,
decryptPrivateKey,
importPublicKey,
deriveSharedKey,
encryptForShare,
decryptShare,
};
})();
/**
* 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 };
})();