From 5a280872074428893b8893fa0c731ccc2b531243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Fri, 19 Feb 2021 15:59:16 +0100 Subject: [PATCH 01/20] Add nature for SessionCertificate --- packages/core/src/Blocks/Nature.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/Blocks/Nature.js b/packages/core/src/Blocks/Nature.js index 1433de2b..2a2cd673 100644 --- a/packages/core/src/Blocks/Nature.js +++ b/packages/core/src/Blocks/Nature.js @@ -19,6 +19,7 @@ export const NATURE = Object.freeze({ user_group_addition_v2: 16, user_group_creation_v3: 17, user_group_addition_v3: 18, + session_certificate: 19, }); const NATURE_INT = Object.values(NATURE); @@ -40,6 +41,7 @@ export const NATURE_KIND = Object.freeze({ user_group_addition: 7, key_publish_to_provisional_user: 8, provisional_identity_claim: 9, + session_certificate: 10, }); export type NatureKind = $Values; @@ -56,6 +58,7 @@ export function preferredNature(kind: NatureKind): Nature { case NATURE_KIND.user_group_creation: return NATURE.user_group_creation_v3; case NATURE_KIND.user_group_addition: return NATURE.user_group_addition_v3; case NATURE_KIND.provisional_identity_claim: return NATURE.provisional_identity_claim; + case NATURE_KIND.session_certificate: return NATURE.session_certificate; default: throw new InternalError(`invalid kind: ${kind}`); } } @@ -79,6 +82,7 @@ export function natureKind(val: Nature): NatureKind { case NATURE.user_group_addition_v2: return NATURE_KIND.user_group_addition; case NATURE.user_group_addition_v3: return NATURE_KIND.user_group_addition; case NATURE.provisional_identity_claim: return NATURE_KIND.provisional_identity_claim; + case NATURE.session_certificate: return NATURE_KIND.session_certificate; default: throw new InternalError(`invalid nature: ${val}`); } } From 7a36cfcd03b897b2b3b80c769f9c8e585d5fecc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Fri, 19 Feb 2021 16:14:30 +0100 Subject: [PATCH 02/20] Generate SessionCertificate blocks --- packages/core/src/Blocks/Serialize.js | 6 ++ .../core/src/LocalUser/SessionCertificate.js | 100 ++++++++++++++++++ .../__tests__/LocalUserSerialization.spec.js | 33 +++++- packages/core/src/LocalUser/types.js | 7 +- 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/LocalUser/SessionCertificate.js diff --git a/packages/core/src/Blocks/Serialize.js b/packages/core/src/Blocks/Serialize.js index 330e1967..9d81637c 100644 --- a/packages/core/src/Blocks/Serialize.js +++ b/packages/core/src/Blocks/Serialize.js @@ -1,6 +1,7 @@ // @flow import varint from 'varint'; import { InternalError } from '@tanker/errors'; +import { utils } from '@tanker/crypto'; type Unserializer = (src: Uint8Array, offset: number) => { newOffset: number, [name: string]: any }; @@ -13,6 +14,11 @@ export function getArray(src: Uint8Array, offset: number, name: string = 'value' return { [name]: buffer, newOffset: pos }; } +export function getString(src: Uint8Array, offset: number, name: string = 'value'): Object { + const result = getArray(src, offset, name); + return { [name]: utils.toString(result[name]), newOffset: result.newOffset }; +} + export function setStaticArray(src: Uint8Array, dest: Uint8Array, offset: number = 0): number { dest.set(src, offset); return offset + src.length; diff --git a/packages/core/src/LocalUser/SessionCertificate.js b/packages/core/src/LocalUser/SessionCertificate.js new file mode 100644 index 00000000..372f5852 --- /dev/null +++ b/packages/core/src/LocalUser/SessionCertificate.js @@ -0,0 +1,100 @@ +// @flow +import { InternalError, InvalidArgument } from '@tanker/errors'; +import { generichash, utils, tcrypto, number } from '@tanker/crypto'; +import varint from 'varint'; +import type { + VerificationMethod, Verification, +} from './types'; +import { getStaticArray, unserializeGeneric } from '../Blocks/Serialize'; +import { NATURE_KIND, preferredNature } from '../Blocks/Nature'; + +export const VERIFICATION_METHOD_TYPES = Object.freeze({ + email: 1, + passphrase: 2, + verificationKey: 3, + oidcIdToken: 4, +}); +const VERIFICATION_METHOD_TYPES_INT = Object.values(VERIFICATION_METHOD_TYPES); +export type VerificationMethodType = $Values; + +export type SessionCertificateRecord = {| + timestamp: number, + verification_method_type: VerificationMethodType, + verification_method_target: Uint8Array, + // If you're wondering, this one is currently unused (future compat) + session_public_signature_key: Uint8Array, +|}; + +function verificationToVerificationMethod(verification: Verification): VerificationMethod { + if ('email' in verification) + // $FlowIgnore[prop-missing] + return { type: 'email', email: verification.email }; + if ('passphrase' in verification) + // $FlowIgnore[prop-missing] + return { type: 'passphrase' }; + if ('verificationKey' in verification) + // $FlowIgnore[prop-missing] + return { type: 'verificationKey' }; + if ('oidcIdToken' in verification) + // $FlowIgnore[prop-missing] + return { type: 'oidcIdToken' }; + throw new InvalidArgument('verification', 'unknown verification method in verificationToVerificationMethod', verification); +} + +export const serializeSessionCertificate = (sessionCertificate: SessionCertificateRecord): Uint8Array => { + if (!(sessionCertificate.verification_method_type in VERIFICATION_METHOD_TYPES_INT)) { + throw new InternalError('Assertion error: invalid session certificate method type'); + } + if (sessionCertificate.verification_method_target.length !== tcrypto.HASH_SIZE) { + throw new InternalError('Assertion error: invalid session certificate method target size'); + } + if (sessionCertificate.session_public_signature_key.length !== tcrypto.SIGNATURE_PUBLIC_KEY_SIZE) + throw new InternalError('Assertion error: invalid session public signature key size'); + + return utils.concatArrays( + sessionCertificate.session_public_signature_key, + number.toUint64le(sessionCertificate.timestamp), + new Uint8Array(varint.encode(sessionCertificate.verification_method_type)), + sessionCertificate.verification_method_target, + ); +}; + +export const unserializeSessionCertificate = (payload: Uint8Array): SessionCertificateRecord => unserializeGeneric(payload, [ + (d, o) => getStaticArray(d, tcrypto.SIGNATURE_PUBLIC_KEY_SIZE, o, 'session_public_signature_key'), + (d, o) => ({ + timestamp: number.fromUint64le(d.subarray(o, o + 8)), + newOffset: o + 8 + }), + (d, o) => ({ + verification_method_type: varint.decode(d, o), + newOffset: o + varint.decode.bytes + }), + (d, o) => getStaticArray(d, tcrypto.HASH_SIZE, o, 'verification_method_target'), +]); + +export const makeSessionCertificate = ( + verification: Verification +) => { + const verifMethod = verificationToVerificationMethod(verification); + let verifTarget; + if (verifMethod.type === 'email') { + verifTarget = generichash(utils.fromString(verifMethod.email)); + } else { + verifTarget = new Uint8Array(tcrypto.HASH_SIZE); + } + + // Note: We don't currently _do_ anything with this one, but we added it to the block format for future compat... + const signatureKeyPair = tcrypto.makeSignKeyPair(); + + const payload = serializeSessionCertificate({ + timestamp: Math.floor(Date.now() / 1000), + verification_method_type: VERIFICATION_METHOD_TYPES[verifMethod.type], + verification_method_target: verifTarget, + session_public_signature_key: signatureKeyPair.publicKey, + }); + + return { + payload, + nature: preferredNature(NATURE_KIND.session_certificate) + }; +}; diff --git a/packages/core/src/LocalUser/__tests__/LocalUserSerialization.spec.js b/packages/core/src/LocalUser/__tests__/LocalUserSerialization.spec.js index 38f68510..cab4b7d9 100644 --- a/packages/core/src/LocalUser/__tests__/LocalUserSerialization.spec.js +++ b/packages/core/src/LocalUser/__tests__/LocalUserSerialization.spec.js @@ -1,9 +1,15 @@ // @flow -import { tcrypto, random } from '@tanker/crypto'; +import { tcrypto, random, generichash, utils } from '@tanker/crypto'; import { expect } from '@tanker/test-utils'; import { serializeTrustchainCreation, unserializeTrustchainCreation } from '../Serialize'; +import { + serializeSessionCertificate, + unserializeSessionCertificate, + VERIFICATION_METHOD_TYPES +} from '../SessionCertificate'; +import type { SessionCertificateRecord } from '../SessionCertificate'; // NOTE: If you ever have to change something here, change it in the Go code too! // The test vectors should stay the same @@ -38,3 +44,28 @@ describe('TrustchainCreation', () => { expect(unserializeTrustchainCreation(serializeTrustchainCreation(trustchainCreation))).to.deep.equal(trustchainCreation); }); }); + +describe('SessionCertificate', () => { + it('should throw when serializing an invalid SessionCertificate', async () => { + const badSessionCertificate = { + timestamp: Math.floor(Date.now() / 1000), + verification_method_type: 999, + verification_method_target: new Uint8Array(0), + session_public_signature_key: new Uint8Array(0), + }; + // Flow noticed that our method type is invalid =) + const sessionCertificate = ((badSessionCertificate: any): SessionCertificateRecord); + expect(() => serializeSessionCertificate(sessionCertificate)).to.throw(); + }); + + it('should serialize/unserialize a SessionCertificate', async () => { + const sessionCertificate = { + timestamp: Math.floor(Date.now() / 1000), + verification_method_type: VERIFICATION_METHOD_TYPES.email, + verification_method_target: generichash(utils.fromString('bob@tanker.io')), + session_public_signature_key: tcrypto.makeSignKeyPair().publicKey, + }; + + expect(unserializeSessionCertificate(serializeSessionCertificate(sessionCertificate))).to.deep.equal(sessionCertificate); + }); +}); diff --git a/packages/core/src/LocalUser/types.js b/packages/core/src/LocalUser/types.js index 03b2392f..b73c4a80 100644 --- a/packages/core/src/LocalUser/types.js +++ b/packages/core/src/LocalUser/types.js @@ -3,10 +3,11 @@ import { InvalidArgument } from '@tanker/errors'; import { assertNotEmptyString } from '@tanker/types'; export type EmailVerificationMethod = $Exact<{ type: 'email', email: string }>; -type PassphraseVerificationMethod = $Exact<{ type: 'passphrase' }>; -type KeyVerificationMethod = $Exact<{ type: 'verificationKey' }>; +export type PassphraseVerificationMethod = $Exact<{ type: 'passphrase' }>; +export type KeyVerificationMethod = $Exact<{ type: 'verificationKey' }>; +export type OIDCVerificationMethod = $Exact<{ type: 'oidcIdToken' }>; -export type VerificationMethod = EmailVerificationMethod | PassphraseVerificationMethod | KeyVerificationMethod; +export type VerificationMethod = EmailVerificationMethod | PassphraseVerificationMethod | KeyVerificationMethod | OIDCVerificationMethod; export type EmailVerification = $Exact<{ email: string, verificationCode: string }>; export type PassphraseVerification = $Exact<{ passphrase: string }>; From c75d82c6cf9c5aafd2cfe9516f6c5cc71cdab320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Wed, 24 Feb 2021 11:45:50 +0100 Subject: [PATCH 03/20] feat: Implement getSessionCertificateProof() --- packages/core/src/LocalUser/Manager.js | 9 +++++++++ packages/core/src/Network/Client.js | 19 +++++++++++++++++++ packages/core/src/Session/Session.js | 2 ++ 3 files changed, 30 insertions(+) diff --git a/packages/core/src/LocalUser/Manager.js b/packages/core/src/LocalUser/Manager.js index 47ebcd9b..c63f918e 100644 --- a/packages/core/src/LocalUser/Manager.js +++ b/packages/core/src/LocalUser/Manager.js @@ -16,6 +16,7 @@ import type { UserData, DelegationToken } from './UserData'; import type { Client, PullOptions } from '../Network/Client'; import { statuses, type Status } from '../Session/status'; import type { Device } from '../Users/types'; +import { makeSessionCertificate } from './SessionCertificate'; export type PrivateProvisionalKeys = {| appEncryptionKeyPair: tcrypto.SodiumKeyPair, @@ -172,6 +173,14 @@ export class LocalUserManager extends EventEmitter { return devices.filter(d => !d.isGhostDevice); } + getSessionCertificateProof = async (verification: Verification): Promise => { + await this.updateLocalUser(); + + const { payload, nature } = makeSessionCertificate(verification); + const block = this._localUser.makeBlock(payload, nature); + return this._client.getSessionCertificateProof({ session_certificate: block }); + } + findUserKey = async (publicKey: Uint8Array): Promise => { const userKey = this._localUser.findUserKey(publicKey); if (!userKey) { diff --git a/packages/core/src/Network/Client.js b/packages/core/src/Network/Client.js index 46ad4b09..8f35f909 100644 --- a/packages/core/src/Network/Client.js +++ b/packages/core/src/Network/Client.js @@ -376,6 +376,25 @@ export class Client { }); } + getSessionCertificateProof = async (body: any): Promise => { + const path = `/users/${urlize(this._userId)}/session-certificates`; + + try { + const { proof } = await this._apiCall(path, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + return proof; + } catch (e) { + if (e instanceof TankerError) { + if (e.apiCode === 'app_not_found') throw new PreconditionFailed(e); + if (e.apiCode === 'user_not_found') throw new PreconditionFailed(e); + } + throw e; + } + } + getGroupHistories = (query: string): Promise<$Exact<{ histories: Array }>> => { // eslint-disable-line arrow-body-style return this._apiCall(`/user-group-histories?${query}&is_light=true`); } diff --git a/packages/core/src/Session/Session.js b/packages/core/src/Session/Session.js index 8cdefb9d..30fe46b8 100644 --- a/packages/core/src/Session/Session.js +++ b/packages/core/src/Session/Session.js @@ -125,6 +125,8 @@ export class Session extends EventEmitter { getVerificationMethods = (...args: any) => this._forward(this._localUserManager, 'getVerificationMethods', ...args) generateVerificationKey = (...args: any) => this._forward(this._localUserManager, 'generateVerificationKey', ...args) + getSessionCertificateProof = async (...args: any) => this._forward(this._localUserManager, 'getSessionCertificateProof', ...args); + upload = (...args: any) => this._forward(this._cloudStorageManager, 'upload', ...args) download = (...args: any) => this._forward(this._cloudStorageManager, 'download', ...args) From 6f5ebc15d2aca65076c878f0b4c3e60360e542c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Fri, 19 Feb 2021 15:55:46 +0100 Subject: [PATCH 04/20] Update registerIdentity/verifyIdentity to take the withProof option --- packages/core/src/LocalUser/types.js | 2 + packages/core/src/Tanker.js | 72 ++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/core/src/LocalUser/types.js b/packages/core/src/LocalUser/types.js index b73c4a80..2002d7a9 100644 --- a/packages/core/src/LocalUser/types.js +++ b/packages/core/src/LocalUser/types.js @@ -17,6 +17,8 @@ export type OIDCVerification = $Exact<{ oidcIdToken: string }>; export type Verification = EmailVerification | PassphraseVerification | KeyVerification | OIDCVerification; export type RemoteVerification = EmailVerification | PassphraseVerification | OIDCVerification; +export type VerificationOptions = $Exact<{ withToken?: bool }>; + const validMethods = ['email', 'passphrase', 'verificationKey', 'oidcIdToken']; const validKeys = [...validMethods, 'verificationCode']; diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index 513e2874..5a2a1d31 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -9,7 +9,14 @@ import { _deserializeProvisionalIdentity } from '@tanker/identity'; import { type ClientOptions, defaultApiEndpoint } from './Network/Client'; import { type DataStoreOptions } from './Session/Storage'; -import type { Verification, EmailVerification, OIDCVerification, RemoteVerification, VerificationMethod } from './LocalUser/types'; +import type { + Verification, + EmailVerification, + OIDCVerification, + RemoteVerification, + VerificationMethod, + VerificationOptions +} from './LocalUser/types'; import { assertVerification } from './LocalUser/types'; import { extractUserData } from './LocalUser/UserData'; @@ -17,7 +24,15 @@ import { assertStatus, statusDefs, statuses, type Status } from './Session/statu import { Session } from './Session/Session'; import type { OutputOptions, ProgressOptions, EncryptionOptions, SharingOptions } from './DataProtection/options'; -import { defaultDownloadType, extractOutputOptions, extractProgressOptions, extractEncryptionOptions, extractSharingOptions, isObject, isSharingOptionsEmpty } from './DataProtection/options'; +import { + defaultDownloadType, + extractOutputOptions, + extractProgressOptions, + extractEncryptionOptions, + extractSharingOptions, + isObject, + isSharingOptionsEmpty +} from './DataProtection/options'; import type { EncryptionStream } from './DataProtection/EncryptionStream'; import type { DecryptionStream } from './DataProtection/DecryptionStream'; import { extractEncryptionFormat, SAFE_EXTRACTION_LENGTH } from './DataProtection/types'; @@ -104,15 +119,23 @@ export class Tanker extends EventEmitter { }, url: defaultApiEndpoint, }; - if (options.url) { clientOptions.url = options.url; } + if (options.url) { + clientOptions.url = options.url; + } this._clientOptions = clientOptions; const datastoreOptions: DataStoreOptions = { adapter: options.dataStore.adapter }; - if (options.dataStore.prefix) { datastoreOptions.prefix = options.dataStore.prefix; } - if (options.dataStore.dbPath) { datastoreOptions.dbPath = options.dataStore.dbPath; } - if (options.dataStore.url) { datastoreOptions.url = options.dataStore.url; } + if (options.dataStore.prefix) { + datastoreOptions.prefix = options.dataStore.prefix; + } + if (options.dataStore.dbPath) { + datastoreOptions.dbPath = options.dataStore.dbPath; + } + if (options.dataStore.url) { + datastoreOptions.url = options.dataStore.url; + } this._dataStoreOptions = datastoreOptions; /* eslint-disable no-underscore-dangle */ @@ -205,16 +228,31 @@ export class Tanker extends EventEmitter { return this.status; } - async registerIdentity(verification: Verification): Promise { + async registerIdentity(verification: Verification, options?: VerificationOptions): Promise { assertStatus(this.status, statuses.IDENTITY_REGISTRATION_NEEDED, 'register an identity'); assertVerification(verification); + await this.session.createUser(verification); + + if (options && options.withToken) { + return this.session.getSessionCertificateProof(verification); + } } - async verifyIdentity(verification: Verification): Promise { - assertStatus(this.status, statuses.IDENTITY_VERIFICATION_NEEDED, 'verify an identity'); + async verifyIdentity(verification: Verification, options?: VerificationOptions): Promise { + if (options && options.withToken) { + assertStatus(this.status, [statuses.IDENTITY_VERIFICATION_NEEDED, statuses.READY], 'verify an identity with proof'); + } else { + assertStatus(this.status, statuses.IDENTITY_VERIFICATION_NEEDED, 'verify an identity'); + } assertVerification(verification); - await this.session.createNewDevice(verification); + if (this.status === statuses.IDENTITY_VERIFICATION_NEEDED) { + await this.session.createNewDevice(verification); + } + + if (options && options.withToken) { + return this.session.getSessionCertificateProof(verification); + } } async setVerificationMethod(verification: RemoteVerification): Promise { @@ -273,12 +311,15 @@ export class Tanker extends EventEmitter { _deviceRevoked = async (): Promise => { this.session = null; // the session has already closed itself this.emit('deviceRevoked'); - } + }; - async getDeviceList(): Promise> { + async getDeviceList(): Promise> { assertStatus(this.status, statuses.READY, 'get the device list'); const devices = await this.session.listDevices(); - return devices.map(d => ({ id: utils.toBase64(d.deviceId), isRevoked: d.revoked })); + return devices.map(d => ({ + id: utils.toBase64(d.deviceId), + isRevoked: d.revoked + })); } async share(resourceIds: Array, options: SharingOptions): Promise { @@ -389,7 +430,10 @@ export class Tanker extends EventEmitter { async decrypt(cipher: Data, options?: $Shape = {}): Promise { const progressOptions = extractProgressOptions(options); - return utils.toString(await this.decryptData(cipher, { ...progressOptions, type: Uint8Array })); + return utils.toString(await this.decryptData(cipher, { + ...progressOptions, + type: Uint8Array + })); } async upload(clearData: Data, options?: $Shape & ProgressOptions> = {}): Promise { From 88f60d40695e4b9f62f3495e78fc25a0084deb11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Thu, 18 Mar 2021 14:53:30 +0100 Subject: [PATCH 05/20] feat(2fa): Forward withToken with rand nonce in Verification obj So that the server can store a per-device SessionPreCertificate in Redis under this random nonce. We re-use it when getting the Session Token. --- packages/core/src/LocalUser/Manager.js | 19 +++++++++++----- packages/core/src/LocalUser/requests.js | 7 ++++-- packages/core/src/LocalUser/types.js | 4 ++++ packages/core/src/Tanker.js | 29 ++++++++++++++++++------- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/core/src/LocalUser/Manager.js b/packages/core/src/LocalUser/Manager.js index c63f918e..f5184cfb 100644 --- a/packages/core/src/LocalUser/Manager.js +++ b/packages/core/src/LocalUser/Manager.js @@ -9,7 +9,12 @@ import type { ProvisionalUserKeyPairs, IndexedProvisionalUserKeyPairs } from './ import type KeyStore from './KeyStore'; import LocalUser from './LocalUser'; import { formatVerificationRequest } from './requests'; -import type { Verification, VerificationMethod, RemoteVerification } from './types'; +import type { + Verification, + VerificationMethod, + VerificationWithToken, + RemoteVerificationWithToken +} from './types'; import { generateUserCreation, generateDeviceFromGhostDevice, makeDeviceRevocation } from './UserCreation'; import type { UserData, DelegationToken } from './UserData'; @@ -86,7 +91,7 @@ export class LocalUserManager extends EventEmitter { }); } - setVerificationMethod = (verification: RemoteVerification): Promise => this._client.setVerificationMethod({ + setVerificationMethod = (verification: RemoteVerificationWithToken): Promise => this._client.setVerificationMethod({ verification: formatVerificationRequest(verification, this._localUser), }); @@ -98,7 +103,7 @@ export class LocalUserManager extends EventEmitter { await this.updateLocalUser({ isLight: true }); } - createUser = async (verification: Verification): Promise => { + createUser = async (verification: VerificationWithToken): Promise => { let ghostDeviceKeys; if (verification.verificationKey) { try { @@ -125,13 +130,14 @@ export class LocalUserManager extends EventEmitter { if (verification.email || verification.passphrase || verification.oidcIdToken) { request.v2_encrypted_verification_key = ghostDeviceToEncryptedVerificationKey(ghostDevice, this._localUser.userSecret); request.verification = formatVerificationRequest(verification, this._localUser); + request.verification.withToken = verification.withToken; // May be undefined } await this._client.createUser(firstDeviceId, firstDeviceSignatureKeyPair, request); await this.updateDeviceInfo(firstDeviceId, firstDeviceEncryptionKeyPair, firstDeviceSignatureKeyPair); } - createNewDevice = async (verification: Verification): Promise => { + createNewDevice = async (verification: VerificationWithToken): Promise => { try { const verificationKey = await this._getVerificationKey(verification); const ghostDevice = extractGhostDevice(verificationKey); @@ -237,12 +243,13 @@ export class LocalUserManager extends EventEmitter { }); } - _getVerificationKey = async (verification: Verification) => { + _getVerificationKey = async (verification: VerificationWithToken) => { if (verification.verificationKey) { return verification.verificationKey; } - const remoteVerification: RemoteVerification = (verification: any); + const remoteVerification: RemoteVerificationWithToken = (verification: any); const request = { verification: formatVerificationRequest(remoteVerification, this._localUser) }; + request.verification.withToken = verification.withToken; // May be undefined const encryptedVerificationKey = await this._client.getVerificationKey(request); return decryptVerificationKey(encryptedVerificationKey, this._localUser.userSecret); } diff --git a/packages/core/src/LocalUser/requests.js b/packages/core/src/LocalUser/requests.js index 213cfb82..5a3cc936 100644 --- a/packages/core/src/LocalUser/requests.js +++ b/packages/core/src/LocalUser/requests.js @@ -4,19 +4,22 @@ import { encryptionV2, generichash, utils } from '@tanker/crypto'; import { InternalError } from '@tanker/errors'; import type LocalUser from './LocalUser'; -import type { RemoteVerification } from './types'; +import type { RemoteVerification, RemoteVerificationWithToken, WithTokenOptions } from './types'; type VerificationRequest = $Exact<{ hashed_passphrase: Uint8Array, + ...WithTokenOptions }> | $Exact<{ hashed_email: Uint8Array, v2_encrypted_email: Uint8Array, verification_code: string, + ...WithTokenOptions }> | $Exact<{ oidc_id_token: string, + ...WithTokenOptions }>; -export const formatVerificationRequest = (verification: RemoteVerification, localUser: LocalUser): VerificationRequest => { +export const formatVerificationRequest = (verification: RemoteVerification | RemoteVerificationWithToken, localUser: LocalUser): VerificationRequest => { if (verification.email) { return { hashed_email: generichash(utils.fromString(verification.email)), diff --git a/packages/core/src/LocalUser/types.js b/packages/core/src/LocalUser/types.js index 2002d7a9..2132ce22 100644 --- a/packages/core/src/LocalUser/types.js +++ b/packages/core/src/LocalUser/types.js @@ -17,6 +17,10 @@ export type OIDCVerification = $Exact<{ oidcIdToken: string }>; export type Verification = EmailVerification | PassphraseVerification | KeyVerification | OIDCVerification; export type RemoteVerification = EmailVerification | PassphraseVerification | OIDCVerification; +export type WithTokenOptions = {| withToken?: {| nonce: string |} |}; +export type VerificationWithToken = {| ...Verification, ...WithTokenOptions |}; +export type RemoteVerificationWithToken = {| ...RemoteVerification, ...WithTokenOptions |}; + export type VerificationOptions = $Exact<{ withToken?: bool }>; const validMethods = ['email', 'passphrase', 'verificationKey', 'oidcIdToken']; diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index 5a2a1d31..9d0d3896 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -15,7 +15,7 @@ import type { OIDCVerification, RemoteVerification, VerificationMethod, - VerificationOptions + VerificationOptions, VerificationWithToken } from './LocalUser/types'; import { assertVerification } from './LocalUser/types'; import { extractUserData } from './LocalUser/UserData'; @@ -232,37 +232,50 @@ export class Tanker extends EventEmitter { assertStatus(this.status, statuses.IDENTITY_REGISTRATION_NEEDED, 'register an identity'); assertVerification(verification); - await this.session.createUser(verification); + // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... + const verifWithToken = (verification: VerificationWithToken); + if (options && options.withToken) { + verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; + } + + await this.session.createUser(verifWithToken); if (options && options.withToken) { - return this.session.getSessionCertificateProof(verification); + return this.session.getSessionCertificateProof(verifWithToken); } } async verifyIdentity(verification: Verification, options?: VerificationOptions): Promise { + assertVerification(verification); + + // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... + const verifWithToken = (verification: VerificationWithToken); if (options && options.withToken) { assertStatus(this.status, [statuses.IDENTITY_VERIFICATION_NEEDED, statuses.READY], 'verify an identity with proof'); + verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } else { assertStatus(this.status, statuses.IDENTITY_VERIFICATION_NEEDED, 'verify an identity'); } - assertVerification(verification); + if (this.status === statuses.IDENTITY_VERIFICATION_NEEDED) { - await this.session.createNewDevice(verification); + await this.session.createNewDevice(verifWithToken); } if (options && options.withToken) { - return this.session.getSessionCertificateProof(verification); + return this.session.getSessionCertificateProof(verifWithToken); } } async setVerificationMethod(verification: RemoteVerification): Promise { assertStatus(this.status, statuses.READY, 'set a verification method'); - assertVerification(verification); if ('verificationKey' in verification) throw new InvalidArgument('verification', 'cannot update a verification key', verification); - return this.session.setVerificationMethod(verification); + // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... + const verifWithToken = (verification: VerificationWithToken); + + return this.session.setVerificationMethod(verifWithToken); } async getVerificationMethods(): Promise> { From 201e5eb41c9652a966e61c42f10448f6646c199b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Thu, 18 Mar 2021 14:53:37 +0100 Subject: [PATCH 06/20] feat(2fa): Add withToken option to setVerificationMethod --- packages/core/src/Tanker.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index 9d0d3896..b823cc33 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -266,7 +266,7 @@ export class Tanker extends EventEmitter { } } - async setVerificationMethod(verification: RemoteVerification): Promise { + async setVerificationMethod(verification: RemoteVerification, options?: VerificationOptions): Promise { assertStatus(this.status, statuses.READY, 'set a verification method'); assertVerification(verification); if ('verificationKey' in verification) @@ -275,7 +275,15 @@ export class Tanker extends EventEmitter { // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); - return this.session.setVerificationMethod(verifWithToken); + if (options && options.withToken) { + verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; + } + + await this.session.setVerificationMethod(verifWithToken); + + if (options && options.withToken) { + return this.session.getSessionCertificateProof(verifWithToken); + } } async getVerificationMethods(): Promise> { From 93a5c2eb857ae967e28933d126facf948c041231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Fri, 26 Feb 2021 11:51:47 +0100 Subject: [PATCH 07/20] feat(2fa): Send nonce in get-session-certificate request --- packages/core/src/LocalUser/Manager.js | 9 ++++++--- packages/core/src/LocalUser/SessionCertificate.js | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/LocalUser/Manager.js b/packages/core/src/LocalUser/Manager.js index f5184cfb..b0b18fb7 100644 --- a/packages/core/src/LocalUser/Manager.js +++ b/packages/core/src/LocalUser/Manager.js @@ -10,7 +10,6 @@ import type KeyStore from './KeyStore'; import LocalUser from './LocalUser'; import { formatVerificationRequest } from './requests'; import type { - Verification, VerificationMethod, VerificationWithToken, RemoteVerificationWithToken @@ -179,12 +178,16 @@ export class LocalUserManager extends EventEmitter { return devices.filter(d => !d.isGhostDevice); } - getSessionCertificateProof = async (verification: Verification): Promise => { + getSessionCertificateProof = async (verification: VerificationWithToken): Promise => { await this.updateLocalUser(); const { payload, nature } = makeSessionCertificate(verification); const block = this._localUser.makeBlock(payload, nature); - return this._client.getSessionCertificateProof({ session_certificate: block }); + + if (verification.withToken === undefined) + throw new InternalError('Cannot get a session certificate without withToken'); + + return this._client.getSessionCertificateProof({ session_certificate: block, nonce: verification.withToken.nonce }); } findUserKey = async (publicKey: Uint8Array): Promise => { diff --git a/packages/core/src/LocalUser/SessionCertificate.js b/packages/core/src/LocalUser/SessionCertificate.js index 372f5852..d7ec2411 100644 --- a/packages/core/src/LocalUser/SessionCertificate.js +++ b/packages/core/src/LocalUser/SessionCertificate.js @@ -3,7 +3,7 @@ import { InternalError, InvalidArgument } from '@tanker/errors'; import { generichash, utils, tcrypto, number } from '@tanker/crypto'; import varint from 'varint'; import type { - VerificationMethod, Verification, + VerificationMethod, VerificationWithToken, } from './types'; import { getStaticArray, unserializeGeneric } from '../Blocks/Serialize'; import { NATURE_KIND, preferredNature } from '../Blocks/Nature'; @@ -25,7 +25,7 @@ export type SessionCertificateRecord = {| session_public_signature_key: Uint8Array, |}; -function verificationToVerificationMethod(verification: Verification): VerificationMethod { +function verificationToVerificationMethod(verification: VerificationWithToken): VerificationMethod { if ('email' in verification) // $FlowIgnore[prop-missing] return { type: 'email', email: verification.email }; @@ -73,7 +73,7 @@ export const unserializeSessionCertificate = (payload: Uint8Array): SessionCerti ]); export const makeSessionCertificate = ( - verification: Verification + verification: VerificationWithToken ) => { const verifMethod = verificationToVerificationMethod(verification); let verifTarget; From 4e9d328a4b5db243fa45f4f93f775762a26bad3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Mon, 1 Mar 2021 15:48:01 +0100 Subject: [PATCH 08/20] fix(2fa): Correctly pass with_token in Verification object --- packages/core/src/LocalUser/Manager.js | 14 +++++++++----- packages/core/src/LocalUser/requests.js | 8 ++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/core/src/LocalUser/Manager.js b/packages/core/src/LocalUser/Manager.js index b0b18fb7..257399cc 100644 --- a/packages/core/src/LocalUser/Manager.js +++ b/packages/core/src/LocalUser/Manager.js @@ -90,9 +90,13 @@ export class LocalUserManager extends EventEmitter { }); } - setVerificationMethod = (verification: RemoteVerificationWithToken): Promise => this._client.setVerificationMethod({ - verification: formatVerificationRequest(verification, this._localUser), - }); + setVerificationMethod = (verification: RemoteVerificationWithToken): Promise => { + const requestVerification = formatVerificationRequest(verification, this._localUser); + requestVerification.with_token = verification.withToken; // May be undefined + return this._client.setVerificationMethod({ + verification: requestVerification, + }); + } updateDeviceInfo = async (id: Uint8Array, encryptionKeyPair: tcrypto.SodiumKeyPair, signatureKeyPair: tcrypto.SodiumKeyPair): Promise => { this._localUser.deviceId = id; @@ -129,7 +133,7 @@ export class LocalUserManager extends EventEmitter { if (verification.email || verification.passphrase || verification.oidcIdToken) { request.v2_encrypted_verification_key = ghostDeviceToEncryptedVerificationKey(ghostDevice, this._localUser.userSecret); request.verification = formatVerificationRequest(verification, this._localUser); - request.verification.withToken = verification.withToken; // May be undefined + request.verification.with_token = verification.withToken; // May be undefined } await this._client.createUser(firstDeviceId, firstDeviceSignatureKeyPair, request); @@ -252,7 +256,7 @@ export class LocalUserManager extends EventEmitter { } const remoteVerification: RemoteVerificationWithToken = (verification: any); const request = { verification: formatVerificationRequest(remoteVerification, this._localUser) }; - request.verification.withToken = verification.withToken; // May be undefined + request.verification.with_token = verification.withToken; // May be undefined const encryptedVerificationKey = await this._client.getVerificationKey(request); return decryptVerificationKey(encryptedVerificationKey, this._localUser.userSecret); } diff --git a/packages/core/src/LocalUser/requests.js b/packages/core/src/LocalUser/requests.js index 5a3cc936..641479a2 100644 --- a/packages/core/src/LocalUser/requests.js +++ b/packages/core/src/LocalUser/requests.js @@ -4,19 +4,19 @@ import { encryptionV2, generichash, utils } from '@tanker/crypto'; import { InternalError } from '@tanker/errors'; import type LocalUser from './LocalUser'; -import type { RemoteVerification, RemoteVerificationWithToken, WithTokenOptions } from './types'; +import type { RemoteVerification, RemoteVerificationWithToken } from './types'; type VerificationRequest = $Exact<{ hashed_passphrase: Uint8Array, - ...WithTokenOptions + with_token?: {| nonce: string |} }> | $Exact<{ hashed_email: Uint8Array, v2_encrypted_email: Uint8Array, verification_code: string, - ...WithTokenOptions + with_token?: {| nonce: string |} }> | $Exact<{ oidc_id_token: string, - ...WithTokenOptions + with_token?: {| nonce: string |} }>; export const formatVerificationRequest = (verification: RemoteVerification | RemoteVerificationWithToken, localUser: LocalUser): VerificationRequest => { From 275116b74bbaac85ac59c504f4f6bbcb73b45d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Mon, 1 Mar 2021 16:04:42 +0100 Subject: [PATCH 09/20] chore(2fa): Minor formatting fixes --- packages/core/src/LocalUser/SessionCertificate.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/LocalUser/SessionCertificate.js b/packages/core/src/LocalUser/SessionCertificate.js index d7ec2411..d0b32f5f 100644 --- a/packages/core/src/LocalUser/SessionCertificate.js +++ b/packages/core/src/LocalUser/SessionCertificate.js @@ -27,8 +27,11 @@ export type SessionCertificateRecord = {| function verificationToVerificationMethod(verification: VerificationWithToken): VerificationMethod { if ('email' in verification) - // $FlowIgnore[prop-missing] - return { type: 'email', email: verification.email }; + return { + type: 'email', + // $FlowIgnore[prop-missing] + email: verification.email + }; if ('passphrase' in verification) // $FlowIgnore[prop-missing] return { type: 'passphrase' }; From 0ac9468f9139c48bb43ad3735e6c774379a9c85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Wed, 3 Mar 2021 11:11:46 +0100 Subject: [PATCH 10/20] refactor(2fa): Rename session certificate proof -> session token --- packages/core/src/LocalUser/Manager.js | 4 ++-- packages/core/src/Network/Client.js | 7 ++++--- packages/core/src/Session/Session.js | 2 +- packages/core/src/Tanker.js | 6 +++--- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/core/src/LocalUser/Manager.js b/packages/core/src/LocalUser/Manager.js index 257399cc..620f15bc 100644 --- a/packages/core/src/LocalUser/Manager.js +++ b/packages/core/src/LocalUser/Manager.js @@ -182,7 +182,7 @@ export class LocalUserManager extends EventEmitter { return devices.filter(d => !d.isGhostDevice); } - getSessionCertificateProof = async (verification: VerificationWithToken): Promise => { + getSessionToken = async (verification: VerificationWithToken): Promise => { await this.updateLocalUser(); const { payload, nature } = makeSessionCertificate(verification); @@ -191,7 +191,7 @@ export class LocalUserManager extends EventEmitter { if (verification.withToken === undefined) throw new InternalError('Cannot get a session certificate without withToken'); - return this._client.getSessionCertificateProof({ session_certificate: block, nonce: verification.withToken.nonce }); + return this._client.getSessionToken({ session_certificate: block, nonce: verification.withToken.nonce }); } findUserKey = async (publicKey: Uint8Array): Promise => { diff --git a/packages/core/src/Network/Client.js b/packages/core/src/Network/Client.js index 8f35f909..675ecbca 100644 --- a/packages/core/src/Network/Client.js +++ b/packages/core/src/Network/Client.js @@ -376,16 +376,17 @@ export class Client { }); } - getSessionCertificateProof = async (body: any): Promise => { + getSessionToken = async (body: any): Promise => { const path = `/users/${urlize(this._userId)}/session-certificates`; try { - const { proof } = await this._apiCall(path, { + // eslint-disable-next-line camelcase + const { session_token: sessionToken } = await this._apiCall(path, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, }); - return proof; + return sessionToken; } catch (e) { if (e instanceof TankerError) { if (e.apiCode === 'app_not_found') throw new PreconditionFailed(e); diff --git a/packages/core/src/Session/Session.js b/packages/core/src/Session/Session.js index 30fe46b8..2ade87b6 100644 --- a/packages/core/src/Session/Session.js +++ b/packages/core/src/Session/Session.js @@ -125,7 +125,7 @@ export class Session extends EventEmitter { getVerificationMethods = (...args: any) => this._forward(this._localUserManager, 'getVerificationMethods', ...args) generateVerificationKey = (...args: any) => this._forward(this._localUserManager, 'generateVerificationKey', ...args) - getSessionCertificateProof = async (...args: any) => this._forward(this._localUserManager, 'getSessionCertificateProof', ...args); + getSessionToken = async (...args: any) => this._forward(this._localUserManager, 'getSessionToken', ...args); upload = (...args: any) => this._forward(this._cloudStorageManager, 'upload', ...args) download = (...args: any) => this._forward(this._cloudStorageManager, 'download', ...args) diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index b823cc33..4705b365 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -241,7 +241,7 @@ export class Tanker extends EventEmitter { await this.session.createUser(verifWithToken); if (options && options.withToken) { - return this.session.getSessionCertificateProof(verifWithToken); + return this.session.getSessionToken(verifWithToken); } } @@ -262,7 +262,7 @@ export class Tanker extends EventEmitter { } if (options && options.withToken) { - return this.session.getSessionCertificateProof(verifWithToken); + return this.session.getSessionToken(verifWithToken); } } @@ -282,7 +282,7 @@ export class Tanker extends EventEmitter { await this.session.setVerificationMethod(verifWithToken); if (options && options.withToken) { - return this.session.getSessionCertificateProof(verifWithToken); + return this.session.getSessionToken(verifWithToken); } } From 9659b01563b22d5a1d7d69e92cb6fac158d0faa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Tue, 9 Mar 2021 17:57:14 +0100 Subject: [PATCH 11/20] fix(2fa): Call getVerificationKey manually when READY in verifyIdentity This is required to create a PreCertificate on the server-side, but we can't call createNewDevice because we're already READY, we already have a device. --- packages/core/src/LocalUser/Manager.js | 4 ++-- packages/core/src/Network/Client.js | 1 - packages/core/src/Session/Session.js | 1 + packages/core/src/Tanker.js | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/LocalUser/Manager.js b/packages/core/src/LocalUser/Manager.js index 620f15bc..36701796 100644 --- a/packages/core/src/LocalUser/Manager.js +++ b/packages/core/src/LocalUser/Manager.js @@ -142,7 +142,7 @@ export class LocalUserManager extends EventEmitter { createNewDevice = async (verification: VerificationWithToken): Promise => { try { - const verificationKey = await this._getVerificationKey(verification); + const verificationKey = await this.getVerificationKey(verification); const ghostDevice = extractGhostDevice(verificationKey); const ghostSignatureKeyPair = tcrypto.getSignatureKeyPairFromPrivateKey(ghostDevice.privateSignatureKey); @@ -250,7 +250,7 @@ export class LocalUserManager extends EventEmitter { }); } - _getVerificationKey = async (verification: VerificationWithToken) => { + getVerificationKey = async (verification: VerificationWithToken) => { if (verification.verificationKey) { return verification.verificationKey; } diff --git a/packages/core/src/Network/Client.js b/packages/core/src/Network/Client.js index 675ecbca..2db7a6c1 100644 --- a/packages/core/src/Network/Client.js +++ b/packages/core/src/Network/Client.js @@ -206,7 +206,6 @@ export class Client { getVerificationKey = async (body: any): Promise => { const path = `/users/${urlize(this._userId)}/verification-key`; - const options = { method: 'POST', body: JSON.stringify(b64RequestObject(body)), diff --git a/packages/core/src/Session/Session.js b/packages/core/src/Session/Session.js index 2ade87b6..2531221b 100644 --- a/packages/core/src/Session/Session.js +++ b/packages/core/src/Session/Session.js @@ -117,6 +117,7 @@ export class Session extends EventEmitter { await this._forwardAndStopOnFail(this._localUserManager, 'createNewDevice', ...args); this.status = statuses.READY; } + getVerificationKey = async (...args: any) => this._forward(this._localUserManager, 'getVerificationKey', ...args) revokeDevice = (...args: any) => this._forward(this._localUserManager, 'revokeDevice', ...args) listDevices = (...args: any) => this._forward(this._localUserManager, 'listDevices', ...args) deviceId = () => this._localUserManager.localUser.deviceId diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index 4705b365..4e6d69a3 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -259,6 +259,8 @@ export class Tanker extends EventEmitter { if (this.status === statuses.IDENTITY_VERIFICATION_NEEDED) { await this.session.createNewDevice(verifWithToken); + } else { + await this.session.getVerificationKey(verification); } if (options && options.withToken) { From d75c6c2618d32995d858d2ead95fa702751c2ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Mon, 8 Mar 2021 13:40:08 +0100 Subject: [PATCH 12/20] feat(2fa): functional tests for getting a session token --- .../functional-tests/src/helpers/AppHelper.js | 4 + packages/functional-tests/src/index.js | 2 + packages/functional-tests/src/sessionToken.js | 139 ++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 packages/functional-tests/src/sessionToken.js diff --git a/packages/functional-tests/src/helpers/AppHelper.js b/packages/functional-tests/src/helpers/AppHelper.js index a44d7b47..ea404637 100644 --- a/packages/functional-tests/src/helpers/AppHelper.js +++ b/packages/functional-tests/src/helpers/AppHelper.js @@ -87,6 +87,10 @@ export class AppHelper { await this._update({ storage_provider: 'none' }); } + async set2FA() { + await this._update({ session_certificates_enabled: true }); + } + generateIdentity(userId?: string): Promise { const id = userId || uuid.v4(); return createIdentity(utils.toBase64(this.appId), utils.toBase64(this.appKeyPair.privateKey), id); diff --git a/packages/functional-tests/src/index.js b/packages/functional-tests/src/index.js index 52f18a24..090ddbac 100644 --- a/packages/functional-tests/src/index.js +++ b/packages/functional-tests/src/index.js @@ -17,6 +17,7 @@ import { generateRevocationTests } from './revocation'; import { generateSessionTests } from './session'; import { generateUploadTests } from './upload'; import { generateVerificationTests } from './verification'; +import { generateSessionTokenTests } from './sessionToken'; export function generateFunctionalTests( name: string, @@ -68,6 +69,7 @@ export function generateFunctionalTests( generateRevocationTests(args); generateNetworkTests(args); generateFakeAuthenticationTests(args); + generateSessionTokenTests(args); }); } diff --git a/packages/functional-tests/src/sessionToken.js b/packages/functional-tests/src/sessionToken.js new file mode 100644 index 00000000..09ed85c5 --- /dev/null +++ b/packages/functional-tests/src/sessionToken.js @@ -0,0 +1,139 @@ +// @flow +import { statuses } from '@tanker/core'; +import { expect, uuid } from '@tanker/test-utils'; + +import { getPublicIdentity } from '@tanker/identity'; +import { utils } from '@tanker/crypto'; +import { fetch } from '@tanker/http-utils'; +import type { TestArgs } from './helpers'; +import { appdUrl } from './helpers'; + +const { READY } = statuses; + +async function checkSessionToken(appHelper, publicIdentity, token, allowedMethods: Array) { + const appId = utils.toSafeBase64(appHelper.appId).replace(/=+$/, ''); + const url = `${appdUrl}/v2/apps/${appId}/verification/session-token`; + const body = { + public_identity: publicIdentity, + session_token: token, + allowed_methods: allowedMethods, + }; + return fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${appHelper.authToken}`, + }, + body: JSON.stringify(body) + }); +} + +export const generateSessionTokenTests = (args: TestArgs) => { + describe('session token (2FA)', () => { + let bobLaptop; + let bobIdentity; + let bobPublicIdentity; + let appHelper; + + before(() => { + ({ appHelper } = args); + }); + + beforeEach(async () => { + await appHelper.set2FA(); + const bobId = uuid.v4(); + bobIdentity = await appHelper.generateIdentity(bobId); + bobPublicIdentity = await getPublicIdentity(bobIdentity); + bobLaptop = args.makeTanker(); + await bobLaptop.start(bobIdentity); + }); + + afterEach(async () => { + await Promise.all([ + bobLaptop.stop(), + ]); + }); + + it('can get a session token after registerIdentity', async () => { + const email = 'john.doe@tanker.io'; + const verificationCode = await appHelper.getVerificationCode(email); + + const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withToken: true }); + expect(token).to.be.a('string'); + }); + + it('can use setVerificationMethod to get a session token', async () => { + await bobLaptop.registerIdentity({ passphrase: 'Space and time are not what you think' }); + + const email = 'john.doe@tanker.io'; + const verificationCode = await appHelper.getVerificationCode(email); + const token = await bobLaptop.setVerificationMethod({ email, verificationCode }, { withToken: true }); + expect(token).to.be.a('string'); + }); + + it('can use verifyIdentity to get a session token when Ready', async () => { + const passphrase = 'Observers disagree about the lengths of objects'; + await bobLaptop.registerIdentity({ passphrase }); + expect(bobLaptop.status).to.equal(READY); + + const token = await bobLaptop.verifyIdentity({ passphrase }, { withToken: true }); + expect(token).to.be.a('string'); + }); + + it('can check a session token returned by registerIdentity', async () => { + const passphrase = 'The ladder will not be able to fit'; + const token = await bobLaptop.registerIdentity({ passphrase }, { withToken: true }); + + const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ + type: 'passphrase', + }]); + expect(response.status).to.eq(200); + const result = await response.json(); + expect(result.verification_method).to.eq('passphrase'); + }); + + it('can check a session token with multiple allowed methods', async () => { + const email = 'john.deer@tanker.io'; + const verificationCode = await appHelper.getVerificationCode(email); + const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withToken: true }); + + const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ + type: 'oidc_id_token', + }, { + type: 'email', + email: 'invalid@example.org' + }, { + type: 'email', + email, + }]); + expect(response.status).to.eq(200); + const result = await response.json(); + expect(result.verification_method).to.eq('email'); + }); + + it('fails to check a session token if the allowed_method is wrong', async () => { + const email = 'john.smith@tanker.io'; + const verificationCode = await appHelper.getVerificationCode(email); + const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withToken: true }); + + const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ + type: 'oidc_id_token', + }]); + expect(response.status).to.eq(401); + }); + + it('fails to check a session token if the token is invalid', async () => { + const email = 'john.smith@tanker.io'; + const verificationCode = await appHelper.getVerificationCode(email); + // $FlowIgnore we assert that the token is a string with expect() + const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withToken: true }); + expect(token).to.be.a('string'); + const badToken = `a${token}`; + + const response = await checkSessionToken(args.appHelper, bobPublicIdentity, badToken, [{ + type: 'email', + email + }]); + expect(response.status).to.eq(400); + }); + }); +}; From 0493b95f1d315b7dc66ed1b9ac16383917497fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Thu, 18 Mar 2021 11:18:27 +0100 Subject: [PATCH 13/20] chore(2fa): Auto-rename withToken -> withSessionToken in options --- packages/core/src/LocalUser/types.js | 2 +- packages/core/src/Tanker.js | 12 ++++++------ packages/functional-tests/src/sessionToken.js | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/core/src/LocalUser/types.js b/packages/core/src/LocalUser/types.js index 2132ce22..c811c397 100644 --- a/packages/core/src/LocalUser/types.js +++ b/packages/core/src/LocalUser/types.js @@ -21,7 +21,7 @@ export type WithTokenOptions = {| withToken?: {| nonce: string |} |}; export type VerificationWithToken = {| ...Verification, ...WithTokenOptions |}; export type RemoteVerificationWithToken = {| ...RemoteVerification, ...WithTokenOptions |}; -export type VerificationOptions = $Exact<{ withToken?: bool }>; +export type VerificationOptions = $Exact<{ withSessionToken?: bool }>; const validMethods = ['email', 'passphrase', 'verificationKey', 'oidcIdToken']; const validKeys = [...validMethods, 'verificationCode']; diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index 4e6d69a3..a4a808fe 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -234,13 +234,13 @@ export class Tanker extends EventEmitter { // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); - if (options && options.withToken) { + if (options && options.withSessionToken) { verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } await this.session.createUser(verifWithToken); - if (options && options.withToken) { + if (options && options.withSessionToken) { return this.session.getSessionToken(verifWithToken); } } @@ -250,7 +250,7 @@ export class Tanker extends EventEmitter { // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); - if (options && options.withToken) { + if (options && options.withSessionToken) { assertStatus(this.status, [statuses.IDENTITY_VERIFICATION_NEEDED, statuses.READY], 'verify an identity with proof'); verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } else { @@ -263,7 +263,7 @@ export class Tanker extends EventEmitter { await this.session.getVerificationKey(verification); } - if (options && options.withToken) { + if (options && options.withSessionToken) { return this.session.getSessionToken(verifWithToken); } } @@ -277,13 +277,13 @@ export class Tanker extends EventEmitter { // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); - if (options && options.withToken) { + if (options && options.withSessionToken) { verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } await this.session.setVerificationMethod(verifWithToken); - if (options && options.withToken) { + if (options && options.withSessionToken) { return this.session.getSessionToken(verifWithToken); } } diff --git a/packages/functional-tests/src/sessionToken.js b/packages/functional-tests/src/sessionToken.js index 09ed85c5..30b7d737 100644 --- a/packages/functional-tests/src/sessionToken.js +++ b/packages/functional-tests/src/sessionToken.js @@ -57,7 +57,7 @@ export const generateSessionTokenTests = (args: TestArgs) => { const email = 'john.doe@tanker.io'; const verificationCode = await appHelper.getVerificationCode(email); - const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withToken: true }); + const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withSessionToken: true }); expect(token).to.be.a('string'); }); @@ -66,7 +66,7 @@ export const generateSessionTokenTests = (args: TestArgs) => { const email = 'john.doe@tanker.io'; const verificationCode = await appHelper.getVerificationCode(email); - const token = await bobLaptop.setVerificationMethod({ email, verificationCode }, { withToken: true }); + const token = await bobLaptop.setVerificationMethod({ email, verificationCode }, { withSessionToken: true }); expect(token).to.be.a('string'); }); @@ -75,13 +75,13 @@ export const generateSessionTokenTests = (args: TestArgs) => { await bobLaptop.registerIdentity({ passphrase }); expect(bobLaptop.status).to.equal(READY); - const token = await bobLaptop.verifyIdentity({ passphrase }, { withToken: true }); + const token = await bobLaptop.verifyIdentity({ passphrase }, { withSessionToken: true }); expect(token).to.be.a('string'); }); it('can check a session token returned by registerIdentity', async () => { const passphrase = 'The ladder will not be able to fit'; - const token = await bobLaptop.registerIdentity({ passphrase }, { withToken: true }); + const token = await bobLaptop.registerIdentity({ passphrase }, { withSessionToken: true }); const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ type: 'passphrase', @@ -94,7 +94,7 @@ export const generateSessionTokenTests = (args: TestArgs) => { it('can check a session token with multiple allowed methods', async () => { const email = 'john.deer@tanker.io'; const verificationCode = await appHelper.getVerificationCode(email); - const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withToken: true }); + const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withSessionToken: true }); const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ type: 'oidc_id_token', @@ -113,7 +113,7 @@ export const generateSessionTokenTests = (args: TestArgs) => { it('fails to check a session token if the allowed_method is wrong', async () => { const email = 'john.smith@tanker.io'; const verificationCode = await appHelper.getVerificationCode(email); - const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withToken: true }); + const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withSessionToken: true }); const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ type: 'oidc_id_token', @@ -125,7 +125,7 @@ export const generateSessionTokenTests = (args: TestArgs) => { const email = 'john.smith@tanker.io'; const verificationCode = await appHelper.getVerificationCode(email); // $FlowIgnore we assert that the token is a string with expect() - const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withToken: true }); + const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withSessionToken: true }); expect(token).to.be.a('string'); const badToken = `a${token}`; From 51f01c8dea2e2f9273443fc505348356b31538e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Thu, 18 Mar 2021 14:29:46 +0100 Subject: [PATCH 14/20] fix(2fa): Address review comments --- packages/core/src/LocalUser/Manager.js | 2 +- packages/core/src/Network/Client.js | 23 ++++++------------- packages/core/src/Tanker.js | 15 +++++++----- packages/functional-tests/src/sessionToken.js | 13 ++++------- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/packages/core/src/LocalUser/Manager.js b/packages/core/src/LocalUser/Manager.js index 36701796..8b3915b1 100644 --- a/packages/core/src/LocalUser/Manager.js +++ b/packages/core/src/LocalUser/Manager.js @@ -188,7 +188,7 @@ export class LocalUserManager extends EventEmitter { const { payload, nature } = makeSessionCertificate(verification); const block = this._localUser.makeBlock(payload, nature); - if (verification.withToken === undefined) + if (!verification.withToken) throw new InternalError('Cannot get a session certificate without withToken'); return this._client.getSessionToken({ session_certificate: block, nonce: verification.withToken.nonce }); diff --git a/packages/core/src/Network/Client.js b/packages/core/src/Network/Client.js index 2db7a6c1..e823fb9d 100644 --- a/packages/core/src/Network/Client.js +++ b/packages/core/src/Network/Client.js @@ -377,22 +377,13 @@ export class Client { getSessionToken = async (body: any): Promise => { const path = `/users/${urlize(this._userId)}/session-certificates`; - - try { - // eslint-disable-next-line camelcase - const { session_token: sessionToken } = await this._apiCall(path, { - method: 'POST', - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }); - return sessionToken; - } catch (e) { - if (e instanceof TankerError) { - if (e.apiCode === 'app_not_found') throw new PreconditionFailed(e); - if (e.apiCode === 'user_not_found') throw new PreconditionFailed(e); - } - throw e; - } + // eslint-disable-next-line camelcase + const { session_token: sessionToken } = await this._apiCall(path, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + return sessionToken; } getGroupHistories = (query: string): Promise<$Exact<{ histories: Array }>> => { // eslint-disable-line arrow-body-style diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index a4a808fe..0cca3092 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -234,13 +234,14 @@ export class Tanker extends EventEmitter { // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); - if (options && options.withSessionToken) { + const withSessionToken = options && options.withSessionToken; + if (withSessionToken) { verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } await this.session.createUser(verifWithToken); - if (options && options.withSessionToken) { + if (withSessionToken) { return this.session.getSessionToken(verifWithToken); } } @@ -250,7 +251,8 @@ export class Tanker extends EventEmitter { // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); - if (options && options.withSessionToken) { + const withSessionToken = options && options.withSessionToken; + if (withSessionToken) { assertStatus(this.status, [statuses.IDENTITY_VERIFICATION_NEEDED, statuses.READY], 'verify an identity with proof'); verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } else { @@ -263,7 +265,7 @@ export class Tanker extends EventEmitter { await this.session.getVerificationKey(verification); } - if (options && options.withSessionToken) { + if (withSessionToken) { return this.session.getSessionToken(verifWithToken); } } @@ -276,14 +278,15 @@ export class Tanker extends EventEmitter { // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); + const withSessionToken = options && options.withSessionToken; - if (options && options.withSessionToken) { + if (withSessionToken) { verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } await this.session.setVerificationMethod(verifWithToken); - if (options && options.withSessionToken) { + if (withSessionToken) { return this.session.getSessionToken(verifWithToken); } } diff --git a/packages/functional-tests/src/sessionToken.js b/packages/functional-tests/src/sessionToken.js index 30b7d737..0a653a97 100644 --- a/packages/functional-tests/src/sessionToken.js +++ b/packages/functional-tests/src/sessionToken.js @@ -6,23 +6,21 @@ import { getPublicIdentity } from '@tanker/identity'; import { utils } from '@tanker/crypto'; import { fetch } from '@tanker/http-utils'; import type { TestArgs } from './helpers'; -import { appdUrl } from './helpers'; +import { trustchaindUrl } from './helpers'; const { READY } = statuses; async function checkSessionToken(appHelper, publicIdentity, token, allowedMethods: Array) { - const appId = utils.toSafeBase64(appHelper.appId).replace(/=+$/, ''); - const url = `${appdUrl}/v2/apps/${appId}/verification/session-token`; + const url = `${trustchaindUrl}/verification/session-token`; const body = { + app_id: utils.toBase64(appHelper.appId), + auth_token: appHelper.authToken, public_identity: publicIdentity, session_token: token, allowed_methods: allowedMethods, }; return fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${appHelper.authToken}`, - }, body: JSON.stringify(body) }); } @@ -99,8 +97,7 @@ export const generateSessionTokenTests = (args: TestArgs) => { const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ type: 'oidc_id_token', }, { - type: 'email', - email: 'invalid@example.org' + type: 'passphrase', }, { type: 'email', email, From 5865cf0be2ad93768e8282ac296477b3c1726b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Thu, 25 Mar 2021 11:59:11 +0100 Subject: [PATCH 15/20] feat: Check VerificationOptions type at runtime --- packages/core/src/LocalUser/types.js | 17 +++++++++++++++++ packages/core/src/Tanker.js | 5 ++++- packages/functional-tests/src/sessionToken.js | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/LocalUser/types.js b/packages/core/src/LocalUser/types.js index c811c397..938d77ce 100644 --- a/packages/core/src/LocalUser/types.js +++ b/packages/core/src/LocalUser/types.js @@ -26,6 +26,8 @@ export type VerificationOptions = $Exact<{ withSessionToken?: bool }>; const validMethods = ['email', 'passphrase', 'verificationKey', 'oidcIdToken']; const validKeys = [...validMethods, 'verificationCode']; +const validVerifOptionsKeys = ['withSessionToken']; + export const assertVerification = (verification: Verification) => { if (!verification || typeof verification !== 'object' || verification instanceof Array) throw new InvalidArgument('verification', 'object', verification); @@ -57,3 +59,18 @@ export const assertVerification = (verification: Verification) => { assertNotEmptyString(verification.oidcIdToken, 'verification.oidcIdToken'); } }; + +export const assertVerificationOptions = (options: ?VerificationOptions) => { + if (!options) + return; + + if (typeof options !== 'object' || options instanceof Array) { + throw new InvalidArgument('options', 'object', options); + } + + if (Object.keys(options).some(k => !validVerifOptionsKeys.includes(k))) + throw new InvalidArgument('options', `should only contain keys in ${JSON.stringify(validVerifOptionsKeys)}`, options); + + if ('withSessionToken' in options && typeof options.withSessionToken !== 'boolean') + throw new InvalidArgument('options', 'withSessionToken must be a boolean', options); +}; diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index 0cca3092..f2dac05f 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -17,7 +17,7 @@ import type { VerificationMethod, VerificationOptions, VerificationWithToken } from './LocalUser/types'; -import { assertVerification } from './LocalUser/types'; +import { assertVerification, assertVerificationOptions } from './LocalUser/types'; import { extractUserData } from './LocalUser/UserData'; import { assertStatus, statusDefs, statuses, type Status } from './Session/status'; @@ -231,6 +231,7 @@ export class Tanker extends EventEmitter { async registerIdentity(verification: Verification, options?: VerificationOptions): Promise { assertStatus(this.status, statuses.IDENTITY_REGISTRATION_NEEDED, 'register an identity'); assertVerification(verification); + assertVerificationOptions(options); // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); @@ -248,6 +249,7 @@ export class Tanker extends EventEmitter { async verifyIdentity(verification: Verification, options?: VerificationOptions): Promise { assertVerification(verification); + assertVerificationOptions(options); // $FlowIgnore Flow will complain that an _optional_ field is missing, because we're casting _from_ $Exact... const verifWithToken = (verification: VerificationWithToken); @@ -273,6 +275,7 @@ export class Tanker extends EventEmitter { async setVerificationMethod(verification: RemoteVerification, options?: VerificationOptions): Promise { assertStatus(this.status, statuses.READY, 'set a verification method'); assertVerification(verification); + assertVerificationOptions(options); if ('verificationKey' in verification) throw new InvalidArgument('verification', 'cannot update a verification key', verification); diff --git a/packages/functional-tests/src/sessionToken.js b/packages/functional-tests/src/sessionToken.js index 0a653a97..9d35d4bd 100644 --- a/packages/functional-tests/src/sessionToken.js +++ b/packages/functional-tests/src/sessionToken.js @@ -19,6 +19,7 @@ async function checkSessionToken(appHelper, publicIdentity, token, allowedMethod session_token: token, allowed_methods: allowedMethods, }; + console.log(JSON.stringify(body)); return fetch(url, { method: 'POST', body: JSON.stringify(body) @@ -26,7 +27,7 @@ async function checkSessionToken(appHelper, publicIdentity, token, allowedMethod } export const generateSessionTokenTests = (args: TestArgs) => { - describe('session token (2FA)', () => { + describe.only('session token (2FA)', () => { let bobLaptop; let bobIdentity; let bobPublicIdentity; From 4a6918473d6da9777c1cab91ead782d63989e586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Thu, 25 Mar 2021 12:01:18 +0100 Subject: [PATCH 16/20] test: Always check session tokens in functional tests --- packages/functional-tests/src/sessionToken.js | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/functional-tests/src/sessionToken.js b/packages/functional-tests/src/sessionToken.js index 9d35d4bd..e33df651 100644 --- a/packages/functional-tests/src/sessionToken.js +++ b/packages/functional-tests/src/sessionToken.js @@ -1,5 +1,5 @@ // @flow -import { statuses } from '@tanker/core'; +import { errors } from '@tanker/core'; import { expect, uuid } from '@tanker/test-utils'; import { getPublicIdentity } from '@tanker/identity'; @@ -8,8 +8,6 @@ import { fetch } from '@tanker/http-utils'; import type { TestArgs } from './helpers'; import { trustchaindUrl } from './helpers'; -const { READY } = statuses; - async function checkSessionToken(appHelper, publicIdentity, token, allowedMethods: Array) { const url = `${trustchaindUrl}/verification/session-token`; const body = { @@ -19,7 +17,6 @@ async function checkSessionToken(appHelper, publicIdentity, token, allowedMethod session_token: token, allowed_methods: allowedMethods, }; - console.log(JSON.stringify(body)); return fetch(url, { method: 'POST', body: JSON.stringify(body) @@ -27,7 +24,7 @@ async function checkSessionToken(appHelper, publicIdentity, token, allowedMethod } export const generateSessionTokenTests = (args: TestArgs) => { - describe.only('session token (2FA)', () => { + describe('session token (2FA)', () => { let bobLaptop; let bobIdentity; let bobPublicIdentity; @@ -55,9 +52,15 @@ export const generateSessionTokenTests = (args: TestArgs) => { it('can get a session token after registerIdentity', async () => { const email = 'john.doe@tanker.io'; const verificationCode = await appHelper.getVerificationCode(email); - const token = await bobLaptop.registerIdentity({ email, verificationCode }, { withSessionToken: true }); - expect(token).to.be.a('string'); + + const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ + type: 'email', + email, + }]); + expect(response.status).to.eq(200); + const result = await response.json(); + expect(result.verification_method).to.eq('email'); }); it('can use setVerificationMethod to get a session token', async () => { @@ -66,21 +69,20 @@ export const generateSessionTokenTests = (args: TestArgs) => { const email = 'john.doe@tanker.io'; const verificationCode = await appHelper.getVerificationCode(email); const token = await bobLaptop.setVerificationMethod({ email, verificationCode }, { withSessionToken: true }); - expect(token).to.be.a('string'); + + const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ + type: 'email', + email, + }]); + expect(response.status).to.eq(200); + const result = await response.json(); + expect(result.verification_method).to.eq('email'); }); it('can use verifyIdentity to get a session token when Ready', async () => { const passphrase = 'Observers disagree about the lengths of objects'; await bobLaptop.registerIdentity({ passphrase }); - expect(bobLaptop.status).to.equal(READY); - const token = await bobLaptop.verifyIdentity({ passphrase }, { withSessionToken: true }); - expect(token).to.be.a('string'); - }); - - it('can check a session token returned by registerIdentity', async () => { - const passphrase = 'The ladder will not be able to fit'; - const token = await bobLaptop.registerIdentity({ passphrase }, { withSessionToken: true }); const response = await checkSessionToken(args.appHelper, bobPublicIdentity, token, [{ type: 'passphrase', From b2375319f068c17884f6a5dc0e9e24698c8c6f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Thu, 25 Mar 2021 12:19:28 +0100 Subject: [PATCH 17/20] fix: Reject trying to get a session token with a verification key --- packages/core/src/Tanker.js | 4 ++++ packages/functional-tests/src/sessionToken.js | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index f2dac05f..2243ebb9 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -237,6 +237,8 @@ export class Tanker extends EventEmitter { const verifWithToken = (verification: VerificationWithToken); const withSessionToken = options && options.withSessionToken; if (withSessionToken) { + if ('verificationKey' in verification) + throw new InvalidArgument('verification', 'cannot get a session token for a verification key', verification); verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } @@ -256,6 +258,8 @@ export class Tanker extends EventEmitter { const withSessionToken = options && options.withSessionToken; if (withSessionToken) { assertStatus(this.status, [statuses.IDENTITY_VERIFICATION_NEEDED, statuses.READY], 'verify an identity with proof'); + if ('verificationKey' in verification) + throw new InvalidArgument('verification', 'cannot get a session token for a verification key', verification); verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; } else { assertStatus(this.status, statuses.IDENTITY_VERIFICATION_NEEDED, 'verify an identity'); diff --git a/packages/functional-tests/src/sessionToken.js b/packages/functional-tests/src/sessionToken.js index e33df651..bfb5ad07 100644 --- a/packages/functional-tests/src/sessionToken.js +++ b/packages/functional-tests/src/sessionToken.js @@ -92,6 +92,12 @@ export const generateSessionTokenTests = (args: TestArgs) => { expect(result.verification_method).to.eq('passphrase'); }); + it('cannot get a session token with a verification key', async () => { + const verificationKey = await bobLaptop.generateVerificationKey(); + const registerFut = bobLaptop.registerIdentity({ verificationKey }, { withSessionToken: true }); + await expect(registerFut).to.be.rejectedWith(errors.InvalidArgument); + }); + it('can check a session token with multiple allowed methods', async () => { const email = 'john.deer@tanker.io'; const verificationCode = await appHelper.getVerificationCode(email); From 8a69b4e1f17cee45290e0c24e8ff1c141c27cbd2 Mon Sep 17 00:00:00 2001 From: Philippe Daouadi Date: Thu, 25 Mar 2021 10:26:54 +0100 Subject: [PATCH 18/20] tests(benchmarks): add verifyIdentity withSessionToken benchmark --- packages/benchmarks/src/index.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/benchmarks/src/index.js b/packages/benchmarks/src/index.js index cd1d58ec..267528c5 100644 --- a/packages/benchmarks/src/index.js +++ b/packages/benchmarks/src/index.js @@ -19,6 +19,7 @@ let appId; before(async () => { appHelper = await AppHelper.newApp(); appId = utils.toBase64(appHelper.appId); + await appHelper.set2FA(); }); after(async () => { @@ -151,6 +152,29 @@ benchmark('verifyIdentity_passphrase', async (state) => { } }); +// What: starts and verifies an identity with a passphrase and asks for a session token +// PreCond: an identity was registered with another device +// PostCond: the session is open and we have a session token +benchmark('verifyIdentity_passphrase_withToken', async (state) => { + const tanker = makeTanker(); + const identity = await appHelper.generateIdentity(); + await tanker.start(identity); + await tanker.registerIdentity({ passphrase: 'passphrase' }); + await tanker.stop(); + + while (state.iter()) { + state.pause(); + const tanker2 = makeTanker(); + state.unpause(); + await tanker2.start(identity); + const token = await tanker2.verifyIdentity({ passphrase: 'passphrase' }, { withSessionToken: true }); + if (!token) + throw new Error("no session token received, this benchmark isn't benchmarking what we thought it would"); + state.pause(); + await tanker2.stop(); + } +}); + // What: encrypts data // PreCond: a session is open // PostCond: the buffer is encrypted From c615702426bc3297df7fb5370347aa58acfc613b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Mon, 29 Mar 2021 14:51:05 +0200 Subject: [PATCH 19/20] fix: Address new review comments --- packages/core/src/LocalUser/Manager.js | 2 +- packages/core/src/LocalUser/SessionCertificate.js | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/core/src/LocalUser/Manager.js b/packages/core/src/LocalUser/Manager.js index 8b3915b1..af6ee36b 100644 --- a/packages/core/src/LocalUser/Manager.js +++ b/packages/core/src/LocalUser/Manager.js @@ -189,7 +189,7 @@ export class LocalUserManager extends EventEmitter { const block = this._localUser.makeBlock(payload, nature); if (!verification.withToken) - throw new InternalError('Cannot get a session certificate without withToken'); + throw new InternalError('Assertion error: Cannot get a session certificate without withToken'); return this._client.getSessionToken({ session_certificate: block, nonce: verification.withToken.nonce }); } diff --git a/packages/core/src/LocalUser/SessionCertificate.js b/packages/core/src/LocalUser/SessionCertificate.js index d0b32f5f..896b8818 100644 --- a/packages/core/src/LocalUser/SessionCertificate.js +++ b/packages/core/src/LocalUser/SessionCertificate.js @@ -33,24 +33,19 @@ function verificationToVerificationMethod(verification: VerificationWithToken): email: verification.email }; if ('passphrase' in verification) - // $FlowIgnore[prop-missing] return { type: 'passphrase' }; if ('verificationKey' in verification) - // $FlowIgnore[prop-missing] return { type: 'verificationKey' }; if ('oidcIdToken' in verification) - // $FlowIgnore[prop-missing] return { type: 'oidcIdToken' }; - throw new InvalidArgument('verification', 'unknown verification method in verificationToVerificationMethod', verification); + throw new InvalidArgument('verification', 'unknown verification method used in verification', verification); } export const serializeSessionCertificate = (sessionCertificate: SessionCertificateRecord): Uint8Array => { - if (!(sessionCertificate.verification_method_type in VERIFICATION_METHOD_TYPES_INT)) { + if (!(sessionCertificate.verification_method_type in VERIFICATION_METHOD_TYPES_INT)) throw new InternalError('Assertion error: invalid session certificate method type'); - } - if (sessionCertificate.verification_method_target.length !== tcrypto.HASH_SIZE) { + if (sessionCertificate.verification_method_target.length !== tcrypto.HASH_SIZE) throw new InternalError('Assertion error: invalid session certificate method target size'); - } if (sessionCertificate.session_public_signature_key.length !== tcrypto.SIGNATURE_PUBLIC_KEY_SIZE) throw new InternalError('Assertion error: invalid session public signature key size'); From 38810e028c63c4fe6774ee938e9a9dbc40cd3ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Isnard?= Date: Mon, 29 Mar 2021 14:59:12 +0200 Subject: [PATCH 20/20] refactor: Replace toBase64(random(16)) calls by dedicated function --- packages/core/src/Tanker.js | 10 +++++----- packages/crypto/src/index.js | 3 ++- packages/crypto/src/random.js | 7 +++++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Tanker.js b/packages/core/src/Tanker.js index 2243ebb9..48a82007 100644 --- a/packages/core/src/Tanker.js +++ b/packages/core/src/Tanker.js @@ -1,6 +1,6 @@ // @flow import EventEmitter from 'events'; -import { random, tcrypto, utils, type b64string } from '@tanker/crypto'; +import { randomBase64Token, tcrypto, utils, type b64string } from '@tanker/crypto'; import { InternalError, InvalidArgument } from '@tanker/errors'; import { assertDataType, assertNotEmptyString, assertB64StringWithSize, castData } from '@tanker/types'; import type { Data } from '@tanker/types'; @@ -111,7 +111,7 @@ export class Tanker extends EventEmitter { const clientOptions: ClientOptions = { instanceInfo: { - id: utils.toBase64(random(16)), + id: randomBase64Token(), }, sdkInfo: { type: options.sdkType, @@ -239,7 +239,7 @@ export class Tanker extends EventEmitter { if (withSessionToken) { if ('verificationKey' in verification) throw new InvalidArgument('verification', 'cannot get a session token for a verification key', verification); - verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; + verifWithToken.withToken = { nonce: randomBase64Token() }; } await this.session.createUser(verifWithToken); @@ -260,7 +260,7 @@ export class Tanker extends EventEmitter { assertStatus(this.status, [statuses.IDENTITY_VERIFICATION_NEEDED, statuses.READY], 'verify an identity with proof'); if ('verificationKey' in verification) throw new InvalidArgument('verification', 'cannot get a session token for a verification key', verification); - verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; + verifWithToken.withToken = { nonce: randomBase64Token() }; } else { assertStatus(this.status, statuses.IDENTITY_VERIFICATION_NEEDED, 'verify an identity'); } @@ -288,7 +288,7 @@ export class Tanker extends EventEmitter { const withSessionToken = options && options.withSessionToken; if (withSessionToken) { - verifWithToken.withToken = { nonce: utils.toBase64(random(16)) }; + verifWithToken.withToken = { nonce: randomBase64Token() }; } await this.session.setVerificationMethod(verifWithToken); diff --git a/packages/crypto/src/index.js b/packages/crypto/src/index.js index aaf5ef79..ce606f1d 100644 --- a/packages/crypto/src/index.js +++ b/packages/crypto/src/index.js @@ -2,7 +2,7 @@ import * as tcrypto from './tcrypto'; import * as aead from './aead'; -import { random } from './random'; +import { random, randomBase64Token } from './random'; import { generichash } from './hash'; import * as utils from './utils'; import * as number from './number'; @@ -18,6 +18,7 @@ export { aead, tcrypto, random, + randomBase64Token, generichash, number, utils, diff --git a/packages/crypto/src/random.js b/packages/crypto/src/random.js index 3cf3f390..06faae3f 100644 --- a/packages/crypto/src/random.js +++ b/packages/crypto/src/random.js @@ -1,5 +1,7 @@ // @flow import crypto from 'crypto'; +import { toBase64 } from './utils'; +import { type b64string } from './aliases'; export function random(size: number): Uint8Array { // Calling getRandomValues() with a zero-length buffer throws InvalidStateError on Edge @@ -18,3 +20,8 @@ export function random(size: number): Uint8Array { return new Uint8Array(crypto.randomBytes(size)); } + +// A string with enough entropy to not collide or be guessable +export function randomBase64Token(): b64string { + return toBase64(random(16)); +}