From f84e22433a9dbee4d34a22a266e028e545bdd8fd Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 22 Aug 2026 20:36:56 +0200 Subject: Initial identicons-web release --- .gitignore | 11 + README.md | 26 ++ go.mod | 5 + identicons-cli.go | 442 ++++++++++++++++++ index.php | 1290 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1774 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 identicons-cli.go create mode 100644 index.php 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 @@ + $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 << + + + + +VFACE Identity — {$u} + + + +

VFACE Identity

+
+
Identicon
+
Username
{$u}
+
Email
{$e}
+
Public Key
{$p}
+
Identity Hash
{$h}
+
+
+VFACE Headers for Usenet / Email:
+Copy these headers into your client. Replace the signature line after signing your message body. +
{$vfaceHeaders}
+
+
+How to verify this identity:

+1. Concatenate this string:
+ +Click to select all — then copy
+2. Calculate SHA256 of that string — it should match the Identity Hash above.

+3. Go to identicons.virebent.art and use the Analyze tab to verify the identicon, or the Create tab to regenerate it from the same input.

+4. For signed messages: verify the Ed25519 signature with the public key above.

+5. Check the .ots file alongside this page for Bitcoin timestamp proof (opentimestamps.org). +
+ + +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'; +?> + + + + + + +VFACE — Your Identity, Cryptographically Yours + + + +
+ +
+

VFACE

+
Your pseudonym, cryptographically yours.
+
+ 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. +
+ How does it work? Read the full explanation → +
+ Reset +
+ +
+ + + +
+ + +
+ + + +
+
+ + +
+ + +
+ + +
+ +
+ + +
Use something@example.invalid if you want a fictional address.
+
+ + +
+ + +
+ + +
+ + +
+ Works with any key type or format: Ed25519, RSA, PEM, SSH, hex strings. + Headers like -----BEGIN PUBLIC KEY----- and SSH prefixes + are stripped automatically.
+ YubiKey users: paste your public key — the private key + stays in the hardware. This is the recommended setup. + Compatible with yubicrypt + and yubisigner. +
+
+ +
+
+ A fresh Ed25519 key pair will be generated for you. + You'll download it as a JSON file — keep it safe, it's your identity. +
+
+ + +
+ + + +
+

Your VFACE Identity

+ +
+
+ Your identicon +
This is your face.
+
+
+ +
+ Name + +
+
+ Email + +
+
+ Public Key + +
+ + +
+ 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. +
+ +
+ Identity Hash + +
+ + +
+ Save your key pair now. 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. +
+ +
+
 '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)) ?>
+ +
+ + +

VFACE Headers

+
+ Copy these into your Usenet/email client. + Replace the signature line after signing your message body. +
+
+
+ +
+ +

What's next?

+
+
+
1
+
+ Save your key pair (if generated above). +
Store the JSON file on an encrypted drive. Consider a YubiKey for maximum security.
+
+
+
+
2
+
+ Publish your identity page. +
Host the static HTML page below on your website or .onion service. It's your public identity card.
+
+
+
+
3
+
+ Timestamp it on Bitcoin. +
Run ots stamp identity-page.html to anchor your identity on the blockchain. + This proves when you first claimed this name — nobody can backdate a fake.
+
+
+
+
4
+
+ Start signing messages. +
Use your private key to sign every message you send. Recipients verify with your public key.
+
+
+
+ +

Download Identity Page

+
+ A ready-to-publish static HTML page. Upload it to your web server + and timestamp it with OpenTimestamps for first-claim proof. +
+
+
+ +
+ + +
+ +
+
+ + +
+
+
+ Upload an identicon image to check if it's a valid + Ch1ffr3punk + identicon. Optionally enter identity details to check if the image belongs + to a specific identity. +
+ +
+ + + +
+ +
+ +
+
Any square PNG: 48×48, 256×256, etc. Max KB.
+
+ +
+
+ Optional — enter identity details to check if this identicon belongs to a specific person. +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+

Analysis

+ + = 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)'; + } + ?> + +
+ + +
+ Uploaded image +
+ + +
    +
  • + Horizontal symmetry (5×5 grid, mirrored columns) +
  • +
  • + Background: +
  • +
  • + Primary color: + + + (palette index ) + + no match in primary palette + +
  • +
  • + Secondary color: + + + (palette index ) + + no match in secondary palette + +
  • +
+ +
+ Dimensions + +
+
+ Cell Size + px +
+
+ Unique Colors + +
+ + +
+

Identity Comparison

+ +
This identicon matches the identity provided.
+ +
This identicon does NOT match the identity provided.
+ +
+ Identity Hash + +
+ +
+
+ Uploaded +
Uploaded
+
+
+ Expected +
Expected
+
+
+ +
+ +
+ +
+
+ + +
+
+
+ Advanced — verify an Ed25519 signed message. + Paste the details from the message headers to check if the signature is genuine. +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ Optional — add identity details for full VFACE verification. +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ + +
+

Verification Result

+ + +
Signature is valid — this message is authentic.
+ +
Signature is INVALID — this message may be forged.
+ + + +
+ Identity Hash + +
+ + + + +
Face header matches the expected identicon.
+ +
Face header does NOT match — possible impersonation.
+ + + + +
+ Expected identicon +
Expected identicon for this identity
+
+ +
+ +
+
+ + + +
+ + + + -- cgit v1.2.3