Skip to content

Commit 40292d8

Browse files
authored
Merge pull request #593 from EdgeApp/william/load-airbitz-stashes
Load Airbitz stashes
2 parents 4831b50 + 820c78a commit 40292d8

9 files changed

Lines changed: 247 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- added: `EdgeContextOptions.airbitzSupport`, for loading legacy Airbitz data from disk.
56
- fixed: Export the `EdgeObjectTemplate` type.
67
- fixed: TypeScript v5 compatibility.
78

src/core/fake/fake-world.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,11 @@ export function makeFakeWorld(
104104
},
105105

106106
async makeEdgeContext(opts: EdgeFakeContextOptions): Promise<EdgeContext> {
107-
const { allowNetworkAccess = false, cleanDevice = false } = opts
107+
const {
108+
allowNetworkAccess = false,
109+
cleanDevice = false,
110+
extraFiles = {}
111+
} = opts
108112

109113
const fakeFetch = makeFetchFunction(fakeServer)
110114
const fetch: EdgeFetchFunction = !allowNetworkAccess
@@ -138,6 +142,12 @@ export function makeFakeWorld(
138142
}
139143
}
140144

145+
if (extraFiles != null) {
146+
for (const path of Object.keys(extraFiles)) {
147+
await fakeIo.disklet.setText(path, extraFiles[path])
148+
}
149+
}
150+
141151
const out = await makeContext({ io: fakeIo, nativeIo }, logBackend, {
142152
...opts
143153
})

src/core/login/airbitz-stashes.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { asCodec, asObject, asOptional, asString, Cleaner } from 'cleaners'
2+
import { justFolders, navigateDisklet } from 'disklet'
3+
4+
import { fixUsername } from '../../client-side'
5+
import { asBase32, asEdgeBox, asEdgeSnrp } from '../../types/server-cleaners'
6+
import { EdgeIo } from '../../types/types'
7+
import { base58, utf8 } from '../../util/encoding'
8+
import { makeJsonFile } from '../../util/file-helpers'
9+
import { userIdSnrp } from '../scrypt/scrypt-selectors'
10+
import { LoginStash } from './login-stash'
11+
12+
/**
13+
* Reads legacy Airbitz login stashes from disk.
14+
*/
15+
export async function loadAirbitzStashes(
16+
io: EdgeIo,
17+
avoidUsernames: Set<string>
18+
): Promise<LoginStash[]> {
19+
const out: LoginStash[] = []
20+
21+
const paths = await io.disklet.list('Accounts').then(justFolders)
22+
for (const path of paths) {
23+
const folder = navigateDisklet(io.disklet, path)
24+
const [
25+
carePackage,
26+
loginPackage,
27+
otp,
28+
pin2Key,
29+
recovery2Key,
30+
usernameJson
31+
] = await Promise.all([
32+
await carePackageFile.load(folder, 'CarePackage.json'),
33+
await loginPackageFile.load(folder, 'LoginPackage.json'),
34+
await otpFile.load(folder, 'OtpKey.json'),
35+
await pin2KeyFile.load(folder, 'Pin2Key.json'),
36+
await recovery2KeyFile.load(folder, 'Recovery2Key.json'),
37+
await usernameFile.load(folder, 'UserName.json')
38+
])
39+
40+
if (usernameJson == null) continue
41+
const username = fixUsername(usernameJson.userName)
42+
if (avoidUsernames.has(username)) continue
43+
const userId = await io.scrypt(
44+
utf8.parse(username),
45+
userIdSnrp.salt_hex,
46+
userIdSnrp.n,
47+
userIdSnrp.r,
48+
userIdSnrp.p,
49+
32
50+
)
51+
52+
// Assemble a modern stash object:
53+
const stash: LoginStash = {
54+
appId: '',
55+
loginId: userId,
56+
pendingVouchers: [],
57+
username
58+
}
59+
if (carePackage != null && loginPackage != null) {
60+
stash.passwordKeySnrp = carePackage.SNRP2
61+
stash.passwordBox = loginPackage.EMK_LP2
62+
stash.syncKeyBox = loginPackage.ESyncKey
63+
stash.passwordAuthBox = loginPackage.ELP1
64+
}
65+
if (otp != null) {
66+
stash.otpKey = otp.TOTP
67+
}
68+
if (pin2Key != null) {
69+
stash.pin2Key = pin2Key.pin2Key
70+
}
71+
if (recovery2Key != null) {
72+
stash.recovery2Key = recovery2Key.recovery2Key
73+
}
74+
75+
out.push(stash)
76+
}
77+
78+
return out
79+
}
80+
81+
/**
82+
* A string of base58-encoded binary data.
83+
*/
84+
const asBase58: Cleaner<Uint8Array> = asCodec(
85+
raw => base58.parse(asString(raw)),
86+
clean => base58.stringify(clean)
87+
)
88+
89+
const carePackageFile = makeJsonFile(
90+
asObject({
91+
SNRP2: asEdgeSnrp, // passwordKeySnrp
92+
SNRP3: asOptional(asEdgeSnrp), // recoveryKeySnrp
93+
SNRP4: asOptional(asEdgeSnrp), // questionKeySnrp
94+
ERQ: asOptional(asEdgeBox) // questionBox
95+
})
96+
)
97+
98+
const loginPackageFile = makeJsonFile(
99+
asObject({
100+
EMK_LP2: asEdgeBox, // passwordBox
101+
EMK_LRA3: asOptional(asEdgeBox), // recoveryBox
102+
103+
ESyncKey: asEdgeBox, // syncKeyBox
104+
ELP1: asEdgeBox // passwordAuthBox
105+
})
106+
)
107+
108+
const otpFile = makeJsonFile(asObject({ TOTP: asBase32 }))
109+
const pin2KeyFile = makeJsonFile(asObject({ pin2Key: asBase58 }))
110+
const recovery2KeyFile = makeJsonFile(asObject({ recovery2Key: asBase58 }))
111+
const usernameFile = makeJsonFile(asObject({ userName: asString }))

