diff --git a/README.md b/README.md index 04f86c4..0ec999b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Drop encrypted stuff you received, then decrypt locally and download result: ## Features (free) * A keypair is automatically generated locally and stored in the browser's IndexedDB +* The private key is created with [```{ extractable: false }```](src/ts/whisper-crypto.ts) and cannot be [exported by the application](https://www.w3.org/TR/WebCryptoAPI/#cryptokey-interface-members). * The public part of the keypair can be copied and distributed to those who want to send you sensitive data * The private part remains local and is the only way to decrypt data addressed to you * Use the hosted version (the good stuff happens locally anyway) or self host and modify it diff --git a/src/index.html b/src/index.html index 9221036..ba7435c 100644 --- a/src/index.html +++ b/src/index.html @@ -306,7 +306,7 @@

Complete!

+ x-show="engineState != CryptoEngineState.Unknown && personalKeyPair && activeScreen == WhisperScreen.Account">

What can Whisper! do?

@@ -341,19 +341,19 @@

Your keys

to receive whispers from.

-

+

Please note, that if you clear your browser data for this site, existing keys will be lost and new keys will be generated. This is by design, so please ensure that senders have your latest Whisper! public key file.

-

+

Before downloading and distributing your key, please provide some info on who you might be, like Name or E-Mail to help senders using it.

+ x-show="accountScreen.publicKeyUiModel && !accountScreen.publicKeyUiModel.xKidHint"> Your keys

