224 lines
6.4 KiB
JavaScript
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
|
|
);
|
|
}
|
|
|