diff options
Diffstat (limited to 'internal/identity')
| -rw-r--r-- | internal/identity/cli.go | 147 | ||||
| -rw-r--r-- | internal/identity/cli_test.go | 62 | ||||
| -rw-r--r-- | internal/identity/verify.go | 92 | ||||
| -rw-r--r-- | internal/identity/vface.go | 94 |
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) +} |
