Skip to content

Instantly share code, notes, and snippets.

@paulmillr
Created April 23, 2024 16:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save paulmillr/f655a39d7144481e1bc0a374fb65689d to your computer and use it in GitHub Desktop.
Save paulmillr/f655a39d7144481e1bc0a374fb65689d to your computer and use it in GitHub Desktop.
noble vs tweetnacl
npm install
npm run build | wc
# => 24    1092   36499 == 36KB
npm run build-nacl | wc
# => 5     225   28459 == 28KB
export { box_keyPair, sign_detached_verify, sign, box, box_open } from 'tweetnacl-ts';
export { xchacha20poly1305 } from '@noble/ciphers/chacha';
export { concatBytes } from '@noble/ciphers/utils';
export { ed25519 } from '@noble/curves/ed25519';
export { hkdf } from '@noble/hashes/hkdf';
export { sha512 } from '@noble/hashes/sha512';
{
"name": "nacl",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "npx esbuild --bundle inp.mjs --minify",
"build-nacl": "npx esbuild --bundle inp-nacl.mjs --minify"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@noble/ciphers": "0.5.2",
"@noble/curves": "1.4.0",
"@noble/hashes": "1.4.0",
"tweetnacl": "1.0.3"
},
"devDependencies": {
"esbuild": "0.20.2"
}
}
@nikitaeverywhere
Copy link

@paulmillr thank you for assembling this quick example. From what I mentioned here, indeed, the size difference is just ~30% when compared to tweetnacl-ts. However, to my understanding, tweetnacl-ts is based on the "fast" (nacl-fast.js) implementation, which is 13.4kb more than nacl.js (nacl-fast.js vs nacl.js), so I assumed I can save another 13kb on replacing a few functions in a forked tweetnacl-ts. As said, the output size is the most critical requirement component in my system, not even speed.

These tests I previously shared, however, are based on real scripts and bundling without any lib modifications:

