-
Notifications
You must be signed in to change notification settings - Fork 307
feat(sdk-api): SJCL-free v1 decrypt with temp fallback #9508
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pranavjain97
wants to merge
1
commit into
master
Choose a base branch
from
pranavjain/wcn-43-remove-sjcl-decrypt-pr1
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+732
−6
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import { base64String, boundedInt, decodeWithCodec } from '@bitgo/sdk-core'; | ||
| import { createDecipheriv, pbkdf2 } from 'crypto'; | ||
| import * as t from 'io-ts'; | ||
| import { promisify } from 'util'; | ||
|
|
||
| /** | ||
| * Minimal shape the decrypt path needs from a crypto module. Both `node:crypto` | ||
| * and `crypto-browserify` satisfy this. Passing this in from tests lets the | ||
| * browser-shim test suite exercise the real decrypt code instead of a copy. | ||
| */ | ||
| export interface CryptoModule { | ||
| pbkdf2: typeof pbkdf2; | ||
| createDecipheriv: typeof createDecipheriv; | ||
| } | ||
|
|
||
| const defaultCrypto: CryptoModule = { pbkdf2, createDecipheriv }; | ||
|
|
||
| /** | ||
| * Upper bound on PBKDF2 iterations accepted from a v1 envelope. BitGo-produced | ||
| * v1 envelopes use 10,000; this cap is 10x that. Envelope validation enforces | ||
| * it up front before any KDF work runs. | ||
| */ | ||
| export const V1_MAX_ITER = 100_000; | ||
|
|
||
| /** | ||
| * io-ts codec for a v1 (SJCL) envelope. | ||
| * | ||
| * Enforces the shape and the `iter` cap up front, before any KDF work runs. | ||
| */ | ||
| const V1EnvelopeCodec = t.intersection([ | ||
| t.type({ | ||
| v: t.literal(1), | ||
| iter: boundedInt(1, V1_MAX_ITER, 'iter'), | ||
| ks: t.union([t.literal(128), t.literal(256)]), | ||
| ts: t.union([t.literal(64), t.literal(96), t.literal(128)]), | ||
| mode: t.literal('ccm'), | ||
| cipher: t.literal('aes'), | ||
| salt: base64String, | ||
| iv: base64String, | ||
| ct: base64String, | ||
| }), | ||
| t.partial({ | ||
| adata: t.string, | ||
| }), | ||
| ]); | ||
|
|
||
| export type V1Envelope = t.TypeOf<typeof V1EnvelopeCodec>; | ||
|
|
||
| export function parseV1Envelope(ciphertext: string): V1Envelope { | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(ciphertext); | ||
| } catch { | ||
| throw new Error('v1 decrypt: invalid JSON envelope'); | ||
| } | ||
| return decodeWithCodec(V1EnvelopeCodec, parsed, 'v1 decrypt: invalid envelope'); | ||
| } | ||
|
|
||
| /** | ||
| * CCM length field size L, in bytes, chosen to encode the plaintext length. | ||
| * | ||
| * SJCL picks the smallest L in [2, 4) that can represent the plaintext length, | ||
| * then derives the nonce length as (15 - L). We mirror that so Node's CCM | ||
| * uses the same nonce framing as the SJCL encoder produced. | ||
| */ | ||
| function ccmNonceLength(plaintextLen: number): number { | ||
| let L = 2; | ||
| while (L < 4 && plaintextLen >= Math.pow(2, 8 * L)) L++; | ||
| return 15 - L; | ||
| } | ||
|
|
||
| /** | ||
| * Decrypt a parsed v1 envelope given a crypto module. | ||
| * | ||
| * v1 = PBKDF2-SHA256(password, salt, iter, keyLen) then AES-CCM(key, nonce, ct||tag). | ||
| * Byte-for-byte compatible with `sjcl.decrypt` output for the same envelope. | ||
| * | ||
| * Exported so tests can inject `crypto-browserify` and exercise the exact | ||
| * runtime path the webpack browser bundle produces, without duplicating the | ||
| * decrypt logic. | ||
| */ | ||
| export async function decryptV1WithCrypto(password: string, ciphertext: string, crypto: CryptoModule): Promise<string> { | ||
| const env = parseV1Envelope(ciphertext); | ||
| const salt = Buffer.from(env.salt, 'base64'); | ||
| const ivFull = Buffer.from(env.iv, 'base64'); | ||
| const full = Buffer.from(env.ct, 'base64'); | ||
| const tagBytes = env.ts / 8; | ||
| if (full.length < tagBytes) throw new Error('v1 decrypt: ciphertext shorter than tag'); | ||
|
|
||
| const cipher = full.subarray(0, full.length - tagBytes); | ||
| const authTag = full.subarray(full.length - tagBytes); | ||
| const nonceLen = ccmNonceLength(cipher.length); | ||
| if (ivFull.length < nonceLen) throw new Error('v1 decrypt: iv shorter than nonce'); | ||
| const iv = ivFull.subarray(0, nonceLen); | ||
|
|
||
| const keyBytes = env.ks / 8; | ||
| const key: Buffer = await promisify(crypto.pbkdf2)(password, salt, env.iter, keyBytes, 'sha256'); | ||
|
|
||
| const decipher = crypto.createDecipheriv(`aes-${env.ks}-ccm`, key, iv, { authTagLength: tagBytes }); | ||
| decipher.setAuthTag(authTag); | ||
| const aad = env.adata ? Buffer.from(env.adata, 'utf8') : Buffer.alloc(0); | ||
| decipher.setAAD(aad, { plaintextLength: cipher.length }); | ||
|
|
||
| const pt = Buffer.concat([decipher.update(cipher), decipher.final()]); | ||
| return pt.toString('utf8'); | ||
| } | ||
|
|
||
| /** | ||
| * Decrypt a v1 (SJCL PBKDF2-SHA256 + AES-CCM) envelope. | ||
| * | ||
| * Runs the same `node:crypto` code on server and browser. The BitGoJS webpack | ||
| * config already maps `crypto` -> `crypto-browserify`, whose `aes-256-ccm` and | ||
| * `pbkdf2` implementations are byte-compatible with Node's native ones and | ||
| * with SJCL's envelope format. Parity is guarded by tests in | ||
| * `test/unit/decryptV1.browser.ts`. | ||
| */ | ||
| export async function decryptV1(password: string, ciphertext: string): Promise<string> { | ||
| return decryptV1WithCrypto(password, ciphertext, defaultCrypto); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import * as sjcl from '@bitgo/sjcl'; | ||
| import assert from 'assert'; | ||
|
|
||
| import { decryptV1WithCrypto, V1_MAX_ITER } from '../../src'; | ||
| import { | ||
| KEYCARD_BOX_A, | ||
| KEYCARD_BOX_A_LENGTH, | ||
| KEYCARD_BOX_B, | ||
| KEYCARD_BOX_B_LENGTH, | ||
| KEYCARD_PASSWORD, | ||
| KEYCARD_PLAINTEXT_PREFIX, | ||
| } from './fixtures/keycard'; | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const browserCrypto = require('crypto-browserify'); | ||
|
|
||
| /** | ||
| * sjcl.encrypt's typings require salt/iv, but the runtime picks them from | ||
| * sjcl.random when omitted. Feed real random words so the call type-checks | ||
| * without an `as` cast. | ||
| */ | ||
| function sjclEncrypt(password: string, plaintext: string, params: sjcl.SjclCipherParams): string { | ||
| const salt = sjcl.random.randomWords(2); // 8 bytes | ||
| const iv = sjcl.random.randomWords(4); // 16 bytes | ||
| return sjcl.encrypt(password, plaintext, { ...params, salt, iv }); | ||
| } | ||
|
|
||
| /** | ||
| * Exercises the real `decryptV1WithCrypto` code path with `crypto-browserify` | ||
| * injected as the crypto module. This is exactly what webpack bundles for the | ||
| * browser (its `crypto` shim), so a green test here proves the browser build | ||
| * stays byte-compatible with SJCL-produced envelopes and the Node path. | ||
| */ | ||
| function decryptV1Browser(password: string, ciphertext: string): Promise<string> { | ||
| return decryptV1WithCrypto(password, ciphertext, browserCrypto); | ||
| } | ||
|
|
||
| describe('decryptV1 browser path (crypto-browserify shim)', () => { | ||
| const password = 'myPassword'; | ||
| const plaintext = 'Hello, Browser!'; | ||
|
|
||
| it('decrypts an SJCL-produced envelope via the browser shim', async () => { | ||
| const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); | ||
| }); | ||
|
|
||
| it('produces the same plaintext as sjcl.decrypt', async () => { | ||
| const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| const browserResult = await decryptV1Browser(password, ciphertext); | ||
| const sjclResult = sjcl.decrypt(password, ciphertext); | ||
| assert.strictEqual(browserResult, sjclResult); | ||
| }); | ||
|
|
||
| it('handles adata (AAD)', async () => { | ||
| const ciphertext = sjclEncrypt(password, plaintext, { | ||
| iter: 10000, | ||
| ks: 256, | ||
| ts: 64, | ||
| mode: 'ccm', | ||
| adata: 'ctx-A', | ||
| }); | ||
| assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); | ||
| }); | ||
|
|
||
| it('handles empty adata (SJCL default)', async () => { | ||
| const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); | ||
| }); | ||
|
|
||
| it('handles UTF-8 passwords', async () => { | ||
| const utf8Password = 'pässwörd中文🔐'; | ||
| const ciphertext = sjclEncrypt(utf8Password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| assert.strictEqual(await decryptV1Browser(utf8Password, ciphertext), plaintext); | ||
| }); | ||
|
|
||
| it('handles UTF-8 plaintext', async () => { | ||
| const utf8Plaintext = 'passphrase: 秘密キー ☃🔑'; | ||
| const ciphertext = sjclEncrypt(password, utf8Plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| assert.strictEqual(await decryptV1Browser(password, ciphertext), utf8Plaintext); | ||
| }); | ||
|
|
||
| it('handles large plaintext (>64 KiB, forces L=3 nonce framing)', async () => { | ||
| const large = 'x'.repeat(70_000); | ||
| const ciphertext = sjclEncrypt(password, large, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| assert.strictEqual(await decryptV1Browser(password, ciphertext), large); | ||
| }); | ||
|
|
||
| it('handles aes-128 envelopes', async () => { | ||
| const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 128, ts: 64, mode: 'ccm' }); | ||
| assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); | ||
| }); | ||
|
|
||
| it('handles 128-bit tag envelopes', async () => { | ||
| const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 128, mode: 'ccm' }); | ||
| assert.strictEqual(await decryptV1Browser(password, ciphertext), plaintext); | ||
| }); | ||
|
|
||
| it('rejects wrong password', async () => { | ||
| const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| await assert.rejects(() => decryptV1Browser('wrongPassword', ciphertext)); | ||
| }); | ||
|
|
||
| it('rejects envelope with iter above cap before running PBKDF2', async () => { | ||
| const ciphertext = sjclEncrypt(password, plaintext, { iter: 10000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| const envelope = JSON.parse(ciphertext); | ||
| envelope.iter = V1_MAX_ITER + 1; | ||
| const start = Date.now(); | ||
| await assert.rejects(() => decryptV1Browser(password, JSON.stringify(envelope)), /iter/); | ||
| assert.ok(Date.now() - start < 100, 'must reject before any KDF work'); | ||
| }); | ||
|
|
||
| it('parity across 50 randomised inputs', async () => { | ||
| const { randomBytes } = await import('crypto'); | ||
| for (let i = 0; i < 50; i++) { | ||
| const pw = randomBytes(16).toString('hex'); | ||
| const pt = randomBytes(1 + Math.floor(Math.random() * 500)).toString('base64'); | ||
| const ciphertext = sjclEncrypt(pw, pt, { iter: 1000, ks: 256, ts: 64, mode: 'ccm' }); | ||
| assert.strictEqual(await decryptV1Browser(pw, ciphertext), pt, `iteration ${i}`); | ||
| } | ||
| }); | ||
|
|
||
| describe('real BitGo keycard parity via shim', () => { | ||
| it('Box A: shim decrypt matches SJCL byte-for-byte', async () => { | ||
| const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_A); | ||
| const shimResult = await decryptV1Browser(KEYCARD_PASSWORD, KEYCARD_BOX_A); | ||
| assert.strictEqual(shimResult, sjclResult); | ||
| assert.strictEqual(shimResult.length, KEYCARD_BOX_A_LENGTH); | ||
| assert.ok(shimResult.startsWith(KEYCARD_PLAINTEXT_PREFIX)); | ||
| }); | ||
|
|
||
| it('Box B: shim decrypt matches SJCL byte-for-byte', async () => { | ||
| const sjclResult = sjcl.decrypt(KEYCARD_PASSWORD, KEYCARD_BOX_B); | ||
| const shimResult = await decryptV1Browser(KEYCARD_PASSWORD, KEYCARD_BOX_B); | ||
| assert.strictEqual(shimResult, sjclResult); | ||
| assert.strictEqual(shimResult.length, KEYCARD_BOX_B_LENGTH); | ||
| assert.ok(shimResult.startsWith(KEYCARD_PLAINTEXT_PREFIX)); | ||
| }); | ||
|
|
||
| it('wrong password against real keycard envelope throws cleanly', async () => { | ||
| await assert.rejects(() => decryptV1Browser('wrong-password', KEYCARD_BOX_A)); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.