DM keys!!!
This commit is contained in:
parent
a660ba32bd
commit
8f28879f18
4 changed files with 686 additions and 4 deletions
339
webroot/main.js
339
webroot/main.js
|
|
@ -136,6 +136,210 @@ function uint8ToBase64(uint8) {
|
|||
return fixBase64Padding(btoa(String.fromCharCode(...uint8)));
|
||||
}
|
||||
|
||||
function concatUint8(a, b) {
|
||||
const out = new Uint8Array(a.length + b.length);
|
||||
out.set(a, 0);
|
||||
out.set(b, a.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations) {
|
||||
const enc = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(passwordPlain),
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations,
|
||||
hash: "SHA-256"
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptJsonWithPassword(jsonObj, passwordPlain) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const iterations = 310000;
|
||||
const key = await deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations);
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(jsonObj));
|
||||
const ciphertextBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
||||
const ciphertext = new Uint8Array(ciphertextBuf);
|
||||
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
kdf: "PBKDF2-SHA256",
|
||||
iter: iterations,
|
||||
alg: "AES-256-GCM",
|
||||
salt: uint8ToBase64(salt),
|
||||
iv: uint8ToBase64(iv),
|
||||
ct: uint8ToBase64(ciphertext)
|
||||
});
|
||||
}
|
||||
|
||||
async function decryptJsonWithPassword(envelopeJson, passwordPlain) {
|
||||
const env = JSON.parse(envelopeJson);
|
||||
if (!env || env.v !== 1 || env.kdf !== "PBKDF2-SHA256" || env.alg !== "AES-256-GCM") {
|
||||
throw new Error("unsupported.key.envelope");
|
||||
}
|
||||
const salt = base64ToUint8(env.salt);
|
||||
const iv = base64ToUint8(env.iv);
|
||||
const ct = base64ToUint8(env.ct);
|
||||
const key = await deriveAesGcmKeyFromPassword(passwordPlain, salt, env.iter);
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
|
||||
return JSON.parse(new TextDecoder().decode(plainBuf));
|
||||
}
|
||||
|
||||
async function sha256Bytes(bytes) {
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return new Uint8Array(hashBuffer);
|
||||
}
|
||||
|
||||
async function hkdfSha256Bytes(ikmBytes, infoString, saltBytes = null, length = 32) {
|
||||
const info = new TextEncoder().encode(infoString);
|
||||
const salt = saltBytes ?? new Uint8Array(32);
|
||||
const keyMaterial = await crypto.subtle.importKey("raw", ikmBytes, "HKDF", false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt, info },
|
||||
keyMaterial,
|
||||
length * 8
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
async function importAesGcmKey(keyBytes) {
|
||||
return crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
async function encryptAesGcmToBase64(plainText, keyBytes) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await importAesGcmKey(keyBytes);
|
||||
const pt = new TextEncoder().encode(plainText);
|
||||
const ctBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, pt);
|
||||
const combined = new Uint8Array(iv.length + ctBuf.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(ctBuf), iv.length);
|
||||
return uint8ToBase64(combined);
|
||||
}
|
||||
|
||||
async function decryptAesGcmFromBase64(cipherBase64, keyBytes) {
|
||||
const combined = base64ToUint8(cipherBase64);
|
||||
const iv = combined.slice(0, 12);
|
||||
const ct = combined.slice(12);
|
||||
const key = await importAesGcmKey(keyBytes);
|
||||
const ptBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
|
||||
return new TextDecoder().decode(ptBuf);
|
||||
}
|
||||
|
||||
function randomRoomKey256Chars() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(192));
|
||||
return uint8ToBase64(bytes);
|
||||
}
|
||||
|
||||
async function deriveDmSelfWrapKeyBytes() {
|
||||
if (!userKeysPrivate) throw new Error("missing.user.private.keys");
|
||||
const privX = base64ToUint8(userKeysPrivate.privX25519);
|
||||
const privM = base64ToUint8(userKeysPrivate.privMlKem);
|
||||
return await hkdfSha256Bytes(concatUint8(privX, privM), "larpix:dm:selfwrap:v1");
|
||||
}
|
||||
|
||||
async function wrapRoomKeyForSelf(roomKey) {
|
||||
const keyBytes = await deriveDmSelfWrapKeyBytes();
|
||||
const enc = await encryptAesGcmToBase64(roomKey, keyBytes);
|
||||
return `DMK1:${enc}`;
|
||||
}
|
||||
|
||||
async function unwrapRoomKeyForSelf(wrapped) {
|
||||
if (!wrapped || !wrapped.startsWith("DMK1:")) throw new Error("unsupported.dm.key.format");
|
||||
const keyBytes = await deriveDmSelfWrapKeyBytes();
|
||||
return await decryptAesGcmFromBase64(wrapped.substring("DMK1:".length), keyBytes);
|
||||
}
|
||||
|
||||
async function deriveHybridKeyToUser(targetPublicKeysObj) {
|
||||
const pubX25519Target = base64ToUint8(targetPublicKeysObj.pubX25519);
|
||||
const pubMlKemTarget = base64ToUint8(targetPublicKeysObj.pubMlKem);
|
||||
|
||||
const privX25519Mine = base64ToUint8(userKeysPrivate.privX25519);
|
||||
const secretX25519 = window.x25519.getSharedSecret(privX25519Mine, pubX25519Target);
|
||||
|
||||
const mlkem = new window.MlKem768();
|
||||
const [ciphertextMlKem, secretMlKem] = await mlkem.encap(pubMlKemTarget);
|
||||
|
||||
const combined = concatUint8(secretX25519, secretMlKem);
|
||||
const aesKeyBytes = await hkdfSha256Bytes(combined, "larpix:dm:roomkeywrap:v1");
|
||||
return {
|
||||
aesKeyBytes,
|
||||
ciphertextMlKemBase64: uint8ToBase64(ciphertextMlKem)
|
||||
};
|
||||
}
|
||||
|
||||
async function deriveHybridKeyFromSetup(otherPublicKeysObj, ciphertextMlKemBase64) {
|
||||
const pubX25519Other = base64ToUint8(otherPublicKeysObj.pubX25519);
|
||||
const privX25519Mine = base64ToUint8(userKeysPrivate.privX25519);
|
||||
const secretX25519 = window.x25519.getSharedSecret(privX25519Mine, pubX25519Other);
|
||||
|
||||
const ct = base64ToUint8(ciphertextMlKemBase64);
|
||||
const privMlKemMine = base64ToUint8(userKeysPrivate.privMlKem);
|
||||
const mlkem = new window.MlKem768();
|
||||
const secretMlKem = await mlkem.decap(ct, privMlKemMine);
|
||||
|
||||
const combined = concatUint8(secretX25519, secretMlKem);
|
||||
const aesKeyBytes = await hkdfSha256Bytes(combined, "larpix:dm:roomkeywrap:v1");
|
||||
return aesKeyBytes;
|
||||
}
|
||||
|
||||
async function fetchDmKey(dmId) {
|
||||
return await fetchEncrypted("dm/key/get", dmId);
|
||||
}
|
||||
|
||||
async function updateDmKey(dmId, keyValue) {
|
||||
return await fetchEncrypted(
|
||||
"dm/key/update",
|
||||
JSON.stringify({
|
||||
string1: dmId,
|
||||
string2: keyValue
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureDmRoomKey(dmId) {
|
||||
//returns decrypted roomkey, and converts SETUP key
|
||||
await ensureUserKeys();
|
||||
const raw = await fetchDmKey(dmId);
|
||||
if (!raw) throw new Error("error:dm.key.missing");
|
||||
|
||||
if (raw.startsWith("SETUP:")) {
|
||||
const rest = raw.substring("SETUP:".length);
|
||||
const parts = rest.split(";");
|
||||
if (parts.length < 2) throw new Error("error:dm.key.setup.malformed");
|
||||
|
||||
const otherPublicJson = parts[0];
|
||||
const setupPayloadJson = parts.slice(1).join(";");
|
||||
const otherPublic = JSON.parse(otherPublicJson);
|
||||
const setupPayload = JSON.parse(setupPayloadJson);
|
||||
|
||||
const aesKeyBytes = await deriveHybridKeyFromSetup(otherPublic, setupPayload.ctMlKem);
|
||||
const roomKey = await decryptAesGcmFromBase64(setupPayload.enc, aesKeyBytes);
|
||||
|
||||
const selfWrapped = await wrapRoomKeyForSelf(roomKey);
|
||||
await updateDmKey(dmId, selfWrapped);
|
||||
return roomKey;
|
||||
}
|
||||
|
||||
return await unwrapRoomKeyForSelf(raw);
|
||||
}
|
||||
|
||||
window.ensureDmRoomKey = ensureDmRoomKey;
|
||||
|
||||
async function encrypt(plainText, keyBytes) {
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const encoder = new TextEncoder();
|
||||
|
|
@ -671,6 +875,115 @@ var passwordHash = "";
|
|||
var host = "";
|
||||
var lang = getLang();
|
||||
|
||||
|
||||
var userKeysPublic = null; //2string json
|
||||
var userKeysPrivate = null; // decrypted private bundle (JSON object)
|
||||
var userKeysEncrypted = null; // encrypted private bundle (string envelope JSON)
|
||||
|
||||
function publishUserKeysGlobals() {
|
||||
window.userKeysPublic = userKeysPublic;
|
||||
window.userKeysPrivate = userKeysPrivate;
|
||||
window.userKeysEncrypted = userKeysEncrypted;
|
||||
}
|
||||
|
||||
async function generateUserKeysBundle() {
|
||||
// X25519 keypair
|
||||
const privX25519 = window.x25519.utils.randomSecretKey();
|
||||
const pubX25519 = window.x25519.getPublicKey(privX25519);
|
||||
|
||||
// ML-KEM-768 keypair
|
||||
const mlkem = new window.MlKem768();
|
||||
const [pubMlKem, privMlKem] = await mlkem.generateKeyPair();
|
||||
|
||||
const pub = {
|
||||
v: 1,
|
||||
alg: "hybrid-x25519-mlkem768",
|
||||
pubX25519: uint8ToBase64(pubX25519),
|
||||
pubMlKem: uint8ToBase64(pubMlKem)
|
||||
};
|
||||
const priv = {
|
||||
v: 1,
|
||||
alg: "hybrid-x25519-mlkem768",
|
||||
privX25519: uint8ToBase64(privX25519),
|
||||
privMlKem: uint8ToBase64(privMlKem)
|
||||
};
|
||||
return { pub, priv };
|
||||
}
|
||||
|
||||
async function ensureUserKeys() {
|
||||
const lsEncKey = `userKeys.enc.v1:${id}`;
|
||||
const lsPubKey = `userKeys.pub.v1:${id}`;
|
||||
|
||||
try { //1. check server
|
||||
const serverKeysRaw = await fetchEncrypted("user/key/get");
|
||||
if (serverKeysRaw && serverKeysRaw.trim() !== "" && serverKeysRaw.trim().startsWith("{")) {
|
||||
const serverKeys = JSON.parse(serverKeysRaw);
|
||||
const serverEncPriv = serverKeys.string1;
|
||||
const serverPub = serverKeys.string2;
|
||||
if (serverEncPriv && serverPub) {
|
||||
userKeysEncrypted = serverEncPriv;
|
||||
userKeysPublic = JSON.parse(serverPub);
|
||||
userKeysPrivate = await decryptJsonWithPassword(userKeysEncrypted, password);
|
||||
localStorage.setItem(lsEncKey, userKeysEncrypted);
|
||||
localStorage.setItem(lsPubKey, JSON.stringify(userKeysPublic));
|
||||
publishUserKeysGlobals();
|
||||
return "success:keys.server";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
//ignore, fallback to local
|
||||
}
|
||||
|
||||
//fallback to local
|
||||
const localEnc = localStorage.getItem(lsEncKey);
|
||||
const localPub = localStorage.getItem(lsPubKey);
|
||||
if (localEnc && localPub) {
|
||||
try {
|
||||
userKeysEncrypted = localEnc;
|
||||
userKeysPublic = JSON.parse(localPub);
|
||||
userKeysPrivate = await decryptJsonWithPassword(userKeysEncrypted, password);
|
||||
|
||||
//push keys to server
|
||||
try {
|
||||
await fetchEncrypted(
|
||||
"user/key/update",
|
||||
JSON.stringify({
|
||||
string1: userKeysEncrypted,
|
||||
string2: JSON.stringify(userKeysPublic)
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
|
||||
publishUserKeysGlobals();
|
||||
return "success:keys.local";
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
//new keys, encrypt private with user's password
|
||||
const bundle = await generateUserKeysBundle();
|
||||
userKeysPublic = bundle.pub;
|
||||
userKeysPrivate = bundle.priv;
|
||||
userKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, password);
|
||||
|
||||
localStorage.setItem(lsEncKey, userKeysEncrypted);
|
||||
localStorage.setItem(lsPubKey, JSON.stringify(userKeysPublic));
|
||||
|
||||
await fetchEncrypted(
|
||||
"user/key/update",
|
||||
JSON.stringify({
|
||||
string1: userKeysEncrypted,
|
||||
string2: JSON.stringify(userKeysPublic)
|
||||
})
|
||||
);
|
||||
|
||||
publishUserKeysGlobals();
|
||||
return "success:keys.generated";
|
||||
}
|
||||
|
||||
async function mainJS() {
|
||||
await initBlahs();
|
||||
|
||||
|
|
@ -1123,10 +1436,32 @@ async function switchInvitesTab(tab) {
|
|||
async function acceptInvite(targetId) { //TODO: Implement key generation
|
||||
try {
|
||||
showAction("action.invite.accepting", "invite.action");
|
||||
|
||||
await ensureUserKeys();
|
||||
|
||||
const inviterPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`);
|
||||
const inviterPublicKeys = JSON.parse(inviterPublicKeysRaw);
|
||||
|
||||
//generate room key
|
||||
const roomKey = randomRoomKey256Chars();
|
||||
|
||||
//encrypt for self
|
||||
const selfWrapped = await wrapRoomKeyForSelf(roomKey);
|
||||
|
||||
//encrypt for inviter
|
||||
const hybrid = await deriveHybridKeyToUser(inviterPublicKeys);
|
||||
const encForInviter = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
|
||||
const setupPayload = JSON.stringify({
|
||||
v: 1,
|
||||
alg: "hybrid-x25519-mlkem768",
|
||||
ctMlKem: hybrid.ciphertextMlKemBase64,
|
||||
enc: encForInviter
|
||||
});
|
||||
|
||||
let payload = JSON.stringify({
|
||||
string1: targetId,
|
||||
string2: "", // TODO: Generate symmetric keys
|
||||
string3: "" // TODO: Encrypt key for targetId
|
||||
string2: selfWrapped,
|
||||
string3: setupPayload
|
||||
});
|
||||
let res = await fetchEncrypted("user/dm/create", payload);
|
||||
clearAction("invite.action");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue