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
6 changes: 3 additions & 3 deletions 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 Expand Up @@ -179,9 +179,9 @@ npm run test:crypto # crypto regression gate (zero dependencies)
npm run update-csp-hashes # re-pin CSP hashes + SHA256SUMS after editing a shipped HTML file
```

`build` concatenates the hand-written page, the vendored [`qrcode-generator`](https://github.com/kazuhikoarase/qrcode-generator) (MIT), the DOM-free crypto core, and a BIP-39 core generated from `src/lib/bip39.ts` (so the wordlist can never drift), then runs `update-csp-hashes` to lock each inline `<script>` block into the `Content-Security-Policy` as a `'sha256-…'` source and regenerate `SHA256SUMS.txt`. `test:crypto` proves the assembled file both decrypts every historical ciphertext **and** round-trips against the frozen reference implementation in [`src/lib/crypto.ts`](src/lib/crypto.ts) — in both directions, with and without key files.
`build` concatenates the hand-written page, the vendored [`qrcode-generator`](https://github.com/kazuhikoarase/qrcode-generator) (MIT), the DOM-free crypto core, and a BIP-39 core generated from `src/lib/bip39.ts` (so the wordlist can never drift) into `site/index.html`, assembles `site/ittybitz-recovery.html` from the same crypto core and its own page and UI parts, then runs `update-csp-hashes` to lock each inline `<script>` block into the `Content-Security-Policy` as a `'sha256-…'` source and regenerate `SHA256SUMS.txt`. `test:crypto` proves the assembled file both decrypts every historical ciphertext **and** round-trips against the frozen reference implementation in [`src/lib/crypto.ts`](src/lib/crypto.ts) — in both directions, with and without key files.

> **After editing `site/ittybitz-recovery.html` by hand (or anything under `scripts/build/`), run `npm run update-csp-hashes` before committing.** It recomputes the inline-script hashes, rewrites both files' CSP meta tags in place, and regenerates `SHA256SUMS.txt`. It is idempotent — a second run with unchanged scripts writes nothing. Skipping it after a change would leave a stale hash in the CSP and the browser would refuse to run the edited script.
> **Both shipped files are assembled from `scripts/build/` — edit there, never `site/` directly — and after any edit run `npm run build` before committing.** The recovery tool is built from `recovery-head.html`, the same `crypto-core.js` block the app uses (its decrypt half; `crypto-encrypt.js` is appended for the app only), and `recovery-app.js`, so the two files share one source for the container format and key derivation. The build ends with `update-csp-hashes`, which recomputes the inline-script hashes, rewrites both files' CSP meta tags in place, and regenerates `SHA256SUMS.txt`. It is idempotent — a second run with unchanged scripts writes nothing. Skipping it after a change would leave a stale hash in the CSP and the browser would refuse to run the edited script.

There are **no runtime or build dependencies**: `node` (22.6+, for native TypeScript stripping) and Python 3 (only for `npm run dev`'s static server) are all that is used.

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
735a37691e4ad80fe90f0460da2c45543082c4c6efdbe558157415f7c08a9941 ittybitz.html
081ef977dafb73b281645c4e4d3dbf7379911a69ed2ba0139edfd6aec7d90a9d ittybitz-recovery.html
53 changes: 41 additions & 12 deletions scripts/build-app.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
#!/usr/bin/env node
/**
* Assemble site/index.html — the single-file IttyBitz — from reviewable parts.
* Assemble the two shipped files from reviewable parts:
*
* There is no framework and no bundler. This script only concatenates:
* scripts/build/head.html hand-written page (HTML + CSS)
* scripts/build/qrcode-lib.js kazuhikoarase/qrcode-generator (MIT), verbatim
* scripts/build/crypto-core.js DOM-free encrypt/decrypt (mirrors src/lib/crypto.ts)
* <bip39 core> generated from src/lib/bip39.ts (canonical wordlist)
* scripts/build/app.js the UI wiring
* site/index.html — the single-file IttyBitz
* scripts/build/head.html hand-written page (HTML + CSS)
* scripts/build/qrcode-lib.js kazuhikoarase/qrcode-generator (MIT), verbatim
* scripts/build/crypto-core.js DOM-free format + key derivation + decrypt
* scripts/build/crypto-encrypt.js DOM-free encrypt (needs the core above)
* <bip39 core> generated from src/lib/bip39.ts (canonical wordlist)
* scripts/build/fingerprint-core.js
* scripts/build/app.js the UI wiring
*
* The committed site/index.html is the shipped artifact; this script exists so
* that artifact is reproducible and its provenance auditable. Users never run
* it — they just open the HTML file. After running, `npm run test:crypto`
* re-verifies the result against crypto.ts and the historical fixtures.
* site/ittybitz-recovery.html — the decrypt-only recovery tool
* scripts/build/recovery-head.html hand-written page (HTML + CSS)
* scripts/build/crypto-core.js the SAME core block, decrypt half only
* scripts/build/recovery-app.js its UI wiring
*
* There is no framework and no bundler; this only concatenates. The recovery
* tool used to carry a hand-maintained copy of the decrypt core; the
* regression suite caught drift, but every fix still had to be made twice.
* Now there is one source for the container format and key derivation.
*
* The committed site/ files are the shipped artifacts; this script exists so
* they are reproducible and their provenance auditable. Users never run it —
* they just open the HTML file. After running, `npm run test:crypto`
* re-verifies both against crypto.ts and the historical fixtures.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -51,14 +63,17 @@ if (bip39Core.includes("__WORDLIST__")) {
const head = read(join(BUILD, "head.html"));
const qrcode = read(join(BUILD, "qrcode-lib.js"));
const cryptoCore = read(join(BUILD, "crypto-core.js"));
const cryptoEncrypt = read(join(BUILD, "crypto-encrypt.js"));
const fingerprint = read(join(BUILD, "fingerprint-core.js"));
const app = read(join(BUILD, "app.js"));
const recoveryHead = read(join(BUILD, "recovery-head.html"));
const recoveryApp = read(join(BUILD, "recovery-app.js"));

// 4. Assemble. Script ids match what scripts/crypto-regression.mts extracts.
const out =
head +
'\n<script id="ittybitz-vendor-qrcode">\n' + qrcode +
'\n</script>\n<script id="ittybitz-crypto-core">\n' + cryptoCore +
'\n</script>\n<script id="ittybitz-crypto-core">\n' + cryptoCore + '\n' + cryptoEncrypt +
'\n</script>\n<script id="ittybitz-bip39">\n' + bip39Core +
'\n</script>\n<script id="ittybitz-fingerprint">\n' + fingerprint +
'\n</script>\n<script>\n' + app +
Expand All @@ -67,3 +82,17 @@ const out =
const target = join(ROOT, "site", "index.html");
writeFileSync(target, out);
console.log(`Built site/index.html — ${out.length} bytes (${wordCount} BIP-39 words inlined).`);

// 5. The recovery tool: same core block, decrypt half only, no encrypt.
if (/ittybitzEncrypt/.test(cryptoCore)) {
console.error("ERROR: crypto-core.js must not define ittybitzEncrypt; that belongs in crypto-encrypt.js (the recovery tool is decrypt-only)");
process.exit(1);
}
const recovery =
recoveryHead +
'<script id="ittybitz-decrypt-core">\n' + cryptoCore +
'\n</script>\n<script>\n' + recoveryApp +
'</script>\n</body>\n</html>\n';
const recoveryTarget = join(ROOT, "site", "ittybitz-recovery.html");
writeFileSync(recoveryTarget, recovery);
console.log(`Built site/ittybitz-recovery.html — ${recovery.length} bytes.`);
68 changes: 31 additions & 37 deletions scripts/build/crypto-core.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
/* ── IttyBitz crypto core — encrypt + decrypt ─────────────────────────────
/* ── IttyBitz crypto core — container format, key derivation, decrypt ────
Kept free of DOM references so the project's regression suite can extract
this exact block and run it against the historical ciphertext fixtures in
BOTH directions (encrypt→crypto.ts and crypto.ts→decrypt).
this exact block and run it against the historical ciphertext fixtures.
This block is shared by BOTH shipped files: site/index.html carries it
followed by crypto-encrypt.js (as <script id="ittybitz-crypto-core">), and
site/ittybitz-recovery.html carries it alone (as
<script id="ittybitz-decrypt-core">) — one source, assembled by
scripts/build-app.mjs, so the two can never drift apart again.

Wire format, identical to src/lib/crypto.ts:
IBTZ\x01 || salt(16) || iv(12) || AES-256-GCM ciphertext
Key derivation: PBKDF2-SHA256 @ 1,000,000 iterations over the UTF-8
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 +47,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 All @@ -56,37 +74,6 @@ async function ittybitzDeriveKey(password, salt, keyFileBytes, usages) {
);
}

/**
* Encrypt bytes into an IttyBitz v1 container.
* @param {Uint8Array} bytes plaintext bytes
* @param {string} password
* @param {Uint8Array|null} keyFileBytes
* @returns {Promise<Uint8Array>} IBTZ\x01 || salt || iv || ciphertext
*/
async function ittybitzEncrypt(bytes, password, keyFileBytes) {
if (!bytes || !bytes.length) throw new Error('Cannot process empty data.');
if (bytes.length > ITTYBITZ_MAX_FILE_SIZE) {
throw new Error('File is too large. Maximum size is ' + (ITTYBITZ_MAX_FILE_SIZE / 1024 / 1024) + 'MB.');
}
if (!password) throw new Error('A password is required for encryption.');
ittybitzValidatePassword(password);

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 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);
out[0] = ITTYBITZ_MAGIC[0];
out[1] = ITTYBITZ_MAGIC[1];
out[2] = ITTYBITZ_MAGIC[2];
out[3] = ITTYBITZ_MAGIC[3];
out[4] = ITTYBITZ_VERSION;
out.set(salt, 5);
out.set(iv, 5 + salt.length);
out.set(ct, 5 + salt.length + iv.length);
return out;
}

/**
* Decrypt an IttyBitz container (v0 legacy or v1).
Expand All @@ -112,7 +99,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
}
}
}
36 changes: 36 additions & 0 deletions scripts/build/crypto-encrypt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* ── IttyBitz crypto core — encrypt ──────────────────────────────────────
Appended after crypto-core.js in site/index.html only; the recovery tool is
decrypt-only and never carries this. Depends on the constants and
ittybitzDeriveKey() defined there.
───────────────────────────────────────────────────────────────────────── */
/**
* Encrypt bytes into an IttyBitz v1 container.
* @param {Uint8Array} bytes plaintext bytes
* @param {string} password
* @param {Uint8Array|null} keyFileBytes
* @returns {Promise<Uint8Array>} IBTZ\x01 || salt || iv || ciphertext
*/
async function ittybitzEncrypt(bytes, password, keyFileBytes) {
if (!bytes || !bytes.length) throw new Error('Cannot process empty data.');
if (bytes.length > ITTYBITZ_MAX_FILE_SIZE) {
throw new Error('File is too large. Maximum size is ' + (ITTYBITZ_MAX_FILE_SIZE / 1024 / 1024) + 'MB.');
}
if (!password) throw new Error('A password is required for encryption.');
ittybitzValidatePassword(password);

var salt = crypto.getRandomValues(new Uint8Array(ITTYBITZ_SALT_LENGTH));
var iv = crypto.getRandomValues(new Uint8Array(ITTYBITZ_IV_LENGTH));
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);
out[0] = ITTYBITZ_MAGIC[0];
out[1] = ITTYBITZ_MAGIC[1];
out[2] = ITTYBITZ_MAGIC[2];
out[3] = ITTYBITZ_MAGIC[3];
out[4] = ITTYBITZ_VERSION;
out.set(salt, 5);
out.set(iv, 5 + salt.length);
out.set(ct, 5 + salt.length + iv.length);
return out;
}
Loading