diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 7f830bd1a9..8edcfeb513 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -2554,7 +2554,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { const walletPassphrase = buildParams.walletPassphrase; const userKeychain = await this.keychains().get({ id: wallet.keyIds()[0] }); - const userPrv = wallet.getUserPrv({ keychain: userKeychain, walletPassphrase }); + const userPrv = await wallet.getUserPrv({ keychain: userKeychain, walletPassphrase }); const userPrvBuffer = bip32.fromBase58(userPrv).privateKey; if (!userPrvBuffer) { throw new Error('invalid userPrv'); diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index b5d04a47a7..653f7bf59c 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -677,7 +677,7 @@ export abstract class AbstractUtxoCoin /** * @deprecated - use function verifyUserPublicKey instead */ - protected verifyUserPublicKey(params: VerifyUserPublicKeyOptions): boolean { + protected async verifyUserPublicKey(params: VerifyUserPublicKeyOptions): Promise { return verifyUserPublicKey(this.bitgo, params); } diff --git a/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts b/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts index cfb4332773..f9c0e46877 100644 --- a/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts +++ b/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts @@ -302,7 +302,7 @@ export class InscriptionBuilder implements IInscriptionBuilder { txPrebuild: PrebuildTransactionResult ): Promise { const userKeychain = await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[KeyIndices.USER] }); - const prv = this.wallet.getUserPrv({ keychain: userKeychain, walletPassphrase }); + const prv = await this.wallet.getUserPrv({ keychain: userKeychain, walletPassphrase }); const halfSigned = (await this.wallet.signTransaction({ prv, txPrebuild })) as HalfSignedUtxoTransaction; return this.wallet.submitTransaction({ halfSigned }); diff --git a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts index d9dd99a2f1..46f8f01246 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts @@ -79,7 +79,11 @@ export async function verifyTransaction( let userPublicKeyVerified = false; try { // verify the user public key matches the private key - this will throw if there is no match - userPublicKeyVerified = verifyUserPublicKey(bitgo, { userKeychain: keychains.user, disableNetworking, txParams }); + userPublicKeyVerified = await verifyUserPublicKey(bitgo, { + userKeychain: keychains.user, + disableNetworking, + txParams, + }); } catch (e) { debug('failed to verify user public key!', e); } diff --git a/modules/abstract-utxo/src/verifyKey.ts b/modules/abstract-utxo/src/verifyKey.ts index 15dbbc1391..a33ca6f2aa 100644 --- a/modules/abstract-utxo/src/verifyKey.ts +++ b/modules/abstract-utxo/src/verifyKey.ts @@ -84,7 +84,7 @@ export function verifyCustomChangeKeySignatures /** * Decrypt the wallet's user private key and verify that the claimed public key matches */ -export function verifyUserPublicKey(bitgo: BitGoBase, params: VerifyUserPublicKeyOptions): boolean { +export async function verifyUserPublicKey(bitgo: BitGoBase, params: VerifyUserPublicKeyOptions): Promise { const { userKeychain, txParams, disableNetworking } = params; if (!userKeychain) { throw new Error('user keychain is required'); @@ -94,7 +94,7 @@ export function verifyUserPublicKey(bitgo: BitGoBase, params: VerifyUserPublicKe let userPrv = userKeychain.prv; if (!userPrv && txParams.walletPassphrase) { - userPrv = decryptKeychainPrivateKey(bitgo, userKeychain, txParams.walletPassphrase); + userPrv = await decryptKeychainPrivateKey(bitgo, userKeychain, txParams.walletPassphrase); } if (!userPrv) { diff --git a/modules/bitgo/test/unit/decryptKeychain.ts b/modules/bitgo/test/unit/decryptKeychain.ts index 976ae2b632..fed109039c 100644 --- a/modules/bitgo/test/unit/decryptKeychain.ts +++ b/modules/bitgo/test/unit/decryptKeychain.ts @@ -1,5 +1,9 @@ import 'should'; -import { decryptKeychainPrivateKey, OptionalKeychainEncryptedKey } from '@bitgo/sdk-core'; +import { + decryptKeychainPrivateKey, + decryptKeychainPrivateKeyAsync, + OptionalKeychainEncryptedKey, +} from '@bitgo/sdk-core'; import { BitGoAPI } from '@bitgo/sdk-api'; describe('decryptKeychainPrivateKey', () => { @@ -78,3 +82,53 @@ describe('decryptKeychainPrivateKey', () => { (decryptKeychainPrivateKey(bitgo, {}, 'password') === undefined).should.be.true(); }); }); + +describe('decryptKeychainPrivateKeyAsync', () => { + const bitgo = new BitGoAPI(); + + const prv1 = Math.random().toString(); + const password1 = Math.random().toString(); + + const prv2 = Math.random().toString(); + const password2 = Math.random().toString(); + + it('should decrypt encryptedPrv (v1)', async () => { + const keychain: OptionalKeychainEncryptedKey = { + encryptedPrv: bitgo.encrypt({ input: prv1, password: password1 }), + }; + const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, password1); + result!.should.equal(prv1); + }); + + it('should decrypt webauthnDevices encryptedPrv (v1)', async () => { + const keychain: OptionalKeychainEncryptedKey = { + webauthnDevices: [ + { + otpDeviceId: '123', + authenticatorInfo: { + credID: 'credID', + fmt: 'packed', + publicKey: 'some value', + }, + prfSalt: '456', + encryptedPrv: bitgo.encrypt({ input: prv2, password: password2 }), + }, + ], + }; + const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, password2); + result!.should.equal(prv2); + }); + + it('should return undefined if no encryptedPrv can be decrypted', async () => { + const keychain: OptionalKeychainEncryptedKey = { + encryptedPrv: bitgo.encrypt({ input: prv1, password: password1 }), + }; + const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, Math.random().toString()); + (result === undefined).should.equal(true); + }); + + it('should return undefined if no encryptedPrv is present', async () => { + const result = await decryptKeychainPrivateKeyAsync(bitgo, {}, 'password'); + (result === undefined).should.be.true(); + }); +}); diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/createKeychains.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/createKeychains.ts index 21495eba42..dee40f849f 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/createKeychains.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/createKeychains.ts @@ -176,6 +176,55 @@ describe('TSS Ecdsa MPCv2 Utils:', async function () { assert.equal(bitgoKeychain.source, 'bitgo'); }); + it('should generate TSS MPCv2 keys with v2 encryption envelopes', async function () { + const bitgoSession = new DklsDkg.Dkg(3, 2, 2); + + const round1Nock = await nockKeyGenRound1(bitgoSession, 1); + const round2Nock = await nockKeyGenRound2(bitgoSession, 1); + const round3Nock = await nockKeyGenRound3(bitgoSession, 1); + const addKeyNock = await nockAddKeyChain(coinName, 3); + const params = { + passphrase: 'test', + enterprise: enterpriseId, + originalPasscodeEncryptionCode: '123456', + encryptionVersion: 2 as const, + }; + const { userKeychain, backupKeychain, bitgoKeychain } = await tssUtils.createKeychains(params); + assert.ok(round1Nock.isDone()); + assert.ok(round2Nock.isDone()); + assert.ok(round3Nock.isDone()); + assert.ok(addKeyNock.isDone()); + + assert.ok(userKeychain); + assert.equal(userKeychain.source, 'user'); + assert.ok(userKeychain.commonKeychain); + assert.ok(ECDSAUtils.EcdsaMPCv2Utils.validateCommonKeychainPublicKey(userKeychain.commonKeychain)); + + // Verify v2 envelopes for encryptedPrv + assert.ok(userKeychain.encryptedPrv); + const encryptedPrvParsed: { v: number } = JSON.parse(userKeychain.encryptedPrv); + assert.equal(encryptedPrvParsed.v, 2, 'encryptedPrv should be a v2 envelope'); + + // Verify v2 envelopes for reducedEncryptedPrv + assert.ok(userKeychain.reducedEncryptedPrv); + const reducedEncryptedPrvParsed: { v: number } = JSON.parse(userKeychain.reducedEncryptedPrv); + assert.equal(reducedEncryptedPrvParsed.v, 2, 'reducedEncryptedPrv should be a v2 envelope'); + + // Verify v2 envelope is decryptable via decryptAsync + const decrypted = await bitgo.decryptAsync({ input: userKeychain.encryptedPrv, password: params.passphrase }); + assert.ok(decrypted, 'decryptAsync should successfully decrypt v2 envelope'); + + // Verify backup keychain also uses v2 envelopes + assert.ok(backupKeychain); + assert.equal(backupKeychain.source, 'backup'); + assert.ok(backupKeychain.encryptedPrv); + const backupEncryptedPrvParsed: { v: number } = JSON.parse(backupKeychain.encryptedPrv); + assert.equal(backupEncryptedPrvParsed.v, 2, 'backup encryptedPrv should be a v2 envelope'); + + assert.ok(bitgoKeychain); + assert.equal(bitgoKeychain.source, 'bitgo'); + }); + it('should generate TSS MPCv2 keys for retrofit', async function () { const xiList = [ Array.from(bigIntToBufferBE(BigInt(1), 32)), diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts index 9b2a43be4e..1f7bec11bf 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts @@ -263,6 +263,122 @@ describe('signTxRequest:', function () { nockPromises[2].isDone().should.be.true(); }); + describe('v2 encryption (offline rounds with adata)', function () { + it('e2e: 3-round offline signing with v2 encrypted keys preserves adata context binding', async function () { + const walletPassphrase = 'testpassphrase'; + const userShare = fs.readFileSync(shareFiles[vector.party1]); + const userPrvBase64 = Buffer.from(userShare).toString('base64'); + + // Encrypt the prv with v2 to trigger the v2 path + const encryptedPrv = await bitgo.encryptAsync({ + input: userPrvBase64, + password: walletPassphrase, + encryptionVersion: 2, + }); + JSON.parse(encryptedPrv).v.should.equal(2); + + // Round 1: encrypt session + GPG key with v2 + adata (purely local, no server call) + const round1Result = await tssUtils.createOfflineRound1Share({ + txRequest, + prv: userPrvBase64, + walletPassphrase, + encryptedPrv, + }); + + // Verify round 1 output has v2 envelopes with adata + const r1SessionEnvelope = JSON.parse(round1Result.encryptedRound1Session); + r1SessionEnvelope.v.should.equal(2); + r1SessionEnvelope.should.have.property('adata'); + r1SessionEnvelope.should.have.property('hkdfSalt'); + + const r1GpgEnvelope = JSON.parse(round1Result.encryptedUserGpgPrvKey); + r1GpgEnvelope.v.should.equal(2); + r1GpgEnvelope.should.have.property('adata'); + r1SessionEnvelope.adata.should.equal(r1GpgEnvelope.adata); + + // Nock BitGo round 1 response and submit + await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey); + const transactions = getRoute('ecdsa'); + const round1TxRequestResponse = await bitgo + .post(bitgo.url(`/wallet/${txRequest.walletId}/txrequests/${txRequest.txRequestId + transactions}/sign`, 2)) + .send({ + signatureShares: [round1Result.signatureShareRound1], + signerGpgPublicKey: round1Result.userGpgPubKey, + }) + .result(); + + // Merge server response with original txRequest (server only returns signatureShares) + const round1TxReq: TxRequest = { + ...txRequest, + transactions: [ + { + ...txRequest.transactions![0], + signatureShares: round1TxRequestResponse.transactions[0].signatureShares, + }, + ], + }; + + // Round 2: decrypt v2 round 1 session (validates adata), encrypt round 2 session + const round2Result = await tssUtils.createOfflineRound2Share({ + txRequest: round1TxReq, + prv: userPrvBase64, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKey.publicKey, + encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey, + encryptedRound1Session: round1Result.encryptedRound1Session, + }); + + // Verify round 2 output has v2 envelope with adata + const r2Envelope = JSON.parse(round2Result.encryptedRound2Session); + r2Envelope.v.should.equal(2); + r2Envelope.should.have.property('adata'); + r2Envelope.adata.should.equal(r1SessionEnvelope.adata); + + // Nock BitGo round 2 response and submit + await nockTxRequestResponseSignatureShareRoundTwo(bitgoParty, txRequest, bitgoGpgKey); + const round2TxRequestResponse = await bitgo + .post(bitgo.url(`/wallet/${txRequest.walletId}/txrequests/${txRequest.txRequestId + transactions}/sign`, 2)) + .send({ + signatureShares: [round2Result.signatureShareRound2], + signerGpgPublicKey: round1Result.userGpgPubKey, + }) + .result(); + + const round2TxReq: TxRequest = { + ...txRequest, + transactions: [ + { + ...txRequest.transactions![0], + signatureShares: round2TxRequestResponse.transactions[0].signatureShares, + }, + ], + }; + + // Round 3: decrypt v2 round 2 session (validates adata), produce final signature share + const round3Result = await tssUtils.createOfflineRound3Share({ + txRequest: round2TxReq, + prv: userPrvBase64, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKey.publicKey, + encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey, + encryptedRound2Session: round2Result.encryptedRound2Session, + }); + + round3Result.should.have.property('signatureShareRound3'); + }); + + it('validateAdata rejects v2 envelopes with mismatched adata', async function () { + const ct = await bitgo.encryptAsync({ + input: 'test-data', + password: 'testpass', + encryptionVersion: 2, + adata: 'context-A', + }); + + (() => (tssUtils as any).validateAdata('context-B', ct)).should.throw(/Adata does not match/); + }); + }); + it('fails to signs a txRequest for a dkls hot wallet after receiving over 3 429 errors', async function () { const nockPromises = [ await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey), diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts index 6100856062..f75cb90890 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts @@ -547,6 +547,49 @@ describe('TSS Utils:', async function () { }) .should.be.rejectedWith('Failed to create backup keychain - commonKeychains do not match.'); }); + + it('should generate TSS key chains with v2 encryption envelopes', async function () { + const passphrase = 'passphrase'; + const userKeyShare = MPC.keyShare(1, 2, 3); + const backupKeyShare = MPC.keyShare(2, 2, 3); + + await nockBitgoKeychain({ + coin: coinName, + userKeyShare, + backupKeyShare, + bitgoKeyShare, + userGpgKey, + backupGpgKey, + bitgoGpgKey, + }); + await nockUserKeychain({ coin: coinName }); + await nockBackupKeychain({ coin: coinName }); + + const bitgoKeychain = await tssUtils.createBitgoKeychain({ + userGpgKey, + backupGpgKey, + userKeyShare, + backupKeyShare, + }); + const userKeychain = await tssUtils.createUserKeychain({ + userGpgKey, + backupGpgKey, + userKeyShare, + backupKeyShare, + bitgoKeychain, + passphrase, + encryptionVersion: 2, + }); + + should.exist(userKeychain.encryptedPrv); + const envelope = JSON.parse(userKeychain.encryptedPrv!); + envelope.v.should.equal(2); + + const decrypted = await bitgo.decryptAsync({ input: userKeychain.encryptedPrv!, password: passphrase }); + should.exist(decrypted); + const parsed: Record = JSON.parse(decrypted); + should.exist(parsed.uShare); + }); }); describe('signTxRequest:', function () { diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 2e9e9b8e82..ad1bc60e62 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -352,7 +352,7 @@ describe('V2 Wallet:', function () { prv, coldDerivationSeed: '123', }; - wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv); + (await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv); }); it('should use the user keychain derivedFromParentWithSeed as the cold derivation seed if none is provided', async () => { @@ -365,7 +365,7 @@ describe('V2 Wallet:', function () { type: 'independent', }, }; - wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv); + (await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv); }); it('should prefer the explicit cold derivation seed to the user keychain derivedFromParentWithSeed', async () => { @@ -379,7 +379,7 @@ describe('V2 Wallet:', function () { type: 'independent', }, }; - wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv); + (await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv); }); it('should return the prv provided for TSS SMC', async () => { @@ -407,7 +407,7 @@ describe('V2 Wallet:', function () { prv, keychain, }; - wallet.getUserPrv(userPrvOptions).should.eql(prv); + (await wallet.getUserPrv(userPrvOptions)).should.eql(prv); }); }); diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index ae67d2d172..59df11cd92 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -2566,7 +2566,7 @@ describe('V2 Wallets:', function () { const keychainTest: OptionalKeychainEncryptedKey = { encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), }; - const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); if (!userPrv) { throw new Error('Unable to decrypt user keychain'); } @@ -2638,7 +2638,7 @@ describe('V2 Wallets:', function () { const keychainTest: OptionalKeychainEncryptedKey = { encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), }; - const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); if (!userPrv) { throw new Error('Unable to decrypt user keychain'); } @@ -2717,7 +2717,7 @@ describe('V2 Wallets:', function () { const keychainTest: OptionalKeychainEncryptedKey = { encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), }; - const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); if (!userPrv) { throw new Error('Unable to decrypt user keychain'); } @@ -2785,7 +2785,7 @@ describe('V2 Wallets:', function () { const keychainTest: OptionalKeychainEncryptedKey = { encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), }; - const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); if (!userPrv) { throw new Error('Unable to decrypt user keychain'); } @@ -2886,7 +2886,7 @@ describe('V2 Wallets:', function () { const keychainTest: OptionalKeychainEncryptedKey = { encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), }; - const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); if (!userPrv) { throw new Error('Unable to decrypt user keychain'); } @@ -2988,7 +2988,7 @@ describe('V2 Wallets:', function () { const keychainTest: OptionalKeychainEncryptedKey = { encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), }; - const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); if (!userPrv) { throw new Error('Unable to decrypt user keychain'); } diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 19d481893b..34ca3e6b80 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -41,6 +41,8 @@ import { verifyResponseAsync, } from './api'; import { decrypt, decryptAsync, encrypt } from './encrypt'; +import { createEncryptionSession } from './encryptionSession'; +import { encryptV2 } from './encryptV2'; import { verifyAddress } from './v1/verifyAddress'; import { AccessTokenOptions, @@ -715,6 +717,29 @@ export class BitGoAPI implements BitGoBase { return encrypt(params.password, params.input, { adata: params.adata }); } + /** + * Async encrypt that dispatches to v1 (SJCL) or v2 (Argon2id + AES-256-GCM) + * based on `encryptionVersion`. + */ + async encryptAsync(params: EncryptOptions): Promise { + common.validateParams(params, ['input', 'password'], []); + if (!params.password) { + throw new Error('cannot encrypt without password'); + } + if (params.encryptionVersion === 2) { + return encryptV2(params.password, params.input, { adata: params.adata }); + } + return encrypt(params.password, params.input, { adata: params.adata }); + } + + /** + * Create an encryption session for multi-call operations. + * Runs Argon2id once; all subsequent calls derive keys via HKDF. + */ + async createEncryptionSession(password: string) { + return createEncryptionSession(password); + } + /** * Decrypt an encrypted string locally. */ diff --git a/modules/sdk-api/src/encrypt.ts b/modules/sdk-api/src/encrypt.ts index 378714af60..e8d9307e45 100644 --- a/modules/sdk-api/src/encrypt.ts +++ b/modules/sdk-api/src/encrypt.ts @@ -54,16 +54,19 @@ export function decrypt(password: string, ciphertext: string): string { * the breaking release that flips the default to v2. */ export async function decryptAsync(password: string, ciphertext: string): Promise { - let isV2 = false; + let envelopeVersion: number | undefined; try { const envelope = JSON.parse(ciphertext); - isV2 = envelope.v === 2; + envelopeVersion = envelope.v; } catch { throw new Error('decrypt: ciphertext is not valid JSON'); } - if (isV2) { + if (envelopeVersion === 2) { // Do not catch: wrong password on v2 must not silently fall through to v1. return decryptV2(password, ciphertext); } + if (envelopeVersion !== undefined && envelopeVersion !== 1) { + throw new Error(`decrypt: unknown envelope version ${envelopeVersion}`); + } return sjcl.decrypt(password, ciphertext); } diff --git a/modules/sdk-api/src/encryptV2.ts b/modules/sdk-api/src/encryptV2.ts index 03455c9951..66d448939c 100644 --- a/modules/sdk-api/src/encryptV2.ts +++ b/modules/sdk-api/src/encryptV2.ts @@ -49,6 +49,8 @@ const V2EnvelopeCodec = t.intersection([ t.partial({ /** Base64-encoded per-call HKDF salt -- present only in session-produced envelopes */ hkdfSalt: base64String, + /** Additional authenticated data for context binding (e.g. transaction hash + derivation path) */ + adata: t.string, }), ]); @@ -100,17 +102,27 @@ export function hkdfDeriveAesKey(hkdfKey: CryptoKey, hkdfSalt: Uint8Array, usage ); } -export async function aesGcmEncrypt(key: CryptoKey, iv: Uint8Array, plaintext: string): Promise { - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv, tagLength: 128 }, - key, - new TextEncoder().encode(plaintext) - ); +export async function aesGcmEncrypt( + key: CryptoKey, + iv: Uint8Array, + plaintext: string, + additionalData?: Uint8Array +): Promise { + const params: AesGcmParams = { name: 'AES-GCM', iv, tagLength: 128 }; + if (additionalData) params.additionalData = additionalData; + const ct = await crypto.subtle.encrypt(params, key, new TextEncoder().encode(plaintext)); return new Uint8Array(ct); } -export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array, ct: Uint8Array): Promise { - const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, ct); +export async function aesGcmDecrypt( + key: CryptoKey, + iv: Uint8Array, + ct: Uint8Array, + additionalData?: Uint8Array +): Promise { + const params: AesGcmParams = { name: 'AES-GCM', iv, tagLength: 128 }; + if (additionalData) params.additionalData = additionalData; + const plaintext = await crypto.subtle.decrypt(params, key, ct); return new TextDecoder().decode(plaintext); } @@ -138,7 +150,14 @@ export function parseV2Envelope(ciphertext: string): V2Envelope { export async function encryptV2( password: string, plaintext: string, - options?: { salt?: Uint8Array; iv?: Uint8Array; memorySize?: number; iterations?: number; parallelism?: number } + options?: { + salt?: Uint8Array; + iv?: Uint8Array; + memorySize?: number; + iterations?: number; + parallelism?: number; + adata?: string; + } ): Promise { const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize; const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations; @@ -150,10 +169,11 @@ export async function encryptV2( const iv = options?.iv ?? new Uint8Array(randomBytes(GCM_IV_LENGTH)); if (iv.length !== GCM_IV_LENGTH) throw new Error(`iv must be ${GCM_IV_LENGTH} bytes`); + const adataBytes = options?.adata ? new TextEncoder().encode(options.adata) : undefined; const key = await argon2ToAesKey(password, salt, { memorySize, iterations, parallelism }); - const ct = await aesGcmEncrypt(key, iv, plaintext); + const ct = await aesGcmEncrypt(key, iv, plaintext, adataBytes); - return JSON.stringify({ + const envelope: V2Envelope = { v: 2, m: memorySize, t: iterations, @@ -161,7 +181,9 @@ export async function encryptV2( salt: Buffer.from(salt).toString('base64'), iv: Buffer.from(iv).toString('base64'), ct: Buffer.from(ct).toString('base64'), - } satisfies V2Envelope); + }; + if (options?.adata) envelope.adata = options.adata; + return JSON.stringify(envelope); } /** @@ -179,14 +201,15 @@ export async function decryptV2(password: string, ciphertext: string): Promise { + async encrypt(plaintext: string, adata?: string): Promise { const key = this.getKeyOrThrow(); const hkdfSalt = new Uint8Array(randomBytes(HKDF_SALT_LENGTH)); const iv = new Uint8Array(randomBytes(GCM_IV_LENGTH)); + const adataBytes = adata ? new TextEncoder().encode(adata) : undefined; const aesKey = await hkdfDeriveAesKey(key, hkdfSalt, 'encrypt'); - const ct = await aesGcmEncrypt(aesKey, iv, plaintext); - return JSON.stringify(this.buildEnvelope(hkdfSalt, iv, ct)); + const ct = await aesGcmEncrypt(aesKey, iv, plaintext, adataBytes); + const envelope = this.buildEnvelope(hkdfSalt, iv, ct); + if (adata) envelope.adata = adata; + return JSON.stringify(envelope); } async decrypt(ciphertext: string): Promise { @@ -59,8 +62,9 @@ export class EncryptionSession { const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64')); const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64')); const hkdfSalt = new Uint8Array(Buffer.from(envelope.hkdfSalt, 'base64')); + const adataBytes = envelope.adata ? new TextEncoder().encode(envelope.adata) : undefined; const aesKey = await hkdfDeriveAesKey(key, hkdfSalt, 'decrypt'); - return aesGcmDecrypt(aesKey, iv, ct); + return aesGcmDecrypt(aesKey, iv, ct, adataBytes); } destroy(): void { diff --git a/modules/sdk-api/test/unit/encrypt.ts b/modules/sdk-api/test/unit/encrypt.ts index 205ad18f81..2be52c59de 100644 --- a/modules/sdk-api/test/unit/encrypt.ts +++ b/modules/sdk-api/test/unit/encrypt.ts @@ -2,6 +2,7 @@ import assert from 'assert'; import { randomBytes } from 'crypto'; import { decrypt, decryptAsync, decryptV2, encrypt, encryptV2, V2Envelope, createEncryptionSession } from '../../src'; +import { BitGoAPI } from '../../src/bitgoAPI'; describe('encryption methods tests', () => { describe('encrypt', () => { @@ -133,6 +134,22 @@ describe('encryption methods tests', () => { await assert.rejects(() => encryptV2(password, plaintext, { iv: new Uint8Array(8) }), /iv must be 12 bytes/); }); + it('encrypts and decrypts with adata (AAD)', async () => { + const adata = 'txhash:m/0/1'; + const ciphertext = await encryptV2(password, plaintext, { adata }); + const envelope: V2Envelope = JSON.parse(ciphertext); + assert.strictEqual(envelope.adata, adata); + const decrypted = await decryptV2(password, ciphertext); + assert.strictEqual(decrypted, plaintext); + }); + + it('adata mismatch causes GCM decryption failure', async () => { + const ciphertext = await encryptV2(password, plaintext, { adata: 'context-A' }); + const envelope = JSON.parse(ciphertext); + envelope.adata = 'context-B'; + await assert.rejects(() => decryptV2(password, JSON.stringify(envelope)), /operation-specific reason|incorrect/i); + }); + it('v1 and v2 are independent (v1 data does not decrypt with v2)', async () => { const v1ct = encrypt(password, plaintext); await assert.rejects(() => decryptV2(password, v1ct), /invalid envelope/); @@ -303,6 +320,34 @@ describe('encryption methods tests', () => { session2.destroy(); }); + it('session encrypt with adata round-trip', async () => { + const session = await createEncryptionSession(password, opts); + const adata = 'txhash:m/0/1:round1'; + const ct = await session.encrypt(plaintext, adata); + const envelope: V2Envelope = JSON.parse(ct); + assert.strictEqual(envelope.adata, adata); + const result = await session.decrypt(ct); + assert.strictEqual(result, plaintext); + session.destroy(); + }); + + it('session encrypt with adata is decryptable via decryptV2', async () => { + const session = await createEncryptionSession(password, opts); + const ct = await session.encrypt(plaintext, 'context-binding'); + session.destroy(); + const result = await decryptV2(password, ct); + assert.strictEqual(result, plaintext); + }); + + it('session adata mismatch causes GCM failure', async () => { + const session = await createEncryptionSession(password, opts); + const ct = await session.encrypt(plaintext, 'original-context'); + const envelope = JSON.parse(ct); + envelope.adata = 'tampered-context'; + await assert.rejects(() => session.decrypt(JSON.stringify(envelope)), /operation-specific reason|incorrect/i); + session.destroy(); + }); + it('session rejects standard v2 envelopes (no hkdfSalt)', async () => { const v2ct = await encryptV2(password, plaintext, opts); const session = await createEncryptionSession(password, opts); @@ -320,4 +365,57 @@ describe('encryption methods tests', () => { assert.strictEqual(envelope.p, 2); }); }); + + describe('BitGoAPI.encryptAsync', () => { + let bitgo: BitGoAPI; + const password = 'test-password'; + const plaintext = 'hello encryptAsync'; + + before(() => { + bitgo = new BitGoAPI({ env: 'test' }); + }); + + it('dispatches to v1 by default and output is decryptable via decrypt', async () => { + const ct = await bitgo.encryptAsync({ input: plaintext, password }); + const envelope = JSON.parse(ct); + assert.notStrictEqual(envelope.v, 2, 'default should not produce v2 envelope'); + assert.strictEqual(decrypt(password, ct), plaintext); + }); + + it('dispatches to v2 when encryptionVersion: 2 and output is decryptable via decryptAsync', async () => { + const ct = await bitgo.encryptAsync({ input: plaintext, password, encryptionVersion: 2 }); + const envelope: V2Envelope = JSON.parse(ct); + assert.strictEqual(envelope.v, 2); + const result = await decryptAsync(password, ct); + assert.strictEqual(result, plaintext); + }); + + it('forwards adata to v2 envelope', async () => { + const adata = 'txhash:m/0/1'; + const ct = await bitgo.encryptAsync({ input: plaintext, password, encryptionVersion: 2, adata }); + const envelope: V2Envelope = JSON.parse(ct); + assert.strictEqual(envelope.adata, adata); + const result = await decryptAsync(password, ct); + assert.strictEqual(result, plaintext); + }); + }); + + describe('BitGoAPI.createEncryptionSession', () => { + let bitgo: BitGoAPI; + const password = 'test-password'; + const plaintext = 'hello session'; + + before(() => { + bitgo = new BitGoAPI({ env: 'test' }); + }); + + it('returns working session (encrypt/decrypt/destroy)', async () => { + const session = await bitgo.createEncryptionSession(password); + const ct = await session.encrypt(plaintext); + const result = await session.decrypt(ct); + assert.strictEqual(result, plaintext); + session.destroy(); + await assert.rejects(() => session.encrypt(plaintext), /destroyed/); + }); + }); }); diff --git a/modules/sdk-core/src/api/types.ts b/modules/sdk-core/src/api/types.ts index 10e73f3fe3..05a01afa10 100644 --- a/modules/sdk-core/src/api/types.ts +++ b/modules/sdk-core/src/api/types.ts @@ -17,10 +17,24 @@ export interface DecryptKeysOptions { password: string; } +export type EncryptionVersion = 1 | 2; + +/** + * Return type for encryption session operations. + * Runs the expensive KDF once; all subsequent calls derive keys via HKDF. + */ +export interface IEncryptionSession { + encrypt(plaintext: string, adata?: string): Promise; + decrypt(ciphertext: string): Promise; + destroy(): void; +} + export interface EncryptOptions { input: string; password?: string; + /** Additional authenticated data for context binding. Used as CCM adata (v1) or GCM AAD (v2). */ adata?: string; + encryptionVersion?: EncryptionVersion; } export interface GetSharingKeyOptions { diff --git a/modules/sdk-core/src/bitgo/bitgoBase.ts b/modules/sdk-core/src/bitgo/bitgoBase.ts index ac03322b92..a8ca3156c8 100644 --- a/modules/sdk-core/src/bitgo/bitgoBase.ts +++ b/modules/sdk-core/src/bitgo/bitgoBase.ts @@ -4,6 +4,7 @@ import { DecryptOptions, EncryptOptions, GetSharingKeyOptions, + IEncryptionSession, IRequestTracer, } from '../api'; import { IBaseCoin } from './baseCoin'; @@ -19,6 +20,8 @@ export interface BitGoBase { decryptKeys(params: DecryptKeysOptions): string[]; del(url: string): BitGoRequest; encrypt(params: EncryptOptions): string; + encryptAsync(params: EncryptOptions): Promise; + createEncryptionSession(password: string): Promise; readonly env: EnvironmentName; fetchConstants(): Promise; get(url: string): BitGoRequest; diff --git a/modules/sdk-core/src/bitgo/keychain/decryptKeychain.ts b/modules/sdk-core/src/bitgo/keychain/decryptKeychain.ts index 095c62452c..9c6d0e42c8 100644 --- a/modules/sdk-core/src/bitgo/keychain/decryptKeychain.ts +++ b/modules/sdk-core/src/bitgo/keychain/decryptKeychain.ts @@ -13,8 +13,19 @@ function maybeDecrypt(bitgo: BitGoBase, input: string, password: string): string } } +async function maybeDecryptAsync(bitgo: BitGoBase, input: string, password: string): Promise { + try { + return await bitgo.decryptAsync({ + input, + password, + }); + } catch (_e) { + return undefined; + } +} + /** - * Decrypts the private key of a keychain. + * Decrypts the private key of a keychain (sync, v1 only). * This method will try the password against the traditional encryptedPrv, * and any webauthn device encryptedPrvs. * @@ -36,3 +47,28 @@ export function decryptKeychainPrivateKey( } return undefined; } + +/** + * Decrypts the private key of a keychain (async, supports v1 and v2 envelopes). + * This method will try the password against the traditional encryptedPrv, + * and any webauthn device encryptedPrvs. + * Auto-detects v1 (SJCL) and v2 (Argon2id) envelopes. + * + * @param bitgo + * @param keychain + * @param password + */ +export async function decryptKeychainPrivateKeyAsync( + bitgo: BitGoBase, + keychain: OptionalKeychainEncryptedKey, + password: string +): Promise { + const prvs = [keychain.encryptedPrv, ...(keychain.webauthnDevices ?? []).map((d) => d.encryptedPrv)].filter(notEmpty); + for (const prv of prvs) { + const decrypted = await maybeDecryptAsync(bitgo, prv, password); + if (decrypted) { + return decrypted; + } + } + return undefined; +} diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index 4876d031d3..161fc15d31 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -1,4 +1,4 @@ -import { IRequestTracer } from '../../api'; +import { EncryptionVersion, IRequestTracer } from '../../api'; import { KeychainsTriplet, KeyPair } from '../baseCoin'; import { BitgoPubKeyType } from '../utils/tss/baseTypes'; import { IWallet } from '../wallet'; @@ -166,6 +166,7 @@ export interface CreateBackupOptions { prv?: string; encryptedPrv?: string; passphrase?: string; + encryptionVersion?: EncryptionVersion; } export interface CreateBitGoOptions { @@ -188,6 +189,7 @@ export interface CreateMpcOptions { originalPasscodeEncryptionCode?: string; enterprise?: string; retrofit?: DecryptedRetrofitPayload; + encryptionVersion?: EncryptionVersion; } export interface RecreateMpcOptions extends Omit { diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index e299c04225..77a827fbc5 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -275,7 +275,13 @@ export class Keychains implements IKeychains { const key = this.create(); _.extend(params, key); if (params.passphrase !== undefined) { - _.extend(params, { encryptedPrv: this.bitgo.encrypt({ input: key.prv, password: params.passphrase }) }); + _.extend(params, { + encryptedPrv: await this.bitgo.encryptAsync({ + input: key.prv, + password: params.passphrase, + encryptionVersion: params.encryptionVersion, + }), + }); } } @@ -326,6 +332,7 @@ export class Keychains implements IKeychains { enterprise: params.enterprise, originalPasscodeEncryptionCode: params.originalPasscodeEncryptionCode, retrofit: params.retrofit, + encryptionVersion: params.encryptionVersion, }); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index e4dfebe65c..132ab71822 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -1,4 +1,4 @@ -import { IRequestTracer } from '../../../api'; +import { EncryptionVersion, IRequestTracer } from '../../../api'; import * as openpgp from 'openpgp'; import { Key, readKey, SerializedKeyPair } from 'openpgp'; import { IBaseCoin, KeychainsTriplet } from '../../baseCoin'; @@ -194,6 +194,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil enterprise?: string | undefined; originalPasscodeEncryptionCode?: string | undefined; isThirdPartyBackup?: boolean; + encryptionVersion?: EncryptionVersion; }): Promise { throw new Error('Method not implemented.'); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index f773e370a7..d2d637761d 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -1,5 +1,5 @@ import { Key, SerializedKeyPair } from 'openpgp'; -import { IRequestTracer } from '../../../api'; +import { EncryptionVersion, IEncryptionSession, IRequestTracer } from '../../../api'; import { KeychainsTriplet, ParsedTransaction, TransactionParams } from '../../baseCoin'; import { ApiKeyShare, Keychain } from '../../keychain'; import { ApiVersion, Memo, WalletType } from '../../wallet'; @@ -482,10 +482,23 @@ export type CreateKeychainParamsBase = { passphrase?: string; enterprise?: string; originalPasscodeEncryptionCode?: string; + encryptionVersion?: EncryptionVersion; + encryptionSession?: IEncryptionSession; }; export type CreateBitGoKeychainParamsBase = Omit; +/** + * Checks whether a ciphertext string is a v2 encryption envelope. + */ +export function isV2Envelope(ciphertext: string): boolean { + try { + return JSON.parse(ciphertext).v === 2; + } catch { + return false; + } +} + export const SignatureShareType = { USER: 'user', BACKUP: 'backup', @@ -713,6 +726,7 @@ export interface ITssUtils { enterprise?: string; originalPasscodeEncryptionCode?: string; isThirdPartyBackup?: boolean; + encryptionVersion?: EncryptionVersion; }): Promise; signTxRequest(params: { txRequest: string | TxRequest; prv: string; reqId: IRequestTracer }): Promise; signTxRequestForMessage(params: TSSParams): Promise; diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 129877a4af..fc8a3d212b 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -1,3 +1,4 @@ +import { EncryptionVersion } from '../../../../api'; import { bigIntToBufferBE, DklsComms, DklsDkg, DklsDsg, DklsTypes, DklsUtils } from '@bitgo/sdk-lib-mpc'; import * as sjcl from '@bitgo/sjcl'; import assert from 'assert'; @@ -45,6 +46,7 @@ import { TSSParamsForMessageWithPrv, TSSParamsWithPrv, TxRequest, + isV2Envelope, } from '../baseTypes'; import { BaseEcdsaUtils } from './base'; import { EcdsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './ecdsaMPCv2KeyGenSender'; @@ -57,6 +59,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { enterprise: string; originalPasscodeEncryptionCode?: string; retrofit?: DecryptedRetrofitPayload; + encryptionVersion?: EncryptionVersion; }): Promise { const { userSession, backupSession } = this.getUserAndBackupSession(2, 3, params.retrofit); const userGpgKey = await generateGPGKeyPair('secp256k1'); @@ -313,34 +316,42 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { assert.equal(bitgoCommonKeychain, userCommonKeychain, 'User and Bitgo Common keychains do not match'); assert.equal(bitgoCommonKeychain, backupCommonKeychain, 'Backup and Bitgo Common keychains do not match'); - const userKeychainPromise = this.addUserKeychain( - bitgoCommonKeychain, - userPrivateMaterial, - userReducedPrivateMaterial, - params.passphrase, - params.originalPasscodeEncryptionCode - ); - const backupKeychainPromise = this.addBackupKeychain( - bitgoCommonKeychain, - backupPrivateMaterial, - backupReducedPrivateMaterial, - params.passphrase, - params.originalPasscodeEncryptionCode - ); - const bitgoKeychainPromise = this.addBitgoKeychain(bitgoCommonKeychain); - - const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ - userKeychainPromise, - backupKeychainPromise, - bitgoKeychainPromise, - ]); - // #endregion - - return { - userKeychain, - backupKeychain, - bitgoKeychain, - }; + const encryptionSession = + params.encryptionVersion === 2 ? await this.bitgo.createEncryptionSession(params.passphrase) : undefined; + try { + const userKeychainPromise = this.addUserKeychain( + bitgoCommonKeychain, + userPrivateMaterial, + userReducedPrivateMaterial, + params.passphrase, + params.originalPasscodeEncryptionCode, + encryptionSession + ); + const backupKeychainPromise = this.addBackupKeychain( + bitgoCommonKeychain, + backupPrivateMaterial, + backupReducedPrivateMaterial, + params.passphrase, + params.originalPasscodeEncryptionCode, + encryptionSession + ); + const bitgoKeychainPromise = this.addBitgoKeychain(bitgoCommonKeychain); + + const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ + userKeychainPromise, + backupKeychainPromise, + bitgoKeychainPromise, + ]); + // #endregion + + return { + userKeychain, + backupKeychain, + bitgoKeychain, + }; + } finally { + encryptionSession?.destroy(); + } } // #region keychain utils @@ -350,7 +361,12 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { privateMaterial?: Buffer, reducedPrivateMaterial?: Buffer, passphrase?: string, - originalPasscodeEncryptionCode?: string + originalPasscodeEncryptionCode?: string, + encryptionSession?: { + encrypt(plaintext: string): Promise; + decrypt(ciphertext: string): Promise; + destroy(): void; + } ): Promise { let source: string; let encryptedPrv: string | undefined = undefined; @@ -362,20 +378,27 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { assert(privateMaterial, `Private material is required for ${source} keychain`); assert(reducedPrivateMaterial, `Reduced private material is required for ${source} keychain`); assert(passphrase, `Passphrase is required for ${source} keychain`); - encryptedPrv = this.bitgo.encrypt({ - input: privateMaterial.toString('base64'), - password: passphrase, - }); - // Encrypts the CBOR-encoded ReducedKeyShare (which contains the party's private - // scalar s_i) with the wallet passphrase. The result is stored as reducedEncryptedPrv - // on the key card QR code and represents a second copy of private key material - // beyond the server-stored encryptedPrv. - reducedEncryptedPrv = this.bitgo.encrypt({ - // Buffer.toString('base64') can not be used here as it does not work on the browser. - // The browser deals with a Buffer as Uint8Array, therefore in the browser .toString('base64') just creates a comma seperated string of the array values. - input: btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedPrivateMaterial)))), - password: passphrase, - }); + if (encryptionSession) { + encryptedPrv = await encryptionSession.encrypt(privateMaterial.toString('base64')); + reducedEncryptedPrv = await encryptionSession.encrypt( + btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedPrivateMaterial)))) + ); + } else { + encryptedPrv = this.bitgo.encrypt({ + input: privateMaterial.toString('base64'), + password: passphrase, + }); + // Encrypts the CBOR-encoded ReducedKeyShare (which contains the party's private + // scalar s_i) with the wallet passphrase. The result is stored as reducedEncryptedPrv + // on the key card QR code and represents a second copy of private key material + // beyond the server-stored encryptedPrv. + reducedEncryptedPrv = this.bitgo.encrypt({ + // Buffer.toString('base64') can not be used here as it does not work on the browser. + // The browser deals with a Buffer as Uint8Array, therefore in the browser .toString('base64') just creates a comma seperated string of the array values. + input: btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedPrivateMaterial)))), + password: passphrase, + }); + } break; case MPCv2PartiesEnum.BITGO: source = 'bitgo'; @@ -512,7 +535,12 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { privateMaterial: Buffer, reducedPrivateMaterial: Buffer, passphrase: string, - originalPasscodeEncryptionCode?: string + originalPasscodeEncryptionCode?: string, + encryptionSession?: { + encrypt(plaintext: string): Promise; + decrypt(ciphertext: string): Promise; + destroy(): void; + } ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.USER, @@ -520,7 +548,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { privateMaterial, reducedPrivateMaterial, passphrase, - originalPasscodeEncryptionCode + originalPasscodeEncryptionCode, + encryptionSession ); } @@ -529,7 +558,12 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { privateMaterial: Buffer, reducedPrivateMaterial: Buffer, passphrase: string, - originalPasscodeEncryptionCode?: string + originalPasscodeEncryptionCode?: string, + encryptionSession?: { + encrypt(plaintext: string): Promise; + decrypt(ciphertext: string): Promise; + destroy(): void; + } ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.BACKUP, @@ -537,7 +571,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { privateMaterial, reducedPrivateMaterial, passphrase, - originalPasscodeEncryptionCode + originalPasscodeEncryptionCode, + encryptionSession ); } @@ -976,9 +1011,13 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { userGpgKey: pgp.SerializedKeyPair; }> { const bitgoGpgKey = await pgp.readKey({ armoredKey: bitgoPublicGpgKey }); - const userDecryptedKey = await pgp.readKey({ - armoredKey: this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase }), - }); + let decryptedGpgPrvKey: string; + if (isV2Envelope(encryptedUserGpgPrvKey)) { + decryptedGpgPrvKey = await this.bitgo.decryptAsync({ input: encryptedUserGpgPrvKey, password: walletPassphrase }); + } else { + decryptedGpgPrvKey = this.bitgo.decrypt({ input: encryptedUserGpgPrvKey, password: walletPassphrase }); + } + const userDecryptedKey = await pgp.readKey({ armoredKey: decryptedGpgPrvKey }); const userGpgKey: pgp.SerializedKeyPair = { privateKey: userDecryptedKey.armor(), publicKey: userDecryptedKey.toPublic().armor(), @@ -1108,13 +1147,18 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { return sendTxRequest(this.bitgo, txRequestResolved.walletId, txRequestResolved.txRequestId, requestType, reqId); } - async createOfflineRound1Share(params: { txRequest: TxRequest; prv: string; walletPassphrase: string }): Promise<{ + async createOfflineRound1Share(params: { + txRequest: TxRequest; + prv: string; + walletPassphrase: string; + encryptedPrv?: string; + }): Promise<{ signatureShareRound1: SignatureShareRecord; userGpgPubKey: string; encryptedRound1Session: string; encryptedUserGpgPrvKey: string; }> { - const { prv, walletPassphrase, txRequest } = params; + const { prv, walletPassphrase, txRequest, encryptedPrv } = params; const { hashBuffer, derivationPath } = this.getHashStringAndDerivationPath(txRequest); const adata = `${hashBuffer.toString('hex')}:${derivationPath}`; @@ -1124,10 +1168,22 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer); const userSignerBroadcastMsg1 = await userSigner.init(); const signatureShareRound1 = await getSignatureShareRoundOne(userSignerBroadcastMsg1, userGpgKey); - const session = userSigner.getSession(); - const encryptedRound1Session = this.bitgo.encrypt({ input: session, password: walletPassphrase, adata }); - + const sessionData = userSigner.getSession(); const userGpgPubKey = userGpgKey.publicKey; + + const useV2 = encryptedPrv !== undefined && isV2Envelope(encryptedPrv); + if (useV2) { + const session = await this.bitgo.createEncryptionSession(walletPassphrase); + try { + const encryptedRound1Session = await session.encrypt(sessionData, adata); + const encryptedUserGpgPrvKey = await session.encrypt(userGpgKey.privateKey, adata); + return { signatureShareRound1, userGpgPubKey, encryptedRound1Session, encryptedUserGpgPrvKey }; + } finally { + session.destroy(); + } + } + + const encryptedRound1Session = this.bitgo.encrypt({ input: sessionData, password: walletPassphrase, adata }); const encryptedUserGpgPrvKey = this.bitgo.encrypt({ input: userGpgKey.privateKey, password: walletPassphrase, @@ -1153,6 +1209,9 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { const { hashBuffer, derivationPath } = this.getHashStringAndDerivationPath(txRequest); const adata = `${hashBuffer.toString('hex')}:${derivationPath}`; + + const useV2 = isV2Envelope(encryptedRound1Session); + const { bitgoGpgKey, userGpgKey } = await this.getBitgoAndUserGpgKeys( bitgoPublicGpgKey, encryptedUserGpgPrvKey, @@ -1173,9 +1232,15 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { bitgoGpgKey ); - const round1Session = this.bitgo.decrypt({ input: encryptedRound1Session, password: walletPassphrase }); + let round1Session: string; + if (useV2) { + round1Session = await this.bitgo.decryptAsync({ input: encryptedRound1Session, password: walletPassphrase }); + this.validateAdata(adata, encryptedRound1Session); + } else { + round1Session = this.bitgo.decrypt({ input: encryptedRound1Session, password: walletPassphrase }); + this.validateAdata(adata, encryptedRound1Session); + } - this.validateAdata(adata, encryptedRound1Session); const userKeyShare = Buffer.from(prv, 'base64'); const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer); await userSigner.setSession(round1Session); @@ -1195,8 +1260,19 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { userGpgKey, bitgoGpgKey ); - const session = userSigner.getSession(); - const encryptedRound2Session = this.bitgo.encrypt({ input: session, password: walletPassphrase, adata }); + const sessionData = userSigner.getSession(); + + if (useV2) { + const encSession = await this.bitgo.createEncryptionSession(walletPassphrase); + try { + const encryptedRound2Session = await encSession.encrypt(sessionData, adata); + return { signatureShareRound2, encryptedRound2Session }; + } finally { + encSession.destroy(); + } + } + + const encryptedRound2Session = this.bitgo.encrypt({ input: sessionData, password: walletPassphrase, adata }); return { signatureShareRound2, @@ -1221,6 +1297,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { const { hashBuffer, derivationPath } = this.getHashStringAndDerivationPath(txRequest); const adata = `${hashBuffer.toString('hex')}:${derivationPath}`; + const useV2 = isV2Envelope(encryptedRound2Session); + const { bitgoGpgKey, userGpgKey } = await this.getBitgoAndUserGpgKeys( bitgoPublicGpgKey, encryptedUserGpgPrvKey, @@ -1246,8 +1324,15 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { broadcastMessages: [], }); - const round2Session = this.bitgo.decrypt({ input: encryptedRound2Session, password: walletPassphrase }); - this.validateAdata(adata, encryptedRound2Session); + let round2Session: string; + if (useV2) { + round2Session = await this.bitgo.decryptAsync({ input: encryptedRound2Session, password: walletPassphrase }); + this.validateAdata(adata, encryptedRound2Session); + } else { + round2Session = this.bitgo.decrypt({ input: encryptedRound2Session, password: walletPassphrase }); + this.validateAdata(adata, encryptedRound2Session); + } + const userKeyShare = Buffer.from(prv, 'base64'); const userSigner = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, hashBuffer); await userSigner.setSession(round2Session); diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts index de4790dfd5..b69e4821b8 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts @@ -32,6 +32,7 @@ import { TSSParamsWithPrv, TxRequest, UnsignedTransactionTss, + isV2Envelope, } from '../baseTypes'; import { CreateEddsaBitGoKeychainParams, CreateEddsaKeychainParams, KeyShare, YShare } from './types'; import baseTSSUtils from '../baseTSSUtils'; @@ -39,7 +40,7 @@ import { BaseEddsaUtils } from './base'; import { KeychainsTriplet } from '../../../baseCoin'; import { exchangeEddsaCommitments } from '../../../tss/common'; import { Ed25519Bip32HdTree } from '@bitgo/sdk-lib-mpc'; -import { IRequestTracer } from '../../../../api'; +import { EncryptionVersion, IRequestTracer } from '../../../../api'; import { getBitgoMpcGpgPubKey } from '../../../tss/bitgoPubKeys'; import { EnvironmentName } from '../../../environments'; import { readKey } from 'openpgp'; @@ -128,6 +129,7 @@ export class EddsaUtils extends baseTSSUtils { bitgoKeychain, passphrase, originalPasscodeEncryptionCode, + encryptionSession, }: CreateEddsaKeychainParams): Promise { const MPC = await Eddsa.initialize(); const bitgoKeyShares = bitgoKeychain.keyShares; @@ -184,10 +186,14 @@ export class EddsaUtils extends baseTSSUtils { originalPasscodeEncryptionCode, }; if (passphrase !== undefined) { - userKeychainParams.encryptedPrv = this.bitgo.encrypt({ - input: JSON.stringify(userSigningMaterial), - password: passphrase, - }); + if (encryptionSession) { + userKeychainParams.encryptedPrv = await encryptionSession.encrypt(JSON.stringify(userSigningMaterial)); + } else { + userKeychainParams.encryptedPrv = this.bitgo.encrypt({ + input: JSON.stringify(userSigningMaterial), + password: passphrase, + }); + } } return await this.baseCoin.keychains().add(userKeychainParams); @@ -211,6 +217,7 @@ export class EddsaUtils extends baseTSSUtils { backupKeyShare, bitgoKeychain, passphrase, + encryptionSession, }: CreateEddsaKeychainParams): Promise { const MPC = await Eddsa.initialize(); const bitgoKeyShares = bitgoKeychain.keyShares; @@ -269,7 +276,11 @@ export class EddsaUtils extends baseTSSUtils { }; if (passphrase !== undefined) { - params.encryptedPrv = this.bitgo.encrypt({ input: prv, password: passphrase }); + if (encryptionSession) { + params.encryptedPrv = await encryptionSession.encrypt(prv); + } else { + params.encryptedPrv = this.bitgo.encrypt({ input: prv, password: passphrase }); + } } return await this.baseCoin.keychains().createBackup(params); @@ -344,6 +355,7 @@ export class EddsaUtils extends baseTSSUtils { passphrase?: string; enterprise?: string; originalPasscodeEncryptionCode?: string; + encryptionVersion?: EncryptionVersion; }): Promise { const MPC = await Eddsa.initialize(); const m = 2; @@ -362,33 +374,41 @@ export class EddsaUtils extends baseTSSUtils { backupKeyShare, enterprise: params.enterprise, }); - const userKeychainPromise = this.createUserKeychain({ - userGpgKey, - userKeyShare, - backupGpgKey, - backupKeyShare, - bitgoKeychain, - passphrase: params.passphrase, - originalPasscodeEncryptionCode: params.originalPasscodeEncryptionCode, - }); - const backupKeychainPromise = this.createBackupKeychain({ - userGpgKey, - userKeyShare, - backupGpgKey, - backupKeyShare, - bitgoKeychain, - passphrase: params.passphrase, - }); - const [userKeychain, backupKeychain] = await Promise.all([userKeychainPromise, backupKeychainPromise]); - // create wallet - const keychains = { - userKeychain, - backupKeychain, - bitgoKeychain, - }; + const encryptionSession = + params.encryptionVersion === 2 && params.passphrase + ? await this.bitgo.createEncryptionSession(params.passphrase) + : undefined; + try { + const userKeychainPromise = this.createUserKeychain({ + userGpgKey, + userKeyShare, + backupGpgKey, + backupKeyShare, + bitgoKeychain, + passphrase: params.passphrase, + originalPasscodeEncryptionCode: params.originalPasscodeEncryptionCode, + encryptionSession, + }); + const backupKeychainPromise = this.createBackupKeychain({ + userGpgKey, + userKeyShare, + backupGpgKey, + backupKeyShare, + bitgoKeychain, + passphrase: params.passphrase, + encryptionSession, + }); + const [userKeychain, backupKeychain] = await Promise.all([userKeychainPromise, backupKeychainPromise]); - return keychains; + return { + userKeychain, + backupKeychain, + bitgoKeychain, + }; + } finally { + encryptionSession?.destroy(); + } } async createCommitmentShareFromTxRequest(params: { @@ -396,6 +416,7 @@ export class EddsaUtils extends baseTSSUtils { prv: string; walletPassphrase: string; bitgoGpgPubKey: string; + encryptedPrv?: string; }): Promise<{ userToBitgoCommitment: CommitmentShareRecord; encryptedSignerShare: EncryptedSignerShareRecord; @@ -441,7 +462,17 @@ export class EddsaUtils extends baseTSSUtils { const encryptedSignerShare = this.createUserToBitgoEncryptedSignerShare(userToBitgoEncryptedSignerShare); const stringifiedRShare = JSON.stringify(userSignShare); - const encryptedRShare = this.bitgo.encrypt({ input: stringifiedRShare, password: params.walletPassphrase }); + let encryptedRShare: string; + if (params.encryptedPrv && isV2Envelope(params.encryptedPrv)) { + const session = await this.bitgo.createEncryptionSession(params.walletPassphrase); + try { + encryptedRShare = await session.encrypt(stringifiedRShare); + } finally { + session.destroy(); + } + } else { + encryptedRShare = this.bitgo.encrypt({ input: stringifiedRShare, password: params.walletPassphrase }); + } const encryptedUserToBitgoRShare = this.createUserToBitgoEncryptedRShare(encryptedRShare); return { userToBitgoCommitment, encryptedSignerShare, encryptedUserToBitgoRShare }; @@ -454,10 +485,18 @@ export class EddsaUtils extends baseTSSUtils { }): Promise<{ rShare: SignShare }> { const { walletPassphrase, encryptedUserToBitgoRShare } = params; - const decryptedRShare = this.bitgo.decrypt({ - input: encryptedUserToBitgoRShare.share, - password: walletPassphrase, - }); + let decryptedRShare: string; + if (isV2Envelope(encryptedUserToBitgoRShare.share)) { + decryptedRShare = await this.bitgo.decryptAsync({ + input: encryptedUserToBitgoRShare.share, + password: walletPassphrase, + }); + } else { + decryptedRShare = this.bitgo.decrypt({ + input: encryptedUserToBitgoRShare.share, + password: walletPassphrase, + }); + } const rShare = JSON.parse(decryptedRShare); assert(rShare.xShare, 'Unable to find xShare in decryptedRShare'); assert(rShare.rShares, 'Unable to find rShares in decryptedRShare'); diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts index 1b4541d5dd..4f88e3df9b 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/typesEddsaMPCv2.ts @@ -1,18 +1,15 @@ import * as t from 'io-ts'; import { - EddsaMPCv2KeyGenRound1Request, - EddsaMPCv2KeyGenRound1Response, - EddsaMPCv2KeyGenRound2Request, - EddsaMPCv2KeyGenRound2Response, + MPCv2KeyGenRound1Request, + MPCv2KeyGenRound1Response, + MPCv2KeyGenRound2Request, + MPCv2KeyGenRound2Response, } from '@bitgo/public-types'; -export const generateEddsaMPCv2KeyRequestBody = t.union([EddsaMPCv2KeyGenRound1Request, EddsaMPCv2KeyGenRound2Request]); +export const generateEddsaMPCv2KeyRequestBody = t.union([MPCv2KeyGenRound1Request, MPCv2KeyGenRound2Request]); export type GenerateEddsaMPCv2KeyRequestBody = t.TypeOf; -export const generateEddsaMPCv2KeyRequestResponse = t.union([ - EddsaMPCv2KeyGenRound1Response, - EddsaMPCv2KeyGenRound2Response, -]); +export const generateEddsaMPCv2KeyRequestResponse = t.union([MPCv2KeyGenRound1Response, MPCv2KeyGenRound2Response]); export type GenerateEddsaMPCv2KeyRequestResponse = t.TypeOf; diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 1f65b5a977..ac0eea4ac9 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -1071,6 +1071,7 @@ export interface IWallet { prebuildTransaction(params?: PrebuildTransactionOptions): Promise; signTransaction(params?: WalletSignTransactionOptions): Promise; getUserPrv(params?: GetUserPrvOptions): string; + getUserPrvAsync(params?: GetUserPrvOptions): Promise; prebuildAndSignTransaction(params?: PrebuildAndSignTransactionOptions): Promise; signAndSendTxRequest(params?: SignAndSendTxRequestOptions): Promise; accelerateTransaction(params?: AccelerateTransactionOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index 416f6682f8..96c6214f86 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -1,6 +1,6 @@ import * as t from 'io-ts'; -import { IRequestTracer } from '../../api'; +import { EncryptionVersion, IRequestTracer } from '../../api'; import { KeychainsTriplet, LightningKeychainsTriplet } from '../baseCoin'; import { Keychain, WebauthnInfo } from '../keychain'; import { IWallet, PaginationOptions, WalletShare } from './iWallet'; @@ -45,6 +45,7 @@ export interface GenerateBaseMpcWalletOptions { export interface GenerateMpcWalletOptions extends GenerateBaseMpcWalletOptions { passphrase: string; originalPasscodeEncryptionCode?: string; + encryptionVersion?: EncryptionVersion; } export interface GenerateSMCMpcWalletOptions extends GenerateBaseMpcWalletOptions { bitgoKeyId: string; @@ -92,6 +93,7 @@ export interface GenerateWalletOptions { evmKeyRingReferenceWalletId?: string; /** Optional WebAuthn PRF-based encryption info. When provided, the user private key is additionally encrypted with the PRF-derived passphrase so the server can store a WebAuthn-protected copy. */ webauthnInfo?: GenerateWalletWebauthnInfo; + encryptionVersion?: EncryptionVersion; } export const GenerateLightningWalletOptionsCodec = t.intersection( @@ -105,20 +107,26 @@ export const GenerateLightningWalletOptionsCodec = t.intersection( }), t.partial({ lightningProvider: t.union([t.literal('amboss'), t.literal('voltage')]), + encryptionVersion: t.literal(2), }), ], 'GenerateLightningWalletOptions' ); export type GenerateLightningWalletOptions = t.TypeOf; -export const GenerateGoAccountWalletOptionsCodec = t.strict( - { - label: t.string, - passphrase: t.string, - enterprise: t.string, - passcodeEncryptionCode: t.string, - type: t.literal('trading'), - }, +export const GenerateGoAccountWalletOptionsCodec = t.intersection( + [ + t.strict({ + label: t.string, + passphrase: t.string, + enterprise: t.string, + passcodeEncryptionCode: t.string, + type: t.literal('trading'), + }), + t.partial({ + encryptionVersion: t.literal(2), + }), + ], 'GenerateGoAccountWalletOptions' ); diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 8bc607b891..4a06e41c39 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -32,7 +32,13 @@ import { import { SubmitTransactionResponse } from '../inscriptionBuilder'; import { drawKeycard } from '../internal'; import * as internal from '../internal/internal'; -import { decryptKeychainPrivateKey, Keychain, KeychainWithEncryptedPrv, KeyIndices } from '../keychain'; +import { + decryptKeychainPrivateKey, + decryptKeychainPrivateKeyAsync, + Keychain, + KeychainWithEncryptedPrv, + KeyIndices, +} from '../keychain'; import { getLightningAuthKey } from '../lightning/lightningWalletUtil'; import { IPendingApproval, PendingApproval, PendingApprovals } from '../pendingApproval'; import { GoStakingWallet, StakingWallet } from '../staking'; @@ -1673,7 +1679,7 @@ export class Wallet implements IWallet { if (!params.walletPassphrase) { throw new Error('wallet passphrase was not provided'); } - const userPrv = decryptKeychainPrivateKey(this.bitgo, userKeychain, params.walletPassphrase); + const userPrv = await decryptKeychainPrivateKeyAsync(this.bitgo, userKeychain, params.walletPassphrase); if (!userPrv) { throw new Error('error decrypting wallet private key'); } @@ -1853,7 +1859,7 @@ export class Wallet implements IWallet { throw new Error('Missing walletPassphrase argument'); } - const prv = decryptKeychainPrivateKey(this.bitgo, keychain, walletPassphrase); + const prv = await decryptKeychainPrivateKeyAsync(this.bitgo, keychain, walletPassphrase); if (!prv) { throw new IncorrectPasswordError('Password shared is incorrect for this wallet'); } @@ -2218,7 +2224,7 @@ export class Wallet implements IWallet { if (this.multisigType() === 'tss') { return this.signTransactionTss({ ...presign, - prv: this.getUserPrv(presign as GetUserPrvOptions), + prv: await this.getUserPrvAsync(presign as GetUserPrvOptions), apiVersion, }); } @@ -2256,7 +2262,7 @@ export class Wallet implements IWallet { } return this.baseCoin.signTransaction({ ...signTransactionParams, - prv: this.getUserPrv(presign as GetUserPrvOptions), + prv: await this.getUserPrvAsync(presign as GetUserPrvOptions), wallet: this, }); } @@ -2291,7 +2297,7 @@ export class Wallet implements IWallet { ...params, walletData: this._wallet, tssUtils: this.tssUtils, - prv: this.getUserPrv(userPrvOptions), + prv: await this.getUserPrvAsync(userPrvOptions), keychain: keychains[0], backupKeychain: keychains.length > 1 ? keychains[1] : null, bitgoKeychain: keychains.length > 2 ? keychains[2] : null, @@ -2330,7 +2336,7 @@ export class Wallet implements IWallet { ...params, walletData: this._wallet, tssUtils: this.tssUtils, - prv: this.getUserPrv(userPrvOptions), + prv: await this.getUserPrvAsync(userPrvOptions), keychain: keychains[0], backupKeychain: keychains.length > 1 ? keychains[1] : null, bitgoKeychain: keychains.length > 2 ? keychains[2] : null, @@ -2383,7 +2389,7 @@ export class Wallet implements IWallet { } /** - * Get the user private key from either a derivation or an encrypted keychain + * Get the user private key from either a derivation or an encrypted keychain (sync, v1 only). * @param [params.keychain / params.key] (object) or params.prv (string) * @param params.walletPassphrase (string) */ @@ -2394,9 +2400,6 @@ export class Wallet implements IWallet { throw new Error('prv must be a string'); } - // use the `derivedFromParentWithSeed` property from the user keychain as the `coldDerivationSeed` - // if no other `coldDerivationSeed` was explicitly provided - // Only for onchain multisig wallets, TSS key derivation happens during the signing process if ( params.coldDerivationSeed === undefined && params.keychain !== undefined && @@ -2407,7 +2410,6 @@ export class Wallet implements IWallet { } if (userPrv && params.coldDerivationSeed) { - // the derivation only makes sense when a key already exists const derivation = this.baseCoin.deriveKeyWithSeed({ key: userPrv, seed: params.coldDerivationSeed, @@ -2432,6 +2434,52 @@ export class Wallet implements IWallet { return userPrv; } + /** + * Async version of getUserPrv that supports both v1 (SJCL) and v2 (Argon2id) envelopes. + * @param [params.keychain / params.key] (object) or params.prv (string) + * @param params.walletPassphrase (string) + */ + async getUserPrvAsync(params: GetUserPrvOptions = {}): Promise { + const userKeychain = params.keychain || params.key; + let userPrv = params.prv; + if (userPrv && typeof userPrv !== 'string') { + throw new Error('prv must be a string'); + } + + if ( + params.coldDerivationSeed === undefined && + params.keychain !== undefined && + params.keychain.derivedFromParentWithSeed !== undefined && + this.multisigType() === 'onchain' + ) { + params.coldDerivationSeed = params.keychain.derivedFromParentWithSeed; + } + + if (userPrv && params.coldDerivationSeed) { + const derivation = this.baseCoin.deriveKeyWithSeed({ + key: userPrv, + seed: params.coldDerivationSeed, + }); + userPrv = derivation.key; + } else if (!userPrv) { + if (!userKeychain || typeof userKeychain !== 'object') { + throw new Error('keychain must be an object'); + } + const userEncryptedPrv = userKeychain.encryptedPrv; + if (!userEncryptedPrv) { + throw new Error('keychain does not have property encryptedPrv'); + } + if (!params.walletPassphrase) { + throw new Error('walletPassphrase property missing'); + } + userPrv = await decryptKeychainPrivateKeyAsync(this.bitgo, userKeychain, params.walletPassphrase); + if (!userPrv) { + throw new Error('failed to decrypt user keychain'); + } + } + return userPrv; + } + /** * Get a transaction prebuild from BitGo, validate it, and then decrypt the user key and sign the transaction * @param params @@ -4409,7 +4457,7 @@ export class Wallet implements IWallet { // we ignore this check with if customSigningFunction is provided // which means that the user is handling the signing in external signing mode if (!customSigningFunction && keychains?.[0]?.encryptedPrv && walletPassphrase) { - if (!decryptKeychainPrivateKey(this.bitgo, keychains[0], walletPassphrase)) { + if (!(await decryptKeychainPrivateKeyAsync(this.bitgo, keychains[0], walletPassphrase))) { const error: Error & { code?: string } = new Error( `unable to decrypt keychain with the given wallet passphrase` ); diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 0c191b1670..3e0bf40520 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -169,7 +169,8 @@ export class Wallets implements IWallets { const reqId = new RequestTracer(); this.bitgo.setRequestTracer(reqId); - const { label, passphrase, enterprise, passcodeEncryptionCode, subType, lightningProvider } = params; + const { label, passphrase, enterprise, passcodeEncryptionCode, subType, lightningProvider, encryptionVersion } = + params; // TODO BTC-1899: only userAuth key is required for custodial lightning wallet. all 3 keys are required for self custodial lightning. // to avoid changing the platform for custodial flow, let us all 3 keys both wallet types. @@ -178,7 +179,11 @@ export class Wallets implements IWallets { const keychain = this.baseCoin.keychains().create(); const keychainParams: AddKeychainOptions = { pub: keychain.pub, - encryptedPrv: this.bitgo.encrypt({ password: passphrase, input: keychain.prv }), + encryptedPrv: await this.bitgo.encryptAsync({ + password: passphrase, + input: keychain.prv, + encryptionVersion, + }), originalPasscodeEncryptionCode: purpose === undefined ? passcodeEncryptionCode : undefined, coinSpecific: purpose === undefined ? undefined : { [this.baseCoin.getChain()]: { purpose } }, keyType: 'independent', @@ -228,13 +233,17 @@ export class Wallets implements IWallets { const reqId = new RequestTracer(); this.bitgo.setRequestTracer(reqId); - const { label, passphrase, enterprise, passcodeEncryptionCode } = params; + const { label, passphrase, enterprise, passcodeEncryptionCode, encryptionVersion } = params; const keychain = this.baseCoin.keychains().create(); const keychainParams: AddKeychainOptions = { pub: keychain.pub, - encryptedPrv: this.bitgo.encrypt({ password: passphrase, input: keychain.prv }), + encryptedPrv: await this.bitgo.encryptAsync({ + password: passphrase, + input: keychain.prv, + encryptionVersion, + }), originalPasscodeEncryptionCode: passcodeEncryptionCode, keyType: 'independent', source: 'user', @@ -318,9 +327,10 @@ export class Wallets implements IWallets { ); const walletData = await this.generateLightningWallet(options); - walletData.encryptedWalletPassphrase = this.bitgo.encrypt({ + walletData.encryptedWalletPassphrase = await this.bitgo.encryptAsync({ input: options.passphrase, password: options.passcodeEncryptionCode, + encryptionVersion: options.encryptionVersion, }); return walletData; } @@ -337,9 +347,10 @@ export class Wallets implements IWallets { ); const walletData = await this.generateGoAccountWallet(options); - walletData.encryptedWalletPassphrase = this.bitgo.encrypt({ + walletData.encryptedWalletPassphrase = await this.bitgo.encryptAsync({ input: options.passphrase, password: options.passcodeEncryptionCode, + encryptionVersion: options.encryptionVersion, }); return walletData; } @@ -437,11 +448,13 @@ export class Wallets implements IWallets { originalPasscodeEncryptionCode: params.passcodeEncryptionCode, enterprise, walletVersion: params.walletVersion, + encryptionVersion: params.encryptionVersion, }); if (params.passcodeEncryptionCode) { - walletData.encryptedWalletPassphrase = this.bitgo.encrypt({ + walletData.encryptedWalletPassphrase = await this.bitgo.encryptAsync({ input: passphrase, password: params.passcodeEncryptionCode, + encryptionVersion: params.encryptionVersion, }); } return walletData; @@ -574,7 +587,11 @@ export class Wallets implements IWallets { } // Create the user key. userKeychain = this.baseCoin.keychains().create(); - userKeychain.encryptedPrv = this.bitgo.encrypt({ password: passphrase, input: userKeychain.prv }); + userKeychain.encryptedPrv = await this.bitgo.encryptAsync({ + password: passphrase, + input: userKeychain.prv, + encryptionVersion: params.encryptionVersion, + }); userKeychainParams = { pub: userKeychain.pub, encryptedPrv: userKeychain.encryptedPrv, @@ -588,9 +605,10 @@ export class Wallets implements IWallets { { otpDeviceId: params.webauthnInfo.otpDeviceId, prfSalt: params.webauthnInfo.prfSalt, - encryptedPrv: this.bitgo.encrypt({ + encryptedPrv: await this.bitgo.encryptAsync({ password: params.webauthnInfo.passphrase, input: userKeychain.prv, + encryptionVersion: params.encryptionVersion, }), }, ]; @@ -611,6 +629,7 @@ export class Wallets implements IWallets { krsSpecific: params.krsSpecific, type: this.baseCoin.getChain(), passphrase: params.passphrase, + encryptionVersion: params.encryptionVersion, reqId, }); } @@ -628,7 +647,11 @@ export class Wallets implements IWallets { throw new Error('cannot generate backup keypair without passphrase'); } // No provided backup xpub or address, so default to creating one here - return this.baseCoin.keychains().createBackup({ reqId, passphrase: params.passphrase }); + return this.baseCoin.keychains().createBackup({ + reqId, + passphrase: params.passphrase, + encryptionVersion: params.encryptionVersion, + }); } }; const { userKeychain, backupKeychain, bitgoKeychain }: KeychainsTriplet = await promiseProps({ @@ -683,9 +706,10 @@ export class Wallets implements IWallets { } if (canEncrypt && params.passcodeEncryptionCode) { - result.encryptedWalletPassphrase = this.bitgo.encrypt({ + result.encryptedWalletPassphrase = await this.bitgo.encryptAsync({ input: passphrase, password: params.passcodeEncryptionCode, + encryptionVersion: params.encryptionVersion, }); } @@ -1500,6 +1524,7 @@ export class Wallets implements IWallets { enterprise, walletVersion, originalPasscodeEncryptionCode, + encryptionVersion, }: GenerateMpcWalletOptions): Promise { if (multisigType === 'tss' && this.baseCoin.getMPCAlgorithm() === 'ecdsa') { const tssSettings: TssSettings = await this.bitgo @@ -1519,6 +1544,7 @@ export class Wallets implements IWallets { passphrase, enterprise, originalPasscodeEncryptionCode, + encryptionVersion, }); // Create Wallet diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletOptionsCodecs.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletOptionsCodecs.ts new file mode 100644 index 0000000000..af77a23b28 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletOptionsCodecs.ts @@ -0,0 +1,49 @@ +import assert from 'assert'; +import { isRight, isLeft } from 'fp-ts/Either'; + +import { + GenerateLightningWalletOptionsCodec, + GenerateGoAccountWalletOptionsCodec, +} from '../../../../src/bitgo/wallet/iWallets'; + +describe('wallet options codecs with encryptionVersion', () => { + const lightningBase = { + label: 'test', + passphrase: 'pass', + enterprise: 'ent', + passcodeEncryptionCode: 'code', + subType: 'lightningCustody' as const, + }; + + const goAccountBase = { + label: 'test', + passphrase: 'pass', + enterprise: 'ent', + passcodeEncryptionCode: 'code', + type: 'trading' as const, + }; + + it('GenerateLightningWalletOptionsCodec accepts encryptionVersion: 2', () => { + assert.ok(isRight(GenerateLightningWalletOptionsCodec.decode({ ...lightningBase, encryptionVersion: 2 }))); + }); + + it('GenerateLightningWalletOptionsCodec rejects encryptionVersion: 3', () => { + assert.ok(isLeft(GenerateLightningWalletOptionsCodec.decode({ ...lightningBase, encryptionVersion: 3 }))); + }); + + it('GenerateLightningWalletOptionsCodec works without encryptionVersion', () => { + assert.ok(isRight(GenerateLightningWalletOptionsCodec.decode(lightningBase))); + }); + + it('GenerateGoAccountWalletOptionsCodec accepts encryptionVersion: 2', () => { + assert.ok(isRight(GenerateGoAccountWalletOptionsCodec.decode({ ...goAccountBase, encryptionVersion: 2 }))); + }); + + it('GenerateGoAccountWalletOptionsCodec rejects encryptionVersion: 3', () => { + assert.ok(isLeft(GenerateGoAccountWalletOptionsCodec.decode({ ...goAccountBase, encryptionVersion: 3 }))); + }); + + it('GenerateGoAccountWalletOptionsCodec works without encryptionVersion', () => { + assert.ok(isRight(GenerateGoAccountWalletOptionsCodec.decode(goAccountBase))); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts index 5e74b7ff80..3167ee9e73 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts @@ -36,6 +36,11 @@ describe('Wallets - WebAuthn wallet creation', function () { encrypt: sinon .stub() .callsFake(({ password, input }: { password: string; input: string }) => `encrypted:${password}:${input}`), + encryptAsync: sinon + .stub() + .callsFake( + async ({ password, input }: { password: string; input: string }) => `encrypted:${password}:${input}` + ), setRequestTracer: sinon.stub(), }; @@ -132,7 +137,7 @@ describe('Wallets - WebAuthn wallet creation', function () { }, }); - const encryptCalls = mockBitGo.encrypt.getCalls(); + const encryptCalls = mockBitGo.encryptAsync.getCalls(); const passwordsUsed = encryptCalls.map((call: sinon.SinonSpyCall) => call.args[0].password); passwordsUsed.should.containEql(walletPassphrase); passwordsUsed.should.containEql(webauthnPassphrase);