summaryrefslogtreecommitdiffstats
path: root/internal/identity
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-22 20:36:56 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-22 20:36:56 +0200
commitc1decadb590c4d79d92bcc4df7772119a5c91244 (patch)
tree468f9fa37535dbb90cc5d4061eb20de83912131e /internal/identity
downloadaegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip
Initial Aegis Usenet client release
Diffstat (limited to 'internal/identity')
-rw-r--r--internal/identity/cli.go147
-rw-r--r--internal/identity/cli_test.go62
-rw-r--r--internal/identity/verify.go92
-rw-r--r--internal/identity/vface.go94
4 files changed, 395 insertions, 0 deletions
diff --git a/internal/identity/cli.go b/internal/identity/cli.go
new file mode 100644
index 0000000..85408c8
--- /dev/null
+++ b/internal/identity/cli.go
@@ -0,0 +1,147 @@
+// Package identity integrates the existing identicon-cli program without
+// copying its GUI or reimplementing its rendering algorithm.
+package identity
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "image"
+ "image/png"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+var cliCandidates = []string{
+ "/home/gabriel1/Projects/identicons/identicons-cli",
+ "/home/gabriel1/Workspace/Projects/identicons/identicons",
+ "/usr/local/bin/identicon-cli",
+ "/usr/bin/identicon-cli",
+ "/SDCard/identicon-cli",
+ "/Shared/identicon-cli",
+}
+
+// FindCLI returns the configured or locally installed identicon-cli binary.
+func FindCLI() (string, error) {
+ if configured := strings.TrimSpace(os.Getenv("AEGIS_IDENTICON_CLI")); configured != "" {
+ if isExecutable(configured) {
+ return configured, nil
+ }
+ return "", fmt.Errorf("AEGIS_IDENTICON_CLI is not executable: %s", configured)
+ }
+ for _, candidate := range cliCandidates {
+ if isExecutable(candidate) {
+ return candidate, nil
+ }
+ }
+ return "", errors.New("identicon-cli executable not found")
+}
+
+func isExecutable(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0
+}
+
+// GenerateFace delegates to identicon-cli with its existing 48px rendering
+// and returns the base64 PNG payload used in a Face header.
+func GenerateFace(seed string) (string, error) {
+ if strings.TrimSpace(seed) == "" {
+ return "", errors.New("identicon seed is required")
+ }
+ return generateFace(seed, 48)
+}
+
+func generateFace(seed string, size int) (string, error) {
+ if strings.TrimSpace(seed) == "" {
+ return "", errors.New("identicon seed is required")
+ }
+ if size <= 0 {
+ return "", errors.New("identicon size must be positive")
+ }
+ executable, err := FindCLI()
+ if err != nil {
+ return "", err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ command := exec.CommandContext(ctx, executable, "-input", seed, "-format", "base64", "-size", fmt.Sprintf("%d", size), "-transparent")
+ var stdout, stderr bytes.Buffer
+ command.Stdout = &stdout
+ command.Stderr = &stderr
+ if err := command.Run(); err != nil {
+ return "", fmt.Errorf("run identicon-cli: %w: %s", err, strings.TrimSpace(stderr.String()))
+ }
+ value := strings.TrimSpace(stdout.String())
+ if _, err := pngImageFromBase64(value, size); err != nil {
+ return "", fmt.Errorf("identicon-cli returned invalid Face data: %w", err)
+ }
+ return value, nil
+}
+
+func decodeFacePNG(value string) ([]byte, error) {
+ return pngBytes(value, 48)
+}
+
+func pngBytes(value string, size int) ([]byte, error) {
+ data, err := base64.StdEncoding.DecodeString(value)
+ if err != nil {
+ return nil, fmt.Errorf("decode base64: %w", err)
+ }
+ if _, err := pngImage(data, size); err != nil {
+ return nil, err
+ }
+ return data, nil
+}
+
+func pngImageFromBase64(value string, size int) (image.Image, error) {
+ data, err := pngBytes(value, size)
+ if err != nil {
+ return nil, err
+ }
+ return pngImage(data, size)
+}
+
+func pngImage(data []byte, size int) (image.Image, error) {
+ img, err := png.Decode(bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("decode PNG: %w", err)
+ }
+ if img.Bounds().Dx() != size || img.Bounds().Dy() != size {
+ return nil, fmt.Errorf("PNG dimensions are %dx%d, want %dx%d", img.Bounds().Dx(), img.Bounds().Dy(), size, size)
+ }
+ return img, nil
+}
+
+// FormatFaceHeader folds the Face value in the same 70/75-byte pattern used
+// by the existing identicon-cli output files.
+func FormatFaceHeader(value string) string {
+ if len(value) <= 70 {
+ return "Face: " + value
+ }
+ var out strings.Builder
+ out.WriteString("Face: ")
+ out.WriteString(value[:70])
+ for position := 70; position < len(value); position += 75 {
+ end := position + 75
+ if end > len(value) {
+ end = len(value)
+ }
+ out.WriteString("\r\n ")
+ out.WriteString(value[position:end])
+ }
+ return out.String()
+}
+
+// CLIPathForDisplay returns a stable path label without exposing environment
+// secrets or invoking a shell.
+func CLIPathForDisplay(path string) string {
+ if path == "" {
+ return ""
+ }
+ return filepath.Clean(path)
+}
diff --git a/internal/identity/cli_test.go b/internal/identity/cli_test.go
new file mode 100644
index 0000000..e096fe4
--- /dev/null
+++ b/internal/identity/cli_test.go
@@ -0,0 +1,62 @@
+package identity
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "strings"
+ "testing"
+)
+
+func TestFormatFaceHeader(t *testing.T) {
+ header := FormatFaceHeader(strings.Repeat("A", 200))
+ for _, line := range strings.Split(header, "\r\n") {
+ if len(line) > 76 {
+ t.Fatalf("folded line length = %d", len(line))
+ }
+ }
+}
+
+func TestGenerateFaceUsesExistingCLI(t *testing.T) {
+ if _, err := FindCLI(); err != nil {
+ t.Skip(err)
+ }
+ value, err := GenerateFace("reader@example.org")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := decodeFacePNG(value); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGenerateVFaceBindsUsernameEmailAndPublicKey(t *testing.T) {
+ if _, err := FindCLI(); err != nil {
+ t.Skip(err)
+ }
+ public, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ publicKey := base64.StdEncoding.EncodeToString(public)
+ profile, err := GenerateVFace("pseudonym", "reader@example.org", publicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ input := "pseudonym|reader@example.org|" + publicKey
+ identityHash := sha256.Sum256([]byte(input))
+ if profile.Input != input || profile.IdentityHash != hex.EncodeToString(identityHash[:]) {
+ t.Fatalf("unexpected VFace identity: %#v", profile)
+ }
+ if profile.PNGHash == "" || profile.FaceBase64 == "" {
+ t.Fatalf("VFace image metadata is incomplete: %#v", profile)
+ }
+ headers := strings.Join(profile.Headers(), "\n")
+ for _, want := range []string{"X-VFace-Version: 1", "X-Ed25519-Pub: " + publicKey, "Identity-Hash: " + profile.IdentityHash, "X-VFace-Hash: sha256:" + profile.IdentityHash, "X-VFace-Verify: " + VFaceVerificationURL} {
+ if !strings.Contains(headers, want) {
+ t.Fatalf("VFace headers missing %q: %s", want, headers)
+ }
+ }
+}
diff --git a/internal/identity/verify.go b/internal/identity/verify.go
new file mode 100644
index 0000000..e337e07
--- /dev/null
+++ b/internal/identity/verify.go
@@ -0,0 +1,92 @@
+package identity
+
+import (
+ "crypto/ed25519"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net/mail"
+ "strings"
+
+ "aegis/internal/cryptokit"
+)
+
+type VerificationResult struct {
+ VFaceValid bool
+ IdentityHashValid bool
+ FaceValid bool
+ SignaturePresent bool
+ SignatureValid bool
+ PublicKey string
+ IdentityHash string
+ PNGHash string
+ Error error
+}
+
+func VerifyArticle(article string) VerificationResult {
+ message, err := mail.ReadMessage(strings.NewReader(article))
+ if err != nil {
+ return VerificationResult{Error: fmt.Errorf("parse article: %w", err)}
+ }
+ from, err := mail.ParseAddress(message.Header.Get("From"))
+ if err != nil {
+ return VerificationResult{Error: fmt.Errorf("parse From: %w", err)}
+ }
+ publicKey := strings.TrimSpace(message.Header.Get("X-Ed25519-Pub"))
+ result := VerificationResult{PublicKey: publicKey, IdentityHash: strings.TrimSpace(message.Header.Get("Identity-Hash")), PNGHash: strings.TrimSpace(message.Header.Get("X-VFace-PNG-SHA256"))}
+ profile, profileErr := GenerateVFace(from.Name, from.Address, publicKey)
+ if profileErr != nil {
+ result.Error = profileErr
+ return result
+ }
+ result.IdentityHashValid = strings.EqualFold(strings.TrimPrefix(result.IdentityHash, "sha256:"), profile.IdentityHash)
+ result.FaceValid = strings.TrimSpace(message.Header.Get("Face")) == profile.FaceBase64
+ result.VFaceValid = result.IdentityHashValid && result.FaceValid
+ if result.PNGHash != "" && !strings.EqualFold(strings.TrimPrefix(result.PNGHash, "sha256:"), profile.PNGHash) {
+ result.VFaceValid = false
+ }
+ signature := strings.TrimSpace(message.Header.Get("X-Ed25519-Sig"))
+ if signature == "" {
+ value := strings.TrimSpace(message.Header.Get("X-Aegis-Signature"))
+ if parts := strings.SplitN(value, ";", 2); len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "ed25519") {
+ signature = strings.TrimSpace(parts[1])
+ }
+ }
+ if signature == "" {
+ return result
+ }
+ result.SignaturePresent = true
+ signatureBytes, decodeErr := base64.StdEncoding.DecodeString(signature)
+ if decodeErr != nil || len(signatureBytes) != ed25519.SignatureSize {
+ result.Error = errors.New("invalid Ed25519 signature encoding")
+ return result
+ }
+ body, readErr := io.ReadAll(io.LimitReader(message.Body, 16<<20))
+ if readErr != nil {
+ result.Error = fmt.Errorf("read article body: %w", readErr)
+ return result
+ }
+ key, keyErr := decodePublicKey(publicKey)
+ if keyErr != nil {
+ result.Error = keyErr
+ return result
+ }
+ result.SignatureValid = ed25519.Verify(key, body, signatureBytes)
+ if !result.SignatureValid {
+ result.Error = errors.New("Ed25519 signature verification failed")
+ }
+ return result
+}
+
+func decodePublicKey(value string) (ed25519.PublicKey, error) {
+ canonicalText, err := cryptokit.CanonicalEd25519PublicKey(value)
+ if err != nil {
+ return nil, err
+ }
+ canonical, err := base64.StdEncoding.DecodeString(canonicalText)
+ if err != nil {
+ return nil, fmt.Errorf("decode Ed25519 public key: %w", err)
+ }
+ return ed25519.PublicKey(canonical), nil
+}
diff --git a/internal/identity/vface.go b/internal/identity/vface.go
new file mode 100644
index 0000000..a5dcef5
--- /dev/null
+++ b/internal/identity/vface.go
@@ -0,0 +1,94 @@
+package identity
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "image"
+ "strings"
+
+ "aegis/internal/cryptokit"
+)
+
+const VFaceVerificationURL = "https://identicons.virebent.art"
+
+// VFace is the deterministic public profile derived from the three identity
+// fields username, email and Ed25519 public key. The private key is never part
+// of this structure.
+type VFace struct {
+ Username string
+ Email string
+ PublicKey string
+ Input string
+ IdentityHash string
+ PNGHash string
+ FaceBase64 string
+ VerificationURL string
+}
+
+// GenerateVFace creates the optional 48x48 transparent PNG used by Face and
+// the profile preview. The seed is exactly username|email|public-key, matching
+// the VFace implementation used by M2Usenet and N2Usenet.
+func GenerateVFace(username, email, publicKey string) (VFace, error) {
+ username = strings.TrimSpace(username)
+ email = strings.TrimSpace(email)
+ if username == "" || email == "" {
+ return VFace{}, errors.New("VFace requires a username and email address")
+ }
+ if strings.ContainsAny(username+email, "\r\n|") {
+ return VFace{}, errors.New("VFace identity fields contain forbidden characters")
+ }
+ canonicalKey, err := cryptokit.CanonicalEd25519PublicKey(publicKey)
+ if err != nil {
+ return VFace{}, fmt.Errorf("canonicalize VFace public key: %w", err)
+ }
+ input := username + "|" + email + "|" + canonicalKey
+ faceBase64, err := generateFace(input, 48)
+ if err != nil {
+ return VFace{}, fmt.Errorf("generate VFace identicon: %w", err)
+ }
+ pngBytes, err := base64.StdEncoding.DecodeString(faceBase64)
+ if err != nil {
+ return VFace{}, fmt.Errorf("decode VFace PNG: %w", err)
+ }
+ if _, err := pngImage(pngBytes, 48); err != nil {
+ return VFace{}, err
+ }
+ identityDigest := sha256.Sum256([]byte(input))
+ pngDigest := sha256.Sum256(pngBytes)
+ return VFace{
+ Username: username,
+ Email: email,
+ PublicKey: canonicalKey,
+ Input: input,
+ IdentityHash: hex.EncodeToString(identityDigest[:]),
+ PNGHash: hex.EncodeToString(pngDigest[:]),
+ FaceBase64: faceBase64,
+ VerificationURL: VFaceVerificationURL,
+ }, nil
+}
+
+// Headers returns the interoperable VFace headers for an article or email.
+// The Face value is folded according to the existing identicon convention.
+func (v VFace) Headers() []string {
+ return []string{
+ FormatFaceHeader(v.FaceBase64),
+ "X-VFace-Version: 1",
+ "X-Ed25519-Pub: " + v.PublicKey,
+ "Identity-Hash: " + v.IdentityHash,
+ "X-VFace-Hash: sha256:" + v.IdentityHash,
+ "X-VFace-PNG-SHA256: " + v.PNGHash,
+ "X-VFace-Verify: " + v.VerificationURL,
+ }
+}
+
+// DecodeFacePNG decodes a generated VFace image for a GUI preview.
+func DecodeFacePNG(value string) (image.Image, error) {
+ data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value))
+ if err != nil {
+ return nil, fmt.Errorf("decode Face base64: %w", err)
+ }
+ return pngImage(data, 48)
+}