Skip to content

feat(sdk-api): SJCL-free v1 decrypt with temp fallback - #9508

Open
pranavjain97 wants to merge 1 commit into
masterfrom
pranavjain/wcn-43-remove-sjcl-decrypt-pr1
Open

feat(sdk-api): SJCL-free v1 decrypt with temp fallback#9508
pranavjain97 wants to merge 1 commit into
masterfrom
pranavjain/wcn-43-remove-sjcl-decrypt-pr1

Conversation

@pranavjain97

@pranavjain97 pranavjain97 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces a native (SJCL-free) v1 decrypt path in @bitgo/sdk-api using node:crypto on the server and crypto-browserify via webpack's shim in the browser. Public decrypt() routes v1 envelopes through the native path with a
temporary sjcl.decrypt fallback 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

  • v1 envelopes: native node:crypto → SJCL fallback + console.warn on native failure
  • v2 envelopes: unchanged (already native Argon2id)
  • Envelope validation throws before the fallback — no SJCL bypass on malformed input
  • Success on native is silent; a warn is a real signal to investigate

Test evidence

  • 32 Node parity tests against SJCL-produced envelopes (aes-128/256, tag sizes, adata, UTF-8, >64 KiB plaintext, 50 randomised inputs, malformed rejection)
  • 12 browser-shim parity tests through crypto-browserify
  • 8 real BitGo keycard fixture assertions: byte-for-byte identical output between SJCL, node:crypto, and crypto-browserify on user + backup keys from a throwaway testnet Solana wallet
  • 3 fallback behavior tests
  • 250 sdk-api tests pass, lint + tsc clean

Test plan

  • v1-encrypted encryptedPrv/keycards decrypts correctly in Express/sdk
  • Tx signing flow end-to-end on express

@linear-code

linear-code Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

WCN-43

@pranavjain97 pranavjain97 changed the title feat(sdk-api): SJCL-free v1 decrypt; Node default, browser opt-in feat(sdk-api): SJCL-free v1 decrypt with temporary fallback Aug 14, 2026
@pranavjain97 pranavjain97 changed the title feat(sdk-api): SJCL-free v1 decrypt with temporary fallback feat(sdk-api): SJCL-free v1 decrypt with temp fallback Aug 14, 2026
@pranavjain97
pranavjain97 force-pushed the pranavjain/wcn-43-remove-sjcl-decrypt-pr1 branch from 72f280a to 9c43b24 Compare August 14, 2026 18:45
@pranavjain97
pranavjain97 marked this pull request as ready for review August 14, 2026 18:49
@pranavjain97
pranavjain97 requested review from a team as code owners August 14, 2026 18:49
@pranavjain97
pranavjain97 force-pushed the pranavjain/wcn-43-remove-sjcl-decrypt-pr1 branch from 9c43b24 to 7cd08ed Compare August 14, 2026 19:14
Comment thread modules/sdk-api/test/unit/decryptV1.ts Outdated

/** 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'))

Comment on lines +85 to +92
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@pranavjain97
pranavjain97 force-pushed the pranavjain/wcn-43-remove-sjcl-decrypt-pr1 branch from 7cd08ed to 2fab2b3 Compare August 14, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants