diff --git a/.eslintrc.json b/.eslintrc.json index 4fdeaff1..e7750c41 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -28,4 +28,4 @@ } ] } -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0153a7b8..714a9e48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules *.swp npm-debug.log +lib +docs diff --git a/WhisperTextProto/GenerateStatics.sh b/WhisperTextProto/GenerateStatics.sh new file mode 100644 index 00000000..c1ce9f24 --- /dev/null +++ b/WhisperTextProto/GenerateStatics.sh @@ -0,0 +1,2 @@ +yarn pbjs -t static-module -w commonjs -o ./WhisperTextProto/index.js ./WhisperTextProto/WhisperTextProtocol.proto +yarn pbts -o ./WhisperTextProto/index.d.ts ./WhisperTextProto/index.js; \ No newline at end of file diff --git a/protos/WhisperTextProtocol.proto b/WhisperTextProto/WhisperTextProtocol.proto similarity index 100% rename from protos/WhisperTextProtocol.proto rename to WhisperTextProto/WhisperTextProtocol.proto diff --git a/WhisperTextProto/index.d.ts b/WhisperTextProto/index.d.ts new file mode 100644 index 00000000..f51f6ce6 --- /dev/null +++ b/WhisperTextProto/index.d.ts @@ -0,0 +1,346 @@ +import * as $protobuf from "protobufjs"; +/** Namespace textsecure. */ +export namespace textsecure { + + /** Properties of a WhisperMessage. */ + interface IWhisperMessage { + + /** WhisperMessage ephemeralKey */ + ephemeralKey?: (Uint8Array|null); + + /** WhisperMessage counter */ + counter?: (number|null); + + /** WhisperMessage previousCounter */ + previousCounter?: (number|null); + + /** WhisperMessage ciphertext */ + ciphertext?: (Uint8Array|null); + } + + /** Represents a WhisperMessage. */ + class WhisperMessage implements IWhisperMessage { + + /** + * Constructs a new WhisperMessage. + * @param [properties] Properties to set + */ + constructor(properties?: textsecure.IWhisperMessage); + + /** WhisperMessage ephemeralKey. */ + public ephemeralKey: Uint8Array; + + /** WhisperMessage counter. */ + public counter: number; + + /** WhisperMessage previousCounter. */ + public previousCounter: number; + + /** WhisperMessage ciphertext. */ + public ciphertext: Uint8Array; + + /** + * Creates a new WhisperMessage instance using the specified properties. + * @param [properties] Properties to set + * @returns WhisperMessage instance + */ + public static create(properties?: textsecure.IWhisperMessage): textsecure.WhisperMessage; + + /** + * Encodes the specified WhisperMessage message. Does not implicitly {@link textsecure.WhisperMessage.verify|verify} messages. + * @param message WhisperMessage message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: textsecure.IWhisperMessage, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified WhisperMessage message, length delimited. Does not implicitly {@link textsecure.WhisperMessage.verify|verify} messages. + * @param message WhisperMessage message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: textsecure.IWhisperMessage, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a WhisperMessage message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns WhisperMessage + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): textsecure.WhisperMessage; + + /** + * Decodes a WhisperMessage message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns WhisperMessage + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): textsecure.WhisperMessage; + + /** + * Verifies a WhisperMessage message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a WhisperMessage message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns WhisperMessage + */ + public static fromObject(object: { [k: string]: any }): textsecure.WhisperMessage; + + /** + * Creates a plain object from a WhisperMessage message. Also converts values to other types if specified. + * @param message WhisperMessage + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: textsecure.WhisperMessage, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this WhisperMessage to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + } + + /** Properties of a PreKeyWhisperMessage. */ + interface IPreKeyWhisperMessage { + + /** PreKeyWhisperMessage registrationId */ + registrationId?: (string|null); + + /** PreKeyWhisperMessage preKeyId */ + preKeyId?: (number|null); + + /** PreKeyWhisperMessage signedPreKeyId */ + signedPreKeyId?: (number|null); + + /** PreKeyWhisperMessage baseKey */ + baseKey?: (Uint8Array|null); + + /** PreKeyWhisperMessage identityKey */ + identityKey?: (Uint8Array|null); + + /** PreKeyWhisperMessage message */ + message?: (Uint8Array|null); + } + + /** Represents a PreKeyWhisperMessage. */ + class PreKeyWhisperMessage implements IPreKeyWhisperMessage { + + /** + * Constructs a new PreKeyWhisperMessage. + * @param [properties] Properties to set + */ + constructor(properties?: textsecure.IPreKeyWhisperMessage); + + /** PreKeyWhisperMessage registrationId. */ + public registrationId: string; + + /** PreKeyWhisperMessage preKeyId. */ + public preKeyId: number; + + /** PreKeyWhisperMessage signedPreKeyId. */ + public signedPreKeyId: number; + + /** PreKeyWhisperMessage baseKey. */ + public baseKey: Uint8Array; + + /** PreKeyWhisperMessage identityKey. */ + public identityKey: Uint8Array; + + /** PreKeyWhisperMessage message. */ + public message: Uint8Array; + + /** + * Creates a new PreKeyWhisperMessage instance using the specified properties. + * @param [properties] Properties to set + * @returns PreKeyWhisperMessage instance + */ + public static create(properties?: textsecure.IPreKeyWhisperMessage): textsecure.PreKeyWhisperMessage; + + /** + * Encodes the specified PreKeyWhisperMessage message. Does not implicitly {@link textsecure.PreKeyWhisperMessage.verify|verify} messages. + * @param message PreKeyWhisperMessage message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: textsecure.IPreKeyWhisperMessage, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified PreKeyWhisperMessage message, length delimited. Does not implicitly {@link textsecure.PreKeyWhisperMessage.verify|verify} messages. + * @param message PreKeyWhisperMessage message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: textsecure.IPreKeyWhisperMessage, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a PreKeyWhisperMessage message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns PreKeyWhisperMessage + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): textsecure.PreKeyWhisperMessage; + + /** + * Decodes a PreKeyWhisperMessage message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns PreKeyWhisperMessage + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): textsecure.PreKeyWhisperMessage; + + /** + * Verifies a PreKeyWhisperMessage message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a PreKeyWhisperMessage message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns PreKeyWhisperMessage + */ + public static fromObject(object: { [k: string]: any }): textsecure.PreKeyWhisperMessage; + + /** + * Creates a plain object from a PreKeyWhisperMessage message. Also converts values to other types if specified. + * @param message PreKeyWhisperMessage + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: textsecure.PreKeyWhisperMessage, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this PreKeyWhisperMessage to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + } + + /** Properties of a KeyExchangeMessage. */ + interface IKeyExchangeMessage { + + /** KeyExchangeMessage id */ + id?: (number|null); + + /** KeyExchangeMessage baseKey */ + baseKey?: (Uint8Array|null); + + /** KeyExchangeMessage ephemeralKey */ + ephemeralKey?: (Uint8Array|null); + + /** KeyExchangeMessage identityKey */ + identityKey?: (Uint8Array|null); + + /** KeyExchangeMessage baseKeySignature */ + baseKeySignature?: (Uint8Array|null); + } + + /** Represents a KeyExchangeMessage. */ + class KeyExchangeMessage implements IKeyExchangeMessage { + + /** + * Constructs a new KeyExchangeMessage. + * @param [properties] Properties to set + */ + constructor(properties?: textsecure.IKeyExchangeMessage); + + /** KeyExchangeMessage id. */ + public id: number; + + /** KeyExchangeMessage baseKey. */ + public baseKey: Uint8Array; + + /** KeyExchangeMessage ephemeralKey. */ + public ephemeralKey: Uint8Array; + + /** KeyExchangeMessage identityKey. */ + public identityKey: Uint8Array; + + /** KeyExchangeMessage baseKeySignature. */ + public baseKeySignature: Uint8Array; + + /** + * Creates a new KeyExchangeMessage instance using the specified properties. + * @param [properties] Properties to set + * @returns KeyExchangeMessage instance + */ + public static create(properties?: textsecure.IKeyExchangeMessage): textsecure.KeyExchangeMessage; + + /** + * Encodes the specified KeyExchangeMessage message. Does not implicitly {@link textsecure.KeyExchangeMessage.verify|verify} messages. + * @param message KeyExchangeMessage message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encode(message: textsecure.IKeyExchangeMessage, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Encodes the specified KeyExchangeMessage message, length delimited. Does not implicitly {@link textsecure.KeyExchangeMessage.verify|verify} messages. + * @param message KeyExchangeMessage message or plain object to encode + * @param [writer] Writer to encode to + * @returns Writer + */ + public static encodeDelimited(message: textsecure.IKeyExchangeMessage, writer?: $protobuf.Writer): $protobuf.Writer; + + /** + * Decodes a KeyExchangeMessage message from the specified reader or buffer. + * @param reader Reader or buffer to decode from + * @param [length] Message length if known beforehand + * @returns KeyExchangeMessage + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): textsecure.KeyExchangeMessage; + + /** + * Decodes a KeyExchangeMessage message from the specified reader or buffer, length delimited. + * @param reader Reader or buffer to decode from + * @returns KeyExchangeMessage + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): textsecure.KeyExchangeMessage; + + /** + * Verifies a KeyExchangeMessage message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a KeyExchangeMessage message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns KeyExchangeMessage + */ + public static fromObject(object: { [k: string]: any }): textsecure.KeyExchangeMessage; + + /** + * Creates a plain object from a KeyExchangeMessage message. Also converts values to other types if specified. + * @param message KeyExchangeMessage + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: textsecure.KeyExchangeMessage, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this KeyExchangeMessage to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + } +} diff --git a/src/WhisperTextProtocol.js b/WhisperTextProto/index.js similarity index 100% rename from src/WhisperTextProtocol.js rename to WhisperTextProto/index.js diff --git a/generate-proto.sh b/generate-proto.sh deleted file mode 100644 index 0c859416..00000000 --- a/generate-proto.sh +++ /dev/null @@ -1 +0,0 @@ -yarn pbjs -t static-module -w commonjs -o ./src/WhisperTextProtocol.js ./protos/WhisperTextProtocol.proto \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index 169ee0fc..00000000 --- a/index.js +++ /dev/null @@ -1,10 +0,0 @@ -'use strict'; - -exports.crypto = require('./src/crypto'); -exports.curve = require('./src/curve'); -exports.keyhelper = require('./src/keyhelper'); -exports.ProtocolAddress = require('./src/protocol_address'); -exports.SessionBuilder = require('./src/session_builder'); -exports.SessionCipher = require('./src/session_cipher'); -exports.SessionRecord = require('./src/session_record'); -Object.assign(exports, require('./src/errors')); diff --git a/package.json b/package.json index e18e7f92..38b2de60 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,16 @@ "version": "2.0.1", "description": "Open Whisper Systems' libsignal for Node.js", "repository": "ForstaLabs/libsignal-node", - "main": "index.js", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build:all": "tsc && typedoc", + "build:docs": "typedoc", + "build:tsc": "tsc", + "prepare": "tsc", + "prepack": "tsc", + "gen:protobuf": "sh WhisperTextProto/GenerateStatics.sh" + }, "keywords": [ "signal", "whispersystems", @@ -12,12 +21,15 @@ "license": "GPL-3.0", "dependencies": { "curve25519-js": "^0.0.4", - "protobufjs": "6.8.8" + "protobufjs": "^6.8.8" }, "files": [ - "src/*" + "lib/*", + "WhisperTextProto/*" ], "devDependencies": { - "eslint": "6.0.1" + "eslint": "6.0.1", + "typedoc": "^0.25.13", + "typescript": "^5.4.5" } } diff --git a/src/.eslintrc.json b/src/.eslintrc.json deleted file mode 100644 index 4fdeaff1..00000000 --- a/src/.eslintrc.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "parserOptions": { - "ecmaVersion": 8 - }, - "env": { - "es6": true, - "node": true - }, - "extends": "eslint:recommended", - "rules": { - "quotes": "off", - "semi": [ - "error", - "always" - ], - "no-console": "off", - "no-debugger": "off", - "no-unused-vars": [ - "error", - { - "args": "none" - } - ], - "no-constant-condition": [ - "error", - { - "checkLoops": false - } - ] - } -} diff --git a/src/Signal/index.ts b/src/Signal/index.ts new file mode 100644 index 00000000..56e608e1 --- /dev/null +++ b/src/Signal/index.ts @@ -0,0 +1,5 @@ +export * from './protocol-address' +export * from './session-builder' +export * from './session-cipher' +export * from './session-entry' +export * from './session-record' diff --git a/src/Signal/protocol-address.ts b/src/Signal/protocol-address.ts new file mode 100644 index 00000000..40114f77 --- /dev/null +++ b/src/Signal/protocol-address.ts @@ -0,0 +1,40 @@ +export class ProtocolAddress { + id: string + deviceId: number + + constructor(id: string, deviceId: number) { + if (typeof id !== 'string') { + throw new TypeError('id required for addr') + } + if (id.indexOf('.') !== -1) { + throw new TypeError('encoded addr detected') + } + if (typeof deviceId !== 'number' || isNaN(deviceId)) { + throw new TypeError('number required for deviceId') + } + + this.id = id + this.deviceId = deviceId + } + + static from(encodedAddress: string): ProtocolAddress { + if (typeof encodedAddress !== 'string' || !encodedAddress.match(/.*\.\d+/)) { + throw new Error('Invalid address encoding') + } + + const parts = encodedAddress.split('.') + return new this(parts[0], parseInt(parts[1], 10)) + } + + toString(): string { + return `${this.id}.${this.deviceId}` + } + + is(other: ProtocolAddress): boolean { + if (!(other instanceof ProtocolAddress)) { + return false + } + + return other.id === this.id && other.deviceId === this.deviceId; + } +} diff --git a/src/session_builder.js b/src/Signal/session-builder.ts similarity index 58% rename from src/session_builder.js rename to src/Signal/session-builder.ts index 9bd333f1..dcc0ed19 100644 --- a/src/session_builder.js +++ b/src/Signal/session-builder.ts @@ -1,35 +1,45 @@ +import { SessionRecord } from './session-record'; +import { BaseKeyType } from '../Types/BaseKey'; +import { ChainType } from '../Types/Chains'; +import * as errors from '../Utils/errors'; +import { calculateAgreement, generateKeyPair, verifySignature } from '../Utils/curve' +import { deriveSecrets } from '../Utils/crypto' +import { queueJob } from '../Utils/queue-job' +import { ProtocolAddress } from './protocol-address'; +import { EphemeralKeyPairDeserialized, PreKeyDeserialized } from '../Types'; +import { SessionEntry } from './session-entry'; -'use strict'; +export class SessionBuilder { + private storage: any; + private addr: ProtocolAddress; -const BaseKeyType = require('./base_key_type'); -const ChainType = require('./chain_type'); -const SessionRecord = require('./session_record'); -const crypto = require('./crypto'); -const curve = require('./curve'); -const errors = require('./errors'); -const queueJob = require('./queue_job'); - - -class SessionBuilder { - - constructor(storage, protocolAddress) { + constructor(storage: any, protocolAddress: ProtocolAddress) { this.addr = protocolAddress; this.storage = storage; } - async initOutgoing(device) { + async initOutgoing(device: any) { const fqAddr = this.addr.toString(); return await queueJob(fqAddr, async () => { if (!await this.storage.isTrustedIdentity(this.addr.id, device.identityKey)) { throw new errors.UntrustedIdentityKeyError(this.addr.id, device.identityKey); } - curve.verifySignature(device.identityKey, device.signedPreKey.publicKey, - device.signedPreKey.signature); - const baseKey = curve.generateKeyPair(); + verifySignature( + device.identityKey, + device.signedPreKey.publicKey, + device.signedPreKey.signature + ); + const baseKey = generateKeyPair(); const devicePreKey = device.preKey && device.preKey.publicKey; - const session = await this.initSession(true, baseKey, undefined, device.identityKey, - devicePreKey, device.signedPreKey.publicKey, - device.registrationId); + const session = await this.initSession( + true, + baseKey, + undefined, + device.identityKey, + devicePreKey, + device.signedPreKey.publicKey, + device.registrationId + ); session.pendingPreKey = { signedKeyId: device.signedPreKey.keyId, baseKey: baseKey.pubKey @@ -52,7 +62,7 @@ class SessionBuilder { }); } - async initIncoming(record, message) { + async initIncoming(record: SessionRecord, message: PreKeyDeserialized) { const fqAddr = this.addr.toString(); if (!await this.storage.isTrustedIdentity(fqAddr, message.identityKey)) { throw new errors.UntrustedIdentityKeyError(this.addr.id, message.identityKey); @@ -74,14 +84,28 @@ class SessionBuilder { console.warn("Closing open session in favor of incoming prekey bundle"); record.closeSession(existingOpenSession); } - record.setSession(await this.initSession(false, preKeyPair, signedPreKeyPair, - message.identityKey, message.baseKey, - undefined, message.registrationId)); + record.setSession(await this.initSession( + false, + preKeyPair, + signedPreKeyPair, + message.identityKey, + message.baseKey, + undefined, + message.registrationId + ) + ); return message.preKeyId; } - async initSession(isInitiator, ourEphemeralKey, ourSignedKey, theirIdentityPubKey, - theirEphemeralPubKey, theirSignedPubKey, registrationId) { + async initSession( + isInitiator: boolean, + ourEphemeralKey: EphemeralKeyPairDeserialized, + ourSignedKey: EphemeralKeyPairDeserialized | undefined, + theirIdentityPubKey: Buffer | Uint8Array, + theirEphemeralPubKey: Buffer | Uint8Array, + theirSignedPubKey: Buffer | undefined, + registrationId: string + ) { if (isInitiator) { if (ourSignedKey) { throw new Error("Invalid call to initSession"); @@ -91,21 +115,21 @@ class SessionBuilder { if (theirSignedPubKey) { throw new Error("Invalid call to initSession"); } - theirSignedPubKey = theirEphemeralPubKey; + theirSignedPubKey = theirEphemeralPubKey as Buffer; } - let sharedSecret; + let sharedSecret: Uint8Array; if (!ourEphemeralKey || !theirEphemeralPubKey) { sharedSecret = new Uint8Array(32 * 4); } else { sharedSecret = new Uint8Array(32 * 5); } - for (var i = 0; i < 32; i++) { + for (let i = 0; i < 32; i++) { sharedSecret[i] = 0xff; } const ourIdentityKey = await this.storage.getOurIdentity(); - const a1 = curve.calculateAgreement(theirSignedPubKey, ourIdentityKey.privKey); - const a2 = curve.calculateAgreement(theirIdentityPubKey, ourSignedKey.privKey); - const a3 = curve.calculateAgreement(theirSignedPubKey, ourSignedKey.privKey); + const a1 = calculateAgreement(theirSignedPubKey, ourIdentityKey.privKey); + const a2 = calculateAgreement(theirIdentityPubKey as Buffer, ourSignedKey!.privKey); + const a3 = calculateAgreement(theirSignedPubKey, ourSignedKey!.privKey); if (isInitiator) { sharedSecret.set(new Uint8Array(a1), 32); sharedSecret.set(new Uint8Array(a2), 32 * 2); @@ -115,24 +139,27 @@ class SessionBuilder { } sharedSecret.set(new Uint8Array(a3), 32 * 3); if (ourEphemeralKey && theirEphemeralPubKey) { - const a4 = curve.calculateAgreement(theirEphemeralPubKey, ourEphemeralKey.privKey); + const a4 = calculateAgreement(theirEphemeralPubKey as Buffer, ourEphemeralKey.privKey); sharedSecret.set(new Uint8Array(a4), 32 * 4); } - const masterKey = crypto.deriveSecrets(Buffer.from(sharedSecret), Buffer.alloc(32), - Buffer.from("WhisperText")); + const masterKey = deriveSecrets( + Buffer.from(sharedSecret), + Buffer.alloc(32), + Buffer.from("WhisperText") + ); const session = SessionRecord.createEntry(); session.registrationId = registrationId; session.currentRatchet = { rootKey: masterKey[0], - ephemeralKeyPair: isInitiator ? curve.generateKeyPair() : ourSignedKey, - lastRemoteEphemeralKey: theirSignedPubKey, + ephemeralKeyPair: isInitiator ? generateKeyPair() : ourSignedKey!, + lastRemoteEphemeralKey: theirSignedPubKey!, previousCounter: 0 }; session.indexInfo = { created: Date.now(), used: Date.now(), - remoteIdentityKey: theirIdentityPubKey, - baseKey: isInitiator ? ourEphemeralKey.pubKey : theirEphemeralPubKey, + remoteIdentityKey: theirIdentityPubKey as Buffer, + baseKey: isInitiator ? ourEphemeralKey.pubKey as Buffer : theirEphemeralPubKey as Buffer, baseKeyType: isInitiator ? BaseKeyType.OURS : BaseKeyType.THEIRS, closed: -1 }; @@ -140,15 +167,15 @@ class SessionBuilder { // If we're initiating we go ahead and set our first sending ephemeral key now, // otherwise we figure it out when we first maybeStepRatchet with the remote's // ephemeral key - this.calculateSendingRatchet(session, theirSignedPubKey); + this.calculateSendingRatchet(session, theirSignedPubKey!); } return session; } - calculateSendingRatchet(session, remoteKey) { + calculateSendingRatchet(session: SessionEntry, remoteKey: Buffer) { const ratchet = session.currentRatchet; - const sharedSecret = curve.calculateAgreement(remoteKey, ratchet.ephemeralKeyPair.privKey); - const masterKey = crypto.deriveSecrets(sharedSecret, ratchet.rootKey, Buffer.from("WhisperRatchet")); + const sharedSecret = calculateAgreement(remoteKey, ratchet.ephemeralKeyPair.privKey); + const masterKey = deriveSecrets(sharedSecret, ratchet.rootKey, Buffer.from("WhisperRatchet")); session.addChain(ratchet.ephemeralKeyPair.pubKey, { messageKeys: {}, chainKey: { @@ -160,5 +187,3 @@ class SessionBuilder { ratchet.rootKey = masterKey[0]; } } - -module.exports = SessionBuilder; diff --git a/src/session_cipher.js b/src/Signal/session-cipher.ts similarity index 77% rename from src/session_cipher.js rename to src/Signal/session-cipher.ts index 0e6df11e..7eefa25d 100644 --- a/src/session_cipher.js +++ b/src/Signal/session-cipher.ts @@ -1,43 +1,36 @@ -// vim: ts=4:sw=4:expandtab +import { SESSION_CIPHER_VERSION } from '../Types/constants' +import { ChainDeserialized, ChainType } from '../Types/Chains' +import { PreKeyDeserialized } from '../Types/PreKey' -const ChainType = require('./chain_type'); -const ProtocolAddress = require('./protocol_address'); -const SessionBuilder = require('./session_builder'); -const SessionRecord = require('./session_record'); -const crypto = require('./crypto'); -const curve = require('./curve'); -const errors = require('./errors'); -const protobufs = require('./protobufs'); -const queueJob = require('./queue_job'); +import { ProtocolAddress } from './protocol-address' +import { SessionBuilder } from './session-builder' +import { SessionRecord } from './session-record' +import { SessionEntry } from "./session-entry" +import { queueJob } from '../Utils/queue-job' -const VERSION = 3; +import * as crypto from '../Utils/crypto' +import * as curve from '../Utils/curve' +import * as errors from '../Utils/errors' +import * as proto from '../../WhisperTextProto/index' -function assertBuffer(value) { - if (!(value instanceof Buffer)) { - throw TypeError(`Expected Buffer instead of: ${value.constructor.name}`); - } - return value; -} +export class SessionCipher { + private addr: ProtocolAddress + private storage: any -class SessionCipher { - - constructor(storage, protocolAddress) { - if (!(protocolAddress instanceof ProtocolAddress)) { - throw new TypeError("protocolAddress must be a ProtocolAddress"); - } + constructor(storage: any, protocolAddress: ProtocolAddress) { this.addr = protocolAddress; this.storage = storage; } - _encodeTupleByte(number1, number2) { + private _encodeTupleByte(number1: number, number2: number) { if (number1 > 15 || number2 > 15) { throw TypeError("Numbers must be 4 bits or less"); } return (number1 << 4) | number2; } - _decodeTupleByte(byte) { + private _decodeTupleByte(byte: number) { return [byte >> 4, byte & 0xf]; } @@ -53,17 +46,16 @@ class SessionCipher { return record; } - async storeRecord(record) { + async storeRecord(record: SessionRecord) { record.removeOldSessions(); await this.storage.storeSession(this.addr.toString(), record); } - async queueJob(awaitable) { + async queueJob(awaitable: () => Promise) { return await queueJob(this.addr.toString(), awaitable); } - async encrypt(data) { - assertBuffer(data); + async encrypt(data: Buffer | Uint8Array) { const ourIdentityKey = await this.storage.getOurIdentity(); return await this.queueJob(async () => { const record = await this.getRecord(); @@ -83,30 +75,33 @@ class SessionCipher { throw new Error("Tried to encrypt on a receiving chain"); } this.fillMessageKeys(chain, chain.chainKey.counter + 1); - const keys = crypto.deriveSecrets(chain.messageKeys[chain.chainKey.counter], - Buffer.alloc(32), Buffer.from("WhisperMessageKeys")); + const keys = crypto.deriveSecrets( + chain.messageKeys[chain.chainKey.counter], + Buffer.alloc(32), + Buffer.from("WhisperMessageKeys") + ); delete chain.messageKeys[chain.chainKey.counter]; - const msg = protobufs.WhisperMessage.create(); + const msg = proto.textsecure.WhisperMessage.create(); msg.ephemeralKey = session.currentRatchet.ephemeralKeyPair.pubKey; msg.counter = chain.chainKey.counter; msg.previousCounter = session.currentRatchet.previousCounter; msg.ciphertext = crypto.encrypt(keys[0], data, keys[2].slice(0, 16)); - const msgBuf = protobufs.WhisperMessage.encode(msg).finish(); + const msgBuf = proto.textsecure.WhisperMessage.encode(msg).finish(); const macInput = Buffer.alloc(msgBuf.byteLength + (33 * 2) + 1); macInput.set(ourIdentityKey.pubKey); macInput.set(session.indexInfo.remoteIdentityKey, 33); - macInput[33 * 2] = this._encodeTupleByte(VERSION, VERSION); + macInput[33 * 2] = this._encodeTupleByte(SESSION_CIPHER_VERSION, SESSION_CIPHER_VERSION); macInput.set(msgBuf, (33 * 2) + 1); const mac = crypto.calculateMAC(keys[1], macInput); const result = Buffer.alloc(msgBuf.byteLength + 9); - result[0] = this._encodeTupleByte(VERSION, VERSION); + result[0] = this._encodeTupleByte(SESSION_CIPHER_VERSION, SESSION_CIPHER_VERSION); result.set(msgBuf, 1); result.set(mac.slice(0, 8), msgBuf.byteLength + 1); await this.storeRecord(record); let type, body; if (session.pendingPreKey) { type = 3; // prekey bundle - const preKeyMsg = protobufs.PreKeyWhisperMessage.create({ + const preKeyMsg = proto.textsecure.PreKeyWhisperMessage.create({ identityKey: ourIdentityKey.pubKey, registrationId: await this.storage.getOurRegistrationId(), baseKey: session.pendingPreKey.baseKey, @@ -117,9 +112,9 @@ class SessionCipher { preKeyMsg.preKeyId = session.pendingPreKey.preKeyId; } body = Buffer.concat([ - Buffer.from([this._encodeTupleByte(VERSION, VERSION)]), + Buffer.from([this._encodeTupleByte(SESSION_CIPHER_VERSION, SESSION_CIPHER_VERSION)]), Buffer.from( - protobufs.PreKeyWhisperMessage.encode(preKeyMsg).finish() + proto.textsecure.PreKeyWhisperMessage.encode(preKeyMsg).finish() ) ]); } else { @@ -134,13 +129,13 @@ class SessionCipher { }); } - async decryptWithSessions(data, sessions) { + async decryptWithSessions(data: Buffer | Uint8Array, sessions: SessionEntry[]) { // Iterate through the sessions, attempting to decrypt using each one. // Stop and return the result if we get a valid result. if (!sessions.length) { throw new errors.SessionError("No sessions available"); } - const errs = []; + const errs: Error[] = []; for (const session of sessions) { let plaintext; try { @@ -151,7 +146,7 @@ class SessionCipher { plaintext }; } catch(e) { - errs.push(e); + errs.push(e as Error); } } console.error("Failed to decrypt message with any known session..."); @@ -161,8 +156,7 @@ class SessionCipher { throw new errors.SessionError("No matching sessions found for message"); } - async decryptWhisperMessage(data) { - assertBuffer(data); + async decryptWhisperMessage(data: Buffer | Uint8Array) { return await this.queueJob(async () => { const record = await this.getRecord(); if (!record) { @@ -186,15 +180,14 @@ class SessionCipher { }); } - async decryptPreKeyWhisperMessage(data) { - assertBuffer(data); + async decryptPreKeyWhisperMessage(data: Buffer) { const versions = this._decodeTupleByte(data[0]); if (versions[1] > 3 || versions[0] < 3) { // min version > 3 or max version < 3 throw new Error("Incompatible version number on PreKeyWhisperMessage"); } return await this.queueJob(async () => { let record = await this.getRecord(); - const preKeyProto = protobufs.PreKeyWhisperMessage.decode(data.slice(1)); + const preKeyProto = proto.textsecure.PreKeyWhisperMessage.decode(data.slice(1)); if (!record) { if (preKeyProto.registrationId == null) { throw new Error("No registrationId"); @@ -204,7 +197,7 @@ class SessionCipher { const builder = new SessionBuilder(this.storage, this.addr); const preKeyId = await builder.initIncoming(record, preKeyProto); const session = record.getSession(preKeyProto.baseKey); - const plaintext = await this.doDecryptWhisperMessage(preKeyProto.message, session); + const plaintext = await this.doDecryptWhisperMessage(preKeyProto.message as Buffer, session); await this.storeRecord(record); if (preKeyId) { await this.storage.removePreKey(preKeyId); @@ -213,8 +206,7 @@ class SessionCipher { }); } - async doDecryptWhisperMessage(messageBuffer, session) { - assertBuffer(messageBuffer); + async doDecryptWhisperMessage(messageBuffer: Buffer | Uint8Array, session: SessionEntry) { if (!session) { throw new TypeError("session required"); } @@ -223,8 +215,8 @@ class SessionCipher { throw new Error("Incompatible version number on WhisperMessage"); } const messageProto = messageBuffer.slice(1, -8); - const message = protobufs.WhisperMessage.decode(messageProto); - this.maybeStepRatchet(session, message.ephemeralKey, message.previousCounter); + const message = proto.textsecure.WhisperMessage.decode(messageProto); + this.maybeStepRatchet(session, message.ephemeralKey as Buffer, message.previousCounter); const chain = session.getChain(message.ephemeralKey); if (chain.chainType === ChainType.SENDING) { throw new Error("Tried to decrypt on a sending chain"); @@ -237,13 +229,15 @@ class SessionCipher { } const messageKey = chain.messageKeys[message.counter]; delete chain.messageKeys[message.counter]; - const keys = crypto.deriveSecrets(messageKey, Buffer.alloc(32), - Buffer.from("WhisperMessageKeys")); + const keys = crypto.deriveSecrets( + messageKey, Buffer.alloc(32), + Buffer.from("WhisperMessageKeys") + ); const ourIdentityKey = await this.storage.getOurIdentity(); const macInput = Buffer.alloc(messageProto.byteLength + (33 * 2) + 1); macInput.set(session.indexInfo.remoteIdentityKey); macInput.set(ourIdentityKey.pubKey, 33); - macInput[33 * 2] = this._encodeTupleByte(VERSION, VERSION); + macInput[33 * 2] = this._encodeTupleByte(SESSION_CIPHER_VERSION, SESSION_CIPHER_VERSION); macInput.set(messageProto, (33 * 2) + 1); // This is where we most likely fail if the session is not a match. // Don't misinterpret this as corruption. @@ -253,7 +247,7 @@ class SessionCipher { return plaintext; } - fillMessageKeys(chain, counter) { + fillMessageKeys(chain: ChainDeserialized, counter: number): void{ if (chain.chainKey.counter >= counter) { return; } @@ -264,13 +258,13 @@ class SessionCipher { throw new errors.SessionError('Chain closed'); } const key = chain.chainKey.key; - chain.messageKeys[chain.chainKey.counter + 1] = crypto.calculateMAC(key, Buffer.from([1])); - chain.chainKey.key = crypto.calculateMAC(key, Buffer.from([2])); + chain.messageKeys[chain.chainKey.counter + 1] = crypto.calculateMAC(key as Buffer, Buffer.from([1])); + chain.chainKey.key = crypto.calculateMAC(key as Buffer, Buffer.from([2])); chain.chainKey.counter += 1; return this.fillMessageKeys(chain, counter); } - maybeStepRatchet(session, remoteKey, previousCounter) { + maybeStepRatchet(session: SessionEntry, remoteKey: Buffer, previousCounter: number) { if (session.getChain(remoteKey)) { return; } @@ -292,11 +286,15 @@ class SessionCipher { ratchet.lastRemoteEphemeralKey = remoteKey; } - calculateRatchet(session, remoteKey, sending) { + calculateRatchet(session: SessionEntry, remoteKey: Buffer, sending: boolean) { let ratchet = session.currentRatchet; const sharedSecret = curve.calculateAgreement(remoteKey, ratchet.ephemeralKeyPair.privKey); - const masterKey = crypto.deriveSecrets(sharedSecret, ratchet.rootKey, - Buffer.from("WhisperRatchet"), /*chunks*/ 2); + const masterKey = crypto.deriveSecrets( + sharedSecret, + ratchet.rootKey, + Buffer.from("WhisperRatchet"), + /*chunks*/ 2); + const chainKey = sending ? ratchet.ephemeralKeyPair.pubKey : remoteKey; session.addChain(chainKey, { messageKeys: {}, @@ -306,6 +304,7 @@ class SessionCipher { }, chainType: sending ? ChainType.SENDING : ChainType.RECEIVING }); + ratchet.rootKey = masterKey[0]; } @@ -332,5 +331,3 @@ class SessionCipher { }); } } - -module.exports = SessionCipher; diff --git a/src/Signal/session-entry.ts b/src/Signal/session-entry.ts new file mode 100644 index 00000000..9540b738 --- /dev/null +++ b/src/Signal/session-entry.ts @@ -0,0 +1,180 @@ +import { EphemeralKeyPairDeserialized, SessionDeserialized, SessionSerialized } from '../Types/Session'; +import { ChainDeserialized, ChainsDeserialized, ChainsSerialized } from '../Types/Chains' +import { BaseKeyType } from '../Types/BaseKey'; +import { PendingPreKeyDeserialized, PreKeyDeserialized } from '../Types/PreKey'; + +export class SessionEntry implements SessionDeserialized { + registrationId: string; + currentRatchet: { + ephemeralKeyPair: EphemeralKeyPairDeserialized + lastRemoteEphemeralKey: Buffer; + previousCounter: number; + rootKey: Buffer; + }; + indexInfo: { + baseKey: Buffer; + baseKeyType: BaseKeyType; + closed: number; + used: number; + created: number; + remoteIdentityKey: Buffer; + }; + _chains: ChainsDeserialized; + pendingPreKey?: PendingPreKeyDeserialized + + constructor() { + this.registrationId = ''; + this.currentRatchet = { + ephemeralKeyPair: { + pubKey: Buffer.alloc(0), + privKey: Buffer.alloc(0) + }, + lastRemoteEphemeralKey: Buffer.alloc(0), + previousCounter: 0, + rootKey: Buffer.alloc(0) + }; + this.indexInfo = { + baseKey: Buffer.alloc(0), + baseKeyType: BaseKeyType.THEIRS, + closed: 0, + used: 0, + created: 0, + remoteIdentityKey: Buffer.alloc(0) + }; + this._chains = {}; + this.pendingPreKey = undefined; + } + + toString(): string { + const baseKey = this.indexInfo && this.indexInfo.baseKey && + this.indexInfo.baseKey.toString('base64'); + return ``; + } + + inspect(): string { + return this.toString(); + } + + addChain(key: Buffer | Uint8Array, value: any): void { + const id = key.toString('base64'); + if (this._chains.hasOwnProperty(id)) { + throw new Error("Overwrite attempt"); + } + this._chains[id] = value; + } + + getChain(key: Buffer | Uint8Array): ChainDeserialized { + return this._chains[key.toString('base64')]; + } + + deleteChain(key: Buffer | Uint8Array): void { + const id = key.toString('base64'); + if (!this._chains.hasOwnProperty(id)) { + throw new ReferenceError("Not Found"); + } + delete this._chains[id]; + } + + *chains(): IterableIterator<[Buffer, ChainDeserialized]> { + for (const [k, v] of Object.entries(this._chains)) { + yield [Buffer.from(k, 'base64'), v]; + } + } + + serialize(): SessionSerialized { + const data: SessionSerialized = { + registrationId: this.registrationId, + currentRatchet: { + ephemeralKeyPair: { + pubKey: this.currentRatchet!.ephemeralKeyPair!.pubKey.toString('base64'), + privKey: this.currentRatchet!.ephemeralKeyPair!.privKey.toString('base64') + }, + lastRemoteEphemeralKey: this.currentRatchet!.lastRemoteEphemeralKey!.toString('base64'), + previousCounter: this.currentRatchet!.previousCounter, + rootKey: this.currentRatchet!.rootKey.toString('base64') + }, + indexInfo: { + baseKey: this.indexInfo!.baseKey.toString('base64'), + baseKeyType: this.indexInfo!.baseKeyType, + closed: this.indexInfo!.closed, + used: this.indexInfo!.used, + created: this.indexInfo!.created, + remoteIdentityKey: this.indexInfo!.remoteIdentityKey.toString('base64') + }, + _chains: this._serialize_chains(this._chains) + }; + if (this.pendingPreKey) { + data.pendingPreKey = {} + data.pendingPreKey.baseKey = this.pendingPreKey.baseKey?.toString('base64'); + } + return data; + } + + static deserialize(data: SessionSerialized): SessionEntry { + const obj = new this(); + obj.registrationId = data.registrationId; + obj.currentRatchet = { + ephemeralKeyPair: { + pubKey: Buffer.from(data.currentRatchet.ephemeralKeyPair.pubKey, 'base64'), + privKey: Buffer.from(data.currentRatchet.ephemeralKeyPair.privKey, 'base64') + }, + lastRemoteEphemeralKey: Buffer.from(data.currentRatchet.lastRemoteEphemeralKey, 'base64'), + previousCounter: data.currentRatchet.previousCounter, + rootKey: Buffer.from(data.currentRatchet.rootKey, 'base64') + }; + obj.indexInfo = { + baseKey: Buffer.from(data.indexInfo.baseKey, 'base64'), + baseKeyType: data.indexInfo.baseKeyType, + closed: data.indexInfo.closed, + used: data.indexInfo.used, + created: data.indexInfo.created, + remoteIdentityKey: Buffer.from(data.indexInfo.remoteIdentityKey, 'base64') + }; + obj._chains = this._deserialize_chains(data._chains); + if (data.pendingPreKey) { + obj.pendingPreKey = {} + obj.pendingPreKey.baseKey = Buffer.from(data.pendingPreKey.baseKey!, 'base64'); + } + return obj; + } + + private _serialize_chains(chains: ChainsDeserialized){ + const r: ChainsSerialized = {}; + for (const key of Object.keys(chains)) { + const c = chains[key]; + const messageKeys: any = {}; + for (const [idx, key] of Object.entries(c.messageKeys)) { + messageKeys[idx] = (key as Buffer).toString('base64'); + } + r[key] = { + chainKey: { + counter: c.chainKey.counter, + key: c.chainKey.key && c.chainKey.key.toString('base64') + }, + chainType: c.chainType, + messageKeys: messageKeys + }; + } + return r; + } + + private static _deserialize_chains(chains_data: ChainsSerialized) { + const r: ChainsDeserialized = {}; + for (const key of Object.keys(chains_data)) { + const c = chains_data[key]; + const messageKeys: any = {}; + for (const [idx, key] of Object.entries(c.messageKeys)) { + messageKeys[idx] = Buffer.from(key as string, 'base64'); + } + r[key] = { + chainKey: { + counter: c.chainKey.counter, + key: c.chainKey.key && Buffer.from(c.chainKey.key, 'base64') + }, + chainType: c.chainType, + messageKeys: messageKeys + }; + } + return r; + } +} diff --git a/src/Signal/session-record.ts b/src/Signal/session-record.ts new file mode 100644 index 00000000..fc0846c3 --- /dev/null +++ b/src/Signal/session-record.ts @@ -0,0 +1,162 @@ +import { CLOSED_SESSIONS_MAX, SESSION_RECORD_VERSION } from '../Types/constants' +import { SessionSerializedList, Migration, SessionRecordList } from '../Types/Session' +import { BaseKeyType } from '../Types/BaseKey' +import { SessionEntry } from './session-entry' + +const migrations: Migration[] = [{ + version: 'v1', + migrate: function migrateV1(data: SessionRecordList) { + const sessions = data._sessions + if (data.registrationId) { + for (const key in sessions) { + if (!sessions[key].registrationId) { + sessions[key].registrationId = data.registrationId + } + } + } else { + for (const key in sessions) { + if (sessions[key].indexInfo.closed === -1) { + console.error( + 'V1 session storage migration error: registrationId', + data.registrationId, 'for open session version', + data.version + ) + } + } + } + } +}] + +export class SessionRecord { + private sessions: { [key: string]: SessionEntry } + private version: string + + constructor() { + this.sessions = {} + this.version = SESSION_RECORD_VERSION + } + + static createEntry(): SessionEntry { + return new SessionEntry() + } + + static migrate(data: SessionRecordList): void { + let run: boolean = data.version === undefined + for (let i = 0; i < migrations.length; ++i) { + if (run) { + console.info("Migrating session to:", migrations[i].version) + migrations[i].migrate(data) + } else if (migrations[i].version === data.version) { + run = true + } + } + if (!run) { + throw new Error("Error migrating SessionRecord") + } + } + + static deserialize(data: SessionRecordList): SessionRecord { + if (data.version !== SESSION_RECORD_VERSION) { + this.migrate(data) + } + const obj = new this() + if (data._sessions) { + for (const [key, entry] of Object.entries(data._sessions)) { + obj.sessions[key] = SessionEntry.deserialize(entry) + } + } + return obj + } + + serialize(): SessionRecordList { + const _sessions: SessionSerializedList = {} + for (const [key, entry] of Object.entries(this.sessions)) { + _sessions[key] = entry.serialize() + } + return { + _sessions, + version: this.version + } + } + + haveOpenSession(): boolean { + const openSession = this.getOpenSession() + return (!!openSession && typeof openSession.registrationId === 'number') + } + + getSession(key: Buffer | Uint8Array): SessionEntry { + const session = this.sessions[key.toString('base64')] + if (session && session.indexInfo.baseKeyType === BaseKeyType.OURS) { + throw new Error("Tried to lookup a session using our basekey") + } + return session + } + + getOpenSession(): SessionEntry | void { + for (const session of Object.values(this.sessions)) { + if (!this.isClosed(session)) { + return session + } + } + } + + setSession(session: SessionEntry): void { + this.sessions[session.indexInfo.baseKey.toString('base64')] = session + } + + getSessions(): SessionEntry[] { + // Return sessions ordered with most recently used first. + return Array.from(Object.values(this.sessions)).sort((a, b) => { + const aUsed = a.indexInfo.used || 0 + const bUsed = b.indexInfo.used || 0 + return aUsed === bUsed ? 0 : aUsed < bUsed ? 1 : -1 + }) + } + + closeSession(session: SessionEntry): void { + if (this.isClosed(session)) { + console.warn("Session already closed", session) + return + } + console.info("Closing session:", session) + session.indexInfo.closed = Date.now() + } + + openSession(session: SessionEntry): void { + if (!this.isClosed(session)) { + console.warn("Session already open") + } + console.info("Opening session:", session) + session.indexInfo.closed = -1 + } + + isClosed(session: SessionEntry): boolean { + return session.indexInfo.closed !== -1 + } + + removeOldSessions(): void { + while (Object.keys(this.sessions).length > CLOSED_SESSIONS_MAX) { + let oldestKey: string | undefined + let oldestSession: SessionEntry | undefined + for (const [key, session] of Object.entries(this.sessions)) { + if (session.indexInfo.closed !== -1 && + (!oldestSession || session.indexInfo.closed < oldestSession.indexInfo.closed)) { + oldestKey = key + oldestSession = session + } + } + if (oldestKey) { + console.info("Removing old closed session:", oldestSession) + delete this.sessions[oldestKey] + } else { + throw new Error('Corrupt sessions object') + } + } + } + + deleteAllSessions(): void { + for (const key of Object.keys(this.sessions)) { + delete this.sessions[key] + } + } +} diff --git a/src/Types/BaseKey.ts b/src/Types/BaseKey.ts new file mode 100644 index 00000000..5cbd523c --- /dev/null +++ b/src/Types/BaseKey.ts @@ -0,0 +1,4 @@ +export enum BaseKeyType { + OURS = 1, + THEIRS = 2 +} \ No newline at end of file diff --git a/src/Types/Chains.ts b/src/Types/Chains.ts new file mode 100644 index 00000000..9c6cbf17 --- /dev/null +++ b/src/Types/Chains.ts @@ -0,0 +1,30 @@ +export enum ChainType { + SENDING = 1, + RECEIVING = 2 +} + +export interface ChainSerialized { + chainKey: { + counter: number + key?: string + }, + chainType: ChainType + messageKeys: { [key: number] : string } +} + +export interface ChainDeserialized { + chainKey: { + counter: number + key?: string | Buffer + }, + chainType: ChainType + messageKeys: { [key: number] : Buffer } +} + +export interface ChainsSerialized { + [key: string] : ChainSerialized +} + +export interface ChainsDeserialized { + [key: string] : ChainDeserialized +} diff --git a/src/Types/PreKey.ts b/src/Types/PreKey.ts new file mode 100644 index 00000000..2d387cef --- /dev/null +++ b/src/Types/PreKey.ts @@ -0,0 +1,35 @@ +export interface PreKeyDeserialized { + registrationId: string + preKeyId: number + signedPreKeyId?: number + baseKey: Buffer | Uint8Array + identityKey: Buffer | Uint8Array + message?: Buffer | Uint8Array +} + +export interface PreKeySerialized { + registrationId: string + preKeyId: number + signedPreKeyId?: number + baseKey: string + identityKey: string + message?: string +} + +export interface PendingPreKeyDeserialized { + registrationId?: number + preKeyId?: number + signedKeyId?: number + baseKey?: Buffer | Uint8Array + identityKey?: Buffer | Uint8Array + message?: Buffer | Uint8Array +} + +export interface PendingPreKeySerialized { + registrationId?: number + preKeyId?: number + signedKeyId?: number + baseKey?: string + identityKey?: string + message?: string +} \ No newline at end of file diff --git a/src/Types/Session.ts b/src/Types/Session.ts new file mode 100644 index 00000000..2c3a44d8 --- /dev/null +++ b/src/Types/Session.ts @@ -0,0 +1,72 @@ +import { BaseKeyType } from './BaseKey' +import { ChainsDeserialized, ChainsSerialized } from './Chains' +import { PendingPreKeyDeserialized, PendingPreKeySerialized, PreKeyDeserialized, PreKeySerialized } from './PreKey' + +export interface EphemeralKeyPairSerialized { + pubKey: string + privKey: string +} + +export interface EphemeralKeyPairDeserialized { + pubKey: Buffer | Uint8Array + privKey: Buffer +} + +export interface SessionSerialized { + registrationId: string + currentRatchet: { + ephemeralKeyPair: EphemeralKeyPairSerialized + lastRemoteEphemeralKey: string + previousCounter: number + rootKey: string + } + indexInfo: { + baseKey: string + baseKeyType: BaseKeyType + closed: number + used: number + created: number + remoteIdentityKey: string + } + _chains: ChainsSerialized + pendingPreKey?: PendingPreKeySerialized +} + +export interface SessionDeserialized { + registrationId: string + currentRatchet: { + ephemeralKeyPair: EphemeralKeyPairDeserialized + lastRemoteEphemeralKey: Buffer + previousCounter: number + rootKey: Buffer + } + indexInfo: { + baseKey: Buffer + baseKeyType: BaseKeyType + closed: number + used: number + created: number + remoteIdentityKey: Buffer + } + _chains: ChainsDeserialized + pendingPreKey?: PendingPreKeyDeserialized +} + +export interface SessionDeserializedList { + [key: string]: SessionDeserialized +} + +export interface SessionSerializedList { + [key: string]: SessionSerialized +} + +export interface SessionRecordList { + _sessions: SessionSerializedList + version: string + registrationId?: string +} + +export interface Migration { + version: string + migrate(data: any): void +} diff --git a/src/Types/constants.ts b/src/Types/constants.ts new file mode 100644 index 00000000..50c8759d --- /dev/null +++ b/src/Types/constants.ts @@ -0,0 +1,4 @@ +export const CLOSED_SESSIONS_MAX = 40 + +export const SESSION_RECORD_VERSION = 'v1' +export const SESSION_CIPHER_VERSION = 3 diff --git a/src/Types/index.ts b/src/Types/index.ts new file mode 100644 index 00000000..ca748b8a --- /dev/null +++ b/src/Types/index.ts @@ -0,0 +1,4 @@ +export * from './BaseKey' +export * from './Chains' +export * from './PreKey' +export * from './Session' diff --git a/src/Utils/crypto.ts b/src/Utils/crypto.ts new file mode 100644 index 00000000..2c62e077 --- /dev/null +++ b/src/Utils/crypto.ts @@ -0,0 +1,67 @@ +import * as crypto from 'crypto' +import assert from 'assert' + +export function calculateMAC(key: Buffer, data: Buffer) { + const hmac = crypto.createHmac('sha256', key) + hmac.update(data) + return Buffer.from(hmac.digest()) +} + +export function encrypt(key: Buffer, data: Buffer | Uint8Array, iv: Buffer) { + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) + return Buffer.concat([cipher.update(data), cipher.final()]) +} + +export function decrypt(key: Buffer, data: Buffer | Uint8Array, iv: Buffer) { + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + return Buffer.concat([decipher.update(data), decipher.final()]) +} + +export function hash(data: Buffer) { + const sha512 = crypto.createHash('sha512') + sha512.update(data) + return sha512.digest() +} + +/** Salts always end up being 32 bytes + * + * Specific implementation of RFC 5869 that only returns the first 3 32-byte chunks +*/ +export function deriveSecrets(input: Buffer, salt: Buffer, info: Buffer, chunks?: number) { + if (salt.byteLength !== 32) { + throw new Error("Got salt of incorrect length") + } + + chunks = chunks || 3 + assert(chunks >= 1 && chunks <= 3) + + const PRK = calculateMAC(salt, input) + const infoArray = new Uint8Array(info.byteLength + 1 + 32) + + infoArray.set(info, 32) + infoArray[infoArray.length - 1] = 1 + const signed = [calculateMAC(PRK, Buffer.from(infoArray.slice(32)))] + + if (chunks > 1) { + infoArray.set(signed[signed.length - 1]) + infoArray[infoArray.length - 1] = 2 + signed.push(calculateMAC(PRK, Buffer.from(infoArray))) + } + if (chunks > 2) { + infoArray.set(signed[signed.length - 1]) + infoArray[infoArray.length - 1] = 3 + signed.push(calculateMAC(PRK, Buffer.from(infoArray))) + } + + return signed +} + +export function verifyMAC(data: Buffer, key: Buffer, mac: Buffer | Uint8Array, length: number) { + const calculatedMac = calculateMAC(key, data).slice(0, length) + if (mac.length !== length || calculatedMac.length !== length) { + throw new Error("Bad MAC length") + } + if (!Buffer.from(mac).equals(calculatedMac)) { + throw new Error("Bad MAC") + } +} diff --git a/src/Utils/curve.ts b/src/Utils/curve.ts new file mode 100644 index 00000000..eec8ead8 --- /dev/null +++ b/src/Utils/curve.ts @@ -0,0 +1,70 @@ +import * as curveJs from 'curve25519-js' +import * as crypto from 'crypto' + +function validatePrivKey(privKey: Buffer) { + if (privKey === undefined) { + throw new Error("Undefined private key") + } + if (!(privKey instanceof Buffer)) { + throw new Error(`Invalid private key type`) + } + if (privKey.byteLength != 32) { + throw new Error(`Incorrect private key length`) + } +} + +function scrubPubKeyFormat(pubKey: Buffer | undefined) { + if (!(pubKey instanceof Buffer)) { + throw new Error(`Invalid public key type`) + } + if (pubKey === undefined || ((pubKey.byteLength != 33 || pubKey[0] != 5) && pubKey.byteLength != 32)) { + throw new Error("Invalid public key") + } + if (pubKey.byteLength == 33) { + return pubKey.slice(1) + } else { + console.error("WARNING: Expected pubkey of length 33, please report the ST and client that generated the pubkey") + return pubKey + } +} + +export function generateKeyPair() { + const keyPair = curveJs.generateKeyPair(crypto.randomBytes(32)) + return { + privKey: Buffer.from(keyPair.private), + pubKey: Buffer.from(keyPair.public) + } +} + +export function calculateAgreement(pubKey: Buffer | undefined, privKey: Buffer) { + pubKey = scrubPubKeyFormat(pubKey) + validatePrivKey(privKey) + if (!pubKey || pubKey.byteLength != 32) { + throw new Error("Invalid public key") + } + + const secret = curveJs.sharedKey(privKey, pubKey) + return Buffer.from(secret) +} + +export function calculateSignature(privKey: Buffer, message: any) { + validatePrivKey(privKey) + if (!message) { + throw new Error("Invalid message") + } + return Buffer.from(curveJs.sign(privKey, message, [])) +} + +export function verifySignature(pubKey: Buffer, msg: any, sig: Buffer) { + pubKey = scrubPubKeyFormat(pubKey) + if (!pubKey || pubKey.byteLength != 32) { + throw new Error("Invalid public key") + } + if (!msg) { + throw new Error("Invalid message") + } + if (!sig || sig.byteLength != 64) { + throw new Error("Invalid signature") + } + return curveJs.verify(pubKey, msg, sig) +} diff --git a/src/Utils/errors.ts b/src/Utils/errors.ts new file mode 100644 index 00000000..2d8c71c7 --- /dev/null +++ b/src/Utils/errors.ts @@ -0,0 +1,39 @@ +export class SignalError extends Error { + constructor(message?: string) { + super(message); + this.name = 'SignalError'; + } +} + +export class UntrustedIdentityKeyError extends SignalError { + addr: string; + identityKey: string; + + constructor(addr: string, identityKey: any) { + super(); + this.name = 'UntrustedIdentityKeyError'; + this.addr = addr; + this.identityKey = identityKey; + } +} + +export class SessionError extends SignalError { + constructor(message: string) { + super(message); + this.name = 'SessionError'; + } +} + +export class MessageCounterError extends SessionError { + constructor(message: string) { + super(message); + this.name = 'MessageCounterError'; + } +} + +export class PreKeyError extends SessionError { + constructor(message: string) { + super(message); + this.name = 'PreKeyError'; + } +} diff --git a/src/Utils/index.ts b/src/Utils/index.ts new file mode 100644 index 00000000..4d89381f --- /dev/null +++ b/src/Utils/index.ts @@ -0,0 +1,5 @@ +export * from './crypto' +export * from './curve' +export * from './errors' +export * from './keyutils' +export * from './queue-job' diff --git a/src/Utils/keyutils.ts b/src/Utils/keyutils.ts new file mode 100644 index 00000000..0ca035bb --- /dev/null +++ b/src/Utils/keyutils.ts @@ -0,0 +1,38 @@ +import { EphemeralKeyPairDeserialized } from '../Types/Session'; +import { generateKeyPair, calculateSignature } from '../Utils/curve'; +import * as crypto from 'crypto' + +function isNonNegativeInteger(n: number) { + return Number.isInteger(n) && n >= 0; +} + +export function generateRegistrationId() { + var registrationId = Uint16Array.from(crypto.randomBytes(2))[0]; + return registrationId & 0x3fff; +}; + +export function generateSignedPreKey(identityKeyPair: EphemeralKeyPairDeserialized, signedKeyId: number) { + if (!isNonNegativeInteger(signedKeyId)) { + throw new TypeError('Invalid argument for signedKeyId: ' + signedKeyId); + } + + const keyPair = generateKeyPair(); + const sig = calculateSignature(identityKeyPair.privKey, keyPair.pubKey); + return { + keyId: signedKeyId, + keyPair: keyPair, + signature: sig + }; +}; + +export function generatePreKey(keyId: number) { + if (!isNonNegativeInteger(keyId)) { + throw new TypeError('Invalid argument for keyId: ' + keyId); + } + + const keyPair = generateKeyPair() + return { + keyId, + keyPair + } +} diff --git a/src/Utils/queue-job.ts b/src/Utils/queue-job.ts new file mode 100644 index 00000000..b76059b0 --- /dev/null +++ b/src/Utils/queue-job.ts @@ -0,0 +1,60 @@ +interface QueueJob { + awaitable: () => Promise + resolve: (value?: T | PromiseLike) => void + reject: (reason?: any) => void +} + +const _queueAsyncBuckets: Map[]> = new Map(); +const _gcLimit = 10000; + +async function _asyncQueueExecutor(queue: QueueJob[], cleanup: () => void) { + let offt = 0; + while (true) { + let limit = Math.min(queue.length, _gcLimit); // Break up thundering hurds for GC duty. + for (let i = offt; i < limit; i++) { + const job = queue[i]; + try { + job.resolve(await job.awaitable()); + } catch(e) { + job.reject(e); + } + } + if (limit < queue.length) { + /* Perform lazy GC of queue for faster iteration. */ + if (limit >= _gcLimit) { + queue.splice(0, limit); + offt = 0; + } else { + offt = limit; + } + } else { + break; + } + } + cleanup(); +} + +/** queueJob manages multiple queues indexed by device to serialize session io ops on the database. + * + * Run the async awaitable only when all other async calls registered + * here have completed (or thrown). The bucket argument is a hashable + * key representing the task queue to use. +*/ +export function queueJob(bucket: string, awaitable: () => Promise): Promise { + let inactive; + if (!_queueAsyncBuckets.has(bucket)) { + _queueAsyncBuckets.set(bucket, []) + inactive = true + } + const queue = _queueAsyncBuckets.get(bucket) + const job = new Promise((resolve, reject) => queue?.push({ + awaitable, + resolve, + reject + })) + if (inactive && queue) { + /* An executor is not currently active; Start one now. */ + _asyncQueueExecutor(queue, () => _queueAsyncBuckets.delete(bucket)) + } + return job +}; diff --git a/src/base_key_type.js b/src/base_key_type.js deleted file mode 100644 index d64dd714..00000000 --- a/src/base_key_type.js +++ /dev/null @@ -1,7 +0,0 @@ - -const BaseKeyType = { - OURS: 1, - THEIRS: 2 -}; - -module.exports = BaseKeyType; diff --git a/src/chain_type.js b/src/chain_type.js deleted file mode 100644 index 7d66fcf5..00000000 --- a/src/chain_type.js +++ /dev/null @@ -1,6 +0,0 @@ -const ChainType = { - SENDING: 1, - RECEIVING: 2 -}; - -module.exports = ChainType; diff --git a/src/crypto.js b/src/crypto.js deleted file mode 100644 index 6db46d86..00000000 --- a/src/crypto.js +++ /dev/null @@ -1,98 +0,0 @@ -// vim: ts=4:sw=4 - -'use strict'; - -const nodeCrypto = require('crypto'); -const assert = require('assert'); - - -function assertBuffer(value) { - if (!(value instanceof Buffer)) { - throw TypeError(`Expected Buffer instead of: ${value.constructor.name}`); - } - return value; -} - - -function encrypt(key, data, iv) { - assertBuffer(key); - assertBuffer(data); - assertBuffer(iv); - const cipher = nodeCrypto.createCipheriv('aes-256-cbc', key, iv); - return Buffer.concat([cipher.update(data), cipher.final()]); -} - - -function decrypt(key, data, iv) { - assertBuffer(key); - assertBuffer(data); - assertBuffer(iv); - const decipher = nodeCrypto.createDecipheriv('aes-256-cbc', key, iv); - return Buffer.concat([decipher.update(data), decipher.final()]); -} - - -function calculateMAC(key, data) { - assertBuffer(key); - assertBuffer(data); - const hmac = nodeCrypto.createHmac('sha256', key); - hmac.update(data); - return Buffer.from(hmac.digest()); -} - - -function hash(data) { - assertBuffer(data); - const sha512 = nodeCrypto.createHash('sha512'); - sha512.update(data); - return sha512.digest(); -} - - -// Salts always end up being 32 bytes -function deriveSecrets(input, salt, info, chunks) { - // Specific implementation of RFC 5869 that only returns the first 3 32-byte chunks - assertBuffer(input); - assertBuffer(salt); - assertBuffer(info); - if (salt.byteLength != 32) { - throw new Error("Got salt of incorrect length"); - } - chunks = chunks || 3; - assert(chunks >= 1 && chunks <= 3); - const PRK = calculateMAC(salt, input); - const infoArray = new Uint8Array(info.byteLength + 1 + 32); - infoArray.set(info, 32); - infoArray[infoArray.length - 1] = 1; - const signed = [calculateMAC(PRK, Buffer.from(infoArray.slice(32)))]; - if (chunks > 1) { - infoArray.set(signed[signed.length - 1]); - infoArray[infoArray.length - 1] = 2; - signed.push(calculateMAC(PRK, Buffer.from(infoArray))); - } - if (chunks > 2) { - infoArray.set(signed[signed.length - 1]); - infoArray[infoArray.length - 1] = 3; - signed.push(calculateMAC(PRK, Buffer.from(infoArray))); - } - return signed; -} - -function verifyMAC(data, key, mac, length) { - const calculatedMac = calculateMAC(key, data).slice(0, length); - if (mac.length !== length || calculatedMac.length !== length) { - throw new Error("Bad MAC length"); - } - if (!mac.equals(calculatedMac)) { - throw new Error("Bad MAC"); - } -} - -module.exports = { - deriveSecrets, - decrypt, - encrypt, - hash, - calculateMAC, - verifyMAC -}; diff --git a/src/curve.js b/src/curve.js deleted file mode 100644 index 5c5fe59e..00000000 --- a/src/curve.js +++ /dev/null @@ -1,120 +0,0 @@ - -'use strict'; - -const curveJs = require('curve25519-js'); -const nodeCrypto = require('crypto'); -// from: https://github.com/digitalbazaar/x25519-key-agreement-key-2019/blob/master/lib/crypto.js -const PUBLIC_KEY_DER_PREFIX = Buffer.from([ - 48, 42, 48, 5, 6, 3, 43, 101, 110, 3, 33, 0 -]); - -const PRIVATE_KEY_DER_PREFIX = Buffer.from([ - 48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 110, 4, 34, 4, 32 -]); - -function validatePrivKey(privKey) { - if (privKey === undefined) { - throw new Error("Undefined private key"); - } - if (!(privKey instanceof Buffer)) { - throw new Error(`Invalid private key type: ${privKey.constructor.name}`); - } - if (privKey.byteLength != 32) { - throw new Error(`Incorrect private key length: ${privKey.byteLength}`); - } -} - -function scrubPubKeyFormat(pubKey) { - if (!(pubKey instanceof Buffer)) { - throw new Error(`Invalid public key type: ${pubKey.constructor.name}`); - } - if (pubKey === undefined || ((pubKey.byteLength != 33 || pubKey[0] != 5) && pubKey.byteLength != 32)) { - throw new Error("Invalid public key"); - } - if (pubKey.byteLength == 33) { - return pubKey.slice(1); - } else { - console.error("WARNING: Expected pubkey of length 33, please report the ST and client that generated the pubkey"); - return pubKey; - } -} - -exports.generateKeyPair = function() { - if(typeof nodeCrypto.generateKeyPairSync === 'function') { - const {publicKey: publicDerBytes, privateKey: privateDerBytes} = nodeCrypto.generateKeyPairSync( - 'x25519', - { - publicKeyEncoding: { format: 'der', type: 'spki' }, - privateKeyEncoding: { format: 'der', type: 'pkcs8' } - } - ); - // 33 bytes - // first byte = 5 (version byte) - const pubKey = publicDerBytes.slice(PUBLIC_KEY_DER_PREFIX.length-1, PUBLIC_KEY_DER_PREFIX.length + 32); - pubKey[0] = 5; - - const privKey = privateDerBytes.slice(PRIVATE_KEY_DER_PREFIX.length, PRIVATE_KEY_DER_PREFIX.length + 32); - - return { - pubKey, - privKey - }; - } else { - const keyPair = curveJs.generateKeyPair(nodeCrypto.randomBytes(32)); - return { - privKey: Buffer.from(keyPair.private), - pubKey: Buffer.from(keyPair.public), - }; - } -}; - -exports.calculateAgreement = function(pubKey, privKey) { - pubKey = scrubPubKeyFormat(pubKey); - validatePrivKey(privKey); - if (!pubKey || pubKey.byteLength != 32) { - throw new Error("Invalid public key"); - } - - if(typeof nodeCrypto.diffieHellman === 'function') { - const nodePrivateKey = nodeCrypto.createPrivateKey({ - key: Buffer.concat([PRIVATE_KEY_DER_PREFIX, privKey]), - format: 'der', - type: 'pkcs8' - }); - const nodePublicKey = nodeCrypto.createPublicKey({ - key: Buffer.concat([PUBLIC_KEY_DER_PREFIX, pubKey]), - format: 'der', - type: 'spki' - }); - - return nodeCrypto.diffieHellman({ - privateKey: nodePrivateKey, - publicKey: nodePublicKey, - }); - } else { - const secret = curveJs.sharedKey(privKey, pubKey); - return Buffer.from(secret); - } -}; - -exports.calculateSignature = function(privKey, message) { - validatePrivKey(privKey); - if (!message) { - throw new Error("Invalid message"); - } - return Buffer.from(curveJs.sign(privKey, message)); -}; - -exports.verifySignature = function(pubKey, msg, sig) { - pubKey = scrubPubKeyFormat(pubKey); - if (!pubKey || pubKey.byteLength != 32) { - throw new Error("Invalid public key"); - } - if (!msg) { - throw new Error("Invalid message"); - } - if (!sig || sig.byteLength != 64) { - throw new Error("Invalid signature"); - } - return curveJs.verify(pubKey, msg, sig); -}; \ No newline at end of file diff --git a/src/errors.js b/src/errors.js deleted file mode 100644 index 331958d9..00000000 --- a/src/errors.js +++ /dev/null @@ -1,33 +0,0 @@ -// vim: ts=4:sw=4:expandtab - -exports.SignalError = class SignalError extends Error {}; - -exports.UntrustedIdentityKeyError = class UntrustedIdentityKeyError extends exports.SignalError { - constructor(addr, identityKey) { - super(); - this.name = 'UntrustedIdentityKeyError'; - this.addr = addr; - this.identityKey = identityKey; - } -}; - -exports.SessionError = class SessionError extends exports.SignalError { - constructor(message) { - super(message); - this.name = 'SessionError'; - } -}; - -exports.MessageCounterError = class MessageCounterError extends exports.SessionError { - constructor(message) { - super(message); - this.name = 'MessageCounterError'; - } -}; - -exports.PreKeyError = class PreKeyError extends exports.SessionError { - constructor(message) { - super(message); - this.name = 'PreKeyError'; - } -}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..7e6a4a23 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from './Utils' +export * from './Signal' +export * from './Types' diff --git a/src/keyhelper.js b/src/keyhelper.js deleted file mode 100644 index 5d7f417f..00000000 --- a/src/keyhelper.js +++ /dev/null @@ -1,45 +0,0 @@ -// vim: ts=4:sw=4:expandtab - -const curve = require('./curve'); -const nodeCrypto = require('crypto'); - -function isNonNegativeInteger(n) { - return (typeof n === 'number' && (n % 1) === 0 && n >= 0); -} - -exports.generateIdentityKeyPair = curve.generateKeyPair; - -exports.generateRegistrationId = function() { - var registrationId = Uint16Array.from(nodeCrypto.randomBytes(2))[0]; - return registrationId & 0x3fff; -}; - -exports.generateSignedPreKey = function(identityKeyPair, signedKeyId) { - if (!(identityKeyPair.privKey instanceof Buffer) || - identityKeyPair.privKey.byteLength != 32 || - !(identityKeyPair.pubKey instanceof Buffer) || - identityKeyPair.pubKey.byteLength != 33) { - throw new TypeError('Invalid argument for identityKeyPair'); - } - if (!isNonNegativeInteger(signedKeyId)) { - throw new TypeError('Invalid argument for signedKeyId: ' + signedKeyId); - } - const keyPair = curve.generateKeyPair(); - const sig = curve.calculateSignature(identityKeyPair.privKey, keyPair.pubKey); - return { - keyId: signedKeyId, - keyPair: keyPair, - signature: sig - }; -}; - -exports.generatePreKey = function(keyId) { - if (!isNonNegativeInteger(keyId)) { - throw new TypeError('Invalid argument for keyId: ' + keyId); - } - const keyPair = curve.generateKeyPair(); - return { - keyId, - keyPair - }; -}; diff --git a/src/numeric_fingerprint.js b/src/numeric_fingerprint.js deleted file mode 100644 index baadb58e..00000000 --- a/src/numeric_fingerprint.js +++ /dev/null @@ -1,72 +0,0 @@ - -const crypto = require('./crypto.js'); - -var VERSION = 0; - - -async function iterateHash(data, key, count) { - const combined = (new Uint8Array(Buffer.concat([data, key]))).buffer; - const result = crypto.hash(combined); - if (--count === 0) { - return result; - } else { - return iterateHash(result, key, count); - } -} - - -function shortToArrayBuffer(number) { - return new Uint16Array([number]).buffer; -} - -function getEncodedChunk(hash, offset) { - var chunk = ( hash[offset] * Math.pow(2,32) + - hash[offset+1] * Math.pow(2,24) + - hash[offset+2] * Math.pow(2,16) + - hash[offset+3] * Math.pow(2,8) + - hash[offset+4] ) % 100000; - var s = chunk.toString(); - while (s.length < 5) { - s = '0' + s; - } - return s; -} - -async function getDisplayStringFor(identifier, key, iterations) { - const bytes = Buffer.concat([ - shortToArrayBuffer(VERSION), - key, - identifier - ]); - const arraybuf = (new Uint8Array(bytes)).buffer; - const output = new Uint8Array(await iterateHash(arraybuf, key, iterations)); - return getEncodedChunk(output, 0) + - getEncodedChunk(output, 5) + - getEncodedChunk(output, 10) + - getEncodedChunk(output, 15) + - getEncodedChunk(output, 20) + - getEncodedChunk(output, 25); -} - -exports.FingerprintGenerator = function(iterations) { - this.iterations = iterations; -}; - -exports.FingerprintGenerator.prototype = { - createFor: function(localIdentifier, localIdentityKey, - remoteIdentifier, remoteIdentityKey) { - if (typeof localIdentifier !== 'string' || - typeof remoteIdentifier !== 'string' || - !(localIdentityKey instanceof ArrayBuffer) || - !(remoteIdentityKey instanceof ArrayBuffer)) { - throw new Error('Invalid arguments'); - } - - return Promise.all([ - getDisplayStringFor(localIdentifier, localIdentityKey, this.iterations), - getDisplayStringFor(remoteIdentifier, remoteIdentityKey, this.iterations) - ]).then(function(fingerprints) { - return fingerprints.sort().join(''); - }); - } -}; diff --git a/src/protobufs.js b/src/protobufs.js deleted file mode 100644 index af732795..00000000 --- a/src/protobufs.js +++ /dev/null @@ -1,10 +0,0 @@ -'use strict'; - -const { - textsecure: { - WhisperMessage, - PreKeyWhisperMessage - } -} = require('./WhisperTextProtocol.js'); - -module.exports = { WhisperMessage, PreKeyWhisperMessage }; \ No newline at end of file diff --git a/src/protocol_address.js b/src/protocol_address.js deleted file mode 100644 index 84ceb694..00000000 --- a/src/protocol_address.js +++ /dev/null @@ -1,40 +0,0 @@ -// vim: ts=4:sw=4:expandtab - - -class ProtocolAddress { - - static from(encodedAddress) { - if (typeof encodedAddress !== 'string' || !encodedAddress.match(/.*\.\d+/)) { - throw new Error('Invalid address encoding'); - } - const parts = encodedAddress.split('.'); - return new this(parts[0], parseInt(parts[1])); - } - - constructor(id, deviceId) { - if (typeof id !== 'string') { - throw new TypeError('id required for addr'); - } - if (id.indexOf('.') !== -1) { - throw new TypeError('encoded addr detected'); - } - this.id = id; - if (typeof deviceId !== 'number') { - throw new TypeError('number required for deviceId'); - } - this.deviceId = deviceId; - } - - toString() { - return `${this.id}.${this.deviceId}`; - } - - is(other) { - if (!(other instanceof ProtocolAddress)) { - return false; - } - return other.id === this.id && other.deviceId === this.deviceId; - } -} - -module.exports = ProtocolAddress; diff --git a/src/queue_job.js b/src/queue_job.js deleted file mode 100644 index baab89c4..00000000 --- a/src/queue_job.js +++ /dev/null @@ -1,69 +0,0 @@ -// vim: ts=4:sw=4:expandtab - - /* - * jobQueue manages multiple queues indexed by device to serialize - * session io ops on the database. - */ -'use strict'; - - -const _queueAsyncBuckets = new Map(); -const _gcLimit = 10000; - -async function _asyncQueueExecutor(queue, cleanup) { - let offt = 0; - while (true) { - let limit = Math.min(queue.length, _gcLimit); // Break up thundering hurds for GC duty. - for (let i = offt; i < limit; i++) { - const job = queue[i]; - try { - job.resolve(await job.awaitable()); - } catch(e) { - job.reject(e); - } - } - if (limit < queue.length) { - /* Perform lazy GC of queue for faster iteration. */ - if (limit >= _gcLimit) { - queue.splice(0, limit); - offt = 0; - } else { - offt = limit; - } - } else { - break; - } - } - cleanup(); -} - -module.exports = function(bucket, awaitable) { - /* Run the async awaitable only when all other async calls registered - * here have completed (or thrown). The bucket argument is a hashable - * key representing the task queue to use. */ - if (!awaitable.name) { - // Make debuging easier by adding a name to this function. - Object.defineProperty(awaitable, 'name', {writable: true}); - if (typeof bucket === 'string') { - awaitable.name = bucket; - } else { - console.warn("Unhandled bucket type (for naming):", typeof bucket, bucket); - } - } - let inactive; - if (!_queueAsyncBuckets.has(bucket)) { - _queueAsyncBuckets.set(bucket, []); - inactive = true; - } - const queue = _queueAsyncBuckets.get(bucket); - const job = new Promise((resolve, reject) => queue.push({ - awaitable, - resolve, - reject - })); - if (inactive) { - /* An executor is not currently active; Start one now. */ - _asyncQueueExecutor(queue, () => _queueAsyncBuckets.delete(bucket)); - } - return job; -}; diff --git a/src/session_record.js b/src/session_record.js deleted file mode 100644 index 7626a392..00000000 --- a/src/session_record.js +++ /dev/null @@ -1,316 +0,0 @@ -// vim: ts=4:sw=4 - -const BaseKeyType = require('./base_key_type'); - -const CLOSED_SESSIONS_MAX = 40; -const SESSION_RECORD_VERSION = 'v1'; - -function assertBuffer(value) { - if (!Buffer.isBuffer(value)) { - throw new TypeError("Buffer required"); - } -} - - -class SessionEntry { - - constructor() { - this._chains = {}; - } - - toString() { - const baseKey = this.indexInfo && this.indexInfo.baseKey && - this.indexInfo.baseKey.toString('base64'); - return ``; - } - - inspect() { - return this.toString(); - } - - addChain(key, value) { - assertBuffer(key); - const id = key.toString('base64'); - if (this._chains.hasOwnProperty(id)) { - throw new Error("Overwrite attempt"); - } - this._chains[id] = value; - } - - getChain(key) { - assertBuffer(key); - return this._chains[key.toString('base64')]; - } - - deleteChain(key) { - assertBuffer(key); - const id = key.toString('base64'); - if (!this._chains.hasOwnProperty(id)) { - throw new ReferenceError("Not Found"); - } - delete this._chains[id]; - } - - *chains() { - for (const [k, v] of Object.entries(this._chains)) { - yield [Buffer.from(k, 'base64'), v]; - } - } - - serialize() { - const data = { - registrationId: this.registrationId, - currentRatchet: { - ephemeralKeyPair: { - pubKey: this.currentRatchet.ephemeralKeyPair.pubKey.toString('base64'), - privKey: this.currentRatchet.ephemeralKeyPair.privKey.toString('base64') - }, - lastRemoteEphemeralKey: this.currentRatchet.lastRemoteEphemeralKey.toString('base64'), - previousCounter: this.currentRatchet.previousCounter, - rootKey: this.currentRatchet.rootKey.toString('base64') - }, - indexInfo: { - baseKey: this.indexInfo.baseKey.toString('base64'), - baseKeyType: this.indexInfo.baseKeyType, - closed: this.indexInfo.closed, - used: this.indexInfo.used, - created: this.indexInfo.created, - remoteIdentityKey: this.indexInfo.remoteIdentityKey.toString('base64') - }, - _chains: this._serialize_chains(this._chains) - }; - if (this.pendingPreKey) { - data.pendingPreKey = Object.assign({}, this.pendingPreKey); - data.pendingPreKey.baseKey = this.pendingPreKey.baseKey.toString('base64'); - } - return data; - } - - static deserialize(data) { - const obj = new this(); - obj.registrationId = data.registrationId; - obj.currentRatchet = { - ephemeralKeyPair: { - pubKey: Buffer.from(data.currentRatchet.ephemeralKeyPair.pubKey, 'base64'), - privKey: Buffer.from(data.currentRatchet.ephemeralKeyPair.privKey, 'base64') - }, - lastRemoteEphemeralKey: Buffer.from(data.currentRatchet.lastRemoteEphemeralKey, 'base64'), - previousCounter: data.currentRatchet.previousCounter, - rootKey: Buffer.from(data.currentRatchet.rootKey, 'base64') - }; - obj.indexInfo = { - baseKey: Buffer.from(data.indexInfo.baseKey, 'base64'), - baseKeyType: data.indexInfo.baseKeyType, - closed: data.indexInfo.closed, - used: data.indexInfo.used, - created: data.indexInfo.created, - remoteIdentityKey: Buffer.from(data.indexInfo.remoteIdentityKey, 'base64') - }; - obj._chains = this._deserialize_chains(data._chains); - if (data.pendingPreKey) { - obj.pendingPreKey = Object.assign({}, data.pendingPreKey); - obj.pendingPreKey.baseKey = Buffer.from(data.pendingPreKey.baseKey, 'base64'); - } - return obj; - } - - _serialize_chains(chains) { - const r = {}; - for (const key of Object.keys(chains)) { - const c = chains[key]; - const messageKeys = {}; - for (const [idx, key] of Object.entries(c.messageKeys)) { - messageKeys[idx] = key.toString('base64'); - } - r[key] = { - chainKey: { - counter: c.chainKey.counter, - key: c.chainKey.key && c.chainKey.key.toString('base64') - }, - chainType: c.chainType, - messageKeys: messageKeys - }; - } - return r; - } - - static _deserialize_chains(chains_data) { - const r = {}; - for (const key of Object.keys(chains_data)) { - const c = chains_data[key]; - const messageKeys = {}; - for (const [idx, key] of Object.entries(c.messageKeys)) { - messageKeys[idx] = Buffer.from(key, 'base64'); - } - r[key] = { - chainKey: { - counter: c.chainKey.counter, - key: c.chainKey.key && Buffer.from(c.chainKey.key, 'base64') - }, - chainType: c.chainType, - messageKeys: messageKeys - }; - } - return r; - } - -} - - -const migrations = [{ - version: 'v1', - migrate: function migrateV1(data) { - const sessions = data._sessions; - if (data.registrationId) { - for (const key in sessions) { - if (!sessions[key].registrationId) { - sessions[key].registrationId = data.registrationId; - } - } - } else { - for (const key in sessions) { - if (sessions[key].indexInfo.closed === -1) { - console.error('V1 session storage migration error: registrationId', - data.registrationId, 'for open session version', - data.version); - } - } - } - } -}]; - - -class SessionRecord { - - static createEntry() { - return new SessionEntry(); - } - - static migrate(data) { - let run = (data.version === undefined); - for (let i = 0; i < migrations.length; ++i) { - if (run) { - console.info("Migrating session to:", migrations[i].version); - migrations[i].migrate(data); - } else if (migrations[i].version === data.version) { - run = true; - } - } - if (!run) { - throw new Error("Error migrating SessionRecord"); - } - } - - static deserialize(data) { - if (data.version !== SESSION_RECORD_VERSION) { - this.migrate(data); - } - const obj = new this(); - if (data._sessions) { - for (const [key, entry] of Object.entries(data._sessions)) { - obj.sessions[key] = SessionEntry.deserialize(entry); - } - } - return obj; - } - - constructor() { - this.sessions = {}; - this.version = SESSION_RECORD_VERSION; - } - - serialize() { - const _sessions = {}; - for (const [key, entry] of Object.entries(this.sessions)) { - _sessions[key] = entry.serialize(); - } - return { - _sessions, - version: this.version - }; - } - - haveOpenSession() { - const openSession = this.getOpenSession(); - return (!!openSession && typeof openSession.registrationId === 'number'); - } - - getSession(key) { - assertBuffer(key); - const session = this.sessions[key.toString('base64')]; - if (session && session.indexInfo.baseKeyType === BaseKeyType.OURS) { - throw new Error("Tried to lookup a session using our basekey"); - } - return session; - } - - getOpenSession() { - for (const session of Object.values(this.sessions)) { - if (!this.isClosed(session)) { - return session; - } - } - } - - setSession(session) { - this.sessions[session.indexInfo.baseKey.toString('base64')] = session; - } - - getSessions() { - // Return sessions ordered with most recently used first. - return Array.from(Object.values(this.sessions)).sort((a, b) => { - const aUsed = a.indexInfo.used || 0; - const bUsed = b.indexInfo.used || 0; - return aUsed === bUsed ? 0 : aUsed < bUsed ? 1 : -1; - }); - } - - closeSession(session) { - if (this.isClosed(session)) { - console.warn("Session already closed", session); - return; - } - console.info("Closing session:", session); - session.indexInfo.closed = Date.now(); - } - - openSession(session) { - if (!this.isClosed(session)) { - console.warn("Session already open"); - } - console.info("Opening session:", session); - session.indexInfo.closed = -1; - } - - isClosed(session) { - return session.indexInfo.closed !== -1; - } - - removeOldSessions() { - while (Object.keys(this.sessions).length > CLOSED_SESSIONS_MAX) { - let oldestKey; - let oldestSession; - for (const [key, session] of Object.entries(this.sessions)) { - if (session.indexInfo.closed !== -1 && - (!oldestSession || session.indexInfo.closed < oldestSession.indexInfo.closed)) { - oldestKey = key; - oldestSession = session; - } - } - if (oldestKey) { - console.info("Removing old closed session:", oldestSession); - delete this.sessions[oldestKey]; - } else { - throw new Error('Corrupt sessions object'); - } - } - } - - deleteAllSessions() { - for (const key of Object.keys(this.sessions)) { - delete this.sessions[key]; - } - } -} - -module.exports = SessionRecord; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..b426c7be --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2018", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "experimentalDecorators": true, + "allowJs": false, + "checkJs": false, + "outDir": "lib", + "strict": false, + "strictNullChecks": true, + "skipLibCheck": true, + "noImplicitThis": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "lib": ["es2020", "esnext.array", "DOM"] + }, + "include": ["./src/**/*.ts"], + "exclude": ["node_modules", "WhisperTextProto/GenerateStatics.sh"] +}