Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions modules/bitgo/test/v2/unit/pendingApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ describe('Pending Approvals:', () => {

const coin = 'tbtc';
const walletId = 'wallet_id';
const attestation = {
signature: 'signature',
credentialId: 'credential-id',
clientDataJSON: 'client-data-json',
authenticatorData: 'authenticator-data',
};

const pendingApprovalData: PendingApprovalData = {
id: 'pa0',
Expand Down Expand Up @@ -149,6 +155,25 @@ describe('Pending Approvals:', () => {
paScope.isDone().should.be.true();
});

it('should pass attestation without bypassing multisig half-signing', async () => {
const pendingApproval = new PendingApproval(bitgo, basecoin, pendingApprovalData, wallet);
const halfSignedTxHex = 'half-signed-transaction';
const paScope = nock(bgUrl)
.put(`/api/v2/${coin}/pendingapprovals/${pendingApprovalData.id}`, {
state: 'approved',
attestation,
halfSigned: { txHex: halfSignedTxHex },
})
.reply(200, {
...pendingApprovalData,
state: 'approved',
});

await pendingApproval.approve({ tx: halfSignedTxHex, attestation });

paScope.isDone().should.be.true();
});

function testRecreateTransaction(coinName: string, recreateTransaction: boolean, type: Type) {
it(`[${coinName}] should ${
recreateTransaction ? 'not ' : ''
Expand Down
3 changes: 3 additions & 0 deletions modules/express/src/typedRoutes/api/v1/pendingApproval.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as t from 'io-ts';
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
import { AttestationPayload } from '@bitgo/public-types';
import { BitgoExpressError } from '../../schemas/error';

export const pendingApprovalRequestParams = {
Expand All @@ -14,6 +15,8 @@ export const pendingApprovalRequestBody = {
walletPassphrase: optional(t.string),
/** One-time password for 2FA verification */
otp: optional(t.string),
/** WebAuthn proof that the approver authorized the pending transaction intent */
attestation: optional(AttestationPayload),
/** Pre-signed transaction hex to use instead of reconstructing (for transactionRequest approvals) */
tx: optional(t.string),
/** Extended private key as alternative to walletPassphrase (for transactionRequest approvals) */
Expand Down
3 changes: 3 additions & 0 deletions modules/express/src/typedRoutes/api/v2/pendingApproval.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as t from 'io-ts';
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
import { AttestationPayload } from '@bitgo/public-types';
import { BitgoExpressError } from '../../schemas/error';

/**
Expand All @@ -22,6 +23,8 @@ export const PendingApprovalRequestBody = {
walletPassphrase: optional(t.string),
/** Second factor authentication token */
otp: optional(t.string),
/** WebAuthn proof that the approver authorized the pending transaction intent */
attestation: optional(AttestationPayload),
/** Transaction hex to use instead of the original transaction */
tx: optional(t.string),
/** Private key in string form, if walletPassphrase is not available */
Expand Down
52 changes: 52 additions & 0 deletions modules/express/test/unit/typedRoutes/pendingApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import { setupAgent } from '../../lib/testutil';
*/

describe('PendingApproval codec tests', function () {
const attestation = {
signature: 'signature',
credentialId: 'credential-id',
clientDataJSON: 'client-data-json',
authenticatorData: 'authenticator-data',
};

describe('pendingApprovalRequestParams', function () {
it('should validate valid params', function () {
const validParams = {
Expand Down Expand Up @@ -52,6 +59,7 @@ describe('PendingApproval codec tests', function () {
state: 'approved',
walletPassphrase: 'mySecurePassword',
otp: '123456',
attestation,
tx: 'transactionHexString',
xprv: 'xprvString',
previewPendingTxs: true,
Expand All @@ -62,6 +70,7 @@ describe('PendingApproval codec tests', function () {
assert.strictEqual(decoded.state, validBody.state);
assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase);
assert.strictEqual(decoded.otp, validBody.otp);
assert.deepStrictEqual(decoded.attestation, attestation);
assert.strictEqual(decoded.tx, validBody.tx);
assert.strictEqual(decoded.xprv, validBody.xprv);
assert.strictEqual(decoded.previewPendingTxs, validBody.previewPendingTxs);
Expand All @@ -77,6 +86,7 @@ describe('PendingApproval codec tests', function () {
assert.strictEqual(decoded.state, validBody.state);
assert.strictEqual(decoded.walletPassphrase, undefined);
assert.strictEqual(decoded.otp, undefined);
assert.strictEqual(decoded.attestation, undefined);
assert.strictEqual(decoded.tx, undefined);
assert.strictEqual(decoded.xprv, undefined);
assert.strictEqual(decoded.previewPendingTxs, undefined);
Expand Down Expand Up @@ -173,6 +183,21 @@ describe('PendingApproval codec tests', function () {
});
});

it('should reject an incomplete attestation', function () {
const invalidBody = {
state: 'approved',
attestation: {
credentialId: 'credential-id',
clientDataJSON: 'client-data-json',
authenticatorData: 'authenticator-data',
},
};

assert.throws(() => {
assertDecode(t.type(pendingApprovalRequestBody), invalidBody);
});
});

it('should reject body with non-string tx', function () {
const invalidBody = {
state: 'approved',
Expand Down Expand Up @@ -390,6 +415,33 @@ describe('PendingApproval codec tests', function () {
assert.strictEqual(result.body.state, 'approved');
});

it('should pass attestation to approve', async function () {
const approvalId = '123456789abcdef';
const requestBody = {
state: 'approved',
walletPassphrase: 'mySecurePassword',
attestation,
};
const approve = sinon.stub().resolves(mockApprovedResponse);
const mockPendingApprovalObject = {
approve,
reject: sinon.stub().resolves(mockRejectedResponse),
};

sinon.stub(BitGo.prototype, 'pendingApprovals').returns({
get: sinon.stub().resolves(mockPendingApprovalObject),
} as any);

const result = await agent
.put(`/api/v1/pendingapprovals/${approvalId}/express`)
.set('Authorization', 'Bearer test_access_token_12345')
.set('Content-Type', 'application/json')
.send(requestBody);

assert.strictEqual(result.status, 200);
sinon.assert.calledWith(approve, sinon.match({ walletPassphrase: 'mySecurePassword', attestation }));
});

it('should successfully approve with xprv', async function () {
const approvalId = '123456789abcdef';
const requestBody = {
Expand Down
49 changes: 49 additions & 0 deletions modules/express/test/unit/typedRoutes/pendingApprovalV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import { BitGo } from 'bitgo';
import { setupAgent } from '../../lib/testutil';

describe('V2 PendingApproval API Tests', function () {
const attestation = {
signature: 'signature',
credentialId: 'credential-id',
clientDataJSON: 'client-data-json',
authenticatorData: 'authenticator-data',
};

describe('Codec Validation Tests', function () {
describe('PendingApprovalParams', function () {
it('should validate valid params', function () {
Expand Down Expand Up @@ -50,6 +57,7 @@ describe('V2 PendingApproval API Tests', function () {
state: 'approved',
walletPassphrase: 'mySecurePassword',
otp: '123456',
attestation,
tx: 'transactionHexString',
xprv: 'xprvString',
previewPendingTxs: true,
Expand All @@ -60,6 +68,7 @@ describe('V2 PendingApproval API Tests', function () {
assert.strictEqual(decoded.state, validBody.state);
assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase);
assert.strictEqual(decoded.otp, validBody.otp);
assert.deepStrictEqual(decoded.attestation, attestation);
assert.strictEqual(decoded.tx, validBody.tx);
assert.strictEqual(decoded.xprv, validBody.xprv);
assert.strictEqual(decoded.previewPendingTxs, validBody.previewPendingTxs);
Expand All @@ -77,6 +86,15 @@ describe('V2 PendingApproval API Tests', function () {
assert.throws(() => assertDecode(t.type(PendingApprovalRequestBody), { walletPassphrase: 12345 }));
assert.throws(() => assertDecode(t.type(PendingApprovalRequestBody), { otp: 123456 }));
assert.throws(() => assertDecode(t.type(PendingApprovalRequestBody), { previewPendingTxs: 'true' }));
assert.throws(() =>
assertDecode(t.type(PendingApprovalRequestBody), {
attestation: {
credentialId: 'credential-id',
clientDataJSON: 'client-data-json',
authenticatorData: 'authenticator-data',
},
})
);
});
});

Expand Down Expand Up @@ -432,6 +450,37 @@ describe('V2 PendingApproval API Tests', function () {
sinon.assert.calledWith(mockPendingApprovalObject.approve, sinon.match({ otp: '123456' }));
});

it('should pass attestation to approve', async function () {
const coin = 'tbtc';
const approvalId = '123456789abcdef';
const requestBody = {
state: 'approved',
walletPassphrase: 'mySecurePassword',
attestation,
};
const approve = sinon.stub().resolves(mockApprovedResponse);
const mockPendingApprovalObject = {
approve,
reject: sinon.stub().resolves(mockRejectedResponse),
};
const mockCoin = {
pendingApprovals: sinon.stub().returns({
get: sinon.stub().resolves(mockPendingApprovalObject),
}),
};

sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);

const result = await agent
.put(`/api/v2/${coin}/pendingapprovals/${approvalId}`)
.set('Authorization', 'Bearer test_access_token_12345')
.set('Content-Type', 'application/json')
.send(requestBody);

assert.strictEqual(result.status, 200);
sinon.assert.calledWith(approve, sinon.match({ walletPassphrase: 'mySecurePassword', attestation }));
});

it('should handle transaction request with fanout', async function () {
const coin = 'tbtc';
const approvalId = '123456789abcdef';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AttestationPayload } from '@bitgo/public-types';
import { IRequestTracer } from '../../api';

export enum OwnerType {
Expand Down Expand Up @@ -28,6 +29,8 @@ export enum Type {
export interface ApproveOptions {
walletPassphrase?: string;
otp?: string;
/** WebAuthn proof that the approver authorized the pending transaction intent. */
attestation?: AttestationPayload;
tx?: string;
xprv?: string;
previewPendingTxs?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import * as _ from 'lodash';
import * as utxolib from '@bitgo/utxo-lib';
import type { AttestationPayload } from '@bitgo/public-types';
import { IBaseCoin } from '../baseCoin';
import { BitGoBase } from '../bitgoBase';
import {
Expand Down Expand Up @@ -31,11 +32,10 @@ type PreApproveResult = {
halfSigned?: string;
};

// TODO(WCN-541): add optional attestation pass-through for PUT /pendingapprovals/:id (deferred
// until multisig attestation, WCN-539, is verified end-to-end on staging).
type ApprovePendingApprovalRequestBody = {
state: 'approved';
otp: string | undefined;
attestation?: AttestationPayload;
halfSigned?: string | Omit<PreApproveResult, 'halfSigned'>;
};

Expand Down Expand Up @@ -185,6 +185,9 @@ export class PendingApproval implements IPendingApproval {
const transaction = await this.preApprove(params, reqId);

const approvalParams: ApprovePendingApprovalRequestBody = { state: 'approved', otp: params.otp };
if (params.attestation) {
approvalParams.attestation = params.attestation;
}
if (transaction) {
// if the transaction already has a half signed property, we take that directly
approvalParams.halfSigned = transaction.halfSigned || transaction;
Expand Down
Loading