Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ The security of your data is the highest priority. Here is a summary of the secu
- **Client-Side operations:** all encryption and decryption processes happen entirely within your browser. Your password, key files, and secret data are **never** transmitted over the internet or stored on any server.

- **Strong encryption standard:** IttyBitz uses **AES-256-GCM**, a modern authenticated encryption cipher that provides both confidentiality and data integrity.
- **Strong key derivation:** your password is not used directly as the encryption key. Instead, it is run through the **PBKDF2** (Password-Based Key Derivation Function 2) algorithm with **1,000,000 iterations**. This makes brute-force attacks against your password extremely slow and computationally expensive, even for weak passwords.
- **Strong key derivation:** your password is not used directly as the encryption key. Instead, it is run through the **PBKDF2** (Password-Based Key Derivation Function 2) algorithm with **1,000,000 iterations**. This makes brute-force attacks against your password extremely slow and computationally expensive, even for weak passwords. The password is Unicode-normalized (NFC) before derivation, so an accented character typed on macOS and on Windows produces the same key; files encrypted before this normalization still open, because decryption also tries the exact typed form.
- **Cryptographically secure randomness:** the application uses `window.crypto.getRandomValues()` to generate the salt for key derivation, the Initialization Vector (IV) for AES-GCM, the random characters for the password generator, and the data for the key file generator. This is a cryptographically secure pseudo-random number generator (CSPRNG) that is suitable for security-sensitive applications.
- **Password strength indicator:** to encourage strong security practices, the UI provides real-time feedback, guiding users to create passwords that are at least 24 characters long and contain a mix of character types.
- **Best-effort memory clearing:** after an encryption or decryption operation is complete, the application overwrites sensitive variables (like the derived key and salt) in memory. Note: JavaScript's garbage collector may retain copies of data elsewhere in the heap, so this is a best-effort mitigation rather than a guarantee.
Expand Down
4 changes: 2 additions & 2 deletions SHA256SUMS.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
783f720327251aa6b85db63e358b1d1460b0c975d5b9fbb39c9b0de8898e8068 ittybitz.html
40dc1cf811d66ecae145c95a651831983c10fc8efa9f01efb7de2acd856fd81d ittybitz-recovery.html
5df22c74f300263ccdc243f56bcde658312a9411eb5e216fba33d55861cbe785 ittybitz.html
2989384d01b4e2cbef9faf614c454b67c417c1b162b4cfe121f1ac5236f87942 ittybitz-recovery.html
29 changes: 25 additions & 4 deletions scripts/build/crypto-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
password bytes, with the key file bytes (if any) appended after them.
v0 (headerless) containers from IttyBitz 1.x still decrypt.

Password Unicode form: the password is NFC-normalized before encryption,
because "é" can arrive as U+00E9 (NFC) or "e"+U+0301 (NFD) depending on
platform and input method, and those are different bytes to PBKDF2.
Decryption tries NFC first, then — only if the typed string was not
already NFC — the exact typed bytes, which is how ciphertexts made before
this normalization were keyed. Every older file still opens.

The PBKDF2/AES output depends only on (password, key file, salt, iv) — never
on the CryptoKey's declared usages — so a key derived here with ['encrypt']
interoperates byte-for-byte with the app's ['encrypt','decrypt'] key.
Expand Down Expand Up @@ -36,6 +43,13 @@ function ittybitzValidatePassword(password) {
if (password.indexOf('\0') >= 0) throw new Error('Password contains invalid characters.');
}

// Password strings to try on decrypt: NFC first, then the exact typed form if
// it differs. No duplicates, so an already-NFC password costs one PBKDF2 run.
function ittybitzPasswordCandidates(password) {
var nfc = password.normalize('NFC');
return nfc === password ? [password] : [nfc, password];
}

async function ittybitzDeriveKey(password, salt, keyFileBytes, usages) {
var pw = new TextEncoder().encode(password);
var material;
Expand Down Expand Up @@ -73,7 +87,7 @@ async function ittybitzEncrypt(bytes, password, keyFileBytes) {

var salt = crypto.getRandomValues(new Uint8Array(ITTYBITZ_SALT_LENGTH));
var iv = crypto.getRandomValues(new Uint8Array(ITTYBITZ_IV_LENGTH));
var key = await ittybitzDeriveKey(password, salt, keyFileBytes, ['encrypt']);
var key = await ittybitzDeriveKey(password.normalize('NFC'), salt, keyFileBytes, ['encrypt']);
var ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, bytes));

