Skip to content

Instantly share code, notes, and snippets.

@clementauger
Created October 5, 2020 17:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save clementauger/020dac3d06e0567f16cf274ad778548c to your computer and use it in GitHub Desktop.
Save clementauger/020dac3d06e0567f16cf274ad778548c to your computer and use it in GitHub Desktop.
double ratchet javascript experimentation, adapted from https://github.com/HR/ciphora
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 }
}
}
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
}
}
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
}
}
(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);
})();
'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