feat(sdk-api): SJCL-free v1 decrypt with temp fallback - #9508
feat(sdk-api): SJCL-free v1 decrypt with temp fallback#9508pranavjain97 wants to merge 1 commit into
Conversation
72f280a to
9c43b24
Compare
9c43b24 to
7cd08ed
Compare
|
|
||
| /** sjcl.encrypt types insist on salt/iv but the runtime generates them if omitted. */ | ||
| function sjclEncrypt(password: string, plaintext: string, params: sjcl.SjclCipherParams): string { | ||
| return sjcl.encrypt(password, plaintext, params as sjcl.SjclCipherEncryptParams); |
There was a problem hiding this comment.
might be able to just pass generated salt/iv so this call type-checks without the as
const salt = ...
const iv = ...
return sjcl.encrypt(password, plaintext, { ...params, salt, iv });| }); | ||
| }); | ||
|
|
||
| describe('SJCL fallback behavior', () => { |
There was a problem hiding this comment.
a test like this could be worth adding before fallback is removed
it('falls back to SJCL when native throws a non-auth error', async () => {
const ct = await encrypt(password, plaintext, { encryptionVersion: 1 });
const stub = sinon.stub(decryptV1Module, 'decryptV1').rejects(new Error('unsupported algorithm'));
try {
assert.strictEqual(await decrypt(password, ct), plaintext);
assert.strictEqual(warnings.length, 1);
} finally {
stub.restore();
}
});| return 15 - L; | ||
| } | ||
|
|
||
| async function decryptV1Browser(password: string, ciphertext: string): Promise<string> { |
There was a problem hiding this comment.
ccmNonceLength / decryptV1Browser seems to copy the decrypt steps, it proves the copy works with crypto-browserify but a Node-only crypto call in the real module would still pass these tests.
try calling the real decrypt and pass in crypto-browserify instead and delete ccmNonceLength / decryptV1Browser here
// in decryptV1.ts
export async function decryptV1(password: string, ciphertext: string): Promise<string> {
return decryptV1WithCrypto(password, ciphertext, { pbkdf2, createDecipheriv });
}
// test: decryptV1WithCrypto(password, ciphertext, require('crypto-browserify'))| try { | ||
| return await decryptV1(password, ciphertext); | ||
| } catch (nativeErr) { | ||
| const message = nativeErr instanceof Error ? nativeErr.message : String(nativeErr); | ||
| // eslint-disable-next-line no-console | ||
| console.warn('[bitgo-sdk] v1 native decrypt failed on well-formed envelope; using SJCL fallback:', message); | ||
| return sjcl.decrypt(password, ciphertext); | ||
| } |
There was a problem hiding this comment.
in this try/catch a wrong password / bad auth tag still hits the sjcl fallback + console.warn? might want to rethrow actual auth failures, and only have the fallback on unexpected crypto errors.
try {
return await decryptV1(password, ciphertext);
} catch (nativeErr) {
if (isAuthFailure(nativeErr)) {
throw nativeErr;
}
const message = nativeErr instanceof Error ? nativeErr.message : String(nativeErr);
// eslint-disable-next-line no-console
console.warn('[bitgo-sdk] v1 native decrypt failed on well-formed envelope; using SJCL fallback:', message);
return sjcl.decrypt(password, ciphertext);
}Introduces decryptV1, a native (SJCL-free) replacement for v1 envelope decrypt using node:crypto on the server and crypto-browserify via the existing webpack shim in browser bundles. Byte-for-byte compatible with SJCL's envelope format. Public decrypt() wraps the native path in a temporary SJCL fallback so callers are never blocked if native fails on an unexpected envelope shape. Envelope validation (parseV1Envelope) runs BEFORE the try/catch, so malformed input still throws immediately -- only crypto-level failures on well-formed envelopes fall through to sjcl.decrypt. Auth failures (wrong password, tampered ciphertext) are rethrown as-is; only unexpected native errors emit a console.warn and enter the fallback. decryptV1WithCrypto accepts an injected crypto module so the browser shim test can exercise the real decrypt code with crypto-browserify instead of duplicating the CCM logic. decryptV1WithFallback is exported and accepts an injected native fn so tests can trigger the fallback path deterministically without stub frameworks. An io-ts codec enforces an iter cap of 100k on v1 envelopes up front, before any KDF work runs. Test coverage (251 total, all passing): - 32 Node parity tests (aes-128/256, adata, UTF-8, >64KiB, 128-bit tags, 50 randomised inputs, malformed envelope rejection) - 12 browser-shim parity tests via crypto-browserify - 8 real BitGo keycard fixture assertions across both paths - 4 fallback behavior tests including a deterministic non-auth fallback test that injects a broken native fn TICKET: WCN-2079
7cd08ed to
2fab2b3
Compare
Summary
Introduces a native (SJCL-free) v1 decrypt path in
@bitgo/sdk-apiusingnode:cryptoon the server andcrypto-browserifyvia webpack's shim in the browser. Publicdecrypt()routes v1 envelopes through the native path with atemporary
sjcl.decryptfallback so callers are never blocked if native fails on an unexpected envelope shape.Part of the ongoing SJCL migration. Also introduces an io-ts codec that validates v1 envelope shape and bounds params before any KDF work.
Behavior
node:crypto→ SJCL fallback +console.warnon native failureTest evidence
crypto-browserifynode:crypto, andcrypto-browserifyon user + backup keys from a throwaway testnet Solana walletTest plan