Script 1 size:
~42.19kb with @noble/* cryptography libraries
~39.65 tweetnacl
~33.8kb tweetnacl-ts
~25.64kb tweetnacl ("slower version"), (~13.41 gzipped)

Script 2 size:
~45.15kb @noble/* cryptography libraries
~30.77kb tweetnacl (due to no tree shaking I guess; script 2 doesn't use sign/verify)
~29.75kb tweetnacl-ts (~14.93 gzipped)
// Presumably slower tree-shaked tweetnacl version port would give ~23kb

And when I said 100% difference, I meant 23kb vs 45.15kb (+93%) for the Script 2 (not using sign/verify functions) and 42.19kb/25.64kb (+65%) for the Script 1 (using sign/verify functions).

I suspect that @noble/* libs are still somewhat coupled in a way that the primitives for the curve (ed25519) code gets bundled in script 1 but unused. I might be wrong here.

Regardless, for my use case tweetnacl looks like a better match as it's lighter (even 10kb matter, really) and more standardized from API the standpoint as a whole (exactly same lib exists across multiple platforms as mentioned here). Also, it's simpler when I dive into the implementation code, which will be easier to refactor to avoid the use of browser apis.

Both great libs anyways. Amazing contributions to js world.

@nikitaeverywhere
Copy link

nikitaeverywhere commented Apr 24, 2024

BTW @paulmillr as a tiny-little bundle size saver lifehack for consideration, you could have saved at least ~3kb of code (35.7kb turn 32.4kb in the example above) by just wrapping JS error messages in the lib. There are 73 messages which never really happen in production, bloating the bundle. Minor, just a small idea to be even more appealing as a widely-used lib :)

image

I.e. throw new Error('message ' + a) -> f(ERROR_CODE, a), which under the hood could inline something like

const f = (code, ...data) => { throw new Error(`Crypto error, see https://your.link/${code}`, ...data); }

@paulmillr
Copy link
Author

paulmillr commented Apr 24, 2024

@nikitaeverywhere wrapping errors would mess up stack traces. First trace item would always be f declaration. Which seems unreasonable for a good library.

For optimized size, i’ve created single feature “noble-ed25519” library, which is <4kb gzipped. It uses this technique. Switching from noble-curves to noble-ed25519 produces smaller bundle:

export { xchacha20poly1305 } from '@noble/ciphers/_micro'; // 1
export { concatBytes } from '@noble/ciphers/utils';
export { sign, verify } from '@noble/ed25519'; // 2
export { hkdf } from '@noble/hashes/hkdf';
export { sha512 } from '@noble/hashes/sha512';

Also keep in mind that your nacl imports do not have hkdf. When I remove hkdf, noble's bundle becomes 20.25KB.

@nikitaeverywhere
Copy link

nikitaeverywhere commented Apr 24, 2024

wrapping errors would mess up stack traces

Fair enough. Although I sometimes see this in other libraries and personally have no issues when debugging and clicking on the 2nd line instead of the 1st. In React and many other places it's very common (to drill down the stack).

Also keep in mind that your nacl imports do not have hkdf. When I remove hkdf, noble's bundle becomes 20.25KB.

I'm unsure whether there is a way to implement asymmetric cipher without hkdf (I took the implementation from this higher-order lib to noble) to keep it as minimal as possible. Would be happy to re-evaluate things until they're hardcoded if you could suggest something.

@paulmillr
Copy link
Author

hkdf is good and I suggest to keep it. Just pointing out that your 26kb tweetnacl bundle does not include it.

So, overall, noble is smaller than tweetnacl if you use ciphers/_micro and noble-ed25519. As a bonus, it's faster.

@nikitaeverywhere
Copy link

So, overall, noble is smaller than tweetnacl if you use ciphers/_micro and noble-ed25519. As a bonus, it's faster.

Sounds great! Thanks for suggesting.

hkdf is good and I suggest to keep it. Just pointing out that your 26kb tweetnacl bundle does not include it.

I'm a bit confused: tweetnacl doesn't include it - yes, but it does include other algos to achieve

  1. asymmetric encryption / decryption
  2. signing / verifying

=> all I need for my task (plus the minimal possible lib size). To my understanding, I can't do it without hkdf in noble (finding a common secret key for chacha encryption), can I?

I'm unsure what's the most minimal primitives toolkit when using noble for 1 and 2.

@paulmillr
Copy link
Author

nacl uses x25519 for box. Not ed25519. x25519 does not provide signing or verification - it only provides ECDH. See https://nacl.cr.yp.to/box.html

To replicate nacl box:

let shared_key = x25519(public_key_a, private_key_b)
let nonce = randomBytes(24)
xsalsa20poly1305(shared_key, nonce).encrypt(data)

x25519 is available in noble-curves. If you also want to sign and verify something, that would require different set of keys. Tweetnacl AFAICS does not provide a way to convert x25519 key into ed25519 key. So, while tweetnacl has signing and verification separately, you can't really do it, without something like KDF.

@nikitaeverywhere
Copy link

nikitaeverywhere commented Apr 24, 2024

Thanks a ton - your answers really help.

while tweetnacl has signing and verification separately, you can't really do it, without something like KDF.

I'm really thinking high-level here and can't get why you say I can't do it. encryption / decryption and signing / verifying are 2 different tasks in my system, same as exposed in nacl api. I don't need a shared private key between these 2 tasks. I use this approach to give you a bit more of context:

  1. I generate a random keypair for box / open (for x25519 I guess)
  2. I generate a random keypair for sign/verify - used in sign and nacl_detached_verify
  3. "Communication" layer of my system uses (1.) to send end-to-end secure messages over insecure apis (say, browser windows)
  4. I use (2.) to sign the public key (1.) with some other data to verify that this public key is "genuine" (generated by my system) and thus the system by checking it authorizes the communication.

And the question really is: I don't care about what to use, I just need the simplest and the most tiny crypto primitives to do sign+verify and asymmetric encrypt/decrypt as separate tasks. nacl has everything I need: and I'm unsure what minimal set of primitives I need to take from noble to make it happen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment