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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# whisper-ui
![](img-doc/whisper.png) Secure and very easy to use sending of encrypted data anywhere (browser ui)
![](img-doc/whisper.png) Secure and very easy to use local encryption to send data anywhere (browser ui)

## Motivation
Make it super easy to locally encrypt sensible data for designated recipients ensuring privacy, integrity and compliance on whatever way the data is transported. Keys should be considered throw away material and not be reused often in order to render the transported cryptograms useless, even if they are retained in e.g. mailboxes.

## Demo
See it in action and test it at [whisper.syncorix.com](https://whisper.syncorix.com/). Use at your own risk according to [LICENSE](LICENSE).

## How does it work?
![](img-doc/how-does-it-work.png)
This is quite simple: Whisper! uses [asymmetric cryptography](https://en.wikipedia.org/wiki/Public-key_cryptography) and
Expand Down Expand Up @@ -31,12 +34,15 @@ Drop encrypted stuff you received, then decrypt locally and download result:
</div>

## Features (free)
* Easy to use and portable across major browsers
* Completely local, you can disconnect internet after the page is loaded
* 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
* Pretty secure algorithms like ECDH-ES, P-256 for key pair, ECDH-ES+A256KW for key wrapping and A256GCM for content encryption
* Use the hosted version (the good stuff happens locally anyway) or self host and modify it
* TODO: List security and compliance features
* TODO: continue

## Advanced and convenience features (non-free)
* Get a branded, maintained and supported installation for your organization and its partners
Expand Down
1 change: 1 addition & 0 deletions build/build-iconify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const chosenIcons: Array<string> = [
'lock-open-check-outline',
'robot-dead',
'security',
'trash-can-outline',
'tray-arrow-down',
];

Expand Down
4 changes: 4 additions & 0 deletions src/css/mdi-icons.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M12 12h7c-.53 4.11-3.28 7.78-7 8.92zH5V6.3l7-3.11M12 1L3 5v6c0 5.55 3.84 10.73 9 12c5.16-1.27 9-6.45 9-12V5z'/%3E%3C/svg%3E");
}

.icon--mdi--trash-can-outline {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3zM7 6h10v13H7zm2 2v9h2V8zm4 0v9h2V8z'/%3E%3C/svg%3E");
}

.icon--mdi--tray-arrow-down {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M2 12h2v5h16v-5h2v5c0 1.11-.89 2-2 2H4a2 2 0 0 1-2-2zm10 3l5.55-5.46l-1.42-1.41L13 11.25V2h-2v9.25L7.88 8.13L6.46 9.55z'/%3E%3C/svg%3E");
}
10 changes: 10 additions & 0 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
/* Syncorix Lime Greem */
}

/* for foreground elements like dialogs */
::backdrop {
background-color: white;
opacity: 0.75;
}

dialog {
border: 1px solid gray;
}

