From cb5345c2a2cd28e522cc090a8d4bc11085b8d803 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Tue, 26 Mar 2024 16:43:42 -0700 Subject: [PATCH 1/2] Add logic for loading Airbitz data from disk --- CHANGELOG.md | 1 + src/core/login/airbitz-stashes.ts | 111 ++++++++++++++++++++++++++++++ src/core/root.ts | 26 +++++-- src/react-native.tsx | 2 + src/types/exports.ts | 1 + src/types/types.ts | 4 ++ 6 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 src/core/login/airbitz-stashes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f79f7527..a9dd73edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- added: `EdgeContextOptions.airbitzSupport`, for loading legacy Airbitz data from disk. - fixed: Export the `EdgeObjectTemplate` type. - fixed: TypeScript v5 compatibility. diff --git a/src/core/login/airbitz-stashes.ts b/src/core/login/airbitz-stashes.ts new file mode 100644 index 000000000..60bf90557 --- /dev/null +++ b/src/core/login/airbitz-stashes.ts @@ -0,0 +1,111 @@ +import { asCodec, asObject, asOptional, asString, Cleaner } from 'cleaners' +import { justFolders, navigateDisklet } from 'disklet' + +import { fixUsername } from '../../client-side' +import { asBase32, asEdgeBox, asEdgeSnrp } from '../../types/server-cleaners' +import { EdgeIo } from '../../types/types' +import { base58, utf8 } from '../../util/encoding' +import { makeJsonFile } from '../../util/file-helpers' +import { userIdSnrp } from '../scrypt/scrypt-selectors' +import { LoginStash } from './login-stash' + +/** + * Reads legacy Airbitz login stashes from disk. + */ +export async function loadAirbitzStashes( + io: EdgeIo, + avoidUsernames: Set +): Promise { + const out: LoginStash[] = [] + + const paths = await io.disklet.list('Accounts').then(justFolders) + for (const path of paths) { + const folder = navigateDisklet(io.disklet, path) + const [ + carePackage, + loginPackage, + otp, + pin2Key, + recovery2Key, + usernameJson + ] = await Promise.all([ + await carePackageFile.load(folder, 'CarePackage.json'), + await loginPackageFile.load(folder, 'LoginPackage.json'), + await otpFile.load(folder, 'OtpKey.json'), + await pin2KeyFile.load(folder, 'Pin2Key.json'), + await recovery2KeyFile.load(folder, 'Recovery2Key.json'), + await usernameFile.load(folder, 'UserName.json') + ]) + + if (usernameJson == null) continue + const username = fixUsername(usernameJson.userName) + if (avoidUsernames.has(username)) continue + const userId = await io.scrypt( + utf8.parse(username), + userIdSnrp.salt_hex, + userIdSnrp.n, + userIdSnrp.r, + userIdSnrp.p, + 32 + ) + + // Assemble a modern stash object: + const stash: LoginStash = { + appId: '', + loginId: userId, + pendingVouchers: [], + username + } + if (carePackage != null && loginPackage != null) { + stash.passwordKeySnrp = carePackage.SNRP2 + stash.passwordBox = loginPackage.EMK_LP2 + stash.syncKeyBox = loginPackage.ESyncKey + stash.passwordAuthBox = loginPackage.ELP1 + } + if (otp != null) { + stash.otpKey = otp.TOTP + } + if (pin2Key != null) { + stash.pin2Key = pin2Key.pin2Key + } + if (recovery2Key != null) { + stash.recovery2Key = recovery2Key.recovery2Key + } + + out.push(stash) + } + + return out +} + +/** + * A string of base58-encoded binary data. + */ +const asBase58: Cleaner = asCodec( + raw => base58.parse(asString(raw)), + clean => base58.stringify(clean) +) + +const carePackageFile = makeJsonFile( + asObject({ + SNRP2: asEdgeSnrp, // passwordKeySnrp + SNRP3: asOptional(asEdgeSnrp), // recoveryKeySnrp + SNRP4: asOptional(asEdgeSnrp), // questionKeySnrp + ERQ: asOptional(asEdgeBox) // questionBox + }) +) + +const loginPackageFile = makeJsonFile( + asObject({ + EMK_LP2: asEdgeBox, // passwordBox + EMK_LRA3: asOptional(asEdgeBox), // recoveryBox + + ESyncKey: asEdgeBox, // syncKeyBox + ELP1: asEdgeBox // passwordAuthBox + }) +) + +const otpFile = makeJsonFile(asObject({ TOTP: asBase32 })) +const pin2KeyFile = makeJsonFile(asObject({ pin2Key: asBase58 })) +const recovery2KeyFile = makeJsonFile(asObject({ recovery2Key: asBase58 })) +const usernameFile = makeJsonFile(asObject({ userName: asString })) diff --git a/src/core/root.ts b/src/core/root.ts index ba1a7ab56..f3eb7079c 100644 --- a/src/core/root.ts +++ b/src/core/root.ts @@ -9,6 +9,7 @@ import { Dispatch } from './actions' import { CLIENT_FILE_NAME, clientFile } from './context/client-file' import { INFO_CACHE_FILE_NAME, infoCacheFile } from './context/info-cache-file' import { filterLogs, LogBackend, makeLog } from './log/log' +import { loadAirbitzStashes } from './login/airbitz-stashes' import { loadStashes } from './login/login-stash' import { PluginIos, watchPlugins } from './plugins/plugins-actions' import { RootOutput, rootPixie, RootProps } from './root-pixie' @@ -33,15 +34,16 @@ export async function makeContext( ): Promise { const { io } = ios const { + airbitzSupport = false, apiKey, appId = '', authServer = 'https://login.edge.app/api', - infoServer, - syncServer, deviceDescription = null, hideKeys = false, + infoServer, plugins: pluginsInit = {}, - skipBlockHeight = false + skipBlockHeight = false, + syncServer } = opts const infoServers = typeof infoServer === 'string' @@ -82,19 +84,35 @@ export async function makeContext( }) const log = makeLog(logBackend, 'edge-core') + // Load the login stashes from disk: let [clientInfo, infoCache = {}, stashes] = await Promise.all([ clientFile.load(io.disklet, CLIENT_FILE_NAME), infoCacheFile.load(io.disklet, INFO_CACHE_FILE_NAME), loadStashes(io.disklet, log) ]) + // Load legacy stashes from disk + if (airbitzSupport) { + // Edge will write modern files to disk at login time, + // but it won't delete the legacy Airbitz data. + // Once this happens, we need to ignore the legacy files + // and just use the new files: + const avoidUsernames = new Set() + for (const { username } of stashes) { + if (username != null) avoidUsernames.add(username) + } + + const airbitzStashes = await loadAirbitzStashes(io, avoidUsernames) + stashes.push(...airbitzStashes) + } + // Save the clientId if we don't have one: if (clientInfo == null) { clientInfo = { clientId: io.random(16) } await clientFile.save(io.disklet, CLIENT_FILE_NAME, clientInfo) } - // Load the login stashes from disk: + // Write everything to redux: redux.dispatch({ type: 'INIT', payload: { diff --git a/src/react-native.tsx b/src/react-native.tsx index bd248bfc6..c52f1a1f5 100644 --- a/src/react-native.tsx +++ b/src/react-native.tsx @@ -36,6 +36,7 @@ export function MakeEdgeContext(props: EdgeContextProps): JSX.Element { onLog = defaultOnLog, // Inner context options: + airbitzSupport = false, apiKey = '', appId = '', authServer, @@ -63,6 +64,7 @@ export function MakeEdgeContext(props: EdgeContextProps): JSX.Element { bridgifyLogBackend({ crashReporter, onLog }), pluginUris, { + airbitzSupport, apiKey, appId, authServer, diff --git a/src/types/exports.ts b/src/types/exports.ts index 2bd260076..760c55539 100644 --- a/src/types/exports.ts +++ b/src/types/exports.ts @@ -76,6 +76,7 @@ export interface EdgeContextProps extends CommonProps { onLog?: EdgeOnLog // EdgeContextOptions: + airbitzSupport?: boolean apiKey?: string appId?: string authServer?: string diff --git a/src/types/types.ts b/src/types/types.ts index 9562ba4bd..aa89c5f96 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1683,6 +1683,9 @@ export interface EdgeContextOptions { path?: string // Only used on node.js plugins?: EdgeCorePluginsInit + /** True to load Airbitz user files from disk */ + airbitzSupport?: boolean + /** * True to skip updating the `EdgeCurrencyWallet.blockHeight` property. * This may improve performance by reducing bridge traffic, @@ -1857,6 +1860,7 @@ export interface EdgeFakeWorldOptions { export interface EdgeFakeContextOptions { // EdgeContextOptions: + airbitzSupport?: boolean apiKey: string appId: string deviceDescription?: string From 820c78a55bc75b2f867db302c36a876d9e9e7525 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Wed, 27 Mar 2024 14:43:19 -0700 Subject: [PATCH 2/2] Unit-test the Airbitz file support --- src/core/fake/fake-world.ts | 12 ++++++- src/types/types.ts | 3 ++ test/core/login/airbitz.test.ts | 34 +++++++++++++++++++ test/fake/fake-user.ts | 58 +++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 test/core/login/airbitz.test.ts diff --git a/src/core/fake/fake-world.ts b/src/core/fake/fake-world.ts index dfe21829a..808d48180 100644 --- a/src/core/fake/fake-world.ts +++ b/src/core/fake/fake-world.ts @@ -104,7 +104,11 @@ export function makeFakeWorld( }, async makeEdgeContext(opts: EdgeFakeContextOptions): Promise { - const { allowNetworkAccess = false, cleanDevice = false } = opts + const { + allowNetworkAccess = false, + cleanDevice = false, + extraFiles = {} + } = opts const fakeFetch = makeFetchFunction(fakeServer) const fetch: EdgeFetchFunction = !allowNetworkAccess @@ -138,6 +142,12 @@ export function makeFakeWorld( } } + if (extraFiles != null) { + for (const path of Object.keys(extraFiles)) { + await fakeIo.disklet.setText(path, extraFiles[path]) + } + } + const out = await makeContext({ io: fakeIo, nativeIo }, logBackend, { ...opts }) diff --git a/src/types/types.ts b/src/types/types.ts index aa89c5f96..066c66bb6 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1874,6 +1874,9 @@ export interface EdgeFakeContextOptions { // Fake device options: cleanDevice?: boolean + + /** Extra files to be saved on the fake device. */ + extraFiles?: { [path: string]: string } } /** diff --git a/test/core/login/airbitz.test.ts b/test/core/login/airbitz.test.ts new file mode 100644 index 000000000..808fef0a7 --- /dev/null +++ b/test/core/login/airbitz.test.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { makeFakeEdgeWorld } from '../../../src/index' +import { airbitzFiles, fakeUser } from '../../fake/fake-user' + +const quiet = { onLog() {} } + +describe('airbitz stashes', function () { + it('can log into legacy airbitz files', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + airbitzSupport: true, + apiKey: '', + appId: '', + cleanDevice: true, + extraFiles: airbitzFiles + }) + + expect(context.localUsers).deep.equals([ + { + keyLoginEnabled: true, + lastLogin: undefined, + loginId: 'BTnpEn7pabDXbcv7VxnKBDsn4CVSwLRA25J8U84qmg4h', + pinLoginEnabled: true, + recovery2Key: 'NVADGXzb5Zc55PYXVVT7GRcXPnY9NZJUjiZK8aQnidc', + username: 'js test 0', + voucherId: undefined + } + ]) + + await context.loginWithPIN(fakeUser.username, fakeUser.pin) + }) +}) diff --git a/test/fake/fake-user.ts b/test/fake/fake-user.ts index eb4b8de78..4ac5a49e9 100644 --- a/test/fake/fake-user.ts +++ b/test/fake/fake-user.ts @@ -221,3 +221,61 @@ export const fakeUserDump: EdgeFakeUser = { } export const fakeUser = { ...fakeUserDump, ...info } + +/** + * This data comes from the old C++ abc-cli tool. + * It was created by uploading the "js test 0" user above into a + * testing login server, and then running `abc-cli sign-in` + * with the matching username and password. + * These are the exact files abc-cli wrote to disk + * once the sign-in was complete. + */ +export const airbitzFiles = { + 'Accounts/Account0/CarePackage.json': JSON.stringify({ + SNRP2: { + n: 16384, + p: 1, + r: 2, + salt_hex: + 'ed6396d127b60d6ffc469634b9a53bdcfb4ee381e9b9df5e66a0f97895871981' + } + }), + 'Accounts/Account0/LoginPackage.json': JSON.stringify({ + ELP1: { + data_base64: + 'ZHhQtHA48aPf083XbEeNMAzbu4KE5dNLU6q0WzTUwJkxGG72elIha9wMjpAvwxmJ2PC3ZCMya1eiVgHPqTO+zS8dWHmuqzbpNY+IdoAtjF//dZ6O4mCcMR8enmj5xYaVBIIQ8WCcang+2RTqDzOoI+W8p6mM9N528ypy0lkpYi9lpGrxAAAJjhk+9xdBRcL4O5jkCZ0VQEvoRCqlU2y99YtRYtB/+nYj51PTtU00MUpKq7PggNZI5EDmZC9vK/BRnBArLbnwj7L88vuKEXBumYX0GA9ZhTPXMuRfABzvCxPkTKLGG2KmfQAtSAehCDMtkgQzocXSCiUuzqBdId56WkNFYC+Phq6vgflPK2qcxkV6Kz2qu8Yr1nBveyLsUTGOZgoBlya2UEZrQ4B96mUv5Q==', + encryptionType: 0, + iv_hex: 'c801b7e3265734544c08c68bdff86979' + }, + EMK_LP2: { + data_base64: + 'sXBdJaaeVNWOuBWdRVvULaS+VqPkTF1eLR0BMSi2a4F+DCc+4JbMqgBPK3uyp7MHd3qpOOt7Fcth5gnT5hspzh47ONsTTaQNglwZ4lY25OKGsK7ldWrcohiDEgswgG8whGM3tqio6iIMndkuZn3Dn9aj0SwWNdCuW1xFYvbMa7pCgWr0QT+zjWJAPnlT0U1hqJNjGDqFK6jYorClWKsbBZtVJ/dCRMv5+xu05S7fCdgQnz1m5O5nMHTcw6NFR0eBApOOh3KbghOeh0QcBAa5jNm4L61BK5wMCgPydh2/u+MSu34ERsomA5kwp86N35EKHGJH3p0Jq/jf9ToR9wU/MlPivmHvbbspxIzay0feJcanodfyFqLLnsfknSptgiaX3ppat83xrdndQH+JNYweNTgoZmd5pt/8hu/LGk1iAs8Z6e61FaYXm+UI/yxUQFy3A8meST1UfVAxeFw3IRCRZRplll8fgALH67kO15s4bts=', + encryptionType: 0, + iv_hex: '0989bebe4103816be3db48a2ed3ff338' + }, + ESyncKey: { + data_base64: + 'ruf9v3eWUg2GZJf+94boCPyui4nQ9HnJCWx07kmRg+nKns+1MlqSFQQNINgHXLWDrQvho69AnQrP9Ep+PcXOnG+m0kiqlmzm8UdhQoQJOKP/O5S2TFwMuLpLrGN+I4F5HbdGVMA20WJjhfQ7Kzc3H2hHwm1BUo0xItbV/audT6KySR+ugeW+jF5glzB0/8eAYFKloYd0YC6TZg1gmZU7jqBatpylk7a9znrZG6zKVyPQxnkKr6TnF3xQihSw2H7g1Gd4AI4Pttye/RYsVbwqFFnD2OZwgp7kqeyLwVRsU6OboRtkBYuaa4adYhXHda94', + encryptionType: 0, + iv_hex: '59309614b12c169af977681e01d6ad8b' + } + }), + 'Accounts/Account0/OtpKey.json': JSON.stringify({ + TOTP: 'HELLO===' + }), + 'Accounts/Account0/Pin2Key.json': JSON.stringify({ + pin2Key: '22b6wM3F6bd3LpT1UHLhb7pDr5BxzAuVNhuU5HGudZHq' + }), + 'Accounts/Account0/Recovery2Key.json': JSON.stringify({ + recovery2Key: 'NVADGXzb5Zc55PYXVVT7GRcXPnY9NZJUjiZK8aQnidc' + }), + 'Accounts/Account0/RootKey.json': JSON.stringify({ + data_base64: + 'pR+yQsnkynA03Xqa8AYHzRzunxsBoFM39huz09DL+20RZxAAid4iWkkBNei+Z6Mp0sdhDNfilPQmU5rOuABo70NIO+E3GNZ66RmG6SkN0Jo0Fgp28Qfyg/aD6BlMNw++oXS8yGuDvPotDpM/rgYd6l7/OuLLfg5cZw85Qe1D9UM9dqP8EVpKPQTqSsAnTE0RsHG3HFVIFVRQAsIqqsynAC+h8QiKdAFaqzdFVbB75iu4KV27wdjfRnZrTVPqGA9fnC96vhRRUNRmQnWbJRvdyhIXRHYXJbu/ip1eFts054yfjhyxHffOXfcSpm3xwL0itf3Y4rEUG0dEQO5IwfpuRxspFFn3S/Fi4wGkw+PJNNtF3r5djryeYOFE854n3YOkBayhyhnNAJuaaHeOnrP7QaD3V4hDuFezHqTWCU8lA7W0u7SmFZ1IXXXxvjITvglkmTrnx8CbWkmjRXqIbMl8tg==', + encryptionType: 0, + iv_hex: '96cc1ebc2d11a0b38c9259c056d3ca23' + }), + 'Accounts/Account0/UserName.json': JSON.stringify({ + userName: 'js test 0' + }) +}