Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ <h3 class="title">Complete!</h3>

<!-- ==================== YOU / ACCOUNT SCREEN ==================== -->
<div class="pure-g"
x-show="engineState != CryptoEngineState.Unknown && personalPublicKey && personalPrivateKey && activeScreen == WhisperScreen.Account">
x-show="engineState != CryptoEngineState.Unknown && personalKeyPair && activeScreen == WhisperScreen.Account">
<div class="pure-u-1 pure-u-lg-1-2">
<div class="content-box">
<h3 class="title">What can Whisper! do?</h3>
Expand Down Expand Up @@ -341,19 +341,19 @@ <h3 id="your_keys" class="title">Your keys</h3>
to
receive whispers from.
</p>
<p x-show="personalPublicKey && personalPublicKey.xKidHint">
<p x-show="!!accountScreen.publicKeyUiModel?.xKidHint">
<u>Please note</u>, that if you clear your browser data for this site, existing keys <u>will
be
lost</u> and new keys will be generated. This is by design, so please ensure that
senders
have your latest Whisper! public key file.
</p>
<p x-show="personalPublicKey && !personalPublicKey.xKidHint">
<p x-show="accountScreen.publicKeyUiModel && !accountScreen.publicKeyUiModel.xKidHint">
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.
</p>
<div class="pure-form pure-form-aligned"
x-show="personalPublicKey && !personalPublicKey.xKidHint">
x-show="accountScreen.publicKeyUiModel && !accountScreen.publicKeyUiModel.xKidHint">
<label for="key-id-hint">Hint about you identity</label>
<input id="key-id-hint" type="text" size="20" minlength="5" maxlength="50"
x-on:keydown="$refs.btnHintOk.disabled = !$el.validity.valid"
Expand All @@ -366,11 +366,11 @@ <h3 id="your_keys" class="title">Your keys</h3>
</div>
<div class="box-with-icon-and-text">
<a href="#" class="pure-menu-link selectable-image-container"
x-show="personalPublicKey && personalPublicKey.xKidHint"
x-bind:href="'data:application/json;charset=utf-8,' + JSON.stringify(personalPublicKey)"
x-bind:download="personalPublicKey?.xKidHint + '-' + personalPublicKey?.kid.slice(0,8) + FileSuffix.PersonalPublicKey"><span
x-show="!!accountScreen.publicKeyUiModel?.xKidHint"
x-bind:href="'data:application/json;charset=utf-8,' + accountScreen.publicKeyUiModel?.downloadJson || ''"
x-bind:download="accountScreen.publicKeyUiModel?.downloadFileName || ''"><span
class="button-icon icon--mdi icon--mdi--tray-arrow-down"></span><br /><span
class="additional-info" x-text="personalPublicKey?.xKidHint"></span></a>
class="additional-info" x-text="accountScreen.publicKeyUiModel?.xKidHint || ''"></span></a>
</div>
</div>
</div>
Expand Down
80 changes: 45 additions & 35 deletions src/ts/whisper-crypto.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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))
}
}

Expand All @@ -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))
Expand All @@ -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<Uint8Array> {
return jose.importJWK(personalPrivateKey, jweAlg)
.then(pk => jose.generalDecrypt(jwe, pk))
export async function decryptFile(jwe: jose.GeneralJWE, personalPrivateKey: CryptoKey): Promise<Uint8Array> {
return jose.generalDecrypt(jwe, personalPrivateKey)
.then(res => res.plaintext)
;
}
Expand All @@ -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[]) {
Expand All @@ -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 {
Expand All @@ -152,38 +158,42 @@ function onDBSuccessfullyOpened(event: Event, listeners: KeysAvailableListener[]
}

/** Stores a key pair */
async function storeKeyPairlocally(keyPair: JWKPair, targetDatabase: IDBDatabase): Promise<JWKPair> {
async function storeKeyPairlocally(keyPair: KeyPair, targetDatabase: IDBDatabase): Promise<KeyPair> {
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<JWKPair> {
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<KeyPair> {
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<KeyPair> {
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<jose.JWK> {
return fingerprint(jwk)
.then(fp => { jwk.kid = fp; return jwk });
}

async function fingerprint(jwk: jose.JWK): Promise<string> {
if (jwk.x && jwk.y) {
return hashWithSHA256(jwk.x + jwk.y);
Expand Down
65 changes: 44 additions & 21 deletions src/ts/whisper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -81,8 +88,7 @@ interface Store {
const store: Store = {
activeScreen: WhisperScreen.Account,
engineState: CryptoEngineState.Unknown,
personalPublicKey: null,
personalPrivateKey: null,
personalKeyPair: null,
sendScreen: {
recipientPublicKeys: [],
filesToEncrypt: [],
Expand All @@ -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);
Expand All @@ -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() {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 }))
Expand All @@ -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, "");
}
Expand Down