diff options
Diffstat (limited to 'index.php')
| -rw-r--r-- | index.php | 1290 |
1 files changed, 1290 insertions, 0 deletions
diff --git a/index.php b/index.php new file mode 100644 index 0000000..ef26ce3 --- /dev/null +++ b/index.php @@ -0,0 +1,1290 @@ +<?php +/** + * VFACE — Pseudonymous Identity Tool + * + * Create and verify persistent pseudonymous identities. + * Backend: identicons-cli (Ch1ffr3punk algorithm) + PHP sodium (Ed25519) + * + * @version 1.0.0 + */ + +// ============================================ +// CONFIGURATION +// ============================================ + +define('IDENTICONS_CLI', __DIR__ . '/identicons-cli'); +define('MAX_UPLOAD_SIZE', 512 * 1024); +define('BLOG_ARTICLE', 'https://www.virebent.art/blog/vface_identicons.html'); + +// ============================================ +// SECURITY +// ============================================ + +// Reset: redirect clean +if (isset($_GET['reset'])) { + header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?')); + exit; +} + +function sanitize(string $v, int $max = 255): string { + $v = trim($v); + if (mb_strlen($v) > $max) $v = mb_substr($v, 0, $max); + return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $v); +} + +/** + * Clean a public key from any format to raw key content. + * Handles: PEM (any type), SSH format, hex, base64, raw paste. + */ +function cleanPublicKey(string $raw): string { + $key = trim($raw); + if (empty($key)) return ''; + + // Strip PEM headers/footers (-----BEGIN ... -----, -----END ... -----) + $key = preg_replace('/-----BEGIN [A-Z\s]+-----/', '', $key); + $key = preg_replace('/-----END [A-Z\s]+-----/', '', $key); + + // Strip SSH prefix (ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256, etc.) + $key = preg_replace('/^(ssh-\S+|ecdsa-\S+)\s+/', '', trim($key)); + + // Strip SSH trailing comment (user@host) + $key = preg_replace('/\s+\S+@\S+\s*$/', '', $key); + $key = preg_replace('/\s+[^\s=+\/]+\s*$/', '', $key); + + // Remove all whitespace and newlines + $key = preg_replace('/\s+/', '', $key); + + return $key; +} + +// ============================================ +// IDENTICON GENERATION VIA CLI +// ============================================ + +function generateIdenticonBase64(string $input, int $size): ?string { + if (!is_executable(IDENTICONS_CLI)) return null; + $cmd = IDENTICONS_CLI . ' -input ' . escapeshellarg($input) . + ' -size ' . intval($size) . ' -transparent -format base64 2>&1'; + $out = trim(shell_exec($cmd) ?? ''); + if (empty($out) || !preg_match('/^[A-Za-z0-9+\/=]+$/', $out)) return null; + return $out; +} + +// ============================================ +// FACE HEADER FOLDING (RFC 2822) +// ============================================ + +function foldFaceHeader(string $base64): string { + $maxFirst = 72 - strlen('Face: '); + $maxCont = 72 - 1; + $lines = ['Face: ' . substr($base64, 0, $maxFirst)]; + $offset = $maxFirst; + while ($offset < strlen($base64)) { + $lines[] = ' ' . substr($base64, $offset, $maxCont); + $offset += $maxCont; + } + return implode("\n", $lines); +} + +// ============================================ +// ED25519 KEYPAIR GENERATION +// ============================================ + +function generateKeypair(): array { + $kp = sodium_crypto_sign_keypair(); + $sk = sodium_crypto_sign_secretkey($kp); + $pk = sodium_crypto_sign_publickey($kp); + $result = [ + 'publicKey' => base64_encode($pk), + 'secretKey' => base64_encode($sk), + 'created' => gmdate('c'), + ]; + // Wipe sensitive material from memory + sodium_memzero($sk); + sodium_memzero($kp); + return $result; +} + +// ============================================ +// ED25519 SIGNATURE VERIFICATION +// ============================================ + +function verifySignature(string $message, string $signatureB64, string $pubkeyB64): bool { + $sig = base64_decode($signatureB64, true); + $pk = base64_decode($pubkeyB64, true); + if ($sig === false || $pk === false) return false; + if (strlen($sig) !== 64 || strlen($pk) !== 32) return false; + return sodium_crypto_sign_verify_detached($sig, $message, $pk); +} + +// ============================================ +// STATIC IDENTITY PAGE GENERATOR +// ============================================ + +function buildIdentityPageHTML( + string $username, string $email, string $pubkey, + string $hash, string $identicon256, string $faceBase64 +): string { + $u = htmlspecialchars($username, ENT_QUOTES, 'UTF-8'); + $e = htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); + $p = htmlspecialchars($pubkey, ENT_QUOTES, 'UTF-8'); + $h = htmlspecialchars($hash, ENT_QUOTES, 'UTF-8'); + $faceHeader = htmlspecialchars(foldFaceHeader($faceBase64), ENT_QUOTES, 'UTF-8'); + $vfaceHeaders = htmlspecialchars( + "From: {$username} <{$email}>\n" . + "Ed25519-Pub: {$pubkey}\n" . + "Ed25519-Sig: [sign message body with your private key]\n" . + "Identity-Hash: {$hash}\n" . + foldFaceHeader($faceBase64), + ENT_QUOTES, 'UTF-8' + ); + + return <<<HTML +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>VFACE Identity — {$u}</title> +<style> +body{font-family:-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;max-width:600px;margin:2rem auto;padding:1rem;line-height:1.6} +h1{color:#58a6ff;font-size:1.4rem} +.id-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin:1.5rem 0} +.field{margin-bottom:1rem} +.label{color:#8b949e;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px} +.value{font-family:monospace;font-size:0.9rem;word-break:break-all;margin-top:0.2rem} +.hash{color:#58a6ff} +.identicon{text-align:center;margin:1.5rem 0} +.identicon img{border:2px solid #30363d;border-radius:8px;image-rendering:pixelated} +.verify{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-top:1.5rem;font-size:0.85rem;color:#8b949e} +.verify a{color:#58a6ff;text-decoration:none} +.verify a:hover{text-decoration:underline} +.copyable{background:#1c2129;border:1px solid #30363d;border-radius:4px;padding:0.5rem 0.7rem;margin:0.4rem 0;font-family:monospace;font-size:0.78rem;color:#c9d1d9;width:100%;resize:none;height:2.2rem;overflow:hidden;cursor:pointer;display:block;box-sizing:border-box} +.copyable:focus{height:auto;overflow:visible;outline:1px solid #58a6ff} +.copyable-hint{font-size:0.72rem;color:#8b949e;margin-top:0.2rem} +</style> +</head> +<body> +<h1>VFACE Identity</h1> +<div class="id-card"> +<div class="identicon"><img src="data:image/png;base64,{$identicon256}" width="256" height="256" alt="Identicon"></div> +<div class="field"><div class="label">Username</div><div class="value">{$u}</div></div> +<div class="field"><div class="label">Email</div><div class="value">{$e}</div></div> +<div class="field"><div class="label">Public Key</div><div class="value">{$p}</div></div> +<div class="field"><div class="label">Identity Hash</div><div class="value hash">{$h}</div></div> +</div> +<div class="verify" style="margin-top:1.5rem;"> +<strong>VFACE Headers for Usenet / Email:</strong><br> +<span class="copyable-hint">Copy these headers into your client. Replace the signature line after signing your message body.</span> +<pre style="background:#1c2129;border:1px solid #30363d;border-radius:4px;padding:0.8rem;margin:0.6rem 0;font-size:0.78rem;color:#c9d1d9;overflow-x:auto;white-space:pre;line-height:1.5">{$vfaceHeaders}</pre> +</div> +<div class="verify"> +<strong>How to verify this identity:</strong><br><br> +1. Concatenate this string:<br> +<textarea readonly class="copyable" onclick="this.select()">{$u}|{$e}|{$p}</textarea> +<span class="copyable-hint">Click to select all — then copy</span><br> +2. Calculate SHA256 of that string — it should match the Identity Hash above.<br><br> +3. Go to <a href="https://identicons.virebent.art" rel="noopener">identicons.virebent.art</a> and use the Analyze tab to verify the identicon, or the Create tab to regenerate it from the same input.<br><br> +4. For signed messages: verify the Ed25519 signature with the public key above.<br><br> +5. Check the <code>.ots</code> file alongside this page for Bitcoin timestamp proof (<a href="https://opentimestamps.org" rel="noopener">opentimestamps.org</a>). +</div> +</body> +</html> +HTML; +} + +// ============================================ +// CH1FFR3PUNK PALETTES (for analysis) +// ============================================ + +const PRIMARY_PALETTE = [ + [0x00, 0xbf, 0x93], [0x2d, 0xcc, 0x70], [0x42, 0xe4, 0x53], [0xf1, 0xc4, 0x0f], + [0xe6, 0x7f, 0x22], [0xff, 0x94, 0x4e], [0xe8, 0x4c, 0x3d], [0x35, 0x98, 0xdb], + [0x9a, 0x59, 0xb5], [0xef, 0x3e, 0x96], [0xdf, 0x21, 0xb9], [0x7d, 0xc2, 0xd2], + [0x16, 0xa0, 0x86], [0x27, 0xae, 0x61], [0x24, 0xc3, 0x33], [0x1c, 0xab, 0xbb], +]; + +const SECONDARY_PALETTE = [ + [0x34, 0x49, 0x5e], [0x95, 0xa5, 0xa5], [0xd2, 0x54, 0x00], [0xc1, 0x39, 0x2b], + [0x29, 0x7f, 0xb8], [0x8d, 0x44, 0xad], [0xbe, 0x12, 0x7e], [0xe5, 0x23, 0x83], + [0x27, 0xae, 0x61], [0x24, 0xc3, 0x33], [0xd9, 0xd9, 0x21], [0xf3, 0x9c, 0x11], + [0xff, 0x55, 0x00], [0x1c, 0xab, 0xbb], [0x23, 0x23, 0x23], [0x7e, 0x8c, 0x8d], +]; + +const BACKGROUNDS = [ + [255, 255, 255], [243, 245, 247], [236, 240, 241], +]; + +function colorsMatch(array $a, array $b, int $tolerance = 5): bool { + return abs($a['r'] - $b['r']) <= $tolerance && + abs($a['g'] - $b['g']) <= $tolerance && + abs($a['b'] - $b['b']) <= $tolerance; +} + +function colorDistance(array $a, array $b): float { + return sqrt(pow($a[0] - $b[0], 2) + pow($a[1] - $b[1], 2) + pow($a[2] - $b[2], 2)); +} + +function analyzeIdenticon(string $filePath): array { + $result = [ + 'valid' => false, 'dimensions' => null, 'cell_size' => null, + 'symmetric' => false, 'colors_found' => [], + 'primary_color' => null, 'secondary_color' => null, + 'background' => null, 'bg_match' => false, + 'primary_match' => false, 'secondary_match' => false, + 'palette_score' => 0, 'errors' => [], 'dataurl' => null, + ]; + + $info = @getimagesize($filePath); + if ($info === false) { $result['errors'][] = 'Not a valid image file.'; return $result; } + if ($info[2] !== IMAGETYPE_PNG) { $result['errors'][] = 'Image must be PNG format.'; return $result; } + + $width = $info[0]; $height = $info[1]; + $result['dimensions'] = "{$width}x{$height}"; + + if ($width !== $height) { $result['errors'][] = 'Image is not square.'; return $result; } + if ($width < 10) { $result['errors'][] = 'Image too small.'; return $result; } + + $spriteSize = 5; + $cellSize = intdiv($width, $spriteSize); + $margin = intdiv($width - $spriteSize * $cellSize, 2); + $result['cell_size'] = $cellSize; + + $img = @imagecreatefrompng($filePath); + if (!$img) { $result['errors'][] = 'Failed to load PNG.'; return $result; } + + if (!imageistruecolor($img)) { imagepalettetotruecolor($img); } + imagealphablending($img, false); + imagesavealpha($img, true); + + ob_start(); imagepng($img); $pngData = ob_get_clean(); + $result['dataurl'] = 'data:image/png;base64,' . base64_encode($pngData); + + $grid = []; $colorMap = []; + for ($row = 0; $row < $spriteSize; $row++) { + $grid[$row] = []; + for ($col = 0; $col < $spriteSize; $col++) { + $px = min($col * $cellSize + $margin + intdiv($cellSize, 2), $width - 1); + $py = min($row * $cellSize + $margin + intdiv($cellSize, 2), $width - 1); + $rgba = imagecolorat($img, $px, $py); + $r = ($rgba >> 16) & 0xFF; $g = ($rgba >> 8) & 0xFF; + $b = $rgba & 0xFF; $a = ($rgba >> 24) & 0x7F; + $grid[$row][$col] = ['r' => $r, 'g' => $g, 'b' => $b, 'a' => $a]; + if ($a < 64) { $colorMap["{$r},{$g},{$b}"] = [$r, $g, $b]; } + } + } + + $symmetryOk = true; + for ($row = 0; $row < 5; $row++) { + for ($pair = 0; $pair < 2; $pair++) { + if (!colorsMatch($grid[$row][$pair], $grid[$row][4 - $pair], 5)) { + $symmetryOk = false; break 2; + } + } + } + $result['symmetric'] = $symmetryOk; + $result['colors_found'] = array_values($colorMap); + + $bgColor = null; $fgColors = []; + $cornerColor = $grid[0][0]; + + foreach (BACKGROUNDS as $bg) { + if (colorsMatch(['r'=>$bg[0],'g'=>$bg[1],'b'=>$bg[2],'a'=>0], $cornerColor, 5)) { + $result['bg_match'] = true; $bgColor = $bg; break; + } + } + + $hasTransparent = false; + for ($row = 0; $row < 5; $row++) { + for ($col = 0; $col < 5; $col++) { + if ($grid[$row][$col]['a'] >= 64) { $hasTransparent = true; break 2; } + } + } + if ($hasTransparent) { $result['bg_match'] = true; $result['background'] = 'transparent'; } + elseif ($bgColor) { $result['background'] = sprintf('#%02x%02x%02x', $bgColor[0], $bgColor[1], $bgColor[2]); } + + foreach ($colorMap as $key => $rgb) { + if ($bgColor && colorsMatch(['r'=>$rgb[0],'g'=>$rgb[1],'b'=>$rgb[2],'a'=>0], + ['r'=>$bgColor[0],'g'=>$bgColor[1],'b'=>$bgColor[2],'a'=>0], 5)) continue; + $fgColors[] = $rgb; + } + + foreach ($fgColors as $fg) { + foreach (PRIMARY_PALETTE as $idx => $pal) { + if (colorDistance($fg, $pal) < 10) { + $result['primary_match'] = true; + $result['primary_color'] = ['rgb'=>$fg, 'hex'=>sprintf('#%02x%02x%02x',$fg[0],$fg[1],$fg[2]), 'index'=>$idx]; + break 2; + } + } + } + + foreach ($fgColors as $fg) { + if ($result['primary_color'] && colorDistance($fg, $result['primary_color']['rgb']) < 10) continue; + foreach (SECONDARY_PALETTE as $idx => $pal) { + if (colorDistance($fg, $pal) < 10) { + $result['secondary_match'] = true; + $result['secondary_color'] = ['rgb'=>$fg, 'hex'=>sprintf('#%02x%02x%02x',$fg[0],$fg[1],$fg[2]), 'index'=>$idx]; + break 2; + } + } + } + + $score = 0; + if ($result['symmetric']) $score += 30; + if ($result['bg_match']) $score += 20; + if ($result['primary_match']) $score += 25; + if ($result['secondary_match']) $score += 25; + $result['palette_score'] = $score; + + $result['valid'] = ($result['symmetric'] && $result['bg_match'] && + $result['primary_match'] && $result['secondary_match'] && + count($colorMap) >= 2 && count($colorMap) <= 4); + + imagedestroy($img); + return $result; +} + +// ============================================ +// PROCESS REQUESTS +// ============================================ + +$tab = $_POST['tab'] ?? 'create'; +$result = null; +$verifyRes = null; +$anaResult = null; +$error = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $tab = $_POST['tab'] ?? 'create'; + + // ---- TAB: CREATE ---- + if ($tab === 'create') { + $username = sanitize($_POST['username'] ?? '', 64); + $email = sanitize($_POST['email'] ?? '', 254); + $keyMode = $_POST['key_mode'] ?? 'generate'; + $rawPubkey = sanitize($_POST['pubkey'] ?? '', 8192); + + if (empty($username)) { $error = 'Pick a username.'; } + elseif (empty($email)) { $error = 'Enter an email address.'; } + elseif ($keyMode === 'existing' && empty($rawPubkey)) { + $error = 'Paste your public key.'; + } + + if (!$error) { + $keypair = null; + if ($keyMode === 'generate') { + if (!function_exists('sodium_crypto_sign_keypair')) { + $error = 'Server does not support Ed25519 (sodium extension missing).'; + } else { + $keypair = generateKeypair(); + $pubkey = $keypair['publicKey']; + } + } else { + $pubkey = cleanPublicKey($rawPubkey); + if (empty($pubkey)) { + $error = 'Could not extract a valid key from your input.'; + } + } + $keyCleaned = isset($pubkey) && $rawPubkey !== $pubkey; + } + + if (!$error) { + $input = $username . '|' . $email . '|' . $pubkey; + $hash = hash('sha256', $input); + $ico48 = generateIdenticonBase64($input, 48); + $ico256 = generateIdenticonBase64($input, 256); + + if (!$ico48 || !$ico256) { + $error = 'Failed to generate identicon (binary not found).'; + } else { + $result = [ + 'username' => $username, + 'email' => $email, + 'pubkey' => $pubkey, + 'keypair' => $keypair, + 'keyCleaned' => $keyCleaned ?? false, + 'input' => $input, + 'hash' => $hash, + 'ico48' => $ico48, + 'ico256' => $ico256, + 'face' => foldFaceHeader($ico48), + 'headers' => "From: {$username} <{$email}>\n" . + "Ed25519-Pub: {$pubkey}\n" . + "Ed25519-Sig: [sign message body with your private key]\n" . + "Identity-Hash: {$hash}\n" . + foldFaceHeader($ico48), + 'identityPage' => buildIdentityPageHTML( + $username, $email, $pubkey, $hash, $ico256, $ico48 + ), + ]; + } + } + + // ---- TAB: VERIFY ---- + } elseif ($tab === 'verify') { + $vRawPubkey = sanitize($_POST['v_pubkey'] ?? '', 8192); + $vSig = sanitize($_POST['v_signature'] ?? '', 1024); + $vBody = $_POST['v_body'] ?? ''; + $vUser = sanitize($_POST['v_username'] ?? '', 64); + $vEmail = sanitize($_POST['v_email'] ?? '', 254); + $vFace = sanitize($_POST['v_face'] ?? '', 8192); + $vPubkey = cleanPublicKey($vRawPubkey); + + if (empty($vPubkey) || empty($vSig) || empty($vBody)) { + $error = 'Public key, signature, and message body are required.'; + } + + if (!$error) { + $sigValid = verifySignature($vBody, $vSig, $vPubkey); + + $hashCheck = null; + $icoCheck = null; + $icoGenerated = null; + + if (!empty($vUser) && !empty($vEmail)) { + $expectedInput = $vUser . '|' . $vEmail . '|' . $vPubkey; + $expectedHash = hash('sha256', $expectedInput); + $hashCheck = $expectedHash; + + $icoGenerated = generateIdenticonBase64($expectedInput, 48); + + if (!empty($vFace) && $icoGenerated) { + $cleanFace = trim(preg_replace('/^Face:\s*/m', '', $vFace)); + $cleanFace = preg_replace('/\s+/', '', $cleanFace); + $icoCheck = ($cleanFace === $icoGenerated); + } + } + + $verifyRes = [ + 'sig_valid' => $sigValid, + 'hash_expected' => $hashCheck, + 'ico_match' => $icoCheck, + 'ico_generated' => $icoGenerated, + ]; + } + + // ---- TAB: ANALYZE ---- + } elseif ($tab === 'analyze') { + if (!isset($_FILES['identicon_file']) || $_FILES['identicon_file']['error'] !== UPLOAD_ERR_OK) { + $uploadErr = $_FILES['identicon_file']['error'] ?? UPLOAD_ERR_NO_FILE; + $error = match ($uploadErr) { + UPLOAD_ERR_NO_FILE => 'No file uploaded.', + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE => 'File too large.', + default => 'Upload failed (error code: ' . $uploadErr . ').', + }; + } else { + $file = $_FILES['identicon_file']; + if ($file['size'] > MAX_UPLOAD_SIZE) { + $error = 'File exceeds maximum size of ' . (MAX_UPLOAD_SIZE / 1024) . ' KB.'; + } elseif ($file['size'] === 0) { + $error = 'Uploaded file is empty.'; + } else { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($file['tmp_name']); + if ($mime !== 'image/png') { + $error = 'Only PNG files are accepted (detected: ' . htmlspecialchars($mime) . ').'; + } else { + $anaResult = analyzeIdenticon($file['tmp_name']); + if (!empty($anaResult['errors'])) { + $error = implode(' ', $anaResult['errors']); + } + + // Optional: compare with identity + $anaUser = sanitize($_POST['ana_username'] ?? '', 64); + $anaEmail = sanitize($_POST['ana_email'] ?? '', 254); + $anaRawKey = sanitize($_POST['ana_pubkey'] ?? '', 8192); + $anaKey = cleanPublicKey($anaRawKey); + + if (!empty($anaUser) && !empty($anaEmail) && !empty($anaKey) && $anaResult && !$error) { + $compareInput = $anaUser . '|' . $anaEmail . '|' . $anaKey; + $compareHash = hash('sha256', $compareInput); + + // Read raw uploaded file and base64 it + $uploadedB64 = base64_encode(file_get_contents($file['tmp_name'])); + + // Generate at both sizes and try matching + $compareIco48 = generateIdenticonBase64($compareInput, 48); + $compareIco256 = generateIdenticonBase64($compareInput, 256); + + $match = ($compareIco48 && $uploadedB64 === $compareIco48) || + ($compareIco256 && $uploadedB64 === $compareIco256); + + $dims = explode('x', $anaResult['dimensions'] ?? '48x48'); + $showSize = (intval($dims[0]) > 48) ? 256 : 48; + $showIco = ($showSize === 256) ? $compareIco256 : $compareIco48; + + $anaResult['comparison'] = [ + 'hash' => $compareHash, + 'generated' => $showIco, + 'match' => $match, + ]; + } + } + } + } + } +} + +// Preserve form values +$fUser = htmlspecialchars($_POST['username'] ?? '', ENT_QUOTES, 'UTF-8'); +$fEmail = htmlspecialchars($_POST['email'] ?? '', ENT_QUOTES, 'UTF-8'); +$fPubkey = htmlspecialchars($_POST['pubkey'] ?? '', ENT_QUOTES, 'UTF-8'); +$fKeyMode = $_POST['key_mode'] ?? 'generate'; +?> +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<meta name="robots" content="noindex, nofollow"> +<title>VFACE — Your Identity, Cryptographically Yours</title> +<style> + :root { + --bg: #0d1117; + --surface: #161b22; + --surface2: #1c2129; + --border: #30363d; + --accent: #58a6ff; + --accent-dim: #1f6feb; + --text: #c9d1d9; + --muted: #8b949e; + --success: #3fb950; + --error: #f85149; + --warn: #d29922; + --mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + } + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + body { + font-family: var(--sans); + background: var(--bg); + color: var(--text); + min-height: 100vh; + padding: 2rem 1rem; + line-height: 1.6; + } + .container { max-width: 740px; margin: 0 auto; } + + /* Hero */ + .hero { + text-align: center; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); + } + .hero h1 { font-size: 2rem; font-weight: 700; } + .hero h1 em { color: var(--accent); font-style: normal; } + .hero .tagline { + color: var(--muted); + font-size: 1rem; + margin-top: 0.5rem; + } + .hero .explain { + color: var(--text); + font-size: 0.92rem; + margin-top: 1rem; + max-width: 560px; + margin-left: auto; + margin-right: auto; + line-height: 1.7; + } + .hero .learn-more { + display: inline-block; + margin-top: 0.8rem; + color: var(--accent); + text-decoration: none; + font-size: 0.88rem; + } + .hero .learn-more:hover { text-decoration: underline; } + .hero .reset-btn { + display: inline-block; + margin-top: 0.8rem; + background: var(--error); + color: #fff; + text-decoration: none; + font-size: 0.8rem; + padding: 0.35rem 1rem; + border-radius: 4px; + transition: background 0.2s; + } + .hero .reset-btn:hover { background: #da3633; text-decoration: none; } + + /* Tabs */ + .tabs { display: flex; gap: 0; margin-bottom: 1.5rem; border-bottom: 2px solid var(--border); } + .tab-btn { + background: none; border: none; color: var(--muted); + padding: 0.75rem 1.5rem; font-size: 0.95rem; font-family: var(--sans); + cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; + transition: color 0.2s, border-color 0.2s; + } + .tab-btn:hover { color: var(--text); } + .tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); } + .tab-panel { display: none; } + .tab-panel.active { display: block; } + + /* Card */ + .card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; } + + /* Form */ + .form-group { margin-bottom: 1.25rem; } + .form-group label { display: block; color: var(--text); font-size: 0.85rem; font-weight: 500; margin-bottom: 0.4rem; } + .form-group input[type="text"], + .form-group input[type="email"], + .form-group textarea { + width: 100%; background: var(--bg); border: 1px solid var(--border); + border-radius: 6px; color: var(--text); font-family: var(--mono); + font-size: 0.9rem; padding: 0.6rem 0.8rem; + } + .form-group textarea { min-height: 120px; resize: vertical; } + .form-group input:focus, .form-group textarea:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(88,166,255,0.15); + } + .hint { color: var(--muted); font-size: 0.78rem; margin-top: 0.3rem; } + + /* Key mode selector */ + /* Key mode — radio style selector */ + .key-mode { display: flex; gap: 0.75rem; margin-bottom: 1.25rem; } + .key-mode-btn { + display: flex; align-items: center; gap: 0.5rem; + background: none; border: 1px solid var(--border); + border-radius: 6px; color: var(--muted); + padding: 0.6rem 1rem; font-size: 0.85rem; font-family: var(--sans); + cursor: pointer; transition: border-color 0.2s, color 0.2s; flex: 1; + } + .key-mode-btn::before { + content: ''; width: 14px; height: 14px; border-radius: 50%; + border: 2px solid var(--border); flex-shrink: 0; + transition: border-color 0.2s, box-shadow 0.2s; + } + .key-mode-btn:hover { color: var(--text); border-color: var(--muted); } + .key-mode-btn.active { + color: var(--accent); border-color: var(--accent); + } + .key-mode-btn.active::before { + border-color: var(--accent); + box-shadow: inset 0 0 0 3px var(--accent); + } + + .btn { + display: inline-block; background: var(--accent-dim); color: #fff; + border: none; border-radius: 6px; padding: 0.65rem 1.5rem; + font-size: 0.95rem; font-family: var(--sans); cursor: pointer; + transition: background 0.2s; + } + .btn:hover { background: var(--accent); } + .btn-sm { padding: 0.4rem 1rem; font-size: 0.82rem; } + .btn-outline { + background: none; border: 1px solid var(--accent); color: var(--accent); + } + .btn-outline:hover { background: var(--accent-dim); color: #fff; } + + .error-box { + background: rgba(248,81,73,0.1); border: 1px solid var(--error); + color: var(--error); padding: 0.7rem 1rem; border-radius: 6px; + margin-bottom: 1.25rem; font-size: 0.9rem; + } + + /* Results */ + .result-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border); } + .result-section h3 { font-size: 1rem; margin-bottom: 1rem; color: var(--accent); } + + .identicon-display { text-align: center; margin: 1rem 0; } + .identicon-display img { border: 2px solid var(--border); border-radius: 8px; image-rendering: pixelated; } + .identicon-display .size-label { color: var(--muted); font-size: 0.78rem; margin-top: 0.4rem; } + .identicon-pair { display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; } + + .data-row { display: flex; gap: 0.5rem; margin-bottom: 0.6rem; align-items: baseline; } + .data-label { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.5px; min-width: 110px; flex-shrink: 0; } + .data-value { color: var(--text); font-family: var(--mono); font-size: 0.85rem; word-break: break-all; } + .data-value.hash { color: var(--accent); } + + /* Copy blocks */ + .copy-wrap { position: relative; margin-bottom: 1rem; } + .copy-block { + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + padding: 1rem; padding-right: 4rem; font-family: var(--mono); font-size: 0.8rem; + color: var(--text); white-space: pre; overflow-x: auto; line-height: 1.6; margin: 0; + } + .copy-btn { + position: absolute; top: 0.5rem; right: 0.5rem; + background: var(--accent-dim); color: #fff; border: none; border-radius: 4px; + padding: 0.3rem 0.7rem; font-size: 0.78rem; font-family: var(--sans); + cursor: pointer; transition: background 0.2s; + } + .copy-btn:hover { background: var(--accent); } + .copy-btn.copied { background: var(--success); } + + /* Keypair warning */ + .key-warning { + background: rgba(210,153,34,0.1); border: 1px solid var(--warn); + border-radius: 6px; padding: 1rem; margin-bottom: 1.25rem; font-size: 0.88rem; + color: var(--warn); line-height: 1.6; + } + .key-warning strong { color: #e6b422; } + .key-notice { + background: rgba(88,166,255,0.08); border: 1px solid var(--accent); + border-radius: 6px; padding: 0.75rem 1rem; margin: 0.5rem 0 1rem; + font-size: 0.82rem; color: var(--accent); line-height: 1.5; + } + + /* Verify results */ + .verdict { padding: 0.8rem 1rem; border-radius: 6px; margin-bottom: 0.75rem; font-weight: 600; } + .verdict.pass { background: rgba(63,185,80,0.12); border: 1px solid var(--success); color: var(--success); } + .verdict.fail { background: rgba(248,81,73,0.12); border: 1px solid var(--error); color: var(--error); } + .verdict.unknown { background: rgba(139,148,158,0.12); border: 1px solid var(--muted); color: var(--muted); } + + /* Steps */ + .steps { margin: 1.5rem 0; } + .step { display: flex; gap: 1rem; margin-bottom: 1rem; align-items: flex-start; } + .step-num { + min-width: 28px; height: 28px; background: var(--accent-dim); color: #fff; + border-radius: 50%; display: flex; align-items: center; justify-content: center; + font-size: 0.8rem; font-weight: 700; flex-shrink: 0; + } + .step-text { font-size: 0.9rem; padding-top: 0.2rem; } + .step-text strong { color: var(--text); } + .step-text .dim { color: var(--muted); font-size: 0.82rem; } + + footer { + text-align: center; margin-top: 2rem; padding-top: 1rem; + border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; + } + footer a { color: var(--accent); text-decoration: none; } + footer a:hover { text-decoration: underline; } + + /* Analyze tab */ + .check-list { list-style: none; margin: 1rem 0; } + .check-list li { padding: 0.4rem 0; font-size: 0.9rem; } + .check-list .pass::before { content: '\2713 '; color: var(--success); font-weight: 700; } + .check-list .fail::before { content: '\2717 '; color: var(--error); font-weight: 700; } + .color-swatch { + display: inline-block; width: 16px; height: 16px; + border-radius: 3px; border: 1px solid var(--border); + vertical-align: middle; margin-right: 0.4rem; + } + .file-input-wrap input[type="file"] { + width: 100%; background: var(--bg); border: 1px solid var(--border); + border-radius: 6px; color: var(--text); padding: 0.6rem 0.8rem; font-size: 0.9rem; + } + .file-input-wrap input[type="file"]::file-selector-button { + background: var(--accent-dim); color: #fff; border: none; + border-radius: 4px; padding: 0.3rem 0.8rem; margin-right: 0.8rem; + cursor: pointer; font-size: 0.85rem; + } + + @media (max-width: 500px) { + body { padding: 1rem 0.5rem; } + .hero h1 { font-size: 1.5rem; } + .identicon-pair { flex-direction: column; align-items: center; gap: 1rem; } + .data-row { flex-direction: column; gap: 0.2rem; } + .data-label { min-width: unset; } + } +</style> +</head> +<body> +<div class="container"> + +<div class="hero"> + <h1><em>VFACE</em></h1> + <div class="tagline">Your pseudonym, cryptographically yours.</div> + <div class="explain"> + VFACE gives you a persistent identity that nobody can steal or fake. + Pick a name, get a unique visual fingerprint tied to your key. + Anyone can verify it's really you — without knowing who you are. + </div> + <a href="<?= BLOG_ARTICLE ?>" class="learn-more" rel="noopener">How does it work? Read the full explanation →</a> + <br> + <a href="?reset=1" class="reset-btn">Reset</a> +</div> + +<div class="tabs"> + <button type="button" class="tab-btn <?= $tab === 'create' ? 'active' : '' ?>" data-tab="create">Create Identity</button> + <button type="button" class="tab-btn <?= $tab === 'analyze' ? 'active' : '' ?>" data-tab="analyze">Analyze</button> + <button type="button" class="tab-btn <?= $tab === 'verify' ? 'active' : '' ?>" data-tab="verify">Verify Signatures</button> +</div> + +<?php if ($error): ?> +<div class="error-box"><?= htmlspecialchars($error) ?></div> +<?php endif; ?> + +<!-- ==================== CREATE TAB ==================== --> +<div id="panel-create" class="tab-panel <?= $tab === 'create' ? 'active' : '' ?>"> +<div class="card"> + + <?php if (!$result): ?> + <form method="POST" autocomplete="off"> + <input type="hidden" name="tab" value="create"> + + <div class="form-group"> + <label for="username">Pick a name</label> + <input type="text" id="username" name="username" value="<?= $fUser ?>" + placeholder="Your pseudonym or real name" maxlength="64" required> + </div> + + <div class="form-group"> + <label for="email">Email</label> + <input type="email" id="email" name="email" value="<?= $fEmail ?>" + placeholder="Can be a real or fictional address" maxlength="254" required> + <div class="hint">Use something@example.invalid if you want a fictional address.</div> + </div> + + <label style="font-size:0.85rem; font-weight:500; margin-bottom:0.5rem; display:block;">How do you want to handle your signing key?</label> + <div class="key-mode"> + <button type="button" class="key-mode-btn <?= $fKeyMode === 'generate' ? 'active' : '' ?>" + data-mode="generate">Generate new key</button> + <button type="button" class="key-mode-btn <?= $fKeyMode === 'existing' ? 'active' : '' ?>" + data-mode="existing">I have a key (YubiKey / existing)</button> + </div> + <input type="hidden" name="key_mode" id="key_mode" value="<?= htmlspecialchars($fKeyMode) ?>"> + + <div id="key-existing" class="form-group" style="<?= $fKeyMode === 'generate' ? 'display:none' : '' ?>"> + <label for="pubkey">Your public key</label> + <textarea id="pubkey" name="pubkey" rows="3" + style="min-height:70px; font-size:0.82rem;" + placeholder="Paste your public key — PEM, SSH, base64, hex, yubicrypt, yubisigner..."><?= $fPubkey ?></textarea> + <div class="hint"> + Works with any key type or format: Ed25519, RSA, PEM, SSH, hex strings. + Headers like <code>-----BEGIN PUBLIC KEY-----</code> and SSH prefixes + are stripped automatically.<br> + <strong>YubiKey users</strong>: paste your public key — the private key + stays in the hardware. This is the recommended setup. + Compatible with <a href="https://github.com/Ch1ffr3punk/yubicrypt" rel="noopener">yubicrypt</a> + and <a href="https://github.com/Ch1ffr3punk/yubisigner.git" rel="noopener">yubisigner</a>. + </div> + </div> + + <div id="key-generate" style="<?= $fKeyMode === 'existing' ? 'display:none' : '' ?>"> + <div class="hint" style="margin-bottom:1.25rem;"> + A fresh Ed25519 key pair will be generated for you. + You'll download it as a JSON file — <strong>keep it safe</strong>, it's your identity. + </div> + </div> + + <button type="submit" class="btn">Create My Identity</button> + </form> + <?php endif; ?> + + <?php if ($result): ?> + <div class="result-section"> + <h3>Your VFACE Identity</h3> + + <div class="identicon-pair"> + <div class="identicon-display"> + <img src="data:image/png;base64,<?= $result['ico256'] ?>" + alt="Your identicon" width="200" height="200"> + <div class="size-label">This is your face.</div> + </div> + </div> + + <div class="data-row"> + <span class="data-label">Name</span> + <span class="data-value"><?= htmlspecialchars($result['username']) ?></span> + </div> + <div class="data-row"> + <span class="data-label">Email</span> + <span class="data-value"><?= htmlspecialchars($result['email']) ?></span> + </div> + <div class="data-row"> + <span class="data-label">Public Key</span> + <span class="data-value"><?= htmlspecialchars($result['pubkey']) ?></span> + </div> + + <?php if ($result['keyCleaned']): ?> + <div class="key-notice"> + Your key was normalized — headers, prefixes, and formatting were removed. + The cleaned version above is what defines your identity. + Always use this exact value when verifying. + </div> + <?php endif; ?> + <div class="data-row"> + <span class="data-label">Identity Hash</span> + <span class="data-value hash"><?= htmlspecialchars($result['hash']) ?></span> + </div> + + <?php if ($result['keypair']): ?> + <div class="key-warning"> + <strong>Save your key pair now.</strong> This is the only time you'll see your private key. + Download the file below and store it somewhere safe. If you lose it, this identity is gone forever. + Nobody — including this server — has a copy. + </div> + + <div class="copy-wrap"> + <pre class="copy-block" id="keypair-json"><?= htmlspecialchars(json_encode([ + 'algorithm' => 'Ed25519', + 'publicKey' => $result['keypair']['publicKey'], + 'secretKey' => $result['keypair']['secretKey'], + 'created' => $result['keypair']['created'], + 'username' => $result['username'], + 'email' => $result['email'], + 'identityHash' => $result['hash'], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) ?></pre> + <button type="button" class="copy-btn" onclick="copyEl('keypair-json', this)">Copy</button> + </div> + <?php endif; ?> + + <h3 style="margin-top:1.5rem;">VFACE Headers</h3> + <div class="hint" style="margin-bottom:0.8rem;"> + Copy these into your Usenet/email client. + Replace the signature line after signing your message body. + </div> + <div class="copy-wrap"> + <pre class="copy-block" id="vface-headers"><?= htmlspecialchars($result['headers']) ?></pre> + <button type="button" class="copy-btn" onclick="copyEl('vface-headers', this)">Copy</button> + </div> + + <h3 style="margin-top:1.5rem;">What's next?</h3> + <div class="steps"> + <div class="step"> + <div class="step-num">1</div> + <div class="step-text"> + <strong>Save your key pair</strong> (if generated above). + <div class="dim">Store the JSON file on an encrypted drive. Consider a YubiKey for maximum security.</div> + </div> + </div> + <div class="step"> + <div class="step-num">2</div> + <div class="step-text"> + <strong>Publish your identity page.</strong> + <div class="dim">Host the static HTML page below on your website or .onion service. It's your public identity card.</div> + </div> + </div> + <div class="step"> + <div class="step-num">3</div> + <div class="step-text"> + <strong>Timestamp it on Bitcoin.</strong> + <div class="dim">Run <code>ots stamp identity-page.html</code> to anchor your identity on the blockchain. + This proves when you first claimed this name — nobody can backdate a fake.</div> + </div> + </div> + <div class="step"> + <div class="step-num">4</div> + <div class="step-text"> + <strong>Start signing messages.</strong> + <div class="dim">Use your private key to sign every message you send. Recipients verify with your public key.</div> + </div> + </div> + </div> + + <h3 style="margin-top:1.5rem;">Download Identity Page</h3> + <div class="hint" style="margin-bottom:0.8rem;"> + A ready-to-publish static HTML page. Upload it to your web server + and timestamp it with OpenTimestamps for first-claim proof. + </div> + <div class="copy-wrap"> + <pre class="copy-block" id="identity-page" style="max-height:200px; overflow-y:auto; font-size:0.72rem;"><?= htmlspecialchars($result['identityPage']) ?></pre> + <button type="button" class="copy-btn" onclick="copyEl('identity-page', this)">Copy</button> + </div> + + <div style="margin-top:1rem; display:flex; gap:0.8rem; flex-wrap:wrap;"> + <a href="<?= BLOG_ARTICLE ?>" class="btn btn-sm btn-outline" rel="noopener">About VFACE →</a> + <a href="https://www.virebent.art/blog/identicons.html" class="btn btn-sm btn-outline" rel="noopener">How identicons work →</a> + </div> + </div> + <?php endif; ?> +</div> +</div> + +<!-- ==================== ANALYZE TAB ==================== --> +<div id="panel-analyze" class="tab-panel <?= $tab === 'analyze' ? 'active' : '' ?>"> +<div class="card"> + <div class="hint" style="margin-bottom:1.25rem; font-size:0.9rem;"> + Upload an identicon image to check if it's a valid + <a href="https://github.com/Ch1ffr3punk/identicons" rel="noopener">Ch1ffr3punk</a> + identicon. Optionally enter identity details to check if the image belongs + to a specific identity. + </div> + + <form method="POST" enctype="multipart/form-data"> + <input type="hidden" name="tab" value="analyze"> + <input type="hidden" name="MAX_FILE_SIZE" value="<?= MAX_UPLOAD_SIZE ?>"> + + <div class="form-group"> + <label for="identicon_file">Upload Identicon (PNG)</label> + <div class="file-input-wrap"> + <input type="file" id="identicon_file" name="identicon_file" accept="image/png" required> + </div> + <div class="hint">Any square PNG: 48×48, 256×256, etc. Max <?= MAX_UPLOAD_SIZE / 1024 ?> KB.</div> + </div> + + <div style="margin:1.25rem 0; border-top:1px solid var(--border); padding-top:1.25rem;"> + <div class="hint" style="margin-bottom:0.8rem;"> + <strong>Optional</strong> — enter identity details to check if this identicon belongs to a specific person. + </div> + + <div class="form-group"> + <label for="ana_username">Username</label> + <input type="text" id="ana_username" name="ana_username" + value="<?= htmlspecialchars($_POST['ana_username'] ?? '') ?>" + placeholder="e.g. Gabx" maxlength="64"> + </div> + <div class="form-group"> + <label for="ana_email">Email</label> + <input type="email" id="ana_email" name="ana_email" + value="<?= htmlspecialchars($_POST['ana_email'] ?? '') ?>" + placeholder="e.g. user@example.invalid" maxlength="254"> + </div> + <div class="form-group"> + <label for="ana_pubkey">Public key</label> + <textarea id="ana_pubkey" name="ana_pubkey" rows="2" + style="min-height:50px; font-size:0.82rem;" + placeholder="Paste public key — any format"><?= htmlspecialchars($_POST['ana_pubkey'] ?? '') ?></textarea> + </div> + </div> + + <button type="submit" class="btn">Analyze Image</button> + </form> + + <?php if ($anaResult): ?> + <div class="result-section"> + <h3>Analysis</h3> + + <?php + $score = $anaResult['palette_score']; + if ($anaResult['valid']) { + $verdictClass = 'pass'; + $verdictText = 'Valid Ch1ffr3punk identicon (score: ' . $score . '/100)'; + } elseif ($score >= 50) { + $verdictClass = 'unknown'; + $verdictText = 'Partial match — some identicon properties detected (score: ' . $score . '/100)'; + } else { + $verdictClass = 'fail'; + $verdictText = 'Not a valid Ch1ffr3punk identicon (score: ' . $score . '/100)'; + } + ?> + + <div class="verdict <?= $verdictClass ?>"><?= htmlspecialchars($verdictText) ?></div> + + <?php if ($anaResult['dataurl']): ?> + <div class="identicon-display"> + <img src="<?= $anaResult['dataurl'] ?>" alt="Uploaded image" + width="128" height="128" style="image-rendering: pixelated;"> + </div> + <?php endif; ?> + + <ul class="check-list"> + <li class="<?= $anaResult['symmetric'] ? 'pass' : 'fail' ?>"> + Horizontal symmetry (5×5 grid, mirrored columns) + </li> + <li class="<?= $anaResult['bg_match'] ? 'pass' : 'fail' ?>"> + Background: <?= $anaResult['background'] ? htmlspecialchars($anaResult['background']) : 'not recognized' ?> + </li> + <li class="<?= $anaResult['primary_match'] ? 'pass' : 'fail' ?>"> + Primary color: + <?php if ($anaResult['primary_color']): ?> + <span class="color-swatch" style="background:<?= $anaResult['primary_color']['hex'] ?>"></span> + <?= $anaResult['primary_color']['hex'] ?> (palette index <?= $anaResult['primary_color']['index'] ?>) + <?php else: ?> + no match in primary palette + <?php endif; ?> + </li> + <li class="<?= $anaResult['secondary_match'] ? 'pass' : 'fail' ?>"> + Secondary color: + <?php if ($anaResult['secondary_color']): ?> + <span class="color-swatch" style="background:<?= $anaResult['secondary_color']['hex'] ?>"></span> + <?= $anaResult['secondary_color']['hex'] ?> (palette index <?= $anaResult['secondary_color']['index'] ?>) + <?php else: ?> + no match in secondary palette + <?php endif; ?> + </li> + </ul> + + <div class="data-row"> + <span class="data-label">Dimensions</span> + <span class="data-value"><?= htmlspecialchars($anaResult['dimensions']) ?></span> + </div> + <div class="data-row"> + <span class="data-label">Cell Size</span> + <span class="data-value"><?= $anaResult['cell_size'] ?>px</span> + </div> + <div class="data-row"> + <span class="data-label">Unique Colors</span> + <span class="data-value"><?= count($anaResult['colors_found']) ?></span> + </div> + + <?php if (!empty($anaResult['comparison'])): ?> + <div style="margin-top:1.5rem; padding-top:1rem; border-top:1px solid var(--border);"> + <h3>Identity Comparison</h3> + <?php if ($anaResult['comparison']['match']): ?> + <div class="verdict pass">This identicon matches the identity provided.</div> + <?php else: ?> + <div class="verdict fail">This identicon does NOT match the identity provided.</div> + <?php endif; ?> + <div class="data-row"> + <span class="data-label">Identity Hash</span> + <span class="data-value hash"><?= htmlspecialchars($anaResult['comparison']['hash']) ?></span> + </div> + <?php if ($anaResult['comparison']['generated']): ?> + <div class="identicon-pair" style="margin-top:1rem;"> + <div class="identicon-display"> + <img src="<?= $anaResult['dataurl'] ?>" alt="Uploaded" + width="96" height="96" style="image-rendering:pixelated;"> + <div class="size-label">Uploaded</div> + </div> + <div class="identicon-display"> + <img src="data:image/png;base64,<?= $anaResult['comparison']['generated'] ?>" + alt="Expected" width="96" height="96" style="image-rendering:pixelated;"> + <div class="size-label">Expected</div> + </div> + </div> + <?php endif; ?> + </div> + <?php endif; ?> + </div> + <?php endif; ?> +</div> +</div> + +<!-- ==================== VERIFY SIGNATURES TAB ==================== --> +<div id="panel-verify" class="tab-panel <?= $tab === 'verify' ? 'active' : '' ?>"> +<div class="card"> + <div class="hint" style="margin-bottom:1.25rem; font-size:0.9rem;"> + <strong>Advanced</strong> — verify an Ed25519 signed message. + Paste the details from the message headers to check if the signature is genuine. + </div> + + <form method="POST" autocomplete="off"> + <input type="hidden" name="tab" value="verify"> + + <div class="form-group"> + <label for="v_pubkey">Public key (from Ed25519-Pub header)</label> + <textarea id="v_pubkey" name="v_pubkey" rows="2" + style="min-height:50px; font-size:0.82rem;" + placeholder="Paste the public key — any format"><?= htmlspecialchars($_POST['v_pubkey'] ?? '') ?></textarea> + </div> + + <div class="form-group"> + <label for="v_signature">Signature (from Ed25519-Sig header)</label> + <input type="text" id="v_signature" name="v_signature" + value="<?= htmlspecialchars($_POST['v_signature'] ?? '') ?>" + placeholder="base64 signature" maxlength="1024" required> + </div> + + <div class="form-group"> + <label for="v_body">Message body (the signed text)</label> + <textarea id="v_body" name="v_body" placeholder="Paste the exact message body that was signed"><?= htmlspecialchars($_POST['v_body'] ?? '') ?></textarea> + </div> + + <div style="margin:1.25rem 0; border-top:1px solid var(--border); padding-top:1.25rem;"> + <div class="hint" style="margin-bottom:0.8rem;"> + <strong>Optional</strong> — add identity details for full VFACE verification. + </div> + + <div class="form-group"> + <label for="v_username">Username (from From: header)</label> + <input type="text" id="v_username" name="v_username" + value="<?= htmlspecialchars($_POST['v_username'] ?? '') ?>" + placeholder="Optional" maxlength="64"> + </div> + + <div class="form-group"> + <label for="v_email">Email (from From: header)</label> + <input type="email" id="v_email" name="v_email" + value="<?= htmlspecialchars($_POST['v_email'] ?? '') ?>" + placeholder="Optional" maxlength="254"> + </div> + + <div class="form-group"> + <label for="v_face">Face header (base64 identicon)</label> + <textarea id="v_face" name="v_face" style="min-height:60px; font-size:0.8rem;" + placeholder="Optional — paste the Face: header content"><?= htmlspecialchars($_POST['v_face'] ?? '') ?></textarea> + </div> + </div> + + <button type="submit" class="btn">Verify Signature</button> + </form> + + <?php if ($verifyRes): ?> + <div class="result-section"> + <h3>Verification Result</h3> + + <?php if ($verifyRes['sig_valid']): ?> + <div class="verdict pass">Signature is valid — this message is authentic.</div> + <?php else: ?> + <div class="verdict fail">Signature is INVALID — this message may be forged.</div> + <?php endif; ?> + + <?php if ($verifyRes['hash_expected']): ?> + <div class="data-row"> + <span class="data-label">Identity Hash</span> + <span class="data-value hash"><?= htmlspecialchars($verifyRes['hash_expected']) ?></span> + </div> + <?php endif; ?> + + <?php if ($verifyRes['ico_match'] !== null): ?> + <?php if ($verifyRes['ico_match']): ?> + <div class="verdict pass">Face header matches the expected identicon.</div> + <?php else: ?> + <div class="verdict fail">Face header does NOT match — possible impersonation.</div> + <?php endif; ?> + <?php endif; ?> + + <?php if ($verifyRes['ico_generated']): ?> + <div class="identicon-display"> + <img src="data:image/png;base64,<?= $verifyRes['ico_generated'] ?>" + alt="Expected identicon" width="96" height="96" style="image-rendering:pixelated;"> + <div class="size-label">Expected identicon for this identity</div> + </div> + <?php endif; ?> + </div> + <?php endif; ?> +</div> +</div> + +<footer> + VFACE uses <a href="https://github.com/Ch1ffr3punk/identicons" rel="noopener">Ch1ffr3punk's identicons</a> + — Compatible with <a href="https://github.com/Ch1ffr3punk/yubicrypt" rel="noopener">yubicrypt</a> + / <a href="https://github.com/Ch1ffr3punk/yubisigner.git" rel="noopener">yubisigner</a> + — Powered by <a href="https://doc.libsodium.org/" rel="noopener">libsodium</a> + — Built by <a href="https://virebent.art" rel="noopener">virebent.art</a> +</footer> + +</div> + +<script> +(function() { + 'use strict'; + + // Tab switching + document.querySelectorAll('.tab-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + var target = this.getAttribute('data-tab'); + document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); }); + this.classList.add('active'); + document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); }); + var panel = document.getElementById('panel-' + target); + if (panel) panel.classList.add('active'); + }); + }); + + // Key mode toggle + document.querySelectorAll('.key-mode-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + var mode = this.getAttribute('data-mode'); + document.querySelectorAll('.key-mode-btn').forEach(function(b) { b.classList.remove('active'); }); + this.classList.add('active'); + document.getElementById('key_mode').value = mode; + document.getElementById('key-existing').style.display = (mode === 'existing') ? '' : 'none'; + document.getElementById('key-generate').style.display = (mode === 'generate') ? '' : 'none'; + }); + }); + + // Clipboard + window.copyEl = function(id, btn) { + var el = document.getElementById(id); + if (!el) return; + var text = el.textContent || el.innerText; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function() { + btn.textContent = 'Copied'; + btn.classList.add('copied'); + setTimeout(function() { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000); + }); + } + }; +})(); +</script> +</body> +</html> |