var out = new Uint8Array(5 + salt.length + iv.length + ct.length);
Expand Down Expand Up @@ -112,7 +126,14 @@ async function ittybitzDecrypt(bytes, password, keyFileBytes) {
var iv = bytes.slice(offset + ITTYBITZ_SALT_LENGTH, headerEnd);
var ciphertext = bytes.slice(headerEnd);

var key = await ittybitzDeriveKey(password, salt, keyFileBytes, ['decrypt']);
var plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, ciphertext);
return new Uint8Array(plain);
var candidates = ittybitzPasswordCandidates(password);
for (var c = 0; c < candidates.length; c++) {
var key = await ittybitzDeriveKey(candidates[c], salt, keyFileBytes, ['decrypt']);
try {
var plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, ciphertext);
return new Uint8Array(plain);
} catch (e) {
if (c === candidates.length - 1) throw e; // same DOMException as before
}
}
}
10 changes: 10 additions & 0 deletions scripts/crypto-fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,16 @@
"keyFile": true,
"plaintext": "The quick brown fox jumps over the lazy dog 0123456789",
"base64": "SUJUWgHoZ9qGEYZvupvMx/Hv5jo2vP5ONAhqWJPUutBlDl2scTsTbRJnOz9pJx9krC/O0DtWcClBWDP+NI4Id5ZSWYS4mMsGJmfld21u+r44K7EvqUhMZpxD52wWXVU8huqEv2MdjQ=="
},
{
"_comment": "Produced by the v3.0.11 crypto.ts, which keyed PBKDF2 on the exact typed string. The password is deliberately NOT NFC: the é is e + U+0301 (kept as a JSON escape so no editor can silently recompose it). Current code normalizes to NFC first, so this entry proves the exact-bytes fallback keeps pre-normalization files openable. The 'password' field overrides the suite-wide password for this entry only.",
"version": "v3.0.11",
"format": "v1",
"payload": "nfd-password",
"keyFile": false,
"password": "Cafe\u0301-Passphrase-Fixture-Only-2026!",
"plaintext": "encrypted by v3.0.11 with a non-NFC (NFD) password — must decrypt forever",
"base64": "SUJUWgEaSskUq53fKEqoqily3UT0+A70GLYUnWG35zQtA7sSrRHXuBJ2pPkzdBI/vQ56GtZHr9EdRmc8cHJtBEJKenPuttwdF51/dXgK9F6gFCN2apc2AatuPQ7rRegYhAsX63ykYpO+UiERXBIYW6gTWeAfAQostHOg0w=="
}
]
}
78 changes: 75 additions & 3 deletions scripts/crypto-regression.mts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@
* AES-256-GCM, password bytes UTF-8, optional key file appended after the
* password bytes. Verified identical from v1.0 to HEAD.
*
* Password Unicode form: through v3.0.11 the password was keyed on its exact
* typed code points. Since then encryption NFC-normalizes it, and decryption
* tries NFC first and the exact typed form second. Both are gated here: the
* "nfd-password" fixture (made by v3.0.11 with a non-NFC password) must still
* open, and NFC/NFD spellings of one password must open each other's output.
*
* Adding fixtures: APPEND to crypto-fixtures.json. Never modify or delete an
* existing entry — each one is a promise that a real user's file still opens.
* An entry may carry its own "password" to override the suite-wide one.
*
* The password and key file in the fixtures are TEST-ONLY and published in
* this repo. Treat them as compromised; never use them for real data.
Expand All @@ -47,6 +54,11 @@ const FIXTURES = JSON.parse(readFileSync(join(HERE, "crypto-fixtures.json"), "ut
const enc = new TextEncoder();
const dec = new TextDecoder();

// Most fixtures share the suite-wide password; a few carry their own.
function fixturePassword(fx: any): string {
return typeof fx.password === "string" ? fx.password : FIXTURES.password;
}

let failures = 0;
let passed = 0;
function check(condition: boolean, label: string) {
Expand Down Expand Up @@ -83,7 +95,7 @@ async function main() {
try {
const keyFile = fx.keyFile ? hexToArrayBuffer(FIXTURES.keyFileHex) : null;
const plaintext = dec.decode(
await decryptFile(b64ToArrayBuffer(fx.base64), FIXTURES.password, keyFile)
await decryptFile(b64ToArrayBuffer(fx.base64), fixturePassword(fx), keyFile)
);
check(plaintext === fx.plaintext, label);
if (plaintext !== fx.plaintext) {
Expand Down Expand Up @@ -186,6 +198,38 @@ async function main() {
decryptFile(ct.slice(0, 20), FIXTURES.password, null)
);

// ---- 4b. Password Unicode form ----
// One password, two code-point spellings of "é": U+00E9 (NFC, most
// keyboards) and "e"+U+0301 (NFD, some macOS inputs, IMEs, pasted text).
// Before normalization these keyed different PBKDF2 inputs and a file made
// on one machine would not open on another. Now either spelling opens a
// container made with the other, in every implementation, and the
// "nfd-password" fixture above proves pre-normalization files still open.
console.log("\nPassword Unicode normalization:");
const pwNfc = "Caf\u00e9-Normalization-Check-2026!";
const pwNfd = "Cafe\u0301-Normalization-Check-2026!";
check(
pwNfc !== pwNfd && pwNfc.normalize("NFC") === pwNfd.normalize("NFC"),
"NFC and NFD spellings differ as strings but are the same text"
);
// Decrypt to text, or null on failure — so a regression reads as FAIL, not FATAL.
const opens = async (fn: () => Promise<ArrayBuffer | Uint8Array>): Promise<string | null> => {
try { return dec.decode(await fn()); } catch { return null; }
};
const ctNfc = await encryptFile(enc.encode(secret).buffer as ArrayBuffer, pwNfc, null);
const ctNfd = await encryptFile(enc.encode(secret).buffer as ArrayBuffer, pwNfd, null);
check(
(await opens(() => decryptFile(ctNfc.slice(0), pwNfd, null))) === secret,
"crypto.ts: encrypted with NFC spelling, opens with NFD spelling"
);
check(
(await opens(() => decryptFile(ctNfd.slice(0), pwNfc, null))) === secret,
"crypto.ts: encrypted with NFD spelling, opens with NFC spelling"
);
await rejects("crypto.ts: wrong non-NFC password still rejected after both attempts", () =>
decryptFile(ctNfc.slice(0), pwNfd + "x", null)
);

// ---- 5. BIP-39 / SeedQR ----
// Not part of the frozen crypto core, but a wrong word index produces a
// QR that scans cleanly into the WRONG wallet, which is silent and
Expand Down Expand Up @@ -308,7 +352,7 @@ async function main() {
? new Uint8Array(hexToArrayBuffer(FIXTURES.keyFileHex))
: null;
const bytes = new Uint8Array(b64ToArrayBuffer(fx.base64));
const out = dec.decode(await recoveryDecrypt(bytes, FIXTURES.password, keyFile));
const out = dec.decode(await recoveryDecrypt(bytes, fixturePassword(fx), keyFile));
if (out === fx.plaintext) recovered++;
else {
recoveryFailed++;
Expand Down Expand Up @@ -350,6 +394,18 @@ async function main() {
await rejects("recovery file rejects truncated input", () =>
recoveryDecrypt(new Uint8Array(ct.slice(0, 20)), FIXTURES.password, null)
);

// Password Unicode form: either spelling opens a container made with
// the other. (The pre-normalization case is the "nfd-password" fixture,
// already replayed above.)
check(
(await opens(() => recoveryDecrypt(new Uint8Array(ctNfc.slice(0)), pwNfd, null))) === secret,
"recovery file: encrypted with NFC spelling, opens with NFD spelling"
);
check(
(await opens(() => recoveryDecrypt(new Uint8Array(ctNfd.slice(0)), pwNfc, null))) === secret,
"recovery file: encrypted with NFD spelling, opens with NFC spelling"
);
}
}

Expand Down Expand Up @@ -390,7 +446,7 @@ async function main() {
try {
const keyFileU8 = fx.keyFile ? new Uint8Array(hexToArrayBuffer(FIXTURES.keyFileHex)) : null;
const out = dec.decode(
await appDecrypt(new Uint8Array(b64ToArrayBuffer(fx.base64)), FIXTURES.password, keyFileU8)
await appDecrypt(new Uint8Array(b64ToArrayBuffer(fx.base64)), fixturePassword(fx), keyFileU8)
);
if (out === fx.plaintext) appOk++;
else { appBad++; console.error(` FAIL app mismatch on ${fx.version} ${fx.payload}`); }
Expand Down Expand Up @@ -461,6 +517,22 @@ async function main() {
appDecrypt(new Uint8Array(ct.slice(0, 20)), FIXTURES.password, null)
);

// 7d'. Password Unicode form, across implementations: the app must
// normalize on encrypt (so crypto.ts opens its output with the other
// spelling) and try both forms on decrypt.
const appCtNfd = await appEncrypt(enc.encode("app nfd"), pwNfd, null);
check(
(await opens(() => decryptFile(Uint8Array.from(appCtNfd).buffer as ArrayBuffer, pwNfc, null))) === "app nfd",
"app: encrypted with NFD spelling, opens under crypto.ts with NFC spelling"
);
check(
(await opens(() => appDecrypt(new Uint8Array(ctNfc.slice(0)), pwNfd, null))) === secret,
"app: crypto.ts container keyed via NFC spelling, opens with NFD spelling"
);
await rejects("app: wrong non-NFC password still rejected after both attempts", () =>
appDecrypt(new Uint8Array(ctNfc.slice(0)), pwNfd + "x", null)
);

// 7e. BIP-39 parity with src/lib/bip39.ts.
const appValidate = box.ittybitzValidateBip39;
const appSeedQr = box.ittybitzToSeedQR;
Expand Down
31 changes: 26 additions & 5 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
canvas and downloads use blob:/data:, none of which are network fetches.
Nothing here can phone home. -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'sha256-4GSOHG1PWgpqNwON0DpmpOBDoMdMXoBzQI2KhDuK9js=' 'sha256-SJVUXJNlOay+2TG0PE+o+T46Wt7Qxe1B1XpRTxfVTCE=' 'sha256-W+IzleLCL30nXjyiCRp8Rl3j2GFDMC9KAJFnnygxQ8c=' 'sha256-XlgOklfvkE1YTG+BSxN2kJFwnn+L4EhXLVb01Ma4YsM=' 'sha256-NYQ0hM7SpKRLIsnbuTkqUcaQqX7Tt/p2OBOYLmZTRVY='; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; font-src 'none'; form-action 'none'; base-uri 'none'">
content="default-src 'none'; script-src 'sha256-4GSOHG1PWgpqNwON0DpmpOBDoMdMXoBzQI2KhDuK9js=' 'sha256-5YtEeJMZr/vYCDUUA4WAOVstFzwPCiphy/0V73LOtRk=' 'sha256-W+IzleLCL30nXjyiCRp8Rl3j2GFDMC9KAJFnnygxQ8c=' 'sha256-XlgOklfvkE1YTG+BSxN2kJFwnn+L4EhXLVb01Ma4YsM=' 'sha256-NYQ0hM7SpKRLIsnbuTkqUcaQqX7Tt/p2OBOYLmZTRVY='; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; font-src 'none'; form-action 'none'; base-uri 'none'">
<!--
═══════════════════════════════════════════════════════════════════════
IttyBitz — single-file client-side encryption
Expand Down Expand Up @@ -2815,6 +2815,13 @@ <h2>Support IttyBitz</h2>
password bytes, with the key file bytes (if any) appended after them.
v0 (headerless) containers from IttyBitz 1.x still decrypt.

Password Unicode form: the password is NFC-normalized before encryption,
because "é" can arrive as U+00E9 (NFC) or "e"+U+0301 (NFD) depending on
platform and input method, and those are different bytes to PBKDF2.
Decryption tries NFC first, then — only if the typed string was not
already NFC — the exact typed bytes, which is how ciphertexts made before
this normalization were keyed. Every older file still opens.

The PBKDF2/AES output depends only on (password, key file, salt, iv) — never
on the CryptoKey's declared usages — so a key derived here with ['encrypt']
interoperates byte-for-byte with the app's ['encrypt','decrypt'] key.
Expand Down Expand Up @@ -2842,6 +2849,13 @@ <h2>Support IttyBitz</h2>
if (password.indexOf('\0') >= 0) throw new Error('Password contains invalid characters.');
}

// Password strings to try on decrypt: NFC first, then the exact typed form if
// it differs. No duplicates, so an already-NFC password costs one PBKDF2 run.
function ittybitzPasswordCandidates(password) {
var nfc = password.normalize('NFC');
return nfc === password ? [password] : [nfc, password];
}

async function ittybitzDeriveKey(password, salt, keyFileBytes, usages) {
var pw = new TextEncoder().encode(password);
var material;
Expand Down Expand Up @@ -2879,7 +2893,7 @@ <h2>Support IttyBitz</h2>

var salt = crypto.getRandomValues(new Uint8Array(ITTYBITZ_SALT_LENGTH));
var iv = crypto.getRandomValues(new Uint8Array(ITTYBITZ_IV_LENGTH));
var key = await ittybitzDeriveKey(password, salt, keyFileBytes, ['encrypt']);
var key = await ittybitzDeriveKey(password.normalize('NFC'), salt, keyFileBytes, ['encrypt']);
var ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, bytes));

var out = new Uint8Array(5 + salt.length + iv.length + ct.length);
Expand Down Expand Up @@ -2918,9 +2932,16 @@ <h2>Support IttyBitz</h2>
var iv = bytes.slice(offset + ITTYBITZ_SALT_LENGTH, headerEnd);
var ciphertext = bytes.slice(headerEnd);

var key = await ittybitzDeriveKey(password, salt, keyFileBytes, ['decrypt']);
var plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, ciphertext);
return new Uint8Array(plain);
var candidates = ittybitzPasswordCandidates(password);
for (var c = 0; c < candidates.length; c++) {
var key = await ittybitzDeriveKey(candidates[c], salt, keyFileBytes, ['decrypt']);
try {
var plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, ciphertext);
return new Uint8Array(plain);
} catch (e) {
if (c === candidates.length - 1) throw e; // same DOMException as before
}
}
}

</script>
Expand Down
Loading