summaryrefslogtreecommitdiffstats
path: root/internal/identity/vface.go
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/vface.go
downloadaegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip
Initial Aegis Usenet client release
Diffstat (limited to 'internal/identity/vface.go')
-rw-r--r--internal/identity/vface.go94
1 files changed, 94 insertions, 0 deletions
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)
+}