summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore11
-rw-r--r--README.md26
-rw-r--r--go.mod5
-rw-r--r--identicons-cli.go442
-rw-r--r--index.php1290
5 files changed, 1774 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8621ce2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+# Local build output
+/identicons-cli
+
+# Uploaded or generated runtime files
+uploads/
+*.tmp
+
+# Editor and OS files
+.DS_Store
+*.swp
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1470ba7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,26 @@
+# identicons-web
+
+VFace identity generator and verifier for the Virebent web service.
+
+The project contains:
+
+- `index.php`, the PHP verifier and identity page generator;
+- `identicons-cli.go`, a dependency-free Go CLI implementing the
+ Ch1ffr3punk identicon rendering used by VFace and Aegis.
+
+## Build the CLI
+
+```sh
+go build -buildvcs=false -o identicons-cli .
+```
+
+The PHP application expects the compiled `identicons-cli` beside `index.php`
+and requires PHP sodium support. Deploy the directory behind a PHP-capable web
+server. Do not make uploaded files executable or writable by the web process.
+
+## VFace input
+
+The deterministic identity input is `username|email|canonical-ed25519-public-key`.
+The service publishes verification data and PNG hashes, but private keys must
+remain on the user's device and must never be uploaded or committed.
+
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a68ed37
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module identicons-cli
+
+go 1.21
+
+// No external dependencies - uses only Go standard library
diff --git a/identicons-cli.go b/identicons-cli.go
new file mode 100644
index 0000000..05a7667
--- /dev/null
+++ b/identicons-cli.go
@@ -0,0 +1,442 @@
+package main
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "flag"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "os"
+)
+
+// OptimizedIdenticon with indexed colors for smaller file sizes
+type OptimizedIdenticon struct {
+ source []byte
+ size int
+}
+
+// NewOptimizedIdenticon creates a generator with indexed colors
+func NewOptimizedIdenticon(source []byte) *OptimizedIdenticon {
+ return &OptimizedIdenticon{
+ source: source,
+ size: 256,
+ }
+}
+
+// NewOptimizedIdenticonWithSize creates a generator with custom size
+func NewOptimizedIdenticonWithSize(source []byte, size int) *OptimizedIdenticon {
+ return &OptimizedIdenticon{
+ source: source,
+ size: size,
+ }
+}
+
+// getBit returns the n-th bit (0-indexed) from source
+func (identicon *OptimizedIdenticon) getBit(n int) bool {
+ if len(identicon.source) == 0 || n < 0 {
+ return false
+ }
+ byteIndex := n / 8
+ bitIndex := n % 8
+ if byteIndex >= len(identicon.source) {
+ return false
+ }
+ return (identicon.source[byteIndex]>>bitIndex)&1 == 1
+}
+
+// getByte returns the n-th byte, wraps around if needed
+func (identicon *OptimizedIdenticon) getByte(n int) byte {
+ if len(identicon.source) == 0 {
+ return 0
+ }
+ return identicon.source[n%len(identicon.source)]
+}
+
+// getColorIndices returns color indices for indexed version
+func (identicon *OptimizedIdenticon) getColorIndices() (primaryIndex, secondaryIndex, bgIndex uint8) {
+ if len(identicon.source) < 32 {
+ return 0, 1, 2
+ }
+
+ // Primary color index (4 bits → 16 colors)
+ primaryIndex = 0
+ for i := 0; i < 4; i++ {
+ if identicon.getBit(248 + i) {
+ primaryIndex |= 1 << i
+ }
+ }
+ primaryIndex %= 16
+
+ // Secondary color index (4 bits → 16 colors)
+ secondaryIndex = 0
+ for i := 0; i < 4; i++ {
+ if identicon.getBit(244 + i) {
+ secondaryIndex |= 1 << i
+ }
+ }
+ secondaryIndex %= 16
+
+ // Background choice (2 bits → 4 options)
+ bgChoice := 0
+ for i := 0; i < 2; i++ {
+ if identicon.getBit(252 + i) {
+ bgChoice |= 1 << i
+ }
+ }
+ bgIndex = uint8(bgChoice % 3) // 0, 1, or 2
+
+ return primaryIndex, secondaryIndex, bgIndex
+}
+
+// generatePixelPattern generates 5x5 symmetric pixel grid
+func (identicon *OptimizedIdenticon) generatePixelPattern() ([]bool, []bool) {
+ primary := make([]bool, 25)
+ secondary := make([]bool, 25)
+
+ // Use bits 0-14 for primary pattern
+ bitIndex := 0
+ for row := 0; row < 5; row++ {
+ for col := 0; col < 3; col++ {
+ paint := identicon.getBit(bitIndex)
+ bitIndex++
+
+ ix := row*5 + col
+ mirrorIx := row*5 + (4 - col)
+ primary[ix] = paint
+ primary[mirrorIx] = paint
+ }
+ }
+
+ // Use bits 15-29 for secondary pattern
+ for row := 0; row < 5; row++ {
+ for col := 0; col < 3; col++ {
+ paint := identicon.getBit(bitIndex)
+ bitIndex++
+
+ ix := row*5 + col
+ mirrorIx := row*5 + (4 - col)
+ secondary[ix] = paint
+ secondary[mirrorIx] = paint
+ }
+ }
+
+ return primary, secondary
+}
+
+// createPalette creates an optimized palette with only necessary colors
+func createPalette(primaryIdx, secondaryIdx, bgIdx uint8, darkMode bool) color.Palette {
+ // Optimized color palettes - only 16 colors per palette
+ primaryPalette := []color.Color{
+ color.RGBA{0x00, 0xbf, 0x93, 0xff}, // turquoise
+ color.RGBA{0x2d, 0xcc, 0x70, 0xff}, // mint
+ color.RGBA{0x42, 0xe4, 0x53, 0xff}, // green
+ color.RGBA{0xf1, 0xc4, 0x0f, 0xff}, // yellowOrange
+ color.RGBA{0xe6, 0x7f, 0x22, 0xff}, // brown
+ color.RGBA{0xff, 0x94, 0x4e, 0xff}, // orange
+ color.RGBA{0xe8, 0x4c, 0x3d, 0xff}, // red
+ color.RGBA{0x35, 0x98, 0xdb, 0xff}, // blue
+ color.RGBA{0x9a, 0x59, 0xb5, 0xff}, // purple
+ color.RGBA{0xef, 0x3e, 0x96, 0xff}, // magenta
+ color.RGBA{0xdf, 0x21, 0xb9, 0xff}, // violet
+ color.RGBA{0x7d, 0xc2, 0xd2, 0xff}, // lightBlue
+ color.RGBA{0x16, 0xa0, 0x86, 0xff}, // turquoiseIntense
+ color.RGBA{0x27, 0xae, 0x61, 0xff}, // mintIntense
+ color.RGBA{0x24, 0xc3, 0x33, 0xff}, // greenIntense
+ color.RGBA{0x1c, 0xab, 0xbb, 0xff}, // lightBlueIntense
+ }
+
+ secondaryPalette := []color.Color{
+ color.RGBA{0x34, 0x49, 0x5e, 0xff}, // darkBlue
+ color.RGBA{0x95, 0xa5, 0xa5, 0xff}, // grey
+ color.RGBA{0xd2, 0x54, 0x00, 0xff}, // brownIntense
+ color.RGBA{0xc1, 0x39, 0x2b, 0xff}, // redIntense
+ color.RGBA{0x29, 0x7f, 0xb8, 0xff}, // blueIntense
+ color.RGBA{0x8d, 0x44, 0xad, 0xff}, // purpleIntense
+ color.RGBA{0xbe, 0x12, 0x7e, 0xff}, // violetIntense
+ color.RGBA{0xe5, 0x23, 0x83, 0xff}, // magentaIntense
+ color.RGBA{0x27, 0xae, 0x61, 0xff}, // mintIntense
+ color.RGBA{0x24, 0xc3, 0x33, 0xff}, // greenIntense
+ color.RGBA{0xd9, 0xd9, 0x21, 0xff}, // yellowIntense
+ color.RGBA{0xf3, 0x9c, 0x11, 0xff}, // yellowOrangeIntense
+ color.RGBA{0xff, 0x55, 0x00, 0xff}, // orangeIntense
+ color.RGBA{0x1c, 0xab, 0xbb, 0xff}, // lightBlueIntense
+ color.RGBA{0x23, 0x23, 0x23, 0xff}, // lightBlackIntense
+ color.RGBA{0x7e, 0x8c, 0x8d, 0xff}, // greyIntense
+ }
+
+ // Background colors based on mode
+ lightBackgrounds := []color.Color{
+ color.RGBA{255, 255, 255, 255}, // white
+ color.RGBA{243, 245, 247, 255}, // light gray 1
+ color.RGBA{236, 240, 241, 255}, // light gray 2
+ color.RGBA{0, 0, 0, 0}, // transparent (position 3)
+ }
+
+ darkBackgrounds := []color.Color{
+ color.RGBA{30, 30, 30, 255}, // dark gray
+ color.RGBA{45, 62, 80, 255}, // dark blue
+ color.RGBA{57, 57, 57, 255}, // dark gray 2
+ color.RGBA{0, 0, 0, 0}, // transparent (position 3)
+ }
+
+ // Palette in exact order:
+ // 0: Background
+ // 1: Primary color
+ // 2: Secondary color
+ // 3: Transparent (optional)
+ palette := make(color.Palette, 0, 4)
+
+ // Background first
+ if darkMode {
+ palette = append(palette, darkBackgrounds[bgIdx])
+ } else {
+ palette = append(palette, lightBackgrounds[bgIdx])
+ }
+
+ // Then primary and secondary colors
+ palette = append(palette,
+ primaryPalette[primaryIdx],
+ secondaryPalette[secondaryIdx],
+ color.RGBA{0, 0, 0, 0}, // transparent as last option
+ )
+
+ return palette
+}
+
+// Generate48x48ForFace creates a 48x48 pixel image specifically for Face headers
+func (identicon *OptimizedIdenticon) Generate48x48ForFace(transparent bool) *image.Paletted {
+ const (
+ size = 48
+ spriteSize = 5
+ pixelSize = 6 // 48/8 = 6
+ margin = (size - pixelSize*spriteSize) / 2 // = 9
+ )
+
+ // Determine color indices
+ primaryIdx, secondaryIdx, bgIdx := identicon.getColorIndices()
+
+ // For transparent background, set bgIdx to 3 (transparent)
+ if transparent {
+ bgIdx = 3
+ }
+
+ // Palette for export (always light mode for better compatibility)
+ palette := createPalette(primaryIdx, secondaryIdx, bgIdx, false)
+
+ // Create image
+ img := image.NewPaletted(image.Rect(0, 0, size, size), palette)
+
+ // Fill background
+ bgIndex := uint8(0)
+ if transparent {
+ bgIndex = 3 // transparent
+ }
+
+ for i := 0; i < size; i++ {
+ for j := 0; j < size; j++ {
+ img.SetColorIndex(j, i, bgIndex)
+ }
+ }
+
+ primaryPixels, secondaryPixels := identicon.generatePixelPattern()
+
+ // Secondary pixels (index 2)
+ for row := 0; row < spriteSize; row++ {
+ for col := 0; col < spriteSize; col++ {
+ if secondaryPixels[row*spriteSize+col] {
+ x := col*pixelSize + margin
+ y := row*pixelSize + margin
+ for py := y; py < y+pixelSize; py++ {
+ for px := x; px < x+pixelSize; px++ {
+ if px < size && py < size {
+ img.SetColorIndex(px, py, 2)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Primary pixels (index 1)
+ for row := 0; row < spriteSize; row++ {
+ for col := 0; col < spriteSize; col++ {
+ if primaryPixels[row*spriteSize+col] {
+ x := col*pixelSize + margin
+ y := row*pixelSize + margin
+ for py := y; py < y+pixelSize; py++ {
+ for px := x; px < x+pixelSize; px++ {
+ if px < size && py < size {
+ img.SetColorIndex(px, py, 1)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return img
+}
+
+// GenerateForExportOptimized for indexed export (256x256)
+func (identicon *OptimizedIdenticon) GenerateForExportOptimized(transparent bool) *image.Paletted {
+ const (
+ spriteSize = 5
+ )
+
+ pixelSize := identicon.size / 8
+ margin := (identicon.size - pixelSize*spriteSize) / 2
+
+ // Determine color indices
+ primaryIdx, secondaryIdx, bgIdx := identicon.getColorIndices()
+
+ // For transparent background, set bgIdx to 3 (transparent)
+ if transparent {
+ bgIdx = 3
+ }
+
+ // Palette for export (always light mode for better compatibility)
+ palette := createPalette(primaryIdx, secondaryIdx, bgIdx, false)
+
+ // Create image
+ img := image.NewPaletted(image.Rect(0, 0, identicon.size, identicon.size), palette)
+
+ // Fill background
+ bgIndex := uint8(0)
+ if transparent {
+ bgIndex = 3 // transparent
+ }
+
+ for i := 0; i < identicon.size; i++ {
+ for j := 0; j < identicon.size; j++ {
+ img.SetColorIndex(j, i, bgIndex)
+ }
+ }
+
+ primaryPixels, secondaryPixels := identicon.generatePixelPattern()
+
+ // Secondary pixels (index 2)
+ for row := 0; row < spriteSize; row++ {
+ for col := 0; col < spriteSize; col++ {
+ if secondaryPixels[row*spriteSize+col] {
+ x := col*pixelSize + margin
+ y := row*pixelSize + margin
+ for py := y; py < y+pixelSize; py++ {
+ for px := x; px < x+pixelSize; px++ {
+ if px < identicon.size && py < identicon.size {
+ img.SetColorIndex(px, py, 2)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Primary pixels (index 1)
+ for row := 0; row < spriteSize; row++ {
+ for col := 0; col < spriteSize; col++ {
+ if primaryPixels[row*spriteSize+col] {
+ x := col*pixelSize + margin
+ y := row*pixelSize + margin
+ for py := y; py < y+pixelSize; py++ {
+ for px := x; px < x+pixelSize; px++ {
+ if px < identicon.size && py < identicon.size {
+ img.SetColorIndex(px, py, 1)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return img
+}
+
+func main() {
+ // CLI flags
+ input := flag.String("input", "", "Input text (username|email|pubkey)")
+ size := flag.Int("size", 48, "Image size (48 or 256)")
+ transparent := flag.Bool("transparent", true, "Transparent background")
+ outputFormat := flag.String("format", "base64", "Output format: base64, dataurl, or png")
+ outputFile := flag.String("output", "", "Output file (for png format)")
+ hash := flag.String("hash", "", "Direct SHA256 hash (hex) instead of input")
+
+ flag.Parse()
+
+ // Validate input
+ if *input == "" && *hash == "" {
+ fmt.Fprintln(os.Stderr, "Error: -input or -hash required")
+ fmt.Fprintln(os.Stderr, "Usage: identicons-cli -input 'username|email|pubkey' [-size 48|256] [-transparent] [-format base64|dataurl|png] [-output file.png]")
+ os.Exit(1)
+ }
+
+ // Generate hash
+ var hashBytes []byte
+ if *hash != "" {
+ // Use provided hash (hex string)
+ fmt.Sscanf(*hash, "%x", &hashBytes)
+ if len(hashBytes) != 32 {
+ fmt.Fprintln(os.Stderr, "Error: hash must be 32 bytes (64 hex chars)")
+ os.Exit(1)
+ }
+ } else {
+ // Hash the input
+ h := sha256.Sum256([]byte(*input))
+ hashBytes = h[:]
+ }
+
+ // Generate identicon
+ var img *image.Paletted
+ if *size == 48 {
+ identicon := NewOptimizedIdenticonWithSize(hashBytes, 48)
+ img = identicon.Generate48x48ForFace(*transparent)
+ } else if *size == 256 {
+ identicon := NewOptimizedIdenticonWithSize(hashBytes, 256)
+ img = identicon.GenerateForExportOptimized(*transparent)
+ } else {
+ fmt.Fprintln(os.Stderr, "Error: size must be 48 or 256")
+ os.Exit(1)
+ }
+
+ // Encode to PNG
+ var buf bytes.Buffer
+ encoder := png.Encoder{
+ CompressionLevel: png.BestCompression,
+ }
+
+ if err := encoder.Encode(&buf, img); err != nil {
+ fmt.Fprintln(os.Stderr, "Error encoding PNG:", err)
+ os.Exit(1)
+ }
+
+ // Output based on format
+ switch *outputFormat {
+ case "base64":
+ // Output base64 only (for Face header)
+ b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
+ fmt.Print(b64)
+
+ case "dataurl":
+ // Output data URL (for web)
+ b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
+ fmt.Printf("data:image/png;base64,%s", b64)
+
+ case "png":
+ // Output PNG file
+ if *outputFile == "" {
+ fmt.Fprintln(os.Stderr, "Error: -output required for png format")
+ os.Exit(1)
+ }
+ if err := os.WriteFile(*outputFile, buf.Bytes(), 0644); err != nil {
+ fmt.Fprintln(os.Stderr, "Error writing PNG:", err)
+ os.Exit(1)
+ }
+ fmt.Fprintf(os.Stderr, "PNG saved to %s (%d bytes)\n", *outputFile, buf.Len())
+
+ default:
+ fmt.Fprintln(os.Stderr, "Error: format must be base64, dataurl, or png")
+ os.Exit(1)
+ }
+}
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 &rarr;</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 &rarr;</a>
+ <a href="https://www.virebent.art/blog/identicons.html" class="btn btn-sm btn-outline" rel="noopener">How identicons work &rarr;</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&times;48, 256&times;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&times;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>
+ &mdash; 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>
+ &mdash; Powered by <a href="https://doc.libsodium.org/" rel="noopener">libsodium</a>
+ &mdash; 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>