+ class="additional-info" x-text="accountScreen.publicKeyUiModel?.xKidHint || ''">
diff --git a/src/ts/whisper-crypto.ts b/src/ts/whisper-crypto.ts index ecbd336..7858e77 100644 --- a/src/ts/whisper-crypto.ts +++ b/src/ts/whisper-crypto.ts @@ -1,27 +1,28 @@ import * as jose from 'jose' -/** A pair consiting of a private an a public key. */ -export interface JWKPair { - privateJWK: jose.JWK - publicJWK: JWKWithKeyHint +/** A pair consisting of a private an a public key. */ +export interface KeyPair { + privateKey: CryptoKey + publicKey: JWKWithKeyHint } export interface JWKWithKeyHint extends jose.JWK { /** Custom extension to add a hint (like name or email) */ xKidHint?: string; } -const curveAlg = "ES256"; // TODO: Not supported by every browser, try to generate the most secure one -//const curveAlgorithm: string = "Ed25519"; +// See https://github.com/panva/jose/issues/210 +// TODO: Not supported by every browser, try to generate the most secure one suitable for encryption +const curveAlg = "ECDH-ES"; const jweEnc = "A256GCM"; const jweAlg = "ECDH-ES+A256KW"; -const dbSchemaVersion = 1; +const dbSchemaVersion = 2; const keyDatabaseName = "JWKDatabase"; const keyPairsObjectStoreName = 'keys'; enum DBModes { R = 'readonly', RW = 'readwrite' } -type KeysAvailableListener = (personalKeyPair: JWKPair) => void; +type KeysAvailableListener = (personalKeyPair: KeyPair) => void; export default function (...listeners: KeysAvailableListener[]) { // Check for IndexedDB support // TODO: Consider using https://modernizr.com/ for IndexedDB and WebCryptoAPI checks @@ -70,13 +71,13 @@ export async function validateAndParseJWEFile(jweFile: File, jwk: jose.JWK): Pro } /** Updates the changed key pair previously changed by reference (we might want to change this someday) */ -export function storePersonalKeyPair(personalKeyPair: JWKPair, ...listeners: KeysAvailableListener[]) { +export function storePersonalKeyPair(personalKeyPair: KeyPair, ...listeners: KeysAvailableListener[]) { const request = indexedDB.open(keyDatabaseName, dbSchemaVersion); request.onsuccess = e => { const db = (e.target as IDBOpenDBRequest).result; storeKeyPairlocally(personalKeyPair, db) .then(kp => listeners?.forEach(l => l(kp))) - .catch(e => console.warn("Failed to store updated keypair for kid %s", personalKeyPair.publicJWK.kid, e)) + .catch(e => console.warn("Failed to store updated keypair for kid %s", personalKeyPair.publicKey.kid, e)) } } @@ -88,7 +89,7 @@ export async function encryptFileForMultipleRecipients(file: File, recipients: j .then(b => new jose.GeneralEncrypt(b)) // Set JWE encoding and algorithm in protected header .then(e => e.setProtectedHeader({ enc: jweEnc, alg: jweAlg })) - // For each recipient, encrypt the CEK using their ES256 public key + // For each recipient, encrypt the CEK using their public key .then(e => recipients.map(jwk => addRecipient(jwk, e))) // Collect all promisied for the recipients .then(p => Promise.all(p)) @@ -105,9 +106,8 @@ async function addRecipient(jwk: jose.JWK, encryptor: jose.GeneralEncrypt): Prom .then(r => r.setUnprotectedHeader({ kid: jwk.kid })); } -export async function decryptFile(jwe: jose.GeneralJWE, personalPrivateKey: jose.JWK): Promise { - return jose.importJWK(personalPrivateKey, jweAlg) - .then(pk => jose.generalDecrypt(jwe, pk)) +export async function decryptFile(jwe: jose.GeneralJWE, personalPrivateKey: CryptoKey): Promise { + return jose.generalDecrypt(jwe, personalPrivateKey) .then(res => res.plaintext) ; } @@ -123,8 +123,14 @@ function fetchOrGeneratePersonalKeyPair(listeners: KeysAvailableListener[]) { function onDBUpgradeNeeded(event: IDBVersionChangeEvent) { const db = (event.target as IDBOpenDBRequest).result; + if (db.objectStoreNames.contains(keyPairsObjectStoreName)) { + console.warn("An old Whisper! keypair has been found and has unfortunately to be deleted as it cannot be upgraded for security reasons :/") + db.deleteObjectStore(keyPairsObjectStoreName); + console.log("Old IndexDB version dropped and can be recreated with schema version %s!", dbSchemaVersion); + } + // (Re) Create an object store const objectStore = db.createObjectStore(keyPairsObjectStoreName); - console.log("IndexDB upgraded %s to schema version %s!", objectStore.name, dbSchemaVersion); + console.log("IndexDB created %s with schema version %s!", objectStore.name, dbSchemaVersion); } function onDBSuccessfullyOpened(event: Event, listeners: KeysAvailableListener[]) { @@ -138,7 +144,7 @@ function onDBSuccessfullyOpened(event: Event, listeners: KeysAvailableListener[] getRequest.onsuccess = function (event: Event) { const data = (event.target as IDBRequest).result; if (data) { - const keyPair: JWKPair = data; + const keyPair: KeyPair = data; console.log('Retrieved JWK:', keyPair); listeners.forEach(l => l(keyPair)); } else { @@ -152,38 +158,42 @@ function onDBSuccessfullyOpened(event: Event, listeners: KeysAvailableListener[] } /** Stores a key pair */ -async function storeKeyPairlocally(keyPair: JWKPair, targetDatabase: IDBDatabase): Promise { +async function storeKeyPairlocally(keyPair: KeyPair, targetDatabase: IDBDatabase): Promise { const transaction = targetDatabase.transaction([keyPairsObjectStoreName], DBModes.RW); const objectStore = transaction.objectStore(keyPairsObjectStoreName); objectStore.put(keyPair, 0); // For now we only support a single pair - console.log("Successfully persisted keypair for kid %s", keyPair.publicJWK.kid) + console.log("Successfully persisted keypair for kid %s", keyPair.publicKey.kid) return keyPair; } /** Generates a new key pair */ -async function generateNewPair(): Promise { - return jose.generateKeyPair(curveAlg, { extractable: true }) - .then(({ privateKey, publicKey }) => Promise.all([ - jose.exportJWK(privateKey), - jose.exportJWK(publicKey) - ])) - .then(([privateJWK, publicJWK]) => ({ privateJWK, publicJWK })) - .then((pair: JWKPair) => { - // Manually add alg, as it is not done by default! - pair.privateJWK.alg = curveAlg; - pair.publicJWK.alg = curveAlg; - return pair - }) - .then((pair: JWKPair) => { - // Precalculate and add a fingerprint for identification as kid - return fingerprint(pair.publicJWK).then(fp => { pair.privateJWK.kid = fp; pair.publicJWK.kid = fp; return pair }) - }) +async function generateNewPair(): Promise { + return jose.generateKeyPair(curveAlg, { extractable: false }) + .then(toWhisperKeyPair) .catch(error => { console.error('Error generating key pair:', error); throw error; }); } +async function toWhisperKeyPair(keypairResult: jose.GenerateKeyPairResult): Promise { + return jose.exportJWK(keypairResult.publicKey) + // Manually add alg, as it is not done by default! + .then(addAlgorithm) + .then(addFingerprint) + .then(jwk => ({ privateKey: keypairResult.privateKey, publicKey: jwk } as KeyPair)) +} + +function addAlgorithm(jwk: jose.JWK): jose.JWK { + jwk.alg = curveAlg; + return jwk; +} + +async function addFingerprint(jwk: jose.JWK): Promise { + return fingerprint(jwk) + .then(fp => { jwk.kid = fp; return jwk }); +} + async function fingerprint(jwk: jose.JWK): Promise { if (jwk.x && jwk.y) { return hashWithSHA256(jwk.x + jwk.y); diff --git a/src/ts/whisper.ts b/src/ts/whisper.ts index 43c42b2..1ddcd16 100644 --- a/src/ts/whisper.ts +++ b/src/ts/whisper.ts @@ -7,11 +7,11 @@ import 'purecss/build/pure-min.css'; import 'purecss/build/grids-responsive.css'; // Whisper crypto module import * as crypto from './whisper-crypto' -import { JWKWithKeyHint, JWKPair } from './whisper-crypto' +import { JWKWithKeyHint, KeyPair } from './whisper-crypto' // Drag'n'drop handling import * as dropzone from './whisper-dropzone' // Build info -import { softwareVersion} from './build-info' +import { softwareVersion } from './build-info' // Call default module exports, provide listeners crypto.default(keysAvailable) @@ -62,15 +62,22 @@ interface ReceivedScreen { encryptedFiles: EncryptedFile[]; decryptedFiles: DecryptedFile[]; } +// display projection of the public JWK +interface PublicKeyUiModel { + kid: string; + xKidHint: string | null; + downloadJson: string; + downloadFileName: string; +} interface AccountScreen { personalKeyHint: string; + publicKeyUiModel: PublicKeyUiModel | null; } // type of local "session" object interface Store { activeScreen: WhisperScreen; engineState: CryptoEngineState; - personalPublicKey: JWKWithKeyHint | null; - personalPrivateKey: JWK | null; + personalKeyPair: KeyPair | null; sendScreen: SendScreen; receivedScreen: ReceivedScreen; accountScreen: AccountScreen; @@ -81,8 +88,7 @@ interface Store { const store: Store = { activeScreen: WhisperScreen.Account, engineState: CryptoEngineState.Unknown, - personalPublicKey: null, - personalPrivateKey: null, + personalKeyPair: null, sendScreen: { recipientPublicKeys: [], filesToEncrypt: [], @@ -94,9 +100,10 @@ const store: Store = { }, accountScreen: { personalKeyHint: '', + publicKeyUiModel: null }, appVersion: softwareVersion, - shortenTo11: shortenTo11 + shortenTo11: shortenTo11, } // Register store with alpine Alpine.store(whisperStateStoreName, store); @@ -109,12 +116,12 @@ Alpine.magic('executeDecryption', executeDecryption); Alpine.start(); // Update store when keys become accessible -function keysAvailable(personalKeyPair: JWKPair) { +function keysAvailable(personalKeyPair: KeyPair) { const store = (Alpine.store(whisperStateStoreName) as Store) - store.personalPublicKey = personalKeyPair.publicJWK; - store.personalPrivateKey = personalKeyPair.privateJWK; + store.personalKeyPair = personalKeyPair; // Enable all components dependent on crypto engine store.engineState = CryptoEngineState.Initialized; + store.accountScreen.publicKeyUiModel = toUiModel(personalKeyPair.publicKey); } // Reset states in order to start over function reset() { @@ -138,13 +145,14 @@ function reset() { function submitPersonalKeyHint() { // Set the hint via Alpine, to make the UI react to the update const alpineStore = (Alpine.store(whisperStateStoreName) as Store) - if (alpineStore.personalPublicKey) { + if (alpineStore.personalKeyPair && alpineStore.personalKeyPair.publicKey) { // Set custom hint and save updated reference - alpineStore.personalPublicKey.xKidHint = alpineStore.accountScreen.personalKeyHint + alpineStore.personalKeyPair.publicKey.xKidHint = alpineStore.accountScreen.personalKeyHint + alpineStore.accountScreen.publicKeyUiModel = toUiModel(alpineStore.personalKeyPair.publicKey); } - // But store the "raw" keys and not the alpine proxy to IndexedDB - if (store.personalPublicKey && store.personalPrivateKey) { - crypto.storePersonalKeyPair({ privateJWK: store.personalPrivateKey, publicJWK: store.personalPublicKey }) + if (store.personalKeyPair) { + // But store the "raw" keys and not the alpine proxy to IndexedDB + crypto.storePersonalKeyPair(store.personalKeyPair) } } @@ -178,8 +186,8 @@ function executeEncryption() { async function receivedWhisperFilesAdded(files: File[]) { const store = (Alpine.store(whisperStateStoreName) as Store) - if (store.personalPublicKey) { - const jwk: JWK = store.personalPublicKey; + if (store.personalKeyPair) { + const jwk: JWK = store.personalKeyPair.publicKey; files.forEach(f => { crypto.validateAndParseJWEFile(f, jwk) .then(j => ({ name: f.name, jwe: j })) @@ -191,18 +199,33 @@ async function receivedWhisperFilesAdded(files: File[]) { function executeDecryption() { const store = (Alpine.store(whisperStateStoreName) as Store) - if (store.personalPrivateKey) { - const jwk: JWK = store.personalPrivateKey; + if (store.personalKeyPair) { + const kid = store.personalKeyPair.publicKey.kid; + const pk: CryptoKey = store.personalKeyPair.privateKey; store.receivedScreen.encryptedFiles.forEach(f => { - crypto.decryptFile(f.jwe, jwk) + crypto.decryptFile(f.jwe, pk) .then(d => ({ name: tryExtractFileName(f.name), data: base64(d) } as DecryptedFile)) .then(r => store.receivedScreen.decryptedFiles.push(r)) - .then(() => console.log("Successully decrypted %s with kid %s.", f.name, jwk.kid)) + .then(() => console.log("Successully decrypted %s with kid %s.", f.name, kid)) .catch(e => console.warn("Failed to decrypt %s", f.name, e)) }) } } +function toUiModel(publicKey: JWKWithKeyHint): PublicKeyUiModel { + if (!publicKey.kid) { + throw "kid is missing on public jwk!" + } + const kid: string = publicKey.kid + const xKidHint: string | null = publicKey.xKidHint || null; + return { + kid: kid, + xKidHint: xKidHint, + downloadJson: JSON.stringify(publicKey), + downloadFileName: xKidHint + "-" + kid.slice(0, 8) + FileSuffix.PersonalPublicKey + } +} + function tryExtractFileName(fileName: string): string { return fileName.replace(FileSuffix.EncryptedFile, ""); }