/* create a box w/ margins to put content into */
.content-box {
margin-top: 2rem;
Expand Down
31 changes: 30 additions & 1 deletion src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ <h3 id="your_keys" class="title">Your keys</h3>
</p>
<div class="pure-form pure-form-aligned"
x-show="accountScreen.publicKeyUiModel && !accountScreen.publicKeyUiModel.xKidHint">
<label for="key-id-hint">Hint about you identity</label>
<label for="key-id-hint">Hint about your identity</label>
<input id="key-id-hint" type="text" size="20" minlength="5" maxlength="50"
x-on:keydown="$refs.btnHintOk.disabled = !$el.validity.valid"
placeholder="Name or E-Mail" x-model="accountScreen.personalKeyHint" />
Expand All @@ -365,12 +365,41 @@ <h3 id="your_keys" class="title">Your keys</h3>
</div>
</div>
<div class="box-with-icon-and-text">
<!-- download public key -->
<a href="#" class="pure-menu-link selectable-image-container"
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="accountScreen.publicKeyUiModel?.xKidHint || ''"></span></a>
<!-- delete keys and generate new ones -->
<a href="#" class="pure-menu-link selectable-image-container"
x-show="!!accountScreen.publicKeyUiModel?.xKidHint"
x-on:click="$refs.delKeysDialog.showModal()"><span
class="button-icon icon--mdi icon--mdi--trash-can-outline"></span><br /><span
class="additional-info">Throw keys away</span></a>
<!-- modal dialog to confirm key deletion and regeneration -->
<dialog x-ref="delKeysDialog">
<div class="box-with-icon-and-text">
<span class="marker-icon icon--mdi icon--mdi--trash-can-outline"></span>
<div>
<p>
Data encrypted with this key pair cannot be decrypted again and once deleted the
keys cannot be recovered.
</p>
Are you sure?
<div class="box-with-icon-and-text">
<a href="#" class="pure-menu-link selectable-image-container"
x-on:click="$refs.delKeysDialog.close(); $deleteAndRegenerateKeys"><span
class="button-icon icon--mdi icon--mdi--check"></span><br /><span
class="additional-info">Yes</span></a>
<a href="#" class="pure-menu-link selectable-image-container"
x-on:click="$refs.delKeysDialog.close()" autofocus><span
class="button-icon icon--mdi icon--mdi--close"></span><br /><span
class="additional-info">No</span></a>
</div>
</div>
</dialog>
</div>
</div>
</div>
Expand Down
38 changes: 26 additions & 12 deletions src/ts/whisper-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ enum DBModes {
RW = 'readwrite'
}
type KeysAvailableListener = (personalKeyPair: KeyPair) => void;
export default function (...listeners: KeysAvailableListener[]) {


export function start(listeners: KeysAvailableListener[]) {
// Check for IndexedDB support
// TODO: Consider using https://modernizr.com/ for IndexedDB and WebCryptoAPI checks
if (!window.indexedDB) {
Expand All @@ -33,7 +35,19 @@ export default function (...listeners: KeysAvailableListener[]) {
} else {
console.warn("No listeners provided, skipping key retrieval.")
}
} // makes this a module
}

export async function deleteAndRegenerateKeys(listeners: KeysAvailableListener[]) {
const request = indexedDB.open(keyDatabaseName, dbSchemaVersion);
request.onsuccess = e => {
(e.target as IDBOpenDBRequest).result
.transaction([keyPairsObjectStoreName], DBModes.RW)
.objectStore(keyPairsObjectStoreName)
.clear();
console.log("%s deleted from IndexedDB as requested by user", keyPairsObjectStoreName);
start(listeners);
}
}

export async function validateAndParseJWKFile(jwkFile: File): Promise<JWKWithKeyHint> {
// Parse the JWK string into a JSON object
Expand Down Expand Up @@ -98,14 +112,6 @@ export async function encryptFileForMultipleRecipients(file: File, recipients: j
;
}

async function addRecipient(jwk: jose.JWK, encryptor: jose.GeneralEncrypt): Promise<jose.Recipient> {
// Use ECDH-ES+A256KW for key wrapping, will generate an epk (ephemeral public key) and cek (Content Encryption Key)
return jose.importJWK(jwk, jweAlg)
.then(pk => encryptor.addRecipient(pk))
// Add the kid to recipient header in JWE in order to select key when decrypting
.then(r => r.setUnprotectedHeader({ kid: jwk.kid }));
}

export async function decryptFile(jwe: jose.GeneralJWE, personalPrivateKey: CryptoKey): Promise<Uint8Array> {
return jose.generalDecrypt(jwe, personalPrivateKey)
.then(res => res.plaintext)
Expand Down Expand Up @@ -162,7 +168,7 @@ async function storeKeyPairlocally(keyPair: KeyPair, targetDatabase: IDBDatabase
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.publicKey.kid)
console.log("Successfully persisted keypair with label %s and kid %s", keyPair.publicKey.xKidHint, keyPair.publicKey.kid)
return keyPair;
}

Expand All @@ -189,6 +195,14 @@ function addAlgorithm(jwk: jose.JWK): jose.JWK {
return jwk;
}

async function addRecipient(jwk: jose.JWK, encryptor: jose.GeneralEncrypt): Promise<jose.Recipient> {
// Use ECDH-ES+A256KW for key wrapping, will generate an epk (ephemeral public key) and cek (Content Encryption Key)
return jose.importJWK(jwk, jweAlg)
.then(pk => encryptor.addRecipient(pk))
// Add the kid to recipient header in JWE in order to select key when decrypting
.then(r => r.setUnprotectedHeader({ kid: jwk.kid }));
}

async function addFingerprint(jwk: jose.JWK): Promise<jose.JWK> {
return fingerprint(jwk)
.then(fp => { jwk.kid = fp; return jwk });
Expand All @@ -207,4 +221,4 @@ async function hashWithSHA256(input: string): Promise<string> {
const hashArray = Array.from(new Uint8Array(hashBuffer)); // Convert buffer to byte array
const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); // Convert bytes to hex
return hashHex;
}
}
14 changes: 13 additions & 1 deletion src/ts/whisper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import * as dropzone from './whisper-dropzone'
import { softwareVersion } from './build-info'

// Call default module exports, provide listeners
crypto.default(keysAvailable)
crypto.start([keysAvailable])
dropzone.default([whisperFilesAdded], [recipientKeysAdded], [receivedWhisperFilesAdded])

// Screens selectable per menu
Expand Down Expand Up @@ -112,6 +112,7 @@ Alpine.magic('reset', reset);
Alpine.magic('submitPersonalKeyHint', submitPersonalKeyHint);
Alpine.magic('executeEncryption', executeEncryption);
Alpine.magic('executeDecryption', executeDecryption);
Alpine.magic('deleteAndRegenerateKeys', deleteAndRegenerateKeys);
// Start the show
Alpine.start();

Expand Down Expand Up @@ -212,6 +213,17 @@ function executeDecryption() {
}
}

function deleteAndRegenerateKeys() {
// Completely reset UI
reset();
const store = (Alpine.store(whisperStateStoreName) as Store)
store.accountScreen.publicKeyUiModel = null;
store.personalKeyPair = null;
store.engineState = CryptoEngineState.Unknown;
// Delete and regenerate personal key pair
crypto.deleteAndRegenerateKeys([keysAvailable]);
}

function toUiModel(publicKey: JWKWithKeyHint): PublicKeyUiModel {
if (!publicKey.kid) {
throw "kid is missing on public jwk!"
Expand Down