diff --git a/README.md b/README.md index d1f00c66..a3e47485 100644 --- a/README.md +++ b/README.md @@ -205,11 +205,25 @@ These settings are only required when you want to use a **separate AWM instance | Variable | Description | Default | Applies To | | -------------------- | --------------------------------------------------- | ---------------------- | ---------- | | `RECOVERY_MODE` | Enable recovery mode for wallet recovery operations | `false` | Both | +| `MPCV2_RECOVERY_ALLOWED_CLIENT_FINGERPRINTS` | Dedicated mTLS client fingerprints authorized for ECDSA MPCv2 share combination | Unset (disabled) | AWM | +| `MPCV2_RECOVERY_APPROVALS` | JSON array of operator-approved `{ "coin", "pub", "txHexSha256" }` recovery transactions; SHA-256 is over decoded transaction bytes | Unset (disabled) | AWM | | `HTTP_LOGFILE` | Path to HTTP access log file | `logs/http-access.log` | Both | | `KEEP_ALIVE_TIMEOUT` | Keep-alive timeout in milliseconds | - | Both | | `HEADERS_TIMEOUT` | Headers timeout in milliseconds | - | Both | | `IPC` | IPC socket path (alternative to TCP port binding) | - | Both | +ECDSA MPCv2 recovery requires `RECOVERY_MODE=true` and `TLS_MODE=mtls`. A dedicated +recovery client's SHA-256 certificate fingerprint (uppercase hex, without colons) +must be in **both** `MTLS_ALLOWED_CLIENT_FINGERPRINTS` and +`MPCV2_RECOVERY_ALLOWED_CLIENT_FINGERPRINTS`. Before sending the recovery +request, an operator must verify the target wallet's common keychain and the +exact unsigned transaction (including destination) and approve its SHA-256 +hash in `MPCV2_RECOVERY_APPROVALS`, e.g. +`[{"coin":"hteth","pub":"<130 hex characters>","txHexSha256":"<64 hex characters>"}]`. +The hash is of the bytes decoded from `txHex`, not of the UTF-8 hex string. +Absent authorization or approval, AWM refuses the request before contacting +either KMS; returned shares must independently match the approved keychain. + ### TLS/mTLS Configuration #### Basic TLS Settings @@ -218,6 +232,7 @@ These settings are only required when you want to use a **separate AWM instance | ------------------------------- | ------------------------------------- | ------- | | `TLS_MODE` | TLS mode (`mtls` or `disabled`) | `mtls` | | `CLIENT_CERT_ALLOW_SELF_SIGNED` | Allow self-signed client certificates | `false` | +| `MTLS_ALLOWED_CLIENT_FINGERPRINTS` | Allowed client certificate fingerprints | Comma-separated uppercase SHA-256 fingerprints without `sha256:` or colons; surrounding whitespace is ignored | #### Server Certificates (for incoming connections) @@ -474,11 +489,14 @@ export KEY_PROVIDER_SERVER_CA_CERT_PATH=/secure/certs/key-provider-ca.crt # Security settings - production-grade export CLIENT_CERT_ALLOW_SELF_SIGNED=false export KEY_PROVIDER_SERVER_CERT_ALLOW_SELF_SIGNED=false -export MTLS_ALLOWED_CLIENT_FINGERPRINTS=sha256:1a2b3c...,sha256:4d5e6f... +export MTLS_ALLOWED_CLIENT_FINGERPRINTS=A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1,B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2 export BITGO_ENV=prod npm start ``` +The AWM setup above intentionally leaves ECDSA MPCv2 recovery disabled. To enable recovery, also set `RECOVERY_MODE=true`, add the recovery client fingerprint to `MPCV2_RECOVERY_ALLOWED_CLIENT_FINGERPRINTS`, and configure `MPCV2_RECOVERY_APPROVALS` with the approved coin, 130-hex common keychain, and 64-hex digest before starting AWM. + + #### 2. Start Master Express (Port 3081) Run the following in a new terminal: @@ -499,7 +517,7 @@ export AWM_SERVER_CA_CERT_PATH=/secure/certs/awm-ca.crt # Security settings - production-grade export CLIENT_CERT_ALLOW_SELF_SIGNED=false export AWM_SERVER_CERT_ALLOW_SELF_SIGNED=false -export MTLS_ALLOWED_CLIENT_FINGERPRINTS=sha256:7g8h9i...,sha256:0j1k2l... +export MTLS_ALLOWED_CLIENT_FINGERPRINTS=C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3,D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4 npm start ``` @@ -547,13 +565,13 @@ For local testing, you can generate and use demo certificates with the self-sign #### Getting Client Certificate Fingerprints -To obtain certificate fingerprints for `MTLS_ALLOWED_CLIENT_FINGERPRINTS`: +To obtain a certificate fingerprint for the two AWM client allowlists: ```bash -openssl x509 -in /path/to/client-cert.crt -noout -fingerprint -sha256 | cut -d'=' -f2 +openssl x509 -in /path/to/client-cert.crt -noout -fingerprint -sha256 | cut -d'=' -f2 | tr -d ':' ``` -The output format is: `sha256:AB:CD:EF:...` which you can use in the configuration. +Use the resulting uppercase hex in both allowlists; do not include `sha256:`. The same canonical format applies to `MTLS_ALLOWED_CLIENT_FINGERPRINTS` for incoming mTLS clients. #### Certificate Requirements for Production diff --git a/certs/test-ssl-cert.pem b/certs/test-ssl-cert.pem index 773b5a09..c079be31 100644 --- a/certs/test-ssl-cert.pem +++ b/certs/test-ssl-cert.pem @@ -1,19 +1,19 @@ -----BEGIN CERTIFICATE----- -MIIDCTCCAfGgAwIBAgIUYN0EUBLwq7uoLwDuTx7gDW0HS2UwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDUxNDIxMTUzMVoXDTI2MDUx -NDIxMTUzMVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAmxdCF7xCJcE6yyG+wrdhHMwRiQxbhDLW/hiKQcO/nn7z -QMM9LAV04+WG5LCmm0ygocbfXXhfWn5D6SSSESKTb8dbSj6AJbLSmYjA5vDSzh9R -rO9GzNTDCDne+epo+rN4BOjGh7K83naLei/bEfmklMp7x+TyoBC8Ps/3Eq4HOJfd -UzgV2L3oC/4dCbAnkgK2zanL8KEaH6aM0HytIaqMFYLBs2t8s7HHHcSEadHfjlJu -GwTmTS0nVhJBWYJvF6Pv/SwLFuSo93TJybaMMUSF3oJK35NEYXg6EtibJLUC9RvX -FVLytRA5z+x7FBnGBdi4ctMseecokV4u15ePCB3MXwIDAQABo1MwUTAdBgNVHQ4E -FgQUxHrQZFFBfTfuXeOOoXmHZQ8E6rswHwYDVR0jBBgwFoAUxHrQZFFBfTfuXeOO -oXmHZQ8E6rswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAj0hy -bwWh2B26cR0ADhuDC0MtGPBH0BrHypJEZ96nUPTY4oxRrjZusgdq60ooqsNyIW6k -cZHkajC/O0V6hnV4yMhLE8ZwA+31iyQakonpT5N1OON4Ddv9Bfvx8xOn75x/+RP+ -GlAa31XxivryIi/5y7MEI0PwU34T/6bbMWdBaFRQtbuIXJ/90AZ0fBwIV0vJWjaO -1DriZAJe7hl63ZUw6CsfutpoyKkanF5GQB2CpolR3t1oeHwuDbZ550p1g2XFB6UI -9W+zlggQFeAnthzMoi3erO4sQ3j2b15QLZbk1HXHZcn3+89QcvdUcpG0u51bZdFW -SJ+bnT3TZ9H+szoa1w== +MIIDJTCCAg2gAwIBAgIUVGeg7p1/ntt2W18CUNjYSpsMnbkwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkyNDE1MjA0MloXDTM2MDky +MTE1MjA0MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAvG0x4QrDDFIfdhJNqX0DBYtPvSpt6ByBexatAZFF5M7I +3FOqAAauX/HriseKG7QtVQkvsQdlZsMyYsQN4amzxDAAg+8FAtKU2no7kMKx4G1m +YKb7d2GBgoEHMdO5Rc42P2YUR8KUpf1MjBHdcA4sCDwMOR5YDkkIeT/ZmPbcsz9R +FDgp09xAYd6XTbS68lnVqh4yQF4+7KhamnzY50Zx6xBvjGj4BOowB7IKYVf4+387 +25BnqgrkmXEcpkOjf16Q8GShN5s1HEDzEet4LzD82WYSF+/0+m5HdHKda32McsR3 +SyIJu4PDoBhTTAl/i1R9xHJUwU3A+tsLwlup2qBmrQIDAQABo28wbTAdBgNVHQ4E +FgQUWuHWOI25OAqQYnkrdBvw3H6GdJkwHwYDVR0jBBgwFoAUWuHWOI25OAqQYnkr +dBvw3H6GdJkwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAKiEKdTGrg88U7rwg8bm51LxLtqnt0NT +gTCtAZRHAmeQHFx/mklb5ffLZnmZ5pNyN7+tP+5fetStiRzGNU+v4+d8t5SucV2x +Auj2HooDyax24EUUbLS8ra6eiUJHt/qAy2yvaF2JKArIfbVlUnAu/P3PEDj3WPV9 +Ywe38MXdEXFeX1HGzB2kXM888YqZYt9iHrm/2fuBxsUhopY2rw5xBQVakaurh42O +QmXtr59K8Vox22fEMyPze9zOk/SVM2BXQiIAoONKN+Pr+bNBGqC8PVeG4cvhDYO/ +HoHa76jv+K2FYQX/WpnEa80uFgmttxMFGc2iKMKEBOA9spyPkUyxj+g= -----END CERTIFICATE----- diff --git a/certs/test-ssl-key.pem b/certs/test-ssl-key.pem index cf4f9ef0..f516718a 100644 --- a/certs/test-ssl-key.pem +++ b/certs/test-ssl-key.pem @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCbF0IXvEIlwTrL -Ib7Ct2EczBGJDFuEMtb+GIpBw7+efvNAwz0sBXTj5YbksKabTKChxt9deF9afkPp -JJIRIpNvx1tKPoAlstKZiMDm8NLOH1Gs70bM1MMIOd756mj6s3gE6MaHsrzedot6 -L9sR+aSUynvH5PKgELw+z/cSrgc4l91TOBXYvegL/h0JsCeSArbNqcvwoRofpozQ -fK0hqowVgsGza3yzsccdxIRp0d+OUm4bBOZNLSdWEkFZgm8Xo+/9LAsW5Kj3dMnJ -towxRIXegkrfk0RheDoS2JsktQL1G9cVUvK1EDnP7HsUGcYF2Lhy0yx55yiRXi7X -l48IHcxfAgMBAAECggEAAft4dHXgi+ZTX7iiXTobe1MUakxbzcMZ7QzX6jfxTA+o -APeTNuvURFFxDvKUaT8VJ9wzNgOi3F+UHffCB4a0nGTPmDxXm6O/KLKPHKSOso8Y -ln2cdGPHy7l0TdIe3g113EI0FL9GcLrSf5D7Bi4gWhKDJdlETKLKH9dn+2IkD3zE -VM+7pwqYV6XZu2GuZ1om1JE+Hx2D5YLIuRCON0RKpzCLikmM7VErA7sjy7LsCSh9 -ty5n1s9GoNtF+YuOD9WeMWGDonMyJdYWTsmFYoT+LF7W+GHoEwvYm9595QuNnxVx -KQ4P5oKm/EfcSiBhCC8BdCIGch9tQPT7c/syVhHG4QKBgQDTdUVW7rJNnLtHgWF2 -ubjh9b3ZfxPuTEu6ueKON5XXvSgfsMBNgCxwkemefGJ6xIjDu+swud+2H33Tqj23 -GMMTZ1JEzNYINO1m/laSAK+DcL81q4sLLlJTbBhYeE6FBeEO1hAmU2IiYrgU6zbO -eyo4ysXtFJdnHSR9PHjZpt/cMQKBgQC7wmxw2UNPlmwTzl6l7z5DqYxizIfezb9l -pIYrmcD92asxZKPi1soz8PcOn30gGmjtZn/7FkXFHSRsNknUmqJOEbZrvNCcKndz -O+RbKGs8FAKlyog8k3CTToAng4PutsYrAuK/kx84P9FPCgTxdhejRMkfkSebCJQH -fmXRnlRdjwKBgQCm2Drz0rcBIg9q5hz+zp+gOoOnnusc9TozhQPLbvReGzQTfSTe -gamO0LJiiIYzk+rNdfKmqaJoUwS3A/ZaB8G0B6wT+QNPymMfBsNLxBq4PTfBoy68 -jboLdJjpBVP/BZqEWEa51sTxmK7iYo0F8oxn7yaoX7zucUIfRp2cLl0noQKBgB+K -RG8cgAshiJw3IX0cWEhDdfquwvAxfcJURdmTJXE/HFvavREA5cyd4NKLBhjbdt7S -RhNmpWe8Qn8PC430P+l/XjZw7FYfaBtqZyzM+F6KOfuhrwsF9XY5TJvWotX5zAYz -oOVvkGIBjmaJl1T8cnIRvvtXheCsKzmrCO2SfDePAoGAM1ToKwXSeZEm2Kyf/PV9 -74lvBKYP5LP+pSmrcTq9jUbPQy3KmlhBi+kyhVK+2Awmh0J9tzu83C8lWR25L4mc -/Uwjhv2KwmvJKyZ4/5t/oMZ+BsZERSHj39juLNW+UL82M2heM5tv5/MI779SAmzl -VJMN4N1x+L7408dEGu0j2ds= +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC8bTHhCsMMUh92 +Ek2pfQMFi0+9Km3oHIF7Fq0BkUXkzsjcU6oABq5f8euKx4obtC1VCS+xB2VmwzJi +xA3hqbPEMACD7wUC0pTaejuQwrHgbWZgpvt3YYGCgQcx07lFzjY/ZhRHwpSl/UyM +Ed1wDiwIPAw5HlgOSQh5P9mY9tyzP1EUOCnT3EBh3pdNtLryWdWqHjJAXj7sqFqa +fNjnRnHrEG+MaPgE6jAHsgphV/j7fzvbkGeqCuSZcRymQ6N/XpDwZKE3mzUcQPMR +63gvMPzZZhIX7/T6bkd0cp1rfYxyxHdLIgm7g8OgGFNMCX+LVH3EclTBTcD62wvC +W6naoGatAgMBAAECggEABUZ1DYhaYpUiSeyWi0/YBXSkpqRWz84eNr7Dj59Q7Pqc +ybJSyruJuTqkJOCT9nnhO+ED45T4yLbMt0Am5VaslReXOvXIxHzRxwhddB60/3G3 +fxum0aQDjde2xqL74vYPxb0LF07v63IIxISPPLpg2DS+vEnIjFL6r2Uuud4W2rKk +3UXUHIpeZdOIZOAkXepXzqj5YWLREqzuYKIR332bj+0ik9DC0tr5ZcGJuEBKsGi8 +X+mBU9LLL2Kr+3RvAUr8blkxrx2Zp4qkSDQxVIDacXSd7tC0BWIT8RjHHBs0xEa+ +CA6zvF6Wxts+Jb+Q07BZlD3NqLwSYwIdhSSBBORO8QKBgQDk/cnZ3J5+M7mfDupH +A9bH7jimI/w8E4l2+ZJRT1kQLy0zZKE/wQzyH6qK5fqWuotUX7lE2Y4b8Gbo2pc8 +uB8YhMSdsHpPfY8wMsMrrFGblf66MjWm7cshlvRoy+oVi+22Va0S9mKltr2hGBnd ++C328p5oL7u9MrZZct+Mz8HldQKBgQDSppWBYE6Uem9Tf3xk6ihH7QAZ9VkROGqN ++wwVs7m62zarwMhl44UbH1IHMLJpx691BaOOyA8ioSWO0YuWrw0o9q9Y3U5Le98K +wk8aLMJyHOBO0VaG4lZS0WdBL9duZy5z3mwnICyqx8ur73gJfGxINao9W9cz4IzU +I7C2SUf9WQKBgQC9Glxb/tYgMEUE9oNAHAp91tuPUD5XumaWCunrHdTWCa+RNnP3 +O0yeksn6pIs5eFnhVp+gHO0/Y1sIxqOIcCC11cas1nVbObxKuFD2uvo1uzu9tgMa +lFtgpNG3zVXFb2XiYoRoGXJDmVKmO0n5RZ9nxNpN/cVTLBLscXHtThC4lQKBgQCL +kxU8xDNM7lMVYENsGjrWbZLDoG2Dm33+1XkMaCmIRffTlckyua5YakhPu1R8AnOf +dj0JPkXJDD12hCGZliOCIPHOT9Sw70K6PkVrcnzkX33Au7Q5a38HIPjpmGmGHZgJ +7jZ7NjZicj/hjKpcnrSgo+5I+bF+DitB5OQR5DxvAQKBgCtqFGcgblexwcfnduwk +SSgg8NiySq+jABDkRUMienBtbugZpLQVKj0TZn6ldPjeUAl4ZAn8U8N4lHhXZgdT +dFio8hMQnjIJzyDC0PO9fHD1madsctPtLU+3eYYN8+/U8BxHUX71+tbUH5JtSBPV +7mCMEYAEKI1vaiGwyDaqjI23 -----END PRIVATE KEY----- diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index 45992659..df255b14 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -1495,6 +1495,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not Found", "content": { diff --git a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts index 78128c11..6b9a5a2c 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts @@ -2,6 +2,10 @@ import { AppMode, AdvancedWalletManagerConfig, TlsMode, SigningMode } from '../. import { app as advancedWalletManagerApp } from '../../../advancedWalletManagerApp'; import express from 'express'; +import https from 'https'; +import fs from 'fs'; +import path from 'path'; +import { createHash, X509Certificate } from 'crypto'; import nock from 'nock'; import 'should'; import * as request from 'supertest'; @@ -13,9 +17,13 @@ describe('recoveryMpcV2', () => { let cfg: AdvancedWalletManagerConfig; let app: express.Application; let agent: request.SuperAgentTest; + let server: https.Server; + const testCert = fs.readFileSync(path.resolve(__dirname, '../../../../certs/test-ssl-cert.pem')); + const testKey = fs.readFileSync(path.resolve(__dirname, '../../../../certs/test-ssl-key.pem')); + const fingerprint = new X509Certificate(testCert).fingerprint256.replace(/:/g, '').toUpperCase(); // test config - const keyProviderUrl = 'http://key-provider.invalid'; + const keyProviderUrl = 'https://key-provider.invalid'; const ethLikeCoin = 'hteth'; const cosmosLikeCoin = 'tsei'; const accessToken = 'test-token'; @@ -71,7 +79,21 @@ describe('recoveryMpcV2', () => { timeout: 60000, keyProviderUrl: keyProviderUrl, httpLoggerFile: '', - tlsMode: TlsMode.DISABLED, + tlsMode: TlsMode.MTLS, + mtlsAllowedClientFingerprints: [fingerprint], + mpcv2RecoveryAllowedClientFingerprints: [fingerprint], + mpcv2RecoveryApprovals: [ + { + coin: ethLikeCoin, + pub: commonKeychain, + txHexSha256: createHash('sha256').update(Buffer.from(input.txHex, 'hex')).digest('hex'), + }, + { + coin: cosmosLikeCoin, + pub: commonKeychain, + txHexSha256: createHash('sha256').update(Buffer.from(input.txHex, 'hex')).digest('hex'), + }, + ], clientCertAllowSelfSigned: true, recoveryMode: true, }; @@ -80,7 +102,17 @@ describe('recoveryMpcV2', () => { // app setup app = advancedWalletManagerApp(cfg); - agent = request.agent(app); + server = https.createServer( + { cert: testCert, key: testKey, ca: testCert, requestCert: true, rejectUnauthorized: true }, + app, + ); + agent = request.agent(server); + }); + + beforeEach(() => { + nock('https://app.bitgo-test.com') + .get('/api/v1/client/constants') + .reply(200, { constants: {} }); }); afterEach(() => { @@ -89,6 +121,7 @@ describe('recoveryMpcV2', () => { after(() => { sandbox.restore(); + server.close(); }); // happy path test @@ -107,6 +140,9 @@ describe('recoveryMpcV2', () => { const ethLikeSignatureResponse = await agent .post(`/api/${ethLikeCoin}/mpcv2/recovery`) + .cert(testCert) + .key(testKey) + .ca(testCert) .set('Authorization', `Bearer ${accessToken}`) .send(input); @@ -123,6 +159,9 @@ describe('recoveryMpcV2', () => { const cosmosLikeSignatureResponse = await agent .post(`/api/${cosmosLikeCoin}/mpcv2/recovery`) + .cert(testCert) + .key(testKey) + .ca(testCert) .set('Authorization', `Bearer ${accessToken}`) .send(input); @@ -141,9 +180,36 @@ describe('recoveryMpcV2', () => { backupKeyProviderNock.isDone().should.be.true(); }); + it('rejects shares that do not belong to the approved wallet', async () => { + const [differentWalletShare] = await DklsUtils.generateDKGKeyShares(); + const userKeyRequest = nock(keyProviderUrl) + .get(`/key/${input.pub}`) + .query({ source: 'user' }) + .reply(200, mockKeyProviderUserResponse); + const backupKeyRequest = nock(keyProviderUrl) + .get(`/key/${input.pub}`) + .query({ source: 'backup' }) + .reply(200, { + ...mockKeyProviderBackupResponse, + prv: differentWalletShare.getKeyShare().toString('base64'), + }); + + const response = await agent + .post(`/api/${ethLikeCoin}/mpcv2/recovery`) + .cert(testCert) + .key(testKey) + .ca(testCert) + .send(input); + + response.status.should.equal(400); + response.body.details.should.equal('Recovery key shares do not match the approved wallet'); + userKeyRequest.isDone().should.be.true(); + backupKeyRequest.isDone().should.be.true(); + }); + it('should route backup key retrieval to backup KMS when configured', async () => { - const kmsUrl = 'http://kms.invalid'; - const backupKmsUrl = 'http://backup-kms.invalid'; + const kmsUrl = 'https://kms.invalid'; + const backupKmsUrl = 'https://backup-kms.invalid'; const mockKmsUserResponse = { prv: JSON.stringify(userKeyShare), @@ -167,7 +233,11 @@ describe('recoveryMpcV2', () => { }; configStub.returns(dualCfg); const dualApp = advancedWalletManagerApp(dualCfg); - const dualAgent = request.agent(dualApp); + const dualServer = https.createServer( + { cert: testCert, key: testKey, ca: testCert, requestCert: true, rejectUnauthorized: true }, + dualApp, + ); + const dualAgent = request.agent(dualServer); // User key served from primary KMS const userKmsNock = nock(kmsUrl) @@ -185,6 +255,9 @@ describe('recoveryMpcV2', () => { const response = await dualAgent .post(`/api/${ethLikeCoin}/mpcv2/recovery`) + .cert(testCert) + .key(testKey) + .ca(testCert) .set('Authorization', `Bearer ${accessToken}`) .send(input); @@ -194,27 +267,25 @@ describe('recoveryMpcV2', () => { userKmsNock.isDone().should.be.true(); backupKmsNock.isDone().should.be.true(); + dualServer.close(); }); - // failure test case - it('should throw 400 Bad Request if failed to construct eth transaction from message hex', async () => { + it('rejects malformed unsigned transaction bytes before retrieving shares', async () => { const input = { txHex: 'invalid-hex', pub: commonKeychain, }; - // nocks for key provider responses - nock(keyProviderUrl) + const keyRequest = nock(keyProviderUrl) .get(`/key/${input.pub}`) .query({ source: 'user' }) .reply(200, mockKeyProviderUserResponse); - nock(keyProviderUrl) - .get(`/key/${input.pub}`) - .query({ source: 'backup' }) - .reply(200, mockKeyProviderBackupResponse); const signatureResponse = await agent .post(`/api/${ethLikeCoin}/mpcv2/recovery`) + .cert(testCert) + .key(testKey) + .ca(testCert) .set('Authorization', `Bearer ${accessToken}`) .send(input); @@ -222,8 +293,174 @@ describe('recoveryMpcV2', () => { signatureResponse.body.should.have.property('error'); signatureResponse.body.error.should.equal('BadRequestError'); signatureResponse.body.should.have.property('details'); - signatureResponse.body.details.should.startWith( - 'Failed to construct eth transaction from message hex', + signatureResponse.body.details.should.equal('Recovery transaction must be non-empty hex bytes'); + keyRequest.isDone().should.be.false(); + }); +}); + +describe('mpcv2 recovery with recovery mode disabled', () => { + it('rejects before retrieving either private share', async () => { + const keyProviderUrl = 'https://key-provider.invalid'; + const config: AdvancedWalletManagerConfig = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + keyProviderUrl, + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + recoveryMode: false, + }; + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + const pub = 'synthetic-common-keychain'; + const keyRequest = nock(keyProviderUrl) + .get(`/key/${pub}`) + .query({ source: 'user' }) + .reply(200, { prv: 'synthetic-private-share' }); + const response = await request + .agent(advancedWalletManagerApp(config)) + .post('/api/hteth/mpcv2/recovery') + .send({ + pub, + txHex: + '02f6824268018502540be4008504a817c80083030d409443442e403d64d29c4f64065d0c1a0e8edc03d6c88801550f7dca700000823078c0', + }); + + response.status.should.equal(500); + response.body.details.should.equal( + 'Recovery operations are not enabled. The server must be in recovery mode to perform this action.', + ); + keyRequest.isDone().should.be.false(); + nock.cleanAll(); + }); +}); + +describe('mpcv2 recovery authorization', () => { + const testCert = fs.readFileSync(path.resolve(__dirname, '../../../../certs/test-ssl-cert.pem')); + const testKey = fs.readFileSync(path.resolve(__dirname, '../../../../certs/test-ssl-key.pem')); + const fingerprint = new X509Certificate(testCert).fingerprint256.replace(/:/g, '').toUpperCase(); + const pub = 'ab'.repeat(65); + const txHex = 'deadbeef'; + const keyProviderUrl = 'https://key-provider.invalid'; + const cfg: AdvancedWalletManagerConfig = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + keyProviderUrl, + tlsMode: TlsMode.MTLS, + clientCertAllowSelfSigned: true, + recoveryMode: true, + mtlsAllowedClientFingerprints: [fingerprint], + mpcv2RecoveryAllowedClientFingerprints: [fingerprint], + mpcv2RecoveryApprovals: [ + { + coin: 'hteth', + pub, + txHexSha256: createHash('sha256').update(Buffer.from(txHex, 'hex')).digest('hex'), + }, + ], + }; + const app = advancedWalletManagerApp(cfg); + const server = https.createServer( + { cert: testCert, key: testKey, ca: testCert, requestCert: true, rejectUnauthorized: false }, + app, + ); + const agent = request.agent(server); + + before(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + + afterEach(() => nock.cleanAll()); + after(() => server.close()); + + it('rejects missing mTLS identity through the application middleware', async () => { + const keyRequest = nock(keyProviderUrl).get(`/key/${pub}`).query({ source: 'user' }).reply(200); + const response = await agent + .post('/api/hteth/mpcv2/recovery') + .ca(testCert) + .send({ pub, txHex }); + response.status.should.equal(403); + response.body.details.should.equal('Please provide a valid client certificate in your request'); + keyRequest.isDone().should.be.false(); + }); + + it('rejects a generally allowed mTLS client lacking recovery access before key lookup', async () => { + cfg.mpcv2RecoveryAllowedClientFingerprints = []; + const keyRequest = nock(keyProviderUrl).get(`/key/${pub}`).query({ source: 'user' }).reply(200); + try { + const response = await agent + .post('/api/hteth/mpcv2/recovery') + .cert(testCert) + .key(testKey) + .ca(testCert) + .send({ pub, txHex }); + response.status.should.equal(403); + response.body.details.should.equal('Client is not authorized for MPCv2 recovery'); + keyRequest.isDone().should.be.false(); + } finally { + cfg.mpcv2RecoveryAllowedClientFingerprints = [fingerprint]; + } + }); + + it('rejects recovery when no operator approvals are configured', async () => { + const approvals = cfg.mpcv2RecoveryApprovals; + cfg.mpcv2RecoveryApprovals = undefined; + const keyRequest = nock(keyProviderUrl).get(`/key/${pub}`).query({ source: 'user' }).reply(200); + try { + const response = await agent + .post('/api/hteth/mpcv2/recovery') + .cert(testCert) + .key(testKey) + .ca(testCert) + .send({ pub, txHex }); + response.status.should.equal(403); + response.body.details.should.equal( + 'Wallet and transaction are not approved for MPCv2 recovery', + ); + keyRequest.isDone().should.be.false(); + } finally { + cfg.mpcv2RecoveryApprovals = approvals; + } + }); + + it('rejects an unapproved transaction before key lookup', async () => { + const keyRequest = nock(keyProviderUrl).get(`/key/${pub}`).query({ source: 'user' }).reply(200); + const response = await agent + .post('/api/hteth/mpcv2/recovery') + .cert(testCert) + .key(testKey) + .ca(testCert) + .send({ pub, txHex: 'deadbeee' }); + response.status.should.equal(403); + response.body.details.should.equal( + 'Wallet and transaction are not approved for MPCv2 recovery', + ); + keyRequest.isDone().should.be.false(); + }); + + it('rejects a wallet not on the approved list before key lookup', async () => { + const keyRequest = nock(keyProviderUrl) + .get('/key/other-wallet') + .query({ source: 'user' }) + .reply(200); + const response = await agent + .post('/api/hteth/mpcv2/recovery') + .cert(testCert) + .key(testKey) + .ca(testCert) + .send({ pub: 'other-wallet', txHex }); + response.status.should.equal(403); + response.body.details.should.equal( + 'Wallet and transaction are not approved for MPCv2 recovery', ); + keyRequest.isDone().should.be.false(); }); }); diff --git a/src/__tests__/api/master/recoveryWalletMpcV2.test.ts b/src/__tests__/api/master/recoveryWalletMpcV2.test.ts index 9c1b0110..703c80d0 100644 --- a/src/__tests__/api/master/recoveryWalletMpcV2.test.ts +++ b/src/__tests__/api/master/recoveryWalletMpcV2.test.ts @@ -106,6 +106,61 @@ describe('MBE mpcv2 recovery', () => { etherscanBalanceNock.isDone().should.be.true(); awmNock.isDone().should.be.true(); }); + it('should propagate AWM authorization failures to the recovery caller', async () => { + const etherscanTxlistNock = nock('https://api.etherscan.io') + .get( + `/v2/api?chainid=560048&module=account&action=txlist&address=0x43442e403d64d29c4f64065d0c1a0e8edc03d6c8&apikey=etherscan-api-key`, + ) + .matchHeader('any', () => true) + .reply(200, { + result: [ + { + from: '0x43442e403d64d29c4f64065d0c1a0e8edc03d6c8', + }, + ], + }); + + const etherscanBalanceNock = nock('https://api.etherscan.io') + .get( + `/v2/api?chainid=560048&module=account&action=balance&address=0x43442e403d64d29c4f64065d0c1a0e8edc03d6c8&apikey=etherscan-api-key`, + ) + .matchHeader('any', () => true) + .reply(200, { + result: '100000000000000000', + }); + + const awmNock = nock(advancedWalletManagerUrl) + .post(`/api/${ethLikeCoin}/mpcv2/recovery`) + .reply(403, { + error: 'ForbiddenError', + details: 'Client is not authorized for MPCv2 recovery', + }); + + const response = await agent + .post(`/api/v1/${ethLikeCoin}/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + isTssRecovery: true, + tssRecoveryParams: { + commonKeychain: + '03ee2aa7a0951e0ddf5568c9e0008a824b46324900fa50bd62f43dd75705924d1c4dea9e1138b0fd34b77fa5ead28f24d8dcd053b48144915514ef32941f823075', + }, + recoveryDestinationAddress: '0x43442e403d64d29c4f64065d0c1a0e8edc03d6c8', + coinSpecificParams: { + ecdsaEthLikeRecoverySpecificParams: { + walletContractAddress: '0x43442e403d64d29c4f64065d0c1a0e8edc03d6c8', + bitgoDestinationAddress: '0x43442e403d64d29c4f64065d0c1a0e8edc03d6c8', + apiKey: 'etherscan-api-key', + }, + }, + }); + + response.status.should.equal(403); + response.body.should.have.property('details', 'Client is not authorized for MPCv2 recovery'); + etherscanTxlistNock.isDone().should.be.true(); + etherscanBalanceNock.isDone().should.be.true(); + awmNock.isDone().should.be.true(); + }); it('should recover a SEI (a cosmos-like) wallet by calling the advanced wallet manager service', async () => { const seiChainIdNock = nock('https://rest.atlantic-2.seinetwork.io') diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index f57b8e08..cbadec44 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -54,6 +54,8 @@ describe('Configuration', () => { delete process.env.AWM_CLIENT_TLS_CERT_PATH; delete process.env.KEY_PROVIDER_SERVER_CA_CERT_PATH; delete process.env.RECOVERY_MODE; + delete process.env.MPCV2_RECOVERY_ALLOWED_CLIENT_FINGERPRINTS; + delete process.env.MPCV2_RECOVERY_APPROVALS; delete process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL; delete process.env.AWM_BACKUP_SERVER_CA_CERT_PATH; delete process.env.AWM_BACKUP_CLIENT_TLS_KEY_PATH; @@ -148,6 +150,39 @@ describe('Configuration', () => { cfg.recoveryMode!.should.be.true(); }); + it('loads dedicated MPCv2 recovery identities and transaction approvals', () => { + process.env.KEY_PROVIDER_URL = 'http://localhost:3000'; + process.env.TLS_MODE = 'disabled'; + process.env.MPCV2_RECOVERY_ALLOWED_CLIENT_FINGERPRINTS = 'ab:cd, EF12'; + const approval = { coin: 'hteth', pub: 'ab'.repeat(65), txHexSha256: 'cd'.repeat(32) }; + process.env.MPCV2_RECOVERY_APPROVALS = JSON.stringify([approval]); + + const cfg = initConfig(); + if (!isAdvancedWalletManagerConfig(cfg)) throw new Error('Expected AWM config'); + cfg.mpcv2RecoveryAllowedClientFingerprints!.should.deepEqual(['ABCD', 'EF12']); + cfg.mpcv2RecoveryApprovals!.should.deepEqual([approval]); + }); + it('normalizes both mTLS fingerprint allowlists consistently', () => { + process.env.KEY_PROVIDER_URL = 'http://localhost:3000'; + process.env.TLS_MODE = 'disabled'; + process.env.MTLS_ALLOWED_CLIENT_FINGERPRINTS = 'sha256:aa:bb, sha256:cc:dd'; + process.env.MPCV2_RECOVERY_ALLOWED_CLIENT_FINGERPRINTS = 'sha256:aa:bb, sha256:cc:dd'; + + const cfg = initConfig(); + if (!isAdvancedWalletManagerConfig(cfg)) throw new Error('Expected AWM config'); + cfg.mtlsAllowedClientFingerprints!.should.deepEqual(['AABB', 'CCDD']); + cfg.mpcv2RecoveryAllowedClientFingerprints!.should.deepEqual(['AABB', 'CCDD']); + }); + + it('rejects malformed MPCv2 recovery approvals instead of enabling an unbounded signer', () => { + process.env.KEY_PROVIDER_URL = 'http://localhost:3000'; + process.env.TLS_MODE = 'disabled'; + process.env.MPCV2_RECOVERY_APPROVALS = '[{"coin":"hteth","pub":"abc","txHexSha256":"123"}]'; + (() => initConfig()).should.throw(/MPCV2_RECOVERY_APPROVALS requires/); + process.env.MPCV2_RECOVERY_APPROVALS = '{'; + (() => initConfig()).should.throw('MPCV2_RECOVERY_APPROVALS must be a JSON array'); + }); + it('should read TLS mode from environment variables', () => { process.env.KEY_PROVIDER_URL = 'http://localhost:3000'; process.env.SERVER_TLS_KEY = mockTlsKey; diff --git a/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts b/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts index 25a2ee04..72d69cb0 100644 --- a/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts +++ b/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import { DklsDsg, DklsTypes, DklsUtils } from '@bitgo-beta/sdk-lib-mpc'; import { AwmApiSpecRouteRequest, @@ -5,14 +6,13 @@ import { } from '../routers/advancedWalletManagerApiSpec'; import { BaseCoin, ECDSAMethodTypes } from '@bitgo-beta/sdk-core'; import { isCosmosLikeCoin, isEcdsaCoin, isEthLikeCoin } from '../../shared/coinUtils'; -import { BadRequestError, NotImplementedError } from '../../shared/errors'; +import { TlsMode } from '../../shared/types'; +import { BadRequestError, ForbiddenError, NotImplementedError } from '../../shared/errors'; import logger from '../../shared/logger'; import coinFactory from '../../shared/coinFactory'; -import { buildBackupKmsConfig, retrieveKeyProviderPrvKey } from './utils/utils'; - -async function getMessageHash(coin: BaseCoin, txHex: string): Promise { - const txBuffer = Buffer.from(txHex, 'hex'); +import { buildBackupKmsConfig, checkRecoveryMode, retrieveKeyProviderPrvKey } from './utils/utils'; +async function getMessageHash(coin: BaseCoin, txBuffer: Buffer): Promise { if (isEthLikeCoin(coin)) { const { TransactionFactory } = await import('@ethereumjs/tx'); try { @@ -42,7 +42,35 @@ async function getMessageHash(coin: BaseCoin, txHex: string): Promise { export async function ecdsaMPCv2Recovery( req: AwmApiSpecRouteRequest<'v1.mpcv2.recovery', 'post'>, ): Promise { + checkRecoveryMode(req.config); + + // The general AWM client allowlist does not grant permission to combine both shares. + const clientCert = (req as typeof req & { clientCert?: { fingerprint256?: string } }).clientCert; + const fingerprint = clientCert?.fingerprint256?.replace(/:/g, '').toUpperCase(); + if ( + req.config.tlsMode !== TlsMode.MTLS || + !fingerprint || + !req.config.mpcv2RecoveryAllowedClientFingerprints?.includes(fingerprint) + ) { + throw new ForbiddenError('Client is not authorized for MPCv2 recovery'); + } + const { txHex, pub } = req.decoded; + if (!/^(?:[0-9a-f]{2})+$/i.test(txHex)) { + throw new BadRequestError('Recovery transaction must be non-empty hex bytes'); + } + const txBuffer = Buffer.from(txHex, 'hex'); + const txHexSha256 = createHash('sha256').update(txBuffer).digest('hex'); + if ( + !req.config.mpcv2RecoveryApprovals?.some( + (approval) => + approval.coin === req.params.coin && + approval.pub.toLowerCase() === pub.toLowerCase() && + approval.txHexSha256.toLowerCase() === txHexSha256, + ) + ) { + throw new ForbiddenError('Wallet and transaction are not approved for MPCv2 recovery'); + } const bitgo = req.bitgo; const coin = await coinFactory.getCoin(req.params.coin, bitgo); @@ -52,17 +80,22 @@ export async function ecdsaMPCv2Recovery( ); } + const txHash = await getMessageHash(coin, txBuffer); + // setup clients and retrieve the keys const backupCfg = buildBackupKmsConfig(req.config); const userPrv = await retrieveKeyProviderPrvKey({ pub, source: 'user', cfg: req.config }); const backupPrv = await retrieveKeyProviderPrvKey({ pub, source: 'backup', cfg: backupCfg }); - // construct tx builder - const txHash = await getMessageHash(coin, txHex); - // construct buffers const userPrvBuffer = Buffer.from(userPrv, 'base64'); const backupPrvBuffer = Buffer.from(backupPrv, 'base64'); + if ( + DklsTypes.getCommonKeychain(userPrvBuffer).toLowerCase() !== pub.toLowerCase() || + DklsTypes.getCommonKeychain(backupPrvBuffer).toLowerCase() !== pub.toLowerCase() + ) { + throw new BadRequestError('Recovery key shares do not match the approved wallet'); + } // construct distributed signature generation sessions const userDsg = new DklsDsg.Dsg(userPrvBuffer, 0, 'm/0', txHash); diff --git a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts index 068bf06b..4b88ef32 100644 --- a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts +++ b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts @@ -18,7 +18,7 @@ import express from 'express'; import * as t from 'io-ts'; import coinFactory from '../../shared/coinFactory'; -import { ErrorResponses, NotImplementedError } from '../../shared/errors'; +import { ErrorResponses, ForbiddenResponse, NotImplementedError } from '../../shared/errors'; import { postIndependentKey } from '../handlers/postIndependentKey'; import { recoveryMultisigTransaction } from '../handlers/multisigRecovery'; @@ -479,6 +479,7 @@ export const AdvancedWalletManagerApiSpec = apiSpec({ response: { 200: MpcV2RecoveryResponseType, ...ErrorResponses, + ...ForbiddenResponse, }, description: 'Recover a MPC transaction', }), diff --git a/src/initConfig.ts b/src/initConfig.ts index 00a84423..cdda43eb 100644 --- a/src/initConfig.ts +++ b/src/initConfig.ts @@ -32,6 +32,49 @@ function readEnvVar(name: string): string | undefined { } } +function readFingerprintList(name: string): string[] | undefined { + return readEnvVar(name) + ?.split(',') + .map((fingerprint) => + fingerprint + .trim() + .replace(/^sha256:/i, '') + .replace(/:/g, '') + .toUpperCase(), + ); +} + +function readMpcv2RecoveryApprovals(): AdvancedWalletManagerConfig['mpcv2RecoveryApprovals'] { + const value = readEnvVar('MPCV2_RECOVERY_APPROVALS'); + if (!value) return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error('MPCV2_RECOVERY_APPROVALS must be a JSON array'); + } + if ( + !Array.isArray(parsed) || + !parsed.every( + (entry) => + entry !== null && + typeof entry === 'object' && + typeof entry.coin === 'string' && + entry.coin.length > 0 && + typeof entry.pub === 'string' && + /^[0-9a-f]{130}$/i.test(entry.pub) && + typeof entry.txHexSha256 === 'string' && + /^[0-9a-f]{64}$/i.test(entry.txHexSha256), + ) + ) { + throw new Error( + 'MPCV2_RECOVERY_APPROVALS requires coin, 130-hex pub, and 64-hex txHexSha256 per entry', + ); + } + return parsed; +} + function readCertFile(filePath: string, label: string): string { try { const content = fs.readFileSync(filePath, 'utf-8'); @@ -179,7 +222,11 @@ function advancedWalletManagerEnvConfig(): Partial serverTlsCert: readEnvVar('SERVER_TLS_CERT'), tlsMode: determineTlsMode(), signingMode: determineSigningMode(), - mtlsAllowedClientFingerprints: readEnvVar('MTLS_ALLOWED_CLIENT_FINGERPRINTS')?.split(','), + mtlsAllowedClientFingerprints: readFingerprintList('MTLS_ALLOWED_CLIENT_FINGERPRINTS'), + mpcv2RecoveryAllowedClientFingerprints: readFingerprintList( + 'MPCV2_RECOVERY_ALLOWED_CLIENT_FINGERPRINTS', + ), + mpcv2RecoveryApprovals: readMpcv2RecoveryApprovals(), clientCertAllowSelfSigned: readEnvVar('CLIENT_CERT_ALLOW_SELF_SIGNED') === 'true', recoveryMode: readEnvVar('RECOVERY_MODE') === 'true', }; @@ -229,6 +276,8 @@ function mergeAkmConfigs( tlsMode: get('tlsMode'), signingMode: get('signingMode'), mtlsAllowedClientFingerprints: get('mtlsAllowedClientFingerprints'), + mpcv2RecoveryAllowedClientFingerprints: get('mpcv2RecoveryAllowedClientFingerprints'), + mpcv2RecoveryApprovals: get('mpcv2RecoveryApprovals'), clientCertAllowSelfSigned: get('clientCertAllowSelfSigned'), recoveryMode: get('recoveryMode'), }; @@ -438,7 +487,7 @@ function masterExpressEnvConfig(): Partial { serverTlsKey: readEnvVar('SERVER_TLS_KEY'), serverTlsCert: readEnvVar('SERVER_TLS_CERT'), tlsMode, - mtlsAllowedClientFingerprints: readEnvVar('MTLS_ALLOWED_CLIENT_FINGERPRINTS')?.split(','), + mtlsAllowedClientFingerprints: readFingerprintList('MTLS_ALLOWED_CLIENT_FINGERPRINTS'), clientCertAllowSelfSigned, recoveryMode: readEnvVar('RECOVERY_MODE') === 'true', asyncModeConfig: readAsyncModeConfig(isAsyncMode), diff --git a/src/masterBitgoExpress/routers/recoveryRoute.ts b/src/masterBitgoExpress/routers/recoveryRoute.ts index 2eda763b..4a1548cf 100644 --- a/src/masterBitgoExpress/routers/recoveryRoute.ts +++ b/src/masterBitgoExpress/routers/recoveryRoute.ts @@ -1,6 +1,6 @@ import { httpRequest, HttpResponse, httpRoute, optional } from '@api-ts/io-ts-http'; import * as t from 'io-ts'; -import { ErrorResponses } from '../../shared/errors'; +import { ErrorResponses, ForbiddenResponse } from '../../shared/errors'; import { AsyncJobResponseCodec } from './generateWalletRoute'; /** @@ -206,6 +206,7 @@ const RecoveryWalletResponse: HttpResponse = { 200: RecoveryWalletResponseCodec, 202: AsyncJobResponseCodec, ...ErrorResponses, + ...ForbiddenResponse, }; /** diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 4625355d..d24b0727 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -113,6 +113,7 @@ const ErrorResponse = t.type({ details: t.string, }); export const BadRequestResponse = { 400: ErrorResponse }; +export const ForbiddenResponse = { 403: ErrorResponse }; export const NotFoundResponse = { 404: ErrorResponse }; export const ConflictErrorResponse = { 409: ErrorResponse }; export const UnprocessableEntityResponse = { 422: ErrorResponse }; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index b1b4ce66..31e0527e 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -16,6 +16,12 @@ export enum KeySource { export type UserOrBackupKey = KeySource.USER | KeySource.BACKUP; +export interface MpcV2RecoveryApproval { + coin: string; + pub: string; + txHexSha256: string; +} + export enum AppMode { ADVANCED_WALLET_MANAGER = 'advanced-wallet-manager', MASTER_EXPRESS = 'master-express', @@ -65,6 +71,10 @@ export interface AdvancedWalletManagerConfig extends BaseConfig { serverTlsCert?: string; tlsMode: TlsMode; mtlsAllowedClientFingerprints?: string[]; + // Only these mTLS identities may combine MPCv2 user and backup shares. + mpcv2RecoveryAllowedClientFingerprints?: string[]; + // Operator-approved wallet keychains and exact unsigned transaction digests. + mpcv2RecoveryApprovals?: MpcV2RecoveryApproval[]; clientCertAllowSelfSigned?: boolean; signingMode: SigningMode; }