diff --git a/crypto/crypto.test.ts b/crypto/crypto.test.ts index 2e9c807..d5acfd7 100644 --- a/crypto/crypto.test.ts +++ b/crypto/crypto.test.ts @@ -14,6 +14,14 @@ Deno.test("encode & decode keypair", async () => { expect(decoded.id).toBe(keypair.id); }); +Deno.test("encode & decode keypair w/ password", async () => { + const password = "password"; + const encoded = await keypair.encode(password); + const decoded = await BlahKeyPair.fromEncoded(encoded, password); + + expect(decoded.id).toBe(keypair.id); +}); + Deno.test("sign & verify payload", async () => { const payload = { foo: "bar", baz: 123 }; const signedPayload = await keypair.signPayload(payload); diff --git a/crypto/keypair.ts b/crypto/keypair.ts index fd15669..c4cd21f 100644 --- a/crypto/keypair.ts +++ b/crypto/keypair.ts @@ -1,13 +1,22 @@ import canonicalize from "./canonicalize.ts"; +import { hexToBuf } from "./mod.ts"; +import { pbkdf2Key } from "./pbkdf2.ts"; import { BlahPublicKey } from "./publicKey.ts"; import type { BlahPayloadSignee, BlahSignedPayload } from "./signedPayload.ts"; import { bufToHex } from "./utils.ts"; -export type EncodedBlahKeyPair = { - v: "0"; - id: string; - privateKey: JsonWebKey; -}; +export type EncodedBlahKeyPair = + & { + v: "0"; + id: string; + } + & ({ + privateKey: JsonWebKey; + } | { + passwordProtectedPrivateKey: string; + iv: string; + salt: string; + }); export class BlahKeyPair { publicKey: BlahPublicKey; @@ -28,45 +37,98 @@ export class BlahKeyPair { this.privateKey = privateKey; } - static async generate( - extractable: boolean = true, - additionalUsage: KeyUsage[] = [], - ): Promise { + static async generate(extractable: boolean = true): Promise { const { publicKey, privateKey } = await crypto.subtle.generateKey( "Ed25519", extractable, - [ - "sign", - "verify", - ...additionalUsage, - ], + ["sign", "verify"], ) as CryptoKeyPair; const publicIdentity = await BlahPublicKey.fromPublicKey(publicKey); return new BlahKeyPair(publicIdentity, privateKey); } - static async fromEncoded(encoded: EncodedBlahKeyPair): Promise { + static async fromEncoded( + encoded: EncodedBlahKeyPair, + password?: string, + ): Promise { if (encoded.v !== "0") { throw new Error("Unsupported version"); } - const publicIdentity = await BlahPublicKey.fromID(encoded.id); - const privateKey = await crypto.subtle.importKey( - "jwk", - encoded.privateKey, - { name: "Ed25519" }, - true, - ["sign"], - ); - return new BlahKeyPair(publicIdentity, privateKey); + const publicIdentity = await BlahPublicKey.fromID(encoded.id); + + if ("passwordProtectedPrivateKey" in encoded) { + if (!password) { + throw new Error("Private key is password-protected."); + } + + const derviedKey = await pbkdf2Key(password, encoded.salt); + const privateKey = await crypto.subtle.unwrapKey( + "pkcs8", + hexToBuf(encoded.passwordProtectedPrivateKey), + derviedKey, + { + name: "AES-GCM", + iv: hexToBuf(encoded.iv), + }, + { name: "Ed25519" }, + true, + ["sign"], + ); + + return new BlahKeyPair(publicIdentity, privateKey); + } else { + const privateKey = await crypto.subtle.importKey( + "jwk", + encoded.privateKey, + { name: "Ed25519" }, + true, + ["sign"], + ); + + return new BlahKeyPair(publicIdentity, privateKey); + } } - async encode(): Promise { - return { - v: "0", - id: this.publicKey.id, - privateKey: await crypto.subtle.exportKey("jwk", this.privateKey), - }; + async encode(password?: string): Promise { + if (!this.privateKey.extractable) { + throw new Error("Private key is not extractable."); + } + + if (password) { + const saltBuf = new Uint8Array(16); + crypto.getRandomValues(saltBuf); + const salt = bufToHex(saltBuf); + + const ivBuf = new Uint8Array(12); + crypto.getRandomValues(ivBuf); + const iv = bufToHex(ivBuf); + + const derviedKey = await pbkdf2Key(password, saltBuf); + const wrappedPrivateKey = await crypto.subtle.wrapKey( + "pkcs8", + this.privateKey, + derviedKey, + { + name: "AES-GCM", + iv: ivBuf, + }, + ); + + return { + v: "0", + id: this.publicKey.id, + passwordProtectedPrivateKey: bufToHex(wrappedPrivateKey), + iv, + salt, + }; + } else { + return { + v: "0", + id: this.publicKey.id, + privateKey: await crypto.subtle.exportKey("jwk", this.privateKey), + }; + } } async signPayload

( diff --git a/crypto/pbkdf2.ts b/crypto/pbkdf2.ts new file mode 100644 index 0000000..7aa2e82 --- /dev/null +++ b/crypto/pbkdf2.ts @@ -0,0 +1,29 @@ +import { hexToBuf } from "./mod.ts"; + +export async function pbkdf2Key( + password: string, + salt: string | Uint8Array, +): Promise { + const passwordKey = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); + + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: typeof salt === "string" ? hexToBuf(salt) : salt, + iterations: 250000, + hash: "SHA-256", + }, + passwordKey, + { name: "AES-GCM", length: 256 }, + false, + ["wrapKey", "unwrapKey"], + ); + + return key; +}