/* This file is part of GNU Taler (C) 2020 Taler Systems SA GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /** * Implementation of wallet backups (export/import/upload) and sync * server management. * * @author Florian Dold */ /** * Imports. */ import { AbsoluteTime, AttentionType, BackupRecovery, Codec, Duration, EddsaKeyPair, HttpStatusCode, Logger, PreparePayResult, ProviderInfo, ProviderPaymentStatus, RecoveryLoadRequest, RecoveryMergeStrategy, TalerError, TalerErrorCode, TalerPreciseTimestamp, URL, buildCodecForObject, buildCodecForUnion, bytesToString, canonicalJson, checkDbInvariant, checkLogicInvariant, codecForBoolean, codecForConstString, codecForList, codecForString, codecForSyncTermsOfServiceResponse, codecOptional, decodeCrock, eddsaGetPublic, encodeCrock, getRandomBytes, hash, j2s, kdf, notEmpty, secretbox, secretbox_open, stringToBytes, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; import { gunzipSync, gzipSync } from "fflate"; import { addAttentionRequest, removeAttentionRequest } from "../attention.js"; import { TaskIdentifiers, TaskRunResult, TaskRunResultType, } from "../common.js"; import { BackupProviderRecord, BackupProviderState, BackupProviderStateTag, ConfigRecord, ConfigRecordKey, WalletBackupConfState, WalletDbReadOnlyTransaction, timestampOptionalPreciseFromDb, timestampPreciseToDb, } from "../db.js"; import { preparePayForUri } from "../pay-merchant.js"; import { InternalWalletState, WalletExecutionContext } from "../wallet.js"; const logger = new Logger("operations/backup.ts"); function concatArrays(xs: Uint8Array[]): Uint8Array { let len = 0; for (const x of xs) { len += x.byteLength; } const out = new Uint8Array(len); let offset = 0; for (const x of xs) { out.set(x, offset); offset += x.length; } return out; } const magic = "TLRWBK01"; /** * Encrypt the backup. * * Blob format: * Magic "TLRWBK01" (8 bytes) * Nonce (24 bytes) * Compressed JSON blob (rest) */ export async function encryptBackup( config: WalletBackupConfState, blob: any, ): Promise { const chunks: Uint8Array[] = []; chunks.push(stringToBytes(magic)); const nonceStr = config.lastBackupNonce; checkLogicInvariant(!!nonceStr); const nonce = decodeCrock(nonceStr).slice(0, 24); chunks.push(nonce); const backupJsonContent = canonicalJson(blob); logger.trace("backup JSON size", backupJsonContent.length); const compressedContent = gzipSync(stringToBytes(backupJsonContent), { mtime: 0, }); const secret = deriveBlobSecret(config); const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret); chunks.push(encrypted); return concatArrays(chunks); } function deriveAccountKeyPair( bc: WalletBackupConfState, providerUrl: string, ): EddsaKeyPair { const privateKey = kdf( 32, decodeCrock(bc.walletRootPriv), stringToBytes("taler-sync-account-key-salt"), stringToBytes(providerUrl), ); return { eddsaPriv: privateKey, eddsaPub: eddsaGetPublic(privateKey), }; } function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array { return kdf( 32, decodeCrock(bc.walletRootPriv), stringToBytes("taler-sync-blob-secret-salt"), stringToBytes("taler-sync-blob-secret-info"), ); } interface BackupForProviderArgs { backupProviderBaseUrl: string; } function getNextBackupTimestamp(): TalerPreciseTimestamp { // FIXME: Randomize! return AbsoluteTime.toPreciseTimestamp( AbsoluteTime.addDuration( AbsoluteTime.now(), Duration.fromSpec({ minutes: 5 }), ), ); } async function runBackupCycleForProvider( wex: WalletExecutionContext, args: BackupForProviderArgs, ): Promise { const provider = await wex.db.runReadOnlyTx( { storeNames: ["backupProviders"] }, async (tx) => { return tx.backupProviders.get(args.backupProviderBaseUrl); }, ); if (!provider) { logger.warn("provider disappeared"); return TaskRunResult.finished(); } //const backupJson = await exportBackup(ws); // FIXME: re-implement backup const backupJson = {}; const backupConfig = await provideBackupState(wex); const encBackup = await encryptBackup(backupConfig, backupJson); const currentBackupHash = hash(encBackup); const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl); const newHash = encodeCrock(currentBackupHash); const oldHash = provider.lastBackupHash; logger.trace(`trying to upload backup to ${provider.baseUrl}`); logger.trace(`old hash ${oldHash}, new hash ${newHash}`); const syncSigResp = await wex.cryptoApi.makeSyncSignature({ newHash: encodeCrock(currentBackupHash), oldHash: provider.lastBackupHash, accountPriv: encodeCrock(accountKeyPair.eddsaPriv), }); logger.trace(`sync signature is ${syncSigResp}`); const accountBackupUrl = new URL( `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`, provider.baseUrl, ); if (provider.shouldRetryFreshProposal) { accountBackupUrl.searchParams.set("fresh", "yes"); } const resp = await wex.http.fetch(accountBackupUrl.href, { method: "POST", body: encBackup, headers: { "content-type": "application/octet-stream", "sync-signature": syncSigResp.sig, "if-none-match": JSON.stringify(newHash), ...(provider.lastBackupHash ? { "if-match": JSON.stringify(provider.lastBackupHash), } : {}), }, }); logger.trace(`sync response status: ${resp.status}`); if (resp.status === HttpStatusCode.NotModified) { await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { return; } prov.lastBackupCycleTimestamp = timestampPreciseToDb( TalerPreciseTimestamp.now(), ); prov.state = { tag: BackupProviderStateTag.Ready, nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()), }; await tx.backupProviders.put(prov); }, ); removeAttentionRequest(wex, { entityId: provider.baseUrl, type: AttentionType.BackupUnpaid, }); return TaskRunResult.finished(); } if (resp.status === HttpStatusCode.PaymentRequired) { logger.trace("payment required for backup"); logger.trace(`headers: ${j2s(resp.headers)}`); const talerUri = resp.headers.get("taler"); if (!talerUri) { throw Error("no taler URI available to pay provider"); } //We can't delay downloading the proposal since we need the id //FIXME: check download errors let res: PreparePayResult | undefined = undefined; try { res = await preparePayForUri(wex, talerUri); } catch (e) { const error = TalerError.fromException(e); if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) { throw error; } } if (res === undefined) { //claimed await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { logger.warn("backup provider not found anymore"); return; } prov.shouldRetryFreshProposal = true; prov.state = { tag: BackupProviderStateTag.Retrying, }; await tx.backupProviders.put(prov); }, ); throw Error("not implemented"); // return { // type: TaskRunResultType.Pending, // }; } const result = res; await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { logger.warn("backup provider not found anymore"); return; } // const opId = TaskIdentifiers.forBackup(prov); // await scheduleRetryInTx(ws, tx, opId); prov.currentPaymentProposalId = result.proposalId; prov.shouldRetryFreshProposal = false; prov.state = { tag: BackupProviderStateTag.Retrying, }; await tx.backupProviders.put(prov); }, ); addAttentionRequest( wex, { type: AttentionType.BackupUnpaid, provider_base_url: provider.baseUrl, talerUri, }, provider.baseUrl, ); throw Error("not implemented"); // return { // type: TaskRunResultType.Pending, // }; } if (resp.status === HttpStatusCode.NoContent) { await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { return; } prov.lastBackupHash = encodeCrock(currentBackupHash); prov.lastBackupCycleTimestamp = timestampPreciseToDb( TalerPreciseTimestamp.now(), ); prov.state = { tag: BackupProviderStateTag.Ready, nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()), }; await tx.backupProviders.put(prov); }, ); removeAttentionRequest(wex, { entityId: provider.baseUrl, type: AttentionType.BackupUnpaid, }); return { type: TaskRunResultType.Finished, }; } if (resp.status === HttpStatusCode.Conflict) { logger.info("conflicting backup found"); const backupEnc = new Uint8Array(await resp.bytes()); const backupConfig = await provideBackupState(wex); // const blob = await decryptBackup(backupConfig, backupEnc); // FIXME: Re-implement backup import with merging // await importBackup(ws, blob, cryptoData); await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { logger.warn("backup provider not found anymore"); return; } prov.lastBackupHash = encodeCrock(hash(backupEnc)); // FIXME: Allocate error code for this situation? // FIXME: Add operation retry record! const opId = TaskIdentifiers.forBackup(prov); //await scheduleRetryInTx(ws, tx, opId); prov.state = { tag: BackupProviderStateTag.Retrying, }; await tx.backupProviders.put(prov); }, ); logger.info("processed existing backup"); // Now upload our own, merged backup. return await runBackupCycleForProvider(wex, args); } // Some other response that we did not expect! logger.error("parsing error response"); const err = await readTalerErrorResponse(resp); logger.error(`got error response from backup provider: ${j2s(err)}`); return { type: TaskRunResultType.Error, errorDetail: err, }; } export async function processBackupForProvider( wex: WalletExecutionContext, backupProviderBaseUrl: string, ): Promise { const provider = await wex.db.runReadOnlyTx( { storeNames: ["backupProviders"] }, async (tx) => { return await tx.backupProviders.get(backupProviderBaseUrl); }, ); if (!provider) { throw Error("unknown backup provider"); } logger.info(`running backup for provider ${backupProviderBaseUrl}`); return await runBackupCycleForProvider(wex, { backupProviderBaseUrl: provider.baseUrl, }); } export interface RemoveBackupProviderRequest { provider: string; } export const codecForRemoveBackupProvider = (): Codec => buildCodecForObject() .property("provider", codecForString()) .build("RemoveBackupProviderRequest"); export async function removeBackupProvider( wex: WalletExecutionContext, req: RemoveBackupProviderRequest, ): Promise { await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { await tx.backupProviders.delete(req.provider); }, ); } export interface RunBackupCycleRequest { /** * List of providers to backup or empty for all known providers. */ providers?: Array; } export const codecForRunBackupCycle = (): Codec => buildCodecForObject() .property("providers", codecOptional(codecForList(codecForString()))) .build("RunBackupCycleRequest"); /** * Do one backup cycle that consists of: * 1. Exporting a backup and try to upload it. * Stop if this step succeeds. * 2. Download, verify and import backups from connected sync accounts. * 3. Upload the updated backup blob. */ export async function runBackupCycle( wex: WalletExecutionContext, req: RunBackupCycleRequest, ): Promise { const providers = await wex.db.runReadOnlyTx( { storeNames: ["backupProviders"] }, async (tx) => { if (req.providers) { const rs = await Promise.all( req.providers.map((id) => tx.backupProviders.get(id)), ); return rs.filter(notEmpty); } return await tx.backupProviders.iter().toArray(); }, ); for (const provider of providers) { await runBackupCycleForProvider(wex, { backupProviderBaseUrl: provider.baseUrl, }); } } export interface AddBackupProviderRequest { backupProviderBaseUrl: string; name: string; /** * Activate the provider. Should only be done after * the user has reviewed the provider. */ activate?: boolean; } export const codecForAddBackupProviderRequest = (): Codec => buildCodecForObject() .property("backupProviderBaseUrl", codecForString()) .property("name", codecForString()) .property("activate", codecOptional(codecForBoolean())) .build("AddBackupProviderRequest"); export type AddBackupProviderResponse = | AddBackupProviderOk | AddBackupProviderPaymentRequired; interface AddBackupProviderOk { status: "ok"; } interface AddBackupProviderPaymentRequired { status: "payment-required"; talerUri?: string; } export const codecForAddBackupProviderOk = (): Codec => buildCodecForObject() .property("status", codecForConstString("ok")) .build("AddBackupProviderOk"); export const codecForAddBackupProviderPaymenrRequired = (): Codec => buildCodecForObject() .property("status", codecForConstString("payment-required")) .property("talerUri", codecOptional(codecForString())) .build("AddBackupProviderPaymentRequired"); export const codecForAddBackupProviderResponse = (): Codec => buildCodecForUnion() .discriminateOn("status") .alternative("ok", codecForAddBackupProviderOk()) .alternative( "payment-required", codecForAddBackupProviderPaymenrRequired(), ) .build("AddBackupProviderResponse"); export async function addBackupProvider( wex: WalletExecutionContext, req: AddBackupProviderRequest, ): Promise { logger.info(`adding backup provider ${j2s(req)}`); await provideBackupState(wex); const canonUrl = req.backupProviderBaseUrl; await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { const oldProv = await tx.backupProviders.get(canonUrl); if (oldProv) { logger.info("old backup provider found"); if (req.activate) { oldProv.state = { tag: BackupProviderStateTag.Ready, nextBackupTimestamp: timestampPreciseToDb( TalerPreciseTimestamp.now(), ), }; logger.info("setting existing backup provider to active"); await tx.backupProviders.put(oldProv); } return; } }, ); const termsUrl = new URL("config", canonUrl); const resp = await wex.http.fetch(termsUrl.href); const terms = await readSuccessResponseJsonOrThrow( resp, codecForSyncTermsOfServiceResponse(), ); await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { let state: BackupProviderState; //FIXME: what is the difference provisional and ready? if (req.activate) { state = { tag: BackupProviderStateTag.Ready, nextBackupTimestamp: timestampPreciseToDb( TalerPreciseTimestamp.now(), ), }; } else { state = { tag: BackupProviderStateTag.Provisional, }; } await tx.backupProviders.put({ state, name: req.name, terms: { annualFee: terms.annual_fee, storageLimitInMegabytes: terms.storage_limit_in_megabytes, supportedProtocolVersion: terms.version, }, shouldRetryFreshProposal: false, paymentProposalIds: [], baseUrl: canonUrl, uids: [encodeCrock(getRandomBytes(32))], }); }, ); return await runFirstBackupCycleForProvider(wex, { backupProviderBaseUrl: canonUrl, }); } async function runFirstBackupCycleForProvider( wex: WalletExecutionContext, args: BackupForProviderArgs, ): Promise { throw Error("not implemented"); // const resp = await runBackupCycleForProvider(ws, args); // switch (resp.type) { // case TaskRunResultType.Error: // throw TalerError.fromDetail( // TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, // resp.errorDetail as any, //FIXME create an error for backup problems // ); // case TaskRunResultType.Finished: // return { // status: "ok", // }; // case TaskRunResultType.Pending: // return { // status: "payment-required", // talerUri: "FIXME", // //talerUri: resp.result.talerUri, // }; // default: // assertUnreachable(resp); // } } export async function restoreFromRecoverySecret(): Promise { return; } export interface BackupInfo { walletRootPub: string; deviceId: string; providers: ProviderInfo[]; } async function getProviderPaymentInfo( wex: WalletExecutionContext, provider: BackupProviderRecord, ): Promise { throw Error("not implemented"); // if (!provider.currentPaymentProposalId) { // return { // type: ProviderPaymentType.Unpaid, // }; // } // const status = await checkPaymentByProposalId( // ws, // provider.currentPaymentProposalId, // ).catch(() => undefined); // if (!status) { // return { // type: ProviderPaymentType.Unpaid, // }; // } // switch (status.status) { // case PreparePayResultType.InsufficientBalance: // return { // type: ProviderPaymentType.InsufficientBalance, // amount: status.amountRaw, // }; // case PreparePayResultType.PaymentPossible: // return { // type: ProviderPaymentType.Pending, // talerUri: status.talerUri, // }; // case PreparePayResultType.AlreadyConfirmed: // if (status.paid) { // return { // type: ProviderPaymentType.Paid, // paidUntil: AbsoluteTime.addDuration( // AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp), // durationFromSpec({ years: 1 }), //FIXME: take this from the contract term // ), // }; // } else { // return { // type: ProviderPaymentType.Pending, // talerUri: status.talerUri, // }; // } // default: // assertUnreachable(status); // } } /** * Get information about the current state of wallet backups. */ export async function getBackupInfo( wex: WalletExecutionContext, ): Promise { const backupConfig = await provideBackupState(wex); const providerRecords = await wex.db.runReadOnlyTx( { storeNames: ["backupProviders", "operationRetries"] }, async (tx) => { return await tx.backupProviders.iter().mapAsync(async (bp) => { const opId = TaskIdentifiers.forBackup(bp); const retryRecord = await tx.operationRetries.get(opId); return { provider: bp, retryRecord, }; }); }, ); const providers: ProviderInfo[] = []; for (const x of providerRecords) { providers.push({ active: x.provider.state.tag !== BackupProviderStateTag.Provisional, syncProviderBaseUrl: x.provider.baseUrl, lastSuccessfulBackupTimestamp: timestampOptionalPreciseFromDb( x.provider.lastBackupCycleTimestamp, ), paymentProposalIds: x.provider.paymentProposalIds, lastError: x.provider.state.tag === BackupProviderStateTag.Retrying ? x.retryRecord?.lastError : undefined, paymentStatus: await getProviderPaymentInfo(wex, x.provider), terms: x.provider.terms, name: x.provider.name, }); } return { deviceId: backupConfig.deviceId, walletRootPub: backupConfig.walletRootPub, providers, }; } /** * Get backup recovery information, including the wallet's * private key. */ export async function getBackupRecovery( wex: WalletExecutionContext, ): Promise { const bs = await provideBackupState(wex); const providers = await wex.db.runReadOnlyTx( { storeNames: ["backupProviders"] }, async (tx) => { return await tx.backupProviders.iter().toArray(); }, ); return { providers: providers .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional) .map((x) => { return { name: x.name, url: x.baseUrl, }; }), walletRootPriv: bs.walletRootPriv, }; } async function backupRecoveryTheirs( wex: WalletExecutionContext, br: BackupRecovery, ) { await wex.db.runReadWriteTx( { storeNames: ["backupProviders", "config"] }, async (tx) => { let backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); checkDbInvariant(!!backupStateEntry); checkDbInvariant( backupStateEntry.key === ConfigRecordKey.WalletBackupState, ); backupStateEntry.value.lastBackupNonce = undefined; backupStateEntry.value.lastBackupTimestamp = undefined; backupStateEntry.value.lastBackupCheckTimestamp = undefined; backupStateEntry.value.lastBackupPlainHash = undefined; backupStateEntry.value.walletRootPriv = br.walletRootPriv; backupStateEntry.value.walletRootPub = encodeCrock( eddsaGetPublic(decodeCrock(br.walletRootPriv)), ); await tx.config.put(backupStateEntry); for (const prov of br.providers) { const existingProv = await tx.backupProviders.get(prov.url); if (!existingProv) { await tx.backupProviders.put({ baseUrl: prov.url, name: prov.name, paymentProposalIds: [], shouldRetryFreshProposal: false, state: { tag: BackupProviderStateTag.Ready, nextBackupTimestamp: timestampPreciseToDb( TalerPreciseTimestamp.now(), ), }, uids: [encodeCrock(getRandomBytes(32))], }); } } const providers = await tx.backupProviders.iter().toArray(); for (const prov of providers) { prov.lastBackupCycleTimestamp = undefined; prov.lastBackupHash = undefined; await tx.backupProviders.put(prov); } }, ); } async function backupRecoveryOurs( wex: WalletExecutionContext, br: BackupRecovery, ) { throw Error("not implemented"); } export async function loadBackupRecovery( wex: WalletExecutionContext, br: RecoveryLoadRequest, ): Promise { const bs = await provideBackupState(wex); const providers = await wex.db.runReadOnlyTx( { storeNames: ["backupProviders"] }, async (tx) => { return await tx.backupProviders.iter().toArray(); }, ); let strategy = br.strategy; if ( br.recovery.walletRootPriv != bs.walletRootPriv && providers.length > 0 && !strategy ) { throw Error( "recovery load strategy must be specified for wallet with existing providers", ); } else if (!strategy) { // Default to using the new key if we don't have providers yet. strategy = RecoveryMergeStrategy.Theirs; } if (strategy === RecoveryMergeStrategy.Theirs) { return backupRecoveryTheirs(wex, br.recovery); } else { return backupRecoveryOurs(wex, br.recovery); } } export async function decryptBackup( backupConfig: WalletBackupConfState, data: Uint8Array, ): Promise { const rMagic = bytesToString(data.slice(0, 8)); if (rMagic != magic) { throw Error("invalid backup file (magic tag mismatch)"); } const nonce = data.slice(8, 8 + 24); const box = data.slice(8 + 24); const secret = deriveBlobSecret(backupConfig); const dataCompressed = secretbox_open(box, nonce, secret); if (!dataCompressed) { throw Error("decryption failed"); } return JSON.parse(bytesToString(gunzipSync(dataCompressed))); } export async function provideBackupState( wex: WalletExecutionContext, ): Promise { const bs: ConfigRecord | undefined = await wex.db.runReadOnlyTx( { storeNames: ["config"] }, async (tx) => { return await tx.config.get(ConfigRecordKey.WalletBackupState); }, ); if (bs) { checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); return bs.value; } // We need to generate the key outside of the transaction // due to how IndexedDB works. const k = await wex.cryptoApi.createEddsaKeypair({}); const d = getRandomBytes(5); // FIXME: device ID should be configured when wallet is initialized // and be based on hostname const deviceId = `wallet-core-${encodeCrock(d)}`; return await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { let backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); if (!backupStateEntry) { backupStateEntry = { key: ConfigRecordKey.WalletBackupState, value: { deviceId, walletRootPub: k.pub, walletRootPriv: k.priv, lastBackupPlainHash: undefined, }, }; await tx.config.put(backupStateEntry); } checkDbInvariant( backupStateEntry.key === ConfigRecordKey.WalletBackupState, ); return backupStateEntry.value; }); } export async function getWalletBackupState( ws: InternalWalletState, tx: WalletDbReadOnlyTransaction<["config"]>, ): Promise { const bs = await tx.config.get(ConfigRecordKey.WalletBackupState); checkDbInvariant(!!bs, "wallet backup state should be in DB"); checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); return bs.value; } export async function setWalletDeviceId( wex: WalletExecutionContext, deviceId: string, ): Promise { await provideBackupState(wex); await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { let backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); if ( !backupStateEntry || backupStateEntry.key !== ConfigRecordKey.WalletBackupState ) { return; } backupStateEntry.value.deviceId = deviceId; await tx.config.put(backupStateEntry); }); } export async function getWalletDeviceId( wex: WalletExecutionContext, ): Promise { const bs = await provideBackupState(wex); return bs.deviceId; }