src/core/root.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Dispatch } from './actions'
99
import { CLIENT_FILE_NAME, clientFile } from './context/client-file'
1010
import { INFO_CACHE_FILE_NAME, infoCacheFile } from './context/info-cache-file'
1111
import { filterLogs, LogBackend, makeLog } from './log/log'
12+
import { loadAirbitzStashes } from './login/airbitz-stashes'
1213
import { loadStashes } from './login/login-stash'
1314
import { PluginIos, watchPlugins } from './plugins/plugins-actions'
1415
import { RootOutput, rootPixie, RootProps } from './root-pixie'
@@ -33,15 +34,16 @@ export async function makeContext(
3334
): Promise<EdgeContext> {
3435
const { io } = ios
3536
const {
37+
airbitzSupport = false,
3638
apiKey,
3739
appId = '',
3840
authServer = 'https://login.edge.app/api',
39-
infoServer,
40-
syncServer,
4141
deviceDescription = null,
4242
hideKeys = false,
43+
infoServer,
4344
plugins: pluginsInit = {},
44-
skipBlockHeight = false
45+
skipBlockHeight = false,
46+
syncServer
4547
} = opts
4648
const infoServers =
4749
typeof infoServer === 'string'
@@ -82,19 +84,35 @@ export async function makeContext(
8284
})
8385
const log = makeLog(logBackend, 'edge-core')
8486

87+
// Load the login stashes from disk:
8588
let [clientInfo, infoCache = {}, stashes] = await Promise.all([
8689
clientFile.load(io.disklet, CLIENT_FILE_NAME),
8790
infoCacheFile.load(io.disklet, INFO_CACHE_FILE_NAME),
8891
loadStashes(io.disklet, log)
8992
])
9093

94+
// Load legacy stashes from disk
95+
if (airbitzSupport) {
96+
// Edge will write modern files to disk at login time,
97+
// but it won't delete the legacy Airbitz data.
98+
// Once this happens, we need to ignore the legacy files
99+
// and just use the new files:
100+
const avoidUsernames = new Set<string>()
101+
for (const { username } of stashes) {
102+
if (username != null) avoidUsernames.add(username)
103+
}
104+
105+
const airbitzStashes = await loadAirbitzStashes(io, avoidUsernames)
106+
stashes.push(...airbitzStashes)
107+
}
108+
91109
// Save the clientId if we don't have one:
92110
if (clientInfo == null) {
93111
clientInfo = { clientId: io.random(16) }
94112
await clientFile.save(io.disklet, CLIENT_FILE_NAME, clientInfo)
95113
}
96114

97-
// Load the login stashes from disk:
115+
// Write everything to redux:
98116
redux.dispatch({
99117
type: 'INIT',
100118
payload: {

src/react-native.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function MakeEdgeContext(props: EdgeContextProps): JSX.Element {
3636
onLog = defaultOnLog,
3737

3838
// Inner context options:
39+
airbitzSupport = false,
3940
apiKey = '',
4041
appId = '',
4142
authServer,
@@ -63,6 +64,7 @@ export function MakeEdgeContext(props: EdgeContextProps): JSX.Element {
6364
bridgifyLogBackend({ crashReporter, onLog }),
6465
pluginUris,
6566
{
67+
airbitzSupport,
6668
apiKey,
6769
appId,
6870
authServer,

src/types/exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export interface EdgeContextProps extends CommonProps {
7676
onLog?: EdgeOnLog
7777

7878
// EdgeContextOptions:
79+
airbitzSupport?: boolean
7980
apiKey?: string
8081
appId?: string
8182
authServer?: string

src/types/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,6 +1683,9 @@ export interface EdgeContextOptions {
16831683
path?: string // Only used on node.js
16841684
plugins?: EdgeCorePluginsInit
16851685

1686+
/** True to load Airbitz user files from disk */
1687+
airbitzSupport?: boolean
1688+
16861689
/**
16871690
* True to skip updating the `EdgeCurrencyWallet.blockHeight` property.
16881691
* This may improve performance by reducing bridge traffic,
@@ -1857,6 +1860,7 @@ export interface EdgeFakeWorldOptions {
18571860

18581861
export interface EdgeFakeContextOptions {
18591862
// EdgeContextOptions:
1863+
airbitzSupport?: boolean
18601864
apiKey: string
18611865
appId: string
18621866
deviceDescription?: string
@@ -1870,6 +1874,9 @@ export interface EdgeFakeContextOptions {
18701874

18711875
// Fake device options:
18721876
cleanDevice?: boolean
1877+
1878+
/** Extra files to be saved on the fake device. */
1879+
extraFiles?: { [path: string]: string }
18731880
}
18741881

18751882
/**

test/core/login/airbitz.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect } from 'chai'
2+
import { describe, it } from 'mocha'
3+
4+
import { makeFakeEdgeWorld } from '../../../src/index'
5+
import { airbitzFiles, fakeUser } from '../../fake/fake-user'
6+
7+
const quiet = { onLog() {} }
8+
9+
describe('airbitz stashes', function () {
10+
it('can log into legacy airbitz files', async function () {
11+
const world = await makeFakeEdgeWorld([fakeUser], quiet)
12+
const context = await world.makeEdgeContext({
13+
airbitzSupport: true,
14+
apiKey: '',
15+
appId: '',
16+
cleanDevice: true,
17+
extraFiles: airbitzFiles
18+
})
19+
20+
expect(context.localUsers).deep.equals([
21+
{
22+
keyLoginEnabled: true,
23+
lastLogin: undefined,
24+
loginId: 'BTnpEn7pabDXbcv7VxnKBDsn4CVSwLRA25J8U84qmg4h',
25+
pinLoginEnabled: true,
26+
recovery2Key: 'NVADGXzb5Zc55PYXVVT7GRcXPnY9NZJUjiZK8aQnidc',
27+
username: 'js test 0',
28+
voucherId: undefined
29+
}
30+
])
31+
32+
await context.loginWithPIN(fakeUser.username, fakeUser.pin)
33+
})
34+
})

test/fake/fake-user.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,61 @@ export const fakeUserDump: EdgeFakeUser = {
221221
}
222222

223223
export const fakeUser = { ...fakeUserDump, ...info }
224+
225+
/**
226+
* This data comes from the old C++ abc-cli tool.
227+
* It was created by uploading the "js test 0" user above into a
228+
* testing login server, and then running `abc-cli sign-in`
229+
* with the matching username and password.
230+
* These are the exact files abc-cli wrote to disk
231+
* once the sign-in was complete.
232+
*/
233+
export const airbitzFiles = {
234+
'Accounts/Account0/CarePackage.json': JSON.stringify({
235+
SNRP2: {
236+
n: 16384,
237+
p: 1,
238+
r: 2,
239+
salt_hex:
240+
'ed6396d127b60d6ffc469634b9a53bdcfb4ee381e9b9df5e66a0f97895871981'
241+
}
242+
}),
243+
'Accounts/Account0/LoginPackage.json': JSON.stringify({
244+
ELP1: {
245+
data_base64:
246+
'ZHhQtHA48aPf083XbEeNMAzbu4KE5dNLU6q0WzTUwJkxGG72elIha9wMjpAvwxmJ2PC3ZCMya1eiVgHPqTO+zS8dWHmuqzbpNY+IdoAtjF//dZ6O4mCcMR8enmj5xYaVBIIQ8WCcang+2RTqDzOoI+W8p6mM9N528ypy0lkpYi9lpGrxAAAJjhk+9xdBRcL4O5jkCZ0VQEvoRCqlU2y99YtRYtB/+nYj51PTtU00MUpKq7PggNZI5EDmZC9vK/BRnBArLbnwj7L88vuKEXBumYX0GA9ZhTPXMuRfABzvCxPkTKLGG2KmfQAtSAehCDMtkgQzocXSCiUuzqBdId56WkNFYC+Phq6vgflPK2qcxkV6Kz2qu8Yr1nBveyLsUTGOZgoBlya2UEZrQ4B96mUv5Q==',
247+
encryptionType: 0,
248+
iv_hex: 'c801b7e3265734544c08c68bdff86979'
249+
},
250+
EMK_LP2: {
251+
data_base64:
252+
'sXBdJaaeVNWOuBWdRVvULaS+VqPkTF1eLR0BMSi2a4F+DCc+4JbMqgBPK3uyp7MHd3qpOOt7Fcth5gnT5hspzh47ONsTTaQNglwZ4lY25OKGsK7ldWrcohiDEgswgG8whGM3tqio6iIMndkuZn3Dn9aj0SwWNdCuW1xFYvbMa7pCgWr0QT+zjWJAPnlT0U1hqJNjGDqFK6jYorClWKsbBZtVJ/dCRMv5+xu05S7fCdgQnz1m5O5nMHTcw6NFR0eBApOOh3KbghOeh0QcBAa5jNm4L61BK5wMCgPydh2/u+MSu34ERsomA5kwp86N35EKHGJH3p0Jq/jf9ToR9wU/MlPivmHvbbspxIzay0feJcanodfyFqLLnsfknSptgiaX3ppat83xrdndQH+JNYweNTgoZmd5pt/8hu/LGk1iAs8Z6e61FaYXm+UI/yxUQFy3A8meST1UfVAxeFw3IRCRZRplll8fgALH67kO15s4bts=',
253+
encryptionType: 0,
254+
iv_hex: '0989bebe4103816be3db48a2ed3ff338'
255+
},
256+
ESyncKey: {
257+
data_base64:
258+
'ruf9v3eWUg2GZJf+94boCPyui4nQ9HnJCWx07kmRg+nKns+1MlqSFQQNINgHXLWDrQvho69AnQrP9Ep+PcXOnG+m0kiqlmzm8UdhQoQJOKP/O5S2TFwMuLpLrGN+I4F5HbdGVMA20WJjhfQ7Kzc3H2hHwm1BUo0xItbV/audT6KySR+ugeW+jF5glzB0/8eAYFKloYd0YC6TZg1gmZU7jqBatpylk7a9znrZG6zKVyPQxnkKr6TnF3xQihSw2H7g1Gd4AI4Pttye/RYsVbwqFFnD2OZwgp7kqeyLwVRsU6OboRtkBYuaa4adYhXHda94',
259+
encryptionType: 0,
260+
iv_hex: '59309614b12c169af977681e01d6ad8b'
261+
}
262+
}),
263+
'Accounts/Account0/OtpKey.json': JSON.stringify({
264+
TOTP: 'HELLO==='
265+
}),
266+
'Accounts/Account0/Pin2Key.json': JSON.stringify({
267+
pin2Key: '22b6wM3F6bd3LpT1UHLhb7pDr5BxzAuVNhuU5HGudZHq'
268+
}),
269+
'Accounts/Account0/Recovery2Key.json': JSON.stringify({
270+
recovery2Key: 'NVADGXzb5Zc55PYXVVT7GRcXPnY9NZJUjiZK8aQnidc'
271+
}),
272+
'Accounts/Account0/RootKey.json': JSON.stringify({
273+
data_base64:
274+
'pR+yQsnkynA03Xqa8AYHzRzunxsBoFM39huz09DL+20RZxAAid4iWkkBNei+Z6Mp0sdhDNfilPQmU5rOuABo70NIO+E3GNZ66RmG6SkN0Jo0Fgp28Qfyg/aD6BlMNw++oXS8yGuDvPotDpM/rgYd6l7/OuLLfg5cZw85Qe1D9UM9dqP8EVpKPQTqSsAnTE0RsHG3HFVIFVRQAsIqqsynAC+h8QiKdAFaqzdFVbB75iu4KV27wdjfRnZrTVPqGA9fnC96vhRRUNRmQnWbJRvdyhIXRHYXJbu/ip1eFts054yfjhyxHffOXfcSpm3xwL0itf3Y4rEUG0dEQO5IwfpuRxspFFn3S/Fi4wGkw+PJNNtF3r5djryeYOFE854n3YOkBayhyhnNAJuaaHeOnrP7QaD3V4hDuFezHqTWCU8lA7W0u7SmFZ1IXXXxvjITvglkmTrnx8CbWkmjRXqIbMl8tg==',
275+
encryptionType: 0,
276+
iv_hex: '96cc1ebc2d11a0b38c9259c056d3ca23'
277+
}),
278+
'Accounts/Account0/UserName.json': JSON.stringify({
279+
userName: 'js test 0'
280+
})
281+
}

0 commit comments

Comments
 (0)