apeters 1d204f26b8 pre-Korves.Net
Signed-off-by: apeters <apeters@korves.net>
2025-05-21 08:05:07 +00:00

224 lines
6.4 KiB
JavaScript

class UserCryptoVault {
constructor() {
this.encoder = new TextEncoder();
this.decoder = new TextDecoder();
this.keyPair = null;
this.salt = null;
this.iv = null;
this.wrappedPrivateKey = null;
}
async generateKeyPair() {
this.keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveKey", "deriveBits"]
);
}
async exportPublicKeyPEM() {
const spki = await crypto.subtle.exportKey("spki", this.keyPair.publicKey);
const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
const lines = b64.match(/.{1,64}/g).join("\n");
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----`;
}
async wrapPrivateKeyWithPassword(password) {
this.salt = crypto.getRandomValues(new Uint8Array(16));
this.iv = crypto.getRandomValues(new Uint8Array(12));
const baseKey = await crypto.subtle.importKey(
"raw",
this.encoder.encode(password),
"PBKDF2",
false,
["deriveKey"]
);
const kek = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: this.salt,
iterations: 100000,
hash: "SHA-256",
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"]
);
const privateKeyRaw = await crypto.subtle.exportKey("pkcs8", this.keyPair.privateKey);
this.wrappedPrivateKey = await crypto.subtle.encrypt({ name: "AES-GCM", iv: this.iv }, kek, privateKeyRaw);
}
async unlockPrivateKey(wrappedPrivateKey, salt, iv, password, publicKeyPem) {
const baseKey = await crypto.subtle.importKey(
"raw",
this.encoder.encode(password),
"PBKDF2",
false,
["deriveKey"]
);
const kek = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 100000,
hash: "SHA-256",
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);
const rawPrivateKey = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, kek, wrappedPrivateKey);
const privateKey = await crypto.subtle.importKey(
"pkcs8",
rawPrivateKey,
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveKey", "deriveBits"]
);
const stripped = publicKeyPem.replace(/-----.*?-----|\n/g, "");
const spkiBytes = Uint8Array.from(atob(stripped), c => c.charCodeAt(0));
const publicKey = await crypto.subtle.importKey(
"spki",
spkiBytes.buffer,
{ name: "ECDH", namedCurve: "P-256" },
true,
[]
);
this.keyPair = { privateKey, publicKey };
}
async encryptData(message) {
if (!this.keyPair?.publicKey || !this.keyPair?.privateKey) throw new Error("Vault not unlocked");
const ephemeral = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveKey"]
);
const sharedKey = await crypto.subtle.deriveKey(
{
name: "ECDH",
public: this.keyPair.publicKey,
},
ephemeral.privateKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
sharedKey,
this.encoder.encode(message)
));
const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
// Combine [ephemeral | iv | ciphertext]
const combined = new Uint8Array(ephemeralRaw.length + iv.length + ciphertext.length);
combined.set(ephemeralRaw, 0);
combined.set(iv, ephemeralRaw.length);
combined.set(ciphertext, ephemeralRaw.length + iv.length);
return "uv:" + btoa(String.fromCharCode(...combined));
}
async decryptData(blobBase64) {
if (!this.keyPair?.privateKey) throw new Error("Vault not unlocked");
const combined = Uint8Array.from(atob(blobBase64.replace(/^uv:/, "")), c => c.charCodeAt(0));
const ephemeralLength = 65; // uncompressed EC point for P-256
const ivLength = 12;
const ephemeralRaw = combined.slice(0, ephemeralLength);
const iv = combined.slice(ephemeralLength, ephemeralLength + ivLength);
const ciphertext = combined.slice(ephemeralLength + ivLength);
const ephemeralPubKey = await crypto.subtle.importKey(
"raw",
ephemeralRaw.buffer,
{ name: "ECDH", namedCurve: "P-256" },
true,
[]
);
const sharedKey = await crypto.subtle.deriveKey(
{
name: "ECDH",
public: ephemeralPubKey,
},
this.keyPair.privateKey,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, sharedKey, ciphertext);
return this.decoder.decode(plaintext);
}
isUnlocked() {
return !!this.keyPair?.privateKey;
}
lock() {
this.keyPair = null;
}
async changePassword(oldPassword, newPassword, wrappedPrivateKey, salt, iv, publicKeyPem) {
await this.unlockPrivateKey(wrappedPrivateKey, salt, iv, oldPassword, publicKeyPem);
await this.wrapPrivateKeyWithPassword(newPassword);
}
async exportPayload() {
return {
public_key_pem: await this.exportPublicKeyPEM(),
wrapped_private_key: btoa(String.fromCharCode(...new Uint8Array(this.wrappedPrivateKey))),
salt: btoa(String.fromCharCode(...this.salt)),
iv: btoa(String.fromCharCode(...this.iv)),
};
}
}
window.vault = new UserCryptoVault();
async function VaultSetupUserCryptoAndSend(password) {
await window.vault.generateKeyPair();
await window.vault.wrapPrivateKeyWithPassword(password);
const payload = await window.vault.exportPayload();
return payload;
}
async function VaultUnlockPrivateKey(password, keyData) {
await window.vault.unlockPrivateKey(
Uint8Array.from(atob(keyData.wrapped_private_key), c => c.charCodeAt(0)),
Uint8Array.from(atob(keyData.salt), c => c.charCodeAt(0)),
Uint8Array.from(atob(keyData.iv), c => c.charCodeAt(0)),
password,
keyData.public_key_pem
);
}
async function VaultChangePassword(old_password, new_password, keyData) {
await window.vault.changePassword(old_password, new_password,
Uint8Array.from(atob(keyData.wrapped_private_key), c => c.charCodeAt(0)),
Uint8Array.from(atob(keyData.salt), c => c.charCodeAt(0)),
Uint8Array.from(atob(keyData.iv), c => c.charCodeAt(0)),
keyData.public_key_pem
);
}