Created
October 5, 2020 17:26
-
-
Save clementauger/020dac3d06e0567f16cf274ad778548c to your computer and use it in GitHub Desktop.
double ratchet javascript experimentation, adapted from https://github.com/HR/ciphora
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const crypto = require('crypto'), | |
hkdf = require('futoin-hkdf'), | |
// TODO: Replace with crypto.diffieHellman once nodejs#26626 lands on v12 LTS | |
{ box } = require('tweetnacl'), | |
boxutil = require('tweetnacl-util'), | |
{ chunk, hexToUint8 } = require('./utils'), | |
CIPHER = 'aes-256-cbc', | |
RATCHET_KEYS_LEN = 64, | |
RATCHET_KEYS_HASH = 'SHA-256', | |
MESSAGE_KEY_LEN = 80, | |
MESSAGE_CHUNK_LEN = 32, | |
MESSAGE_KEY_SEED = 1, // 0x01 | |
CHAIN_KEY_SEED = 2, // 0x02 | |
RACHET_MESSAGE_COUNT = 10 // Rachet after this no of messages sent | |
module.exports = class Crypto { | |
constructor (signVerifier) { | |
// Ensure singleton | |
if (!!Crypto.instance) { | |
return Crypto.instance | |
} | |
this._chatKeys = {} | |
// this._selfKey = {} | |
this._signVerifier = signVerifier | |
// Bindings | |
this.init = this.init.bind(this) | |
Crypto.instance = this | |
} | |
// Initialise chat keys and load own from store | |
async init (opts) { | |
await this._signVerifier.generateKey(opts) | |
} | |
// Exports self public key | |
publicKey () { | |
return this._signVerifier.publicKey() | |
} | |
// Imports a new chat key | |
async addKey (id, publicKey) { | |
this._chatKeys[id] = { publicKey } | |
} | |
// Deletes a chat key | |
async deleteKey (id) { | |
delete this._chatKeys[id] | |
console.log('Deleted key', id) | |
} | |
// Hash Key Derivation Function (based on HMAC) | |
_HKDF (input, salt, info, length = RATCHET_KEYS_LEN) { | |
// input = input instanceof Uint8Array ? Buffer.from(input) : input | |
// salt = salt instanceof Uint8Array ? Buffer.from(salt) : salt | |
return hkdf(input, length, { | |
salt, | |
info, | |
hash: RATCHET_KEYS_HASH | |
}) | |
} | |
// Hash-based Message Authentication Code | |
_HMAC (key, data, enc = 'utf8', algo = 'sha256') { | |
return crypto | |
.createHmac(algo, key) | |
.update(data) | |
.digest(enc) | |
} | |
// Signs a message with own key | |
async sign (message) { | |
// const { privateKey } = this._selfKey | |
// const { signature } = await pgp.sign({ | |
// message: pgp.cleartext.fromText(message), | |
// privateKeys: [privateKey], | |
// detached: true | |
// }) | |
// console.log('PGP signed message') | |
// return signature | |
return this._signVerifier.sign(message) | |
} | |
// Verifies a message with user's key | |
async verify (id, message, signature) { | |
// Get user's public key | |
const { publicKey } = this._chatKeys[id] | |
// // Fail verification if all params are not supplied | |
// if (!message || !publicKey || !signature) return false | |
// const verified = await pgp.verify({ | |
// message: pgp.cleartext.fromText(message), | |
// signature: await pgp.signature.readArmored(signature), | |
// publicKeys: [publicKey] | |
// }) | |
// console.log('PGP verified message') | |
// return verified.signatures[0].valid | |
return this._signVerifier.verify(publicKey, message, signature) | |
} | |
// Generates a new Curve25519 key pair | |
_generateRatchetKeyPair () { | |
let keyPair = box.keyPair() | |
// Encode in hex for easier handling | |
keyPair.publicKey = Buffer.from(keyPair.publicKey).toString('hex') | |
return keyPair | |
} | |
// Initialises an end-to-end encryption session | |
async initSession (id) { | |
// Generates a new ephemeral ratchet Curve25519 key pair for chat | |
let { publicKey, secretKey } = this._generateRatchetKeyPair() | |
// Initialise session object | |
this._chatKeys[id].session = { | |
currentRatchet: { | |
sendingKeys: { | |
publicKey, | |
secretKey | |
}, | |
previousCounter: 0 | |
}, | |
sending: {}, | |
receiving: {} | |
} | |
// Sign public key | |
const timestamp = new Date().toISOString() | |
const signature = await this.sign(publicKey + timestamp) | |
console.log('Initialised new session', this._chatKeys[id].session) | |
return { publicKey, timestamp, signature } | |
} | |
// Starts the session | |
async startSession (id, keyMessage) { | |
const { publicKey, timestamp, signature } = keyMessage | |
// Validate sender public key | |
const sigValid = await this.verify(id, publicKey + timestamp, signature) | |
// Ignore if new encryption session if signature not valid | |
if (!sigValid) return console.log('PubKey sig invalid', publicKey) | |
const ratchet = this._chatKeys[id].session.currentRatchet | |
const { secretKey } = ratchet.sendingKeys | |
ratchet.receivingKey = publicKey | |
// Derive shared master secret and root key | |
const [rootKey] = await this._calcRatchetKeys( | |
'CiphoraSecret', | |
secretKey, | |
publicKey | |
) | |
ratchet.rootKey = rootKey | |
console.log( | |
'Initialised Session', | |
rootKey.toString('hex'), | |
this._chatKeys[id].session | |
) | |
} | |
// Calculates the ratchet keys (root and chain key) | |
async _calcRatchetKeys (oldRootKey, sendingSecretKey, receivingKey) { | |
// Convert receivingKey to a Uint8Array if it isn't already | |
if (typeof receivingKey === 'string') | |
receivingKey = hexToUint8(receivingKey) | |
// Derive shared ephemeral secret | |
const sharedSecret = box.before(receivingKey, sendingSecretKey) | |
// Derive the new ratchet keys | |
const ratchetKeys = this._HKDF(sharedSecret, oldRootKey, 'CiphoraRatchet') | |
console.log('Derived ratchet keys', ratchetKeys.toString('hex')) | |
// Chunk ratchetKeys output into its parts: root key and chain key | |
return chunk(ratchetKeys, RATCHET_KEYS_LEN / 2) | |
} | |
// Calculates the next receiving or sending ratchet | |
async _calcRatchet (session, sending, receivingKey) { | |
let ratchet = session.currentRatchet | |
let ratchetChains, publicKey, previousChain | |
if (sending) { | |
ratchetChains = session.sending | |
previousChain = ratchetChains[ratchet.sendingKeys.publicKey] | |
// Replace ephemeral ratchet sending keys with new ones | |
ratchet.sendingKeys = this._generateRatchetKeyPair() | |
publicKey = ratchet.sendingKeys.publicKey | |
console.log('New sending keys generated', publicKey) | |
} else { | |
// TODO: Check counters to pre-compute skipped keys | |
ratchetChains = session.receiving | |
previousChain = ratchetChains[ratchet.receivingKey] | |
publicKey = ratchet.receivingKey = receivingKey | |
} | |
if (previousChain) { | |
// Update the previousCounter with the previous chain counter | |
ratchet.previousCounter = previousChain.chain.counter | |
} | |
// Derive new ratchet keys | |
const [rootKey, chainKey] = await this._calcRatchetKeys( | |
ratchet.rootKey, | |
ratchet.sendingKeys.secretKey, | |
ratchet.receivingKey | |
) | |
// Update root key | |
ratchet.rootKey = rootKey | |
// Initialise new chain | |
ratchetChains[publicKey] = { | |
messageKeys: {}, | |
chain: { | |
counter: -1, | |
key: chainKey | |
} | |
} | |
return ratchetChains[publicKey] | |
} | |
// Calculates the next message key for the ratchet and updates it | |
// TODO: Try to get messagekey with message counter otherwise calculate all | |
// message keys up to it and return it (instead of pre-comp on ratchet) | |
_calcMessageKey (ratchet) { | |
let chain = ratchet.chain | |
// Calculate next message key | |
const messageKey = this._HMAC(chain.key, Buffer.alloc(1, MESSAGE_KEY_SEED)) | |
// Calculate next ratchet chain key | |
chain.key = this._HMAC(chain.key, Buffer.alloc(1, CHAIN_KEY_SEED)) | |
// Increment the chain counter | |
chain.counter++ | |
// Save the message key | |
ratchet.messageKeys[chain.counter] = messageKey | |
console.log('Calculated next messageKey', ratchet) | |
// Derive encryption key, mac key and iv | |
return chunk( | |
this._HKDF(messageKey, 'CiphoraCrypt', null, MESSAGE_KEY_LEN), | |
MESSAGE_CHUNK_LEN | |
) | |
} | |
// Encrypts a message | |
async encrypt (id, message, isFile) { | |
let session = this._chatKeys[id].session | |
let ratchet = session.currentRatchet | |
let sendingChain = session.sending[ratchet.sendingKeys.publicKey] | |
// Ratchet after every RACHET_MESSAGE_COUNT of messages | |
let shouldRatchet = | |
sendingChain && sendingChain.chain.counter >= RACHET_MESSAGE_COUNT | |
if (!sendingChain || shouldRatchet) { | |
sendingChain = await this._calcRatchet(session, true) | |
console.log('Calculated new sending ratchet', session) | |
} | |
const { previousCounter } = ratchet | |
const { publicKey } = ratchet.sendingKeys | |
const [encryptKey, macKey, iv] = this._calcMessageKey(sendingChain) | |
console.log( | |
'Calculated encryption creds', | |
encryptKey.toString('hex'), | |
iv.toString('hex') | |
) | |
const { counter } = sendingChain.chain | |
// Encrypt message contents | |
const messageCipher = crypto.createCipheriv(CIPHER, encryptKey, iv) | |
const content = | |
messageCipher.update(message.content, 'utf8', 'hex') + | |
messageCipher.final('hex') | |
// Construct full message | |
let encryptedMessage = { | |
...message, | |
publicKey, | |
previousCounter, | |
counter, | |
content | |
} | |
// Sign message | |
encryptedMessage.signature = await this.sign( | |
JSON.stringify(encryptedMessage) | |
) | |
if (isFile) { | |
// Return cipher | |
const contentCipher = crypto.createCipheriv(CIPHER, encryptKey, iv) | |
return { encryptedMessage, contentCipher } | |
} | |
return { encryptedMessage } | |
} | |
// Decrypts a message | |
async decrypt (id, signedMessage, isFile) { | |
const { signature, ...fullMessage } = signedMessage | |
const sigValid = await this.verify( | |
id, | |
JSON.stringify(fullMessage), | |
signature | |
) | |
// Ignore message if signature invalid | |
if (!sigValid) { | |
console.log('Message signature invalid!') | |
return false | |
} | |
const { publicKey, counter, previousCounter, ...message } = fullMessage | |
let session = this._chatKeys[id].session | |
let receivingChain = session.receiving[publicKey] | |
if (!receivingChain) { | |
// Receiving ratchet for key does not exist so create one | |
receivingChain = await this._calcRatchet(session, false, publicKey) | |
console.log('Calculated new receiving ratchet', receivingChain) | |
} | |
// Derive decryption credentials | |
const [decryptKey, macKey, iv] = this._calcMessageKey(receivingChain) | |
console.log( | |
'Calculated decryption creds', | |
decryptKey.toString('hex'), | |
iv.toString('hex') | |
) | |
// Decrypt the message contents | |
console.log("iv", iv); | |
const messageDecipher = crypto.createDecipheriv(CIPHER, decryptKey, iv) | |
const content = | |
messageDecipher.update(message.content, 'hex', 'utf8') + | |
messageDecipher.final('utf8') | |
console.log('--> Decrypted content', content) | |
const decryptedMessage = { ...message, content } | |
if (isFile) { | |
// Return Decipher | |
const contentDecipher = crypto.createDecipheriv(CIPHER, decryptKey, iv) | |
return { decryptedMessage, contentDecipher } | |
} | |
return { decryptedMessage } | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const nacl = require('tweetnacl'), | |
boxutil = require('tweetnacl-util'); | |
module.exports = class SodiumSignVerify { | |
constructor () { | |
this._selfKey = {} | |
this._b64SelfKey = {} | |
} | |
// Exports self public key | |
publicKey () { | |
return this._selfKey.publicKey | |
} | |
// Imports the given tweet nacl public key and private key as own tweet nacl key | |
async importKey ({ publicKeyb64, privateKeyb64 }) { | |
this._selfKey = { | |
publicKey:boxutil.decodeBase64(publicKeyb64), | |
secretKey:boxutil.decodeBase64(privateKeyb64) | |
} | |
this._b64SelfKey = { publicKey:publicKeyb64, secretKey:privateKeyb64 } | |
console.log('Imported self sodium key') | |
} | |
// Generates new tweet nacl key and sets it up as own tweet nacl key | |
async generateKey ({ /*passphrase, algo, ...userIds*/ }) { | |
try { | |
// this._selfKey = nacl.box.keyPair() | |
this._selfKey = nacl.sign.keyPair() | |
this._b64SelfKey = { | |
publicKey:boxutil.encodeBase64(this._selfKey.publicKey), | |
secretKey:boxutil.encodeBase64(this._selfKey.secretKey) | |
} | |
console.log('Generated self sodium key') | |
} catch (err) { | |
return err | |
} | |
} | |
// Signs a message with own tweet nacl key | |
async sign (message) { | |
const m = boxutil.decodeUTF8(message) | |
const signature = await nacl.sign.detached(m, this._selfKey.secretKey) | |
console.log('sodium signed message') | |
return signature | |
} | |
// Verifies a message with user's tweet nacl key | |
async verify (publicKey, message, signature) { | |
// Fail verification if all params are not supplied | |
if (!message || !publicKey || !signature) return false | |
const m = boxutil.decodeUTF8(message) | |
const verified = await nacl.sign.detached.verify(m, signature, publicKey) | |
console.log('sodium verified message') | |
return verified | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const pgp = require('openpgp'), | |
{ parseAddress } = require('./utils'); | |
// Enable compression by default | |
pgp.config.compression = pgp.enums.compression.zlib | |
module.exports = class PGPSignVerify { | |
constructor () { | |
this._selfKey = {} | |
this._armoredSelfKey = {} | |
} | |
// Exports self public key | |
publicKey () { | |
return this._selfKey.publicKey | |
} | |
// Imports the given PGP public key and private key as own PGP key | |
async importKey ({ passphrase, publicKeyArmored, privateKeyArmored }) { | |
this._armoredSelfKey = { publicKeyArmored, privateKeyArmored } | |
await this._initKey(passphrase) | |
console.log('Imported self PGP key') | |
} | |
// Generates new PGP key and sets it up as own PGP key | |
async generateKey ({ passphrase, algo, ...userIds }) { | |
const [type, variant] = algo.split('-') | |
let genAlgo | |
try { | |
// Parse the type of key generation algorithm selected | |
switch (type) { | |
case 'rsa': | |
genAlgo = { | |
rsaBits: parseInt(variant) // RSA key length | |
} | |
break | |
case 'ecc': | |
genAlgo = { | |
curve: variant // ECC curve name | |
} | |
break | |
default: | |
throw new Error('Unrecognised key generation algorithm') | |
} | |
// Generate new PGP key with the details supplied | |
const { key, ...keyData } = await pgp.generateKey({ | |
userIds: [userIds], | |
...genAlgo, | |
passphrase | |
}) | |
this._armoredSelfKey = keyData | |
await this._initKey(passphrase) | |
console.log('Generated self PGP key') | |
} catch (err) { | |
console.log(err) | |
return err | |
} | |
} | |
// Initialises own PGP key and decrypts its private key | |
async _initKey (passphrase) { | |
const { publicKeyArmored, privateKeyArmored, user } = this._armoredSelfKey | |
const { | |
keys: [publicKey] | |
} = await pgp.key.readArmored(publicKeyArmored) | |
const { | |
keys: [privateKey] | |
} = await pgp.key.readArmored(privateKeyArmored) | |
if(passphrase) { | |
await privateKey.decrypt(passphrase) | |
} | |
// Set user info if not already set | |
if (!user) { | |
this._armoredSelfKey.user = { | |
id: publicKey.getFingerprint(), | |
...parseAddress(publicKey.getUserIds()) | |
} | |
} | |
// Init key | |
this._selfKey = { | |
publicKey, | |
privateKey | |
} | |
console.log('Initialised self PGP key') | |
} | |
// Signs a message with own PGP key | |
async sign (message) { | |
const { privateKey } = this._selfKey | |
const { signature } = await pgp.sign({ | |
message: pgp.cleartext.fromText(message), | |
privateKeys: [privateKey], | |
detached: true | |
}) | |
console.log('PGP signed message') | |
return signature | |
} | |
// Verifies a message with user's PGP key | |
async verify (publicKey, message, signature) { | |
// Fail verification if all params are not supplied | |
if (!message || !publicKey || !signature) return false | |
const verified = await pgp.verify({ | |
message: pgp.cleartext.fromText(message), | |
signature: await pgp.signature.readArmored(signature), | |
publicKeys: [publicKey] | |
}) | |
console.log('PGP verified message') | |
return verified.signatures[0].valid | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(async()=>{ | |
const pgp = require("./pgp-sign.js") | |
const openpgp = require('openpgp') | |
const sodium = require("./nacl-sign.js") | |
const crypt = require("./index.js") | |
const newPeer = async (id)=>{ | |
const p = new crypt(new pgp()); | |
await p.init({algo: "ecc-ed25519", userIds: [{ name: id, email: id+'@example.com' }]}); | |
return p | |
} | |
const alice = await newPeer("alice"); | |
const bob = await newPeer("bob"); | |
alice.addKey("bob", bob.publicKey()) | |
bob.addKey("alice", alice.publicKey()) | |
const bobSess = await bob.initSession("alice") | |
const aliceSess = await alice.initSession("bob") | |
await alice.startSession("bob", bobSess) | |
await bob.startSession("alice", aliceSess) | |
const r = await alice.encrypt("bob", {content:"hello world"}, false); | |
const rr = await alice.encrypt("bob", {content:"hello worldg"}, false); | |
// console.log("alice enrypt", r); | |
const h = await bob.decrypt("alice", r.encryptedMessage); | |
const hh = await bob.decrypt("alice", rr.encryptedMessage); | |
// console.log("bob decrypt", h); | |
})(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict' | |
module.exports = { | |
waitUntil, | |
parseAddress, | |
isEmpty, | |
chunk, | |
hexToUint8, | |
isString | |
} | |
// Wait until a condition is true | |
function waitUntil (conditionFn, timeout, pollInterval = 30) { | |
const start = Date.now() | |
return new Promise((resolve, reject) => { | |
;(function wait () { | |
if (conditionFn()) return resolve() | |
else if (timeout && Date.now() - start >= timeout) | |
return reject(new Error(`Timeout ${timeout} for waitUntil exceeded`)) | |
else setTimeout(wait, pollInterval) | |
})() | |
}) | |
} | |
// Parses name/email address of format '[name] <[email]>' | |
function parseAddress (address) { | |
// Check if unknown | |
address = address && address.length ? address[0] : 'Unknown' | |
// Check if it has an email as well (follows the format) | |
if (!address.includes('<')) { | |
return { name: address } | |
} | |
let [name, email] = address.split('<').map(n => n.trim().replace(/>/g, '')) | |
return { name, email } | |
} | |
// Checks if an object is empty | |
function isEmpty (obj) { | |
return Object.keys(obj).length === 0 && obj.constructor === Object | |
} | |
// Splits a buffer into chunks of a given size | |
function chunk (buffer, chunkSize) { | |
if (!Buffer.isBuffer(buffer)) throw new Error('Buffer is required') | |
let result = [], | |
i = 0, | |
len = buffer.length | |
while (i < len) { | |
// If it does not equally divide then set last to whatever remains | |
result.push(buffer.slice(i, Math.min((i += chunkSize), len))) | |
} | |
return result | |
} | |
// Converts a hex string into a Uint8Array | |
function hexToUint8 (hex) { | |
return Uint8Array.from(Buffer.from(hex, 'hex')) | |
} | |
// Checks if the given object is a string | |
function isString (obj) { | |
return typeof obj === 'string' || obj instanceof String | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment