Files
seedpgp-web/src/lib/seedblend.ts
LC mac 185efe454f feat: mobile-first redesign and layout improvements
## Major Changes

### Mobile-First Responsive Design
- Converted entire app to mobile-first single-column layout
- Constrained max-width to 448px (mobile phone width)
- Black margins on desktop, centered content
- Removed all multi-column grids (md:grid-cols-3)

### Header Reorganization (3-Row Layout)
- Row 1: App logo + title + version
- Row 2: Security badges + action buttons (Empty, Reset)
- Row 3: Navigation tabs (Create, Backup, Restore, Blender)
- Replaced text buttons with emoji icons (📋 clipboard, 🙈 privacy mask)
- Consistent button sizing across all tabs

### Font Size Reductions
- Reduced all button text sizes for mobile density
- Main buttons: py-4 → py-3, added text-sm
- Labels: text-xs → text-[10px]
- Placeholders: consistent text-[10px] across all inputs
- Input fields: text-sm → text-xs, p-4 → p-3

### Create Tab Improvements
- Changed "GENERATE NEW SEED" from button-style to banner
- Left-aligned banner with gradient background
- Equal-width button grid (12/24 Words, Backup/Seed Blender)
- Used grid-cols-2 for consistent sizing

### Backup Tab Improvements
- Simplified drag-drop area with 📎 emoji
- Reduced padding and text sizes
- Cleaner, shorter copy
- PGP label font size: text-xs → text-[12px]

### SeedBlender Component
- Reorganized mnemonic input cards: textarea on row 1, buttons on row 2
- QR button (left) and X button (right) alignment
- Consistent placeholder text sizing (text-[10px])
- Shortened dice roll placeholder text

### HTTPS Development Server
- Added @vitejs/plugin-basic-ssl for HTTPS in dev mode
- Configured server to listen on 0.0.0.0:5173
- Fixed Web Crypto API issues on mobile (requires secure context)
- Enables testing on iPhone via local network

## Technical Details
- All changes maintain cyberpunk theme and color scheme
- Improved mobile usability and visual consistency
- No functionality changes, pure UI/UX improvements
2026-02-09 21:58:18 +08:00

476 lines
14 KiB
TypeScript

/**
* @file Seed Blending Library for seedpgp-web
* @author Gemini
* @version 1.0.0
*
* @summary
* A direct and 100% logic-compliant port of the 'dice_mix_interactive.py'
* Python script to TypeScript for use in browser environments. This module
* implements XOR-based seed blending and HKDF-SHA256 enhancement with dice
* rolls using the Web Crypto API.
*
* @description
* The process involves two stages:
* 1. **Mnemonic Blending**: Multiple BIP39 mnemonics are converted to their
* raw entropy and commutatively blended using a bitwise XOR operation.
* 2. **Dice Mixing**: The blended entropy is combined with entropy from a
* long string of physical dice rolls. The result is processed through
* HKDF-SHA256 to produce a final, cryptographically-strong mnemonic.
*
* This implementation strictly follows the Python script's logic, including
* checksum validation, bitwise operations, and cryptographic constructions,
* to ensure verifiable, deterministic outputs that match the reference script.
*/
import wordlistTxt from '../bip39_wordlist.txt?raw';
// --- Isomorphic Crypto Setup ---
/**
* Asynchronously gets the appropriate SubtleCrypto interface, using a singleton
* pattern to ensure the module is loaded only once.
* This approach uses a dynamic import() to prevent Vite from bundling the
* Node.js 'crypto' module in browser builds.
*/
async function getCrypto(): Promise<SubtleCrypto> {
// Try browser Web Crypto API first
if (globalThis.crypto?.subtle) {
return globalThis.crypto.subtle;
}
// Try Node.js/Bun crypto module (for SSR and tests)
try {
const { webcrypto } = await import('crypto');
if (webcrypto?.subtle) {
return webcrypto.subtle as SubtleCrypto;
}
} catch (e) {
// Ignore import errors
}
throw new Error("SubtleCrypto not found in this environment");
}
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(data.byteLength);
new Uint8Array(buffer).set(data);
return buffer;
}
// --- BIP39 Wordlist Loading ---
/**
* The BIP39 English wordlist, loaded directly from the project file.
*/
export const BIP39_WORDLIST: readonly string[] = wordlistTxt.trim().split('\n');
/**
* A Map for fast, case-insensitive lookup of a word's index.
*/
export const WORD_INDEX = new Map<string, number>(
BIP39_WORDLIST.map((word, index) => [word, index])
);
if (BIP39_WORDLIST.length !== 2048) {
throw new Error(`Invalid wordlist loaded: expected 2048 words, got ${BIP39_WORDLIST.length}`);
}
// --- Web Crypto API Helpers ---
/**
* Computes the SHA-256 hash of the given data.
* @param data The data to hash.
* @returns A promise that resolves to the hash as a Uint8Array.
*/
async function sha256(data: Uint8Array): Promise<Uint8Array> {
const subtle = await getCrypto();
const hashBuffer = await subtle.digest('SHA-256', toArrayBuffer(data));
return new Uint8Array(hashBuffer);
}
/**
* Performs an HMAC-SHA256 operation.
* @param key The HMAC key.
* @param data The data to authenticate.
* @returns A promise that resolves to the HMAC tag.
*/
async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
const subtle = await getCrypto();
const cryptoKey = await subtle.importKey(
'raw',
toArrayBuffer(key),
{ name: 'HMAC', hash: 'SHA-256' },
false, // not exportable
['sign']
);
const signature = await subtle.sign('HMAC', cryptoKey, toArrayBuffer(data));
return new Uint8Array(signature);
}
// --- Core Cryptographic Functions (Ported from Python) ---
/**
* XOR two byte arrays, cycling the shorter one if lengths differ.
* This is a direct port of `xor_bytes` from the Python script.
*/
export function xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
const maxLen = Math.max(a.length, b.length);
const result = new Uint8Array(maxLen);
for (let i = 0; i < maxLen; i++) {
result[i] = a[i % a.length] ^ b[i % b.length];
}
return result;
}
/**
* An asynchronous, browser-compatible port of `hkdf_extract_expand` from the Python script.
* Implements HKDF using HMAC-SHA256 according to RFC 5869.
*
* @param keyMaterial The input keying material (IKM).
* @param length The desired output length in bytes.
* @param info Optional context and application specific information.
* @returns A promise resolving to the output keying material (OKM).
*/
export async function hkdfExtractExpand(
keyMaterial: Uint8Array,
length: number = 32,
info: Uint8Array = new Uint8Array(0)
): Promise<Uint8Array> {
// 1. Extract
const salt = new Uint8Array(32).fill(0); // Fixed zero salt, as in Python script
const prk = await hmacSha256(salt, keyMaterial);
// 2. Expand
let t = new Uint8Array(0);
let okm = new Uint8Array(length);
let written = 0;
let counter = 1;
while (written < length) {
const dataToHmac = new Uint8Array(t.length + info.length + 1);
dataToHmac.set(t, 0);
dataToHmac.set(info, t.length);
dataToHmac.set([counter], t.length + info.length);
t = new Uint8Array(await hmacSha256(prk, dataToHmac));
const toWrite = Math.min(t.length, length - written);
okm.set(t.slice(0, toWrite), written);
written += toWrite;
counter++;
}
return okm;
}
/**
* Converts a BIP39 mnemonic string to its raw entropy bytes.
* Asynchronously performs checksum validation.
* This is a direct port of `mnemonic_to_bytes` from the Python script.
*/
export async function mnemonicToEntropy(mnemonicStr: string): Promise<Uint8Array> {
const words = mnemonicStr.trim().toLowerCase().split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
throw new Error("Mnemonic must be 12 or 24 words");
}
let fullInt = 0n;
for (const word of words) {
const index = WORD_INDEX.get(word);
if (index === undefined) {
throw new Error(`Invalid word: ${word}`);
}
fullInt = (fullInt << 11n) | BigInt(index);
}
const totalBits = words.length * 11;
const CS = totalBits / 33; // 4 for 12 words, 8 for 24 words
const entropyBits = totalBits - CS;
let entropyInt = fullInt >> BigInt(CS);
const entropyBytes = new Uint8Array(entropyBits / 8);
for (let i = entropyBytes.length - 1; i >= 0; i--) {
entropyBytes[i] = Number(entropyInt & 0xFFn);
entropyInt >>= 8n;
}
// Verify checksum
const hashBytes = await sha256(entropyBytes);
const computedChecksum = hashBytes[0] >> (8 - CS);
const originalChecksum = Number(fullInt & ((1n << BigInt(CS)) - 1n));
if (originalChecksum !== computedChecksum) {
throw new Error("Invalid mnemonic checksum");
}
return entropyBytes;
}
/**
* Converts raw entropy bytes to a BIP39 mnemonic string.
* Asynchronously calculates and appends the checksum.
* This is a direct port of `bytes_to_mnemonic` from the Python script.
*/
export async function entropyToMnemonic(entropyBytes: Uint8Array): Promise<string> {
const ENT = entropyBytes.length * 8;
if (ENT !== 128 && ENT !== 256) {
throw new Error("Entropy must be 128 or 256 bits");
}
const CS = ENT / 32;
const hashBytes = await sha256(entropyBytes);
const checksum = hashBytes[0] >> (8 - CS);
let entropyInt = 0n;
for (const byte of entropyBytes) {
entropyInt = (entropyInt << 8n) | BigInt(byte);
}
const fullInt = (entropyInt << BigInt(CS)) | BigInt(checksum);
const totalBits = ENT + CS;
const mnemonicWords: string[] = [];
for (let i = 0; i < totalBits / 11; i++) {
const shift = BigInt(totalBits - (i + 1) * 11);
const index = Number((fullInt >> shift) & 0x7FFn);
mnemonicWords.push(BIP39_WORDLIST[index]);
}
return mnemonicWords.join(' ');
}
// --- Dice and Statistical Functions ---
/**
* Converts a string of dice rolls to a byte array using integer-based math
* to avoid floating point precision issues.
* This is a direct port of the dice conversion logic from the Python script.
*/
export function diceToBytes(diceRolls: string): Uint8Array {
const n = diceRolls.length;
// Integer-based calculation of bits: n * log2(6)
// log2(6) ≈ 2.5849625, so we use a scaled integer 2584965 for precision.
const totalBits = Math.floor(n * 2584965 / 1000000);
const diceBytesLen = Math.ceil(totalBits / 8);
let diceInt = 0n;
for (const roll of diceRolls) {
const value = parseInt(roll, 10);
if (isNaN(value) || value < 1 || value > 6) {
throw new Error(`Invalid dice roll: '${roll}'. Must be 1-6.`);
}
diceInt = diceInt * 6n + BigInt(value - 1);
}
if (diceBytesLen === 0 && diceInt > 0n) {
// This case should not be hit with reasonable inputs but is a safeguard.
throw new Error("Cannot represent non-zero dice value in zero bytes.");
}
const diceBytes = new Uint8Array(diceBytesLen);
for (let i = diceBytes.length - 1; i >= 0; i--) {
diceBytes[i] = Number(diceInt & 0xFFn);
diceInt >>= 8n;
}
return diceBytes;
}
/**
* Detects statistically unlikely patterns in a string of dice rolls.
* This is a direct port of `detect_bad_patterns`.
*/
export function detectBadPatterns(diceRolls: string): { bad: boolean; message?: string } {
const patterns = [
/1{5,}/, /2{5,}/, /3{5,}/, /4{5,}/, /5{5,}/, /6{5,}/, // Long repeats
/(123456){2,}/, /(654321){2,}/, /(123){3,}/, /(321){3,}/, // Sequences
/(?:222333444|333444555|444555666)/, // Grouped increments
/(\d)\1{4,}/, // Any digit repeated 5+
/(?:121212|131313|141414|151515|161616){2,}/, // Alternating
];
for (const pattern of patterns) {
if (pattern.test(diceRolls)) {
return { bad: true, message: `Bad pattern detected: matches ${pattern.source}` };
}
}
return { bad: false };
}
/**
* Interface for dice roll statistics.
*/
export interface DiceStats {
length: number;
distribution: Record<number, number>;
mean: number;
stdDev: number;
estimatedEntropyBits: number;
chiSquare: number;
}
/**
* Calculates and returns various statistics for the given dice rolls.
* Ported from `calculate_dice_stats` and the main script's stats logic.
*/
export function calculateDiceStats(diceRolls: string): DiceStats {
if (!diceRolls) {
return { length: 0, distribution: {}, mean: 0, stdDev: 0, estimatedEntropyBits: 0, chiSquare: 0 };
}
const rolls = diceRolls.split('').map(c => parseInt(c, 10));
const n = rolls.length;
const counts: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
for (const roll of rolls) {
counts[roll]++;
}
const sum = rolls.reduce((a, b) => a + b, 0);
const mean = sum / n;
const stdDev = n > 1 ? Math.sqrt(rolls.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n - 1)) : 0;
const estimatedEntropyBits = n * Math.log2(6);
const expected = n / 6;
let chiSquare = 0;
for (let i = 1; i <= 6; i++) {
chiSquare += Math.pow(counts[i] - expected, 2) / expected;
}
return {
length: n,
distribution: counts,
mean: mean,
stdDev: stdDev,
estimatedEntropyBits,
chiSquare,
};
}
// --- Main Blending Logic ---
/**
* Checks for weak XOR results (low diversity or all zeros).
* Ported from the main logic in the Python script.
*/
export function checkXorStrength(blendedEntropy: Uint8Array): {
isWeak: boolean;
uniqueBytes: number;
allZeros: boolean;
} {
const uniqueBytes = new Set(blendedEntropy).size;
const allZeros = blendedEntropy.every(byte => byte === 0);
// Heuristic from Python script: < 32 unique bytes is a warning.
return {
isWeak: uniqueBytes < 32 || allZeros,
uniqueBytes,
allZeros,
};
}
// --- Main Blending & Mixing Orchestration ---
/**
* Stage 1: Asynchronously blends multiple mnemonics using XOR.
*
* @param mnemonics An array of mnemonic strings to blend.
* @returns A promise that resolves to the blended entropy and preview mnemonics.
*/
export async function blendMnemonicsAsync(mnemonics: string[]): Promise<{
blendedEntropy: Uint8Array;
blendedMnemonic12: string;
blendedMnemonic24?: string;
maxEntropyBits: number;
}> {
if (mnemonics.length === 0) {
throw new Error("At least one mnemonic is required for blending.");
}
const entropies = await Promise.all(mnemonics.map(mnemonicToEntropy));
let maxEntropyBits = 128;
for (const entropy of entropies) {
if (entropy.length * 8 > maxEntropyBits) {
maxEntropyBits = entropy.length * 8;
}
}
// Commutative XOR blending
let blendedEntropy = entropies[0];
for (let i = 1; i < entropies.length; i++) {
blendedEntropy = xorBytes(blendedEntropy, entropies[i]);
}
// Generate previews
const blendedMnemonic12 = await entropyToMnemonic(blendedEntropy.slice(0, 16));
let blendedMnemonic24: string | undefined;
if (blendedEntropy.length >= 32) {
blendedMnemonic24 = await entropyToMnemonic(blendedEntropy.slice(0, 32));
}
return {
blendedEntropy,
blendedMnemonic12,
blendedMnemonic24,
maxEntropyBits
};
}
/**
* Stage 2: Asynchronously mixes blended entropy with dice rolls using HKDF.
*
* @param blendedEntropy The result from the XOR blending stage.
* @param diceRolls A string of dice rolls (e.g., "16345...").
* @param outputBits The desired final entropy size (128 or 256).
* @param info A domain separation tag for HKDF.
* @returns A promise that resolves to the final mnemonic and related data.
*/
export async function mixWithDiceAsync(
blendedEntropy: Uint8Array,
diceRolls: string,
outputBits: 128 | 256 = 256,
info: string = 'seedsigner-dice-mix'
): Promise<{
finalEntropy: Uint8Array;
finalMnemonic: string;
diceOnlyMnemonic: string;
}> {
if (diceRolls.length < 50) {
throw new Error("A minimum of 50 dice rolls is required (99+ recommended).");
}
const diceBytes = diceToBytes(diceRolls);
const outputByteLength = outputBits === 128 ? 16 : 32;
const infoBytes = new TextEncoder().encode(info);
const diceOnlyInfoBytes = new TextEncoder().encode('dice-only');
// Generate dice-only preview
const diceOnlyEntropy = await hkdfExtractExpand(diceBytes, outputByteLength, diceOnlyInfoBytes);
const diceOnlyMnemonic = await entropyToMnemonic(diceOnlyEntropy);
// Combine blended entropy with dice bytes
const combinedMaterial = new Uint8Array(blendedEntropy.length + diceBytes.length);
combinedMaterial.set(blendedEntropy, 0);
combinedMaterial.set(diceBytes, blendedEntropy.length);
// Apply HKDF to the combined material
const finalEntropy = await hkdfExtractExpand(combinedMaterial, outputByteLength, infoBytes);
const finalMnemonic = await entropyToMnemonic(finalEntropy);
return {
finalEntropy,
finalMnemonic,
diceOnlyMnemonic,
};
}