fix(crypto): use 27 base-30 chars for Secret Key to prevent data loss
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 <noreply@anthropic.com>
This commit is contained in:
@@ -103,7 +103,7 @@ export function SecretKeyInput({ value, onChange, error }: SecretKeyInputProps)
|
|||||||
[groups],
|
[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 =
|
const isComplete =
|
||||||
groups.slice(0, 4).every((g) => g.length === 6) && groups[4].length >= 2
|
groups.slice(0, 4).every((g) => g.length === 6) && groups[4].length >= 2
|
||||||
const hasContent = groups.some((g) => g.length > 0)
|
const hasContent = groups.some((g) => g.length > 0)
|
||||||
@@ -151,7 +151,7 @@ export function SecretKeyInput({ value, onChange, error }: SecretKeyInputProps)
|
|||||||
</div>
|
</div>
|
||||||
{error && hasContent && !isComplete && (
|
{error && hasContent && !isComplete && (
|
||||||
<p className="text-xs text-error">
|
<p className="text-xs text-error">
|
||||||
Enter all 30 characters of your Secret Key
|
Enter all characters of your Secret Key
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Secret Key generation and parsing.
|
* 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
|
* using a 30-character alphabet (ambiguous characters removed). It is generated client-side
|
||||||
* and NEVER transmitted to the server.
|
* 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
|
// Uppercase letters minus O, I, L, S (ambiguous) + digits minus 0, 1
|
||||||
// = 22 letters + 8 digits = 30 characters
|
// = 22 letters + 8 digits = 30 characters
|
||||||
const CHARSET = 'ABCDEFGHJKMNPQRTUVWXYZ23456789';
|
const CHARSET = 'ABCDEFGHJKMNPQRTUVWXYZ23456789';
|
||||||
const BASE = BigInt(CHARSET.length); // 30n
|
const BASE = BigInt(CHARSET.length); // 30n
|
||||||
const KEY_CHAR_LENGTH = 26;
|
const KEY_CHAR_LENGTH = 27;
|
||||||
const RAW_BYTE_LENGTH = 16;
|
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 {
|
export function formatSecretKey(raw: Uint8Array): string {
|
||||||
// Convert 16 bytes to a BigInt (big-endian)
|
// Convert 16 bytes to a BigInt (big-endian)
|
||||||
@@ -36,14 +37,14 @@ export function formatSecretKey(raw: Uint8Array): string {
|
|||||||
n = (n << 8n) | BigInt(byte);
|
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[] = [];
|
const chars: string[] = [];
|
||||||
for (let i = 0; i < KEY_CHAR_LENGTH; i++) {
|
for (let i = 0; i < KEY_CHAR_LENGTH; i++) {
|
||||||
chars.push(CHARSET[Number(n % BASE)]);
|
chars.push(CHARSET[Number(n % BASE)]);
|
||||||
n = n / BASE;
|
n = n / BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format: A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX
|
// Format: A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXX
|
||||||
const keyStr = chars.join('');
|
const keyStr = chars.join('');
|
||||||
const groups: string[] = [];
|
const groups: string[] = [];
|
||||||
for (let i = 0; i < keyStr.length; i += 6) {
|
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();
|
const cleaned = input.replace(/-/g, '').replace(/\s/g, '').toUpperCase();
|
||||||
if (!cleaned.startsWith('A3')) return null;
|
if (!cleaned.startsWith('A3')) return null;
|
||||||
const keyPart = cleaned.slice(2);
|
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
|
// Reverse base-30 encoding: reconstruct the BigInt
|
||||||
// chars were pushed least-significant first, so index 0 is the lowest digit
|
// chars were pushed least-significant first, so index 0 is the lowest digit
|
||||||
|
|||||||
Reference in New Issue
Block a user