From d0548bec86ec2f56bf2f3faaa7f85e00b6258ea0 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Tue, 10 Mar 2026 14:04:24 -0500 Subject: [PATCH] fix(crypto): use 27 base-30 chars for Secret Key to prevent data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Secret Key encoder used 26 base-30 characters which can only represent 30^26 ≈ 2^127.58 values. Since the key is 128 bits, ~25% of generated keys silently lost their high bits during formatting, making the Emergency Kit key unable to reconstruct the original bytes on a new browser. Changed KEY_CHAR_LENGTH from 26 to 27 (30^27 > 2^128). Parser accepts both old 26-char and new 27-char keys for backward compatibility. Format: A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXX Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/auth/SecretKeyInput.tsx | 4 ++-- frontend/src/lib/crypto/secretKey.ts | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/auth/SecretKeyInput.tsx b/frontend/src/components/auth/SecretKeyInput.tsx index 2c998da..69e1be7 100644 --- a/frontend/src/components/auth/SecretKeyInput.tsx +++ b/frontend/src/components/auth/SecretKeyInput.tsx @@ -103,7 +103,7 @@ export function SecretKeyInput({ value, onChange, error }: SecretKeyInputProps) [groups], ) - // 26-char key = 4 groups of 6 + 1 group of 2 + // 27-char key = 4 groups of 6 + 1 group of 3 (old keys: 26 chars, last group = 2) const isComplete = groups.slice(0, 4).every((g) => g.length === 6) && groups[4].length >= 2 const hasContent = groups.some((g) => g.length > 0) @@ -151,7 +151,7 @@ export function SecretKeyInput({ value, onChange, error }: SecretKeyInputProps) {error && hasContent && !isComplete && (

- Enter all 30 characters of your Secret Key + Enter all characters of your Secret Key

)} diff --git a/frontend/src/lib/crypto/secretKey.ts b/frontend/src/lib/crypto/secretKey.ts index 4c9d899..83f6865 100644 --- a/frontend/src/lib/crypto/secretKey.ts +++ b/frontend/src/lib/crypto/secretKey.ts @@ -1,18 +1,19 @@ /** * Secret Key generation and parsing. * - * The Secret Key is a 128-bit CSPRNG value formatted as A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX + * The Secret Key is a 128-bit CSPRNG value formatted as A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXX * using a 30-character alphabet (ambiguous characters removed). It is generated client-side * and NEVER transmitted to the server. * - * Encoding: 16 bytes (128 bits) -> BigInt -> base-30 -> 26 characters -> grouped with hyphens. + * Encoding: 16 bytes (128 bits) -> BigInt -> base-30 -> 27 characters -> grouped with hyphens. + * 27 chars needed because ceil(128 / log2(30)) = 27 (30^26 < 2^128 < 30^27). */ // Uppercase letters minus O, I, L, S (ambiguous) + digits minus 0, 1 // = 22 letters + 8 digits = 30 characters const CHARSET = 'ABCDEFGHJKMNPQRTUVWXYZ23456789'; const BASE = BigInt(CHARSET.length); // 30n -const KEY_CHAR_LENGTH = 26; +const KEY_CHAR_LENGTH = 27; const RAW_BYTE_LENGTH = 16; /** @@ -27,7 +28,7 @@ export function generateSecretKey(): { formatted: string; raw: Uint8Array } { } /** - * Encode 16 raw bytes into the A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX format. + * Encode 16 raw bytes into the A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXX format. */ export function formatSecretKey(raw: Uint8Array): string { // Convert 16 bytes to a BigInt (big-endian) @@ -36,14 +37,14 @@ export function formatSecretKey(raw: Uint8Array): string { n = (n << 8n) | BigInt(byte); } - // Base-30 encode to 26 characters (ceil(128 / log2(30)) ~= 26.1) + // Base-30 encode to 27 characters (ceil(128 / log2(30)) = 27) const chars: string[] = []; for (let i = 0; i < KEY_CHAR_LENGTH; i++) { chars.push(CHARSET[Number(n % BASE)]); n = n / BASE; } - // Format: A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX + // Format: A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXX const keyStr = chars.join(''); const groups: string[] = []; for (let i = 0; i < keyStr.length; i += 6) { @@ -61,7 +62,8 @@ export function parseSecretKey(input: string): Uint8Array | null { const cleaned = input.replace(/-/g, '').replace(/\s/g, '').toUpperCase(); if (!cleaned.startsWith('A3')) return null; const keyPart = cleaned.slice(2); - if (keyPart.length < KEY_CHAR_LENGTH) return null; + // Accept both old 26-char and new 27-char keys for backward compatibility + if (keyPart.length < 26) return null; // Reverse base-30 encoding: reconstruct the BigInt // chars were pushed least-significant first, so index 0 is the lowest digit