From c1decadb590c4d79d92bcc4df7772119a5c91244 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 22 Aug 2026 20:36:56 +0200 Subject: Initial Aegis Usenet client release --- internal/compose/mime.go | 147 +++ internal/compose/mime_test.go | 31 + internal/config/config.go | 260 +++++ internal/config/config_test.go | 113 +++ internal/cryptokit/age.go | 91 ++ internal/cryptokit/cryptokit_test.go | 156 +++ internal/cryptokit/ed25519.go | 102 ++ internal/cryptokit/openpgp.go | 240 +++++ internal/cryptokit/yubicrypt.go | 237 +++++ internal/cryptokit/yubicrypt_test.go | 36 + internal/filter/filter.go | 245 +++++ internal/filter/filter_test.go | 24 + internal/identity/cli.go | 147 +++ internal/identity/cli_test.go | 62 ++ internal/identity/verify.go | 92 ++ internal/identity/vface.go | 94 ++ internal/nntp/client.go | 983 +++++++++++++++++++ internal/nntp/client_test.go | 357 +++++++ internal/profile/vault.go | 235 +++++ internal/profile/vault_test.go | 45 + internal/smtpclient/client.go | 139 +++ internal/smtpclient/client_test.go | 17 + internal/store/store.go | 232 +++++ internal/store/store_test.go | 34 + internal/thread/thread.go | 76 ++ internal/thread/thread_test.go | 18 + internal/ui/app.go | 1795 ++++++++++++++++++++++++++++++++++ internal/ui/app_test.go | 201 ++++ 28 files changed, 6209 insertions(+) create mode 100644 internal/compose/mime.go create mode 100644 internal/compose/mime_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/cryptokit/age.go create mode 100644 internal/cryptokit/cryptokit_test.go create mode 100644 internal/cryptokit/ed25519.go create mode 100644 internal/cryptokit/openpgp.go create mode 100644 internal/cryptokit/yubicrypt.go create mode 100644 internal/cryptokit/yubicrypt_test.go create mode 100644 internal/filter/filter.go create mode 100644 internal/filter/filter_test.go create mode 100644 internal/identity/cli.go create mode 100644 internal/identity/cli_test.go create mode 100644 internal/identity/verify.go create mode 100644 internal/identity/vface.go create mode 100644 internal/nntp/client.go create mode 100644 internal/nntp/client_test.go create mode 100644 internal/profile/vault.go create mode 100644 internal/profile/vault_test.go create mode 100644 internal/smtpclient/client.go create mode 100644 internal/smtpclient/client_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/thread/thread.go create mode 100644 internal/thread/thread_test.go create mode 100644 internal/ui/app.go create mode 100644 internal/ui/app_test.go (limited to 'internal') diff --git a/internal/compose/mime.go b/internal/compose/mime.go new file mode 100644 index 0000000..da68bd8 --- /dev/null +++ b/internal/compose/mime.go @@ -0,0 +1,147 @@ +// Package compose builds standards-based MIME bodies for Usenet posts. +package compose + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "mime" + "mime/multipart" + "net/textproto" + "path/filepath" + "strings" + "unicode/utf8" +) + +const maxAttachmentBytes = 8 << 20 + +type Attachment struct { + Filename string + ContentType string + Data []byte +} + +func BuildMixed(text string, attachments []Attachment) (contentType, transferEncoding, body string, err error) { + if !utf8.ValidString(text) { + return "", "", "", errors.New("MIME text is not valid UTF-8") + } + for _, attachment := range attachments { + if strings.TrimSpace(attachment.Filename) == "" { + return "", "", "", errors.New("attachment filename is required") + } + if strings.ContainsAny(attachment.Filename, "\r\n") || len(attachment.Data) > maxAttachmentBytes { + return "", "", "", errors.New("attachment filename or size is invalid") + } + } + boundary, err := boundary() + if err != nil { + return "", "", "", err + } + var builder strings.Builder + writer := multipart.NewWriter(&builder) + if err := writer.SetBoundary(boundary); err != nil { + return "", "", "", err + } + textHeader := make(textproto.MIMEHeader) + textHeader.Set("Content-Type", "text/plain; charset=UTF-8") + textHeader.Set("Content-Transfer-Encoding", "8bit") + part, err := writer.CreatePart(textHeader) + if err != nil { + return "", "", "", err + } + if _, err := part.Write([]byte(normalizeCRLF(text))); err != nil { + return "", "", "", err + } + for _, attachment := range attachments { + header := make(textproto.MIMEHeader) + contentType := strings.TrimSpace(attachment.ContentType) + if contentType == "" { + contentType = "application/octet-stream" + } + if _, _, err := mime.ParseMediaType(contentType); err != nil { + return "", "", "", fmt.Errorf("invalid attachment content type: %w", err) + } + header.Set("Content-Type", contentType+`; name="`+escapeParameter(attachment.Filename)+`"`) + header.Set("Content-Disposition", `attachment; filename="`+escapeParameter(filepath.Base(attachment.Filename))+`"`) + header.Set("Content-Transfer-Encoding", "base64") + part, err := writer.CreatePart(header) + if err != nil { + return "", "", "", err + } + encoded := make([]byte, base64.StdEncoding.EncodedLen(len(attachment.Data))) + base64.StdEncoding.Encode(encoded, attachment.Data) + for len(encoded) > 0 { + lineLen := 76 + if len(encoded) < lineLen { + lineLen = len(encoded) + } + if _, err := part.Write(append(encoded[:lineLen], '\r', '\n')); err != nil { + return "", "", "", err + } + encoded = encoded[lineLen:] + } + } + if err := writer.Close(); err != nil { + return "", "", "", err + } + return `multipart/mixed; boundary="` + boundary + `"`, "7bit", builder.String(), nil +} + +func BuildOpenPGPMIME(armoredCiphertext string) (contentType, transferEncoding, body string, err error) { + if strings.TrimSpace(armoredCiphertext) == "" || strings.Contains(armoredCiphertext, "\x00") { + return "", "", "", errors.New("OpenPGP ciphertext is required") + } + boundary, err := boundary() + if err != nil { + return "", "", "", err + } + var builder strings.Builder + writer := multipart.NewWriter(&builder) + if err := writer.SetBoundary(boundary); err != nil { + return "", "", "", err + } + versionHeader := make(textproto.MIMEHeader) + versionHeader.Set("Content-Type", "application/pgp-encrypted") + versionHeader.Set("Content-Description", "PGP/MIME version identification") + part, err := writer.CreatePart(versionHeader) + if err != nil { + return "", "", "", err + } + if _, err := part.Write([]byte("Version: 1\r\n")); err != nil { + return "", "", "", err + } + cipherHeader := make(textproto.MIMEHeader) + cipherHeader.Set("Content-Type", "application/octet-stream; name=encrypted.asc") + cipherHeader.Set("Content-Disposition", `inline; filename="encrypted.asc"`) + cipherHeader.Set("Content-Description", "OpenPGP encrypted message") + part, err = writer.CreatePart(cipherHeader) + if err != nil { + return "", "", "", err + } + if _, err := part.Write([]byte(normalizeCRLF(armoredCiphertext))); err != nil { + return "", "", "", err + } + if err := writer.Close(); err != nil { + return "", "", "", err + } + return `multipart/encrypted; protocol="application/pgp-encrypted"; boundary="` + boundary + `"`, "7bit", builder.String(), nil +} + +func boundary() (string, error) { + random := make([]byte, 12) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("generate MIME boundary: %w", err) + } + return "aegis-" + base64.RawURLEncoding.EncodeToString(random), nil +} + +func escapeParameter(value string) string { + return strings.NewReplacer("\\", "_", `"`, "'", "\r", "_", "\n", "_").Replace(value) +} + +func normalizeCRLF(value string) string { + value = strings.ReplaceAll(value, "\r\n", "\n") + value = strings.ReplaceAll(value, "\r", "\n") + return strings.ReplaceAll(value, "\n", "\r\n") +} diff --git a/internal/compose/mime_test.go b/internal/compose/mime_test.go new file mode 100644 index 0000000..32fb6b2 --- /dev/null +++ b/internal/compose/mime_test.go @@ -0,0 +1,31 @@ +package compose + +import ( + "mime" + "strings" + "testing" +) + +func TestBuildMixed(t *testing.T) { + contentType, transfer, body, err := BuildMixed("ciao\nmondo", []Attachment{{Filename: "hello.txt", Data: []byte("hello")}}) + if err != nil { + t.Fatal(err) + } + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "multipart/mixed" || params["boundary"] == "" || transfer != "7bit" { + t.Fatalf("unexpected MIME metadata: %q, %q, %v", contentType, transfer, err) + } + if !strings.Contains(body, "Content-Disposition: attachment") || !strings.Contains(body, "aGVsbG8=") { + t.Fatalf("attachment missing: %q", body) + } +} + +func TestBuildOpenPGPMIME(t *testing.T) { + contentType, _, body, err := BuildOpenPGPMIME("-----BEGIN PGP MESSAGE-----\nabc\n-----END PGP MESSAGE-----") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(contentType, "multipart/encrypted") || !strings.Contains(body, "Version: 1") || !strings.Contains(body, "BEGIN PGP MESSAGE") { + t.Fatalf("invalid OpenPGP/MIME body: %q", body) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..19269a9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,260 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const appDir = "aegis" + +type Settings struct { + Host string `json:"host"` + Port string `json:"port"` + UseTLS bool `json:"use_tls"` + StartTLS bool `json:"start_tls,omitempty"` + SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` + Username string `json:"username,omitempty"` + SASLMechanism string `json:"sasl_mechanism,omitempty"` + UseCompression bool `json:"use_compression,omitempty"` + SMTPHost string `json:"smtp_host,omitempty"` + SMTPPort string `json:"smtp_port,omitempty"` + SMTPMode string `json:"smtp_mode,omitempty"` + SMTPUsername string `json:"smtp_username,omitempty"` + SMTPEmail string `json:"smtp_email,omitempty"` + SMTPRecipient string `json:"smtp_recipient,omitempty"` + SMTPSkipVerify bool `json:"smtp_skip_tls_verify,omitempty"` + ProxyType string `json:"proxy_type"` + ProxyAddress string `json:"proxy_address,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Email string `json:"email,omitempty"` + Subscriptions []string `json:"subscriptions,omitempty"` +} + +func Default() Settings { + return Settings{ + Host: "news.tcpreset.net", + Port: "563", + UseTLS: true, + SMTPHost: "qee4i7sags6phsvb2yodwecfj7noimfhhalsjktsvikrwotxzis3raad.onion", + SMTPPort: "25", + SMTPRecipient: "mail2news@mail2news.tcpreset.net", + ProxyType: "SOCKS5", + ProxyAddress: "127.0.0.1:9050", + } +} + +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate user configuration directory: %w", err) + } + return filepath.Join(dir, appDir, "config.json"), nil +} + +func Load(path string) (Settings, error) { + settings := Default() + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return settings, nil + } + if err != nil { + return Settings{}, fmt.Errorf("read settings: %w", err) + } + if err := json.Unmarshal(data, &settings); err != nil { + return Settings{}, fmt.Errorf("decode settings: %w", err) + } + settings.normalize() + if err := settings.Validate(); err != nil { + return Settings{}, fmt.Errorf("validate saved settings: %w", err) + } + return settings, nil +} + +func Save(path string, settings Settings) error { + settings.normalize() + if err := settings.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return fmt.Errorf("encode settings: %w", err) + } + data = append(data, '\n') + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create settings directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return fmt.Errorf("protect settings directory: %w", err) + } + tmp, err := os.CreateTemp(dir, ".config-*.tmp") + if err != nil { + return fmt.Errorf("create temporary settings file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return fmt.Errorf("protect temporary settings file: %w", err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write settings: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync settings: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close settings: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("commit settings: %w", err) + } + return nil +} + +func (s Settings) Validate() error { + if err := validateToken("host", s.Host); err != nil { + return err + } + if net.ParseIP(s.Host) == nil && strings.ContainsAny(s.Host, " /\\") { + return errors.New("host contains invalid characters") + } + port, err := strconv.Atoi(s.Port) + if err != nil || port < 1 || port > 65535 { + return errors.New("port must be between 1 and 65535") + } + if err := validateToken("username", s.Username); err != nil { + return err + } + if s.Username != "" && !s.UseTLS { + return errors.New("authentication requires TLS") + } + if s.StartTLS && !s.UseTLS { + return errors.New("STARTTLS requires TLS") + } + if s.SASLMechanism != "" && !strings.EqualFold(s.SASLMechanism, "PLAIN") { + return errors.New("unsupported SASL mechanism") + } + if s.SASLMechanism != "" && s.Username == "" { + return errors.New("SASL requires a username") + } + if s.UseCompression && s.UseTLS { + return errors.New("COMPRESS DEFLATE cannot be used with TLS") + } + if err := validateSMTP(s); err != nil { + return err + } + switch s.ProxyType { + case "DIRECT": + case "SOCKS5": + if _, _, err := net.SplitHostPort(s.ProxyAddress); err != nil { + return fmt.Errorf("invalid SOCKS5 address: %w", err) + } + default: + return errors.New("proxy type must be DIRECT or SOCKS5") + } + if strings.ContainsAny(s.DisplayName, "\r\n") || strings.ContainsAny(s.Email+s.SMTPEmail+s.SMTPRecipient, "\r\n") { + return errors.New("posting identity must not contain line breaks") + } + for _, group := range s.Subscriptions { + if err := ValidateGroupName(group); err != nil { + return fmt.Errorf("invalid subscription %q: %w", group, err) + } + } + return nil +} + +func ValidateGroupName(group string) error { + if err := validateToken("newsgroup", group); err != nil { + return err + } + if strings.ContainsAny(group, " ,") { + return errors.New("newsgroup contains invalid characters") + } + return nil +} + +func (s *Settings) normalize() { + s.Host = strings.TrimSpace(s.Host) + s.Port = strings.TrimSpace(s.Port) + s.Username = strings.TrimSpace(s.Username) + s.SASLMechanism = strings.ToUpper(strings.TrimSpace(s.SASLMechanism)) + s.SMTPHost = strings.TrimSpace(s.SMTPHost) + s.SMTPPort = strings.TrimSpace(s.SMTPPort) + s.SMTPMode = strings.ToUpper(strings.TrimSpace(s.SMTPMode)) + if s.SMTPMode == "DISABLED" || s.SMTPMode == "CLEARTEXT" { + s.SMTPMode = "" + } + s.SMTPUsername = strings.TrimSpace(s.SMTPUsername) + s.SMTPEmail = strings.TrimSpace(s.SMTPEmail) + s.SMTPRecipient = strings.TrimSpace(s.SMTPRecipient) + s.ProxyType = strings.ToUpper(strings.TrimSpace(s.ProxyType)) + s.ProxyAddress = strings.TrimSpace(s.ProxyAddress) + s.DisplayName = strings.TrimSpace(s.DisplayName) + s.Email = strings.TrimSpace(s.Email) + seen := make(map[string]struct{}, len(s.Subscriptions)) + groups := s.Subscriptions[:0] + for _, group := range s.Subscriptions { + group = strings.TrimSpace(group) + if group == "" { + continue + } + if _, ok := seen[group]; ok { + continue + } + seen[group] = struct{}{} + groups = append(groups, group) + } + sort.Strings(groups) + s.Subscriptions = groups +} + +func validateSMTP(s Settings) error { + if s.SMTPHost == "" && s.SMTPMode == "" && s.SMTPPort == "" { + return nil + } + if s.SMTPHost == "" || s.SMTPPort == "" { + return errors.New("SMTP host and port are required when SMTP is configured") + } + if err := validateToken("SMTP host", s.SMTPHost); err != nil { + return err + } + if net.ParseIP(s.SMTPHost) == nil && strings.ContainsAny(s.SMTPHost, " /\\") { + return errors.New("SMTP host contains invalid characters") + } + port, err := strconv.Atoi(s.SMTPPort) + if err != nil || port < 1 || port > 65535 { + return errors.New("SMTP port must be between 1 and 65535") + } + switch s.SMTPMode { + case "", "TLS", "STARTTLS": + default: + return errors.New("SMTP mode must be cleartext, TLS or STARTTLS") + } + if s.SMTPUsername != "" && s.SMTPMode == "" { + return errors.New("SMTP authentication requires TLS or STARTTLS") + } + if strings.HasSuffix(strings.ToLower(s.SMTPHost), ".onion") && s.ProxyType != "SOCKS5" { + return errors.New(".onion SMTP servers require a SOCKS5 proxy") + } + return nil +} + +func validateToken(name, value string) error { + if name == "host" && value == "" { + return errors.New("host is required") + } + if strings.ContainsAny(value, "\x00\r\n\t") { + return fmt.Errorf("%s contains control characters", name) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c35ee61 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,113 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.json") + want := Settings{ + Host: "news.example.org", + Port: "563", + UseTLS: true, + SMTPHost: "qee4i7sags6phsvb2yodwecfj7noimfhhalsjktsvikrwotxzis3raad.onion", + SMTPPort: "25", + SMTPRecipient: "mail2news@mail2news.tcpreset.net", + Username: "reader", + ProxyType: "SOCKS5", + ProxyAddress: "127.0.0.1:9050", + DisplayName: "Aegis User", + Email: "reader@example.org", + Subscriptions: []string{"comp.lang.go", "sci.crypt"}, + } + if err := Save(path, want); err != nil { + t.Fatalf("Save() error = %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("mode = %o, want 600", got) + } + got, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Load() = %#v, want %#v", got, want) + } +} + +func TestPasswordCannotBePersisted(t *testing.T) { + settingsType := reflect.TypeOf(Settings{}) + if _, ok := settingsType.FieldByName("Password"); ok { + t.Fatal("Settings must not contain a persistent Password field") + } + path := filepath.Join(t.TempDir(), "config.json") + if err := Save(path, Default()); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(data)), "password") { + t.Fatal("saved settings unexpectedly mention a password") + } +} + +func TestValidateRejectsProtocolInjection(t *testing.T) { + settings := Default() + settings.Username = "user\r\nQUIT" + if err := settings.Validate(); err == nil { + t.Fatal("Validate() accepted a username with protocol injection") + } + if err := ValidateGroupName("comp.lang.go\r\nPOST"); err == nil { + t.Fatal("ValidateGroupName() accepted protocol injection") + } +} + +func TestValidateRejectsAuthenticationWithoutTLS(t *testing.T) { + settings := Default() + settings.UseTLS = false + settings.Username = "reader" + if err := settings.Validate(); err == nil { + t.Fatal("Validate() accepted authentication without TLS") + } +} + +func TestValidateRejectsCompressionWithTLS(t *testing.T) { + settings := Default() + settings.UseCompression = true + if err := settings.Validate(); err == nil { + t.Fatal("Validate() accepted compression over TLS") + } +} + +func TestValidateRequiresProxyForOnionSMTP(t *testing.T) { + settings := Default() + settings.SMTPHost = "mail.example.onion" + settings.SMTPPort = "465" + settings.SMTPMode = "" + settings.ProxyType = "DIRECT" + if err := settings.Validate(); err == nil { + t.Fatal("Validate() accepted direct .onion SMTP") + } +} + +func TestValidateAllowsCleartextOnionSMTPThroughSharedProxy(t *testing.T) { + settings := Default() + settings.SMTPHost = "mail.example.onion" + settings.SMTPPort = "25" + settings.SMTPMode = "" + settings.ProxyType = "SOCKS5" + settings.ProxyAddress = "127.0.0.1:9050" + if err := settings.Validate(); err != nil { + t.Fatalf("Validate() rejected cleartext onion SMTP through SOCKS5: %v", err) + } +} diff --git a/internal/cryptokit/age.go b/internal/cryptokit/age.go new file mode 100644 index 0000000..525b1fa --- /dev/null +++ b/internal/cryptokit/age.go @@ -0,0 +1,91 @@ +package cryptokit + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + + "filippo.io/age" + "filippo.io/age/agessh" + agearmor "filippo.io/age/armor" +) + +const maxCryptoMessageBytes = 64 << 20 + +func parseAgeRecipient(value string) (age.Recipient, error) { + value = strings.TrimSpace(value) + if recipient, err := age.ParseX25519Recipient(value); err == nil { + return recipient, nil + } + if recipient, err := agessh.ParseRecipient(value); err == nil { + return recipient, nil + } + return nil, errors.New("unsupported age recipient: expected native X25519 or SSH Ed25519/RSA") +} + +func parseAgeIdentity(value string) (age.Identity, error) { + value = strings.TrimSpace(value) + if identity, err := age.ParseX25519Identity(value); err == nil { + return identity, nil + } + if identity, err := agessh.ParseIdentity([]byte(value)); err == nil { + return identity, nil + } + return nil, errors.New("unsupported age identity: expected native X25519 or SSH Ed25519/RSA private key") +} + +// EncryptAge encrypts to a user-provided native age X25519 recipient or SSH +// Ed25519/RSA recipient and returns ASCII-armored age text. +func EncryptAge(message []byte, recipientKey string) ([]byte, error) { + recipient, err := parseAgeRecipient(recipientKey) + if err != nil { + return nil, fmt.Errorf("parse age recipient: %w", err) + } + var output bytes.Buffer + armored := agearmor.NewWriter(&output) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + _ = armored.Close() + return nil, fmt.Errorf("create age encryption: %w", err) + } + if _, err := writer.Write(message); err != nil { + _ = writer.Close() + _ = armored.Close() + return nil, fmt.Errorf("write age message: %w", err) + } + if err := writer.Close(); err != nil { + _ = armored.Close() + return nil, fmt.Errorf("close age message: %w", err) + } + if err := armored.Close(); err != nil { + return nil, fmt.Errorf("close age armor: %w", err) + } + return output.Bytes(), nil +} + +// DecryptAge decrypts an armored or binary age message with a user-provided +// native X25519 identity or an unencrypted SSH Ed25519/RSA private key. +func DecryptAge(message []byte, identityKey string) ([]byte, error) { + identity, err := parseAgeIdentity(identityKey) + if err != nil { + return nil, fmt.Errorf("parse age identity: %w", err) + } + var input io.Reader = bytes.NewReader(message) + if bytes.HasPrefix(bytes.TrimSpace(message), []byte(agearmor.Header)) { + input = agearmor.NewReader(input) + } + reader, err := age.Decrypt(input, identity) + if err != nil { + return nil, fmt.Errorf("create age decryption: %w", err) + } + plaintext, err := io.ReadAll(io.LimitReader(reader, maxCryptoMessageBytes+1)) + if err != nil { + return nil, fmt.Errorf("read age message: %w", err) + } + if len(plaintext) > maxCryptoMessageBytes { + return nil, errors.New("decrypted age message exceeds the 64 MiB limit") + } + return plaintext, nil +} diff --git a/internal/cryptokit/cryptokit_test.go b/internal/cryptokit/cryptokit_test.go new file mode 100644 index 0000000..928120e --- /dev/null +++ b/internal/cryptokit/cryptokit_test.go @@ -0,0 +1,156 @@ +package cryptokit + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/pem" + "os" + "os/exec" + "testing" + + "filippo.io/age" + "golang.org/x/crypto/ssh" +) + +func TestEd25519SignVerifyAcceptsSeedEncoding(t *testing.T) { + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + message := []byte("Aegis cryptographic test") + signature, err := SignEd25519(message, base64.StdEncoding.EncodeToString(private.Seed())) + if err != nil { + t.Fatal(err) + } + if err := VerifyEd25519(message, signature, base64.StdEncoding.EncodeToString(public)); err != nil { + t.Fatal(err) + } + if err := VerifyEd25519([]byte("tampered"), signature, base64.StdEncoding.EncodeToString(public)); err == nil { + t.Fatal("tampered message verified") + } +} + +func TestEd25519PublicKeyAndFingerprint(t *testing.T) { + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + derived, err := Ed25519PublicKey(base64.StdEncoding.EncodeToString(private.Seed())) + if err != nil { + t.Fatal(err) + } + if derived != base64.StdEncoding.EncodeToString(public) { + t.Fatalf("derived public key = %q, want %q", derived, base64.StdEncoding.EncodeToString(public)) + } + fingerprint, err := Ed25519PublicKeyFingerprint(derived) + if err != nil { + t.Fatal(err) + } + if len(fingerprint) != len("sha256:")+64 || fingerprint[:len("sha256:")] != "sha256:" { + t.Fatalf("unexpected fingerprint %q", fingerprint) + } +} + +func TestAgeRoundTrip(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + ciphertext, err := EncryptAge([]byte("age test"), identity.Recipient().String()) + if err != nil { + t.Fatal(err) + } + plaintext, err := DecryptAge(ciphertext, identity.String()) + if err != nil { + t.Fatal(err) + } + if string(plaintext) != "age test" { + t.Fatalf("unexpected plaintext %q", plaintext) + } +} + +func TestAgeSSHCompatibility(t *testing.T) { + testKey := func(private any) { + signer, err := ssh.NewSignerFromKey(private) + if err != nil { + t.Fatal(err) + } + privatePEM, err := ssh.MarshalPrivateKey(private, "") + if err != nil { + t.Fatal(err) + } + ciphertext, err := EncryptAge([]byte("age SSH test"), string(ssh.MarshalAuthorizedKey(signer.PublicKey()))) + if err != nil { + t.Fatal(err) + } + plaintext, err := DecryptAge(ciphertext, string(pem.EncodeToMemory(privatePEM))) + if err != nil { + t.Fatal(err) + } + if string(plaintext) != "age SSH test" { + t.Fatalf("unexpected plaintext %q", plaintext) + } + } + _, edPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + testKey(edPrivate) + rsaPrivate, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + testKey(rsaPrivate) +} + +func TestOpenPGPRoundTripWithUserKeyMaterial(t *testing.T) { + gpg, err := exec.LookPath("gpg") + if err != nil { + t.Skip("gpg is not installed") + } + home := t.TempDir() + if err := os.Chmod(home, 0o700); err != nil { + t.Fatal(err) + } + run := func(args []string, input []byte) ([]byte, error) { + cmd := exec.Command(gpg, append([]string{"--batch", "--no-tty", "--no-options", "--homedir", home}, args...)...) + cmd.Stdin = bytes.NewReader(input) + return cmd.Output() + } + if _, err := run([]string{"--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", "Aegis Test ", "rsa2048", "sign", "1d"}, nil); err != nil { + t.Skipf("gpg cannot create an ephemeral test key: %v", err) + } + if _, err := run([]string{"--pinentry-mode", "loopback", "--passphrase", "", "--quick-add-key", "aegis@example.invalid", "rsa2048", "encrypt", "1d"}, nil); err != nil { + t.Skipf("gpg cannot create an ephemeral encryption subkey: %v", err) + } + publicKey, err := run([]string{"--armor", "--export", "aegis@example.invalid"}, nil) + if err != nil { + t.Fatal(err) + } + privateKey, err := run([]string{"--armor", "--export-secret-keys", "aegis@example.invalid"}, nil) + if err != nil { + t.Fatal(err) + } + message := []byte("OpenPGP compatibility test") + signature, err := SignOpenPGPDetached(message, string(privateKey)) + if err != nil { + t.Fatal(err) + } + if err := VerifyOpenPGPDetached(message, signature, string(publicKey)); err != nil { + t.Fatal(err) + } + ciphertext, err := EncryptOpenPGP(message, string(publicKey), string(privateKey)) + if err != nil { + t.Fatal(err) + } + plaintext, err := DecryptOpenPGP([]byte(ciphertext), string(privateKey)) + if err != nil { + t.Fatal(err) + } + if string(plaintext) != string(message) { + t.Fatalf("unexpected plaintext %q", plaintext) + } +} diff --git a/internal/cryptokit/ed25519.go b/internal/cryptokit/ed25519.go new file mode 100644 index 0000000..0e08503 --- /dev/null +++ b/internal/cryptokit/ed25519.go @@ -0,0 +1,102 @@ +package cryptokit + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +func decodeKeyText(value string, expected ...int) ([]byte, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, errors.New("key material is required") + } + decoded, base64Err := base64.StdEncoding.DecodeString(value) + if base64Err != nil { + decoded, base64Err = base64.RawStdEncoding.DecodeString(value) + } + if base64Err != nil { + var hexErr error + decoded, hexErr = hex.DecodeString(value) + if hexErr != nil { + return nil, errors.New("key material is not valid base64 or hexadecimal") + } + } + for _, size := range expected { + if len(decoded) == size { + return decoded, nil + } + } + return nil, fmt.Errorf("unexpected key length %d bytes", len(decoded)) +} + +// SignEd25519 signs with a raw Ed25519 private key supplied as base64 or hex. +// Both the 32-byte seed and 64-byte private-key encodings are accepted. +func SignEd25519(message []byte, privateKey string) (string, error) { + key, err := decodeKeyText(privateKey, ed25519.SeedSize, ed25519.PrivateKeySize) + if err != nil { + return "", err + } + if len(key) == ed25519.SeedSize { + key = ed25519.NewKeyFromSeed(key) + } + signature := ed25519.Sign(ed25519.PrivateKey(key), message) + return base64.StdEncoding.EncodeToString(signature), nil +} + +// Ed25519PublicKey derives the public key from a raw Ed25519 private key and +// returns it as standard base64. Both the 32-byte seed and 64-byte private-key +// encodings are accepted. +func Ed25519PublicKey(privateKey string) (string, error) { + key, err := decodeKeyText(privateKey, ed25519.SeedSize, ed25519.PrivateKeySize) + if err != nil { + return "", err + } + if len(key) == ed25519.SeedSize { + key = ed25519.NewKeyFromSeed(key) + } + publicKey := ed25519.PrivateKey(key).Public().(ed25519.PublicKey) + return base64.StdEncoding.EncodeToString(publicKey), nil +} + +// Ed25519PublicKeyFingerprint returns a stable SHA-256 fingerprint for a +// base64 or hexadecimal Ed25519 public key. +func Ed25519PublicKeyFingerprint(publicKey string) (string, error) { + key, err := decodeKeyText(publicKey, ed25519.PublicKeySize) + if err != nil { + return "", err + } + digest := sha256.Sum256(key) + return "sha256:" + hex.EncodeToString(digest[:]), nil +} + +// CanonicalEd25519PublicKey accepts a raw Ed25519 public key as base64 or +// hexadecimal and returns the protocol representation used by VFace. +func CanonicalEd25519PublicKey(publicKey string) (string, error) { + key, err := decodeKeyText(publicKey, ed25519.PublicKeySize) + if err != nil { + return "", fmt.Errorf("decode Ed25519 public key: %w", err) + } + return base64.StdEncoding.EncodeToString(key), nil +} + +// VerifyEd25519 verifies a base64 or hexadecimal Ed25519 signature against a +// raw public key supplied as base64 or hex. +func VerifyEd25519(message []byte, signature, publicKey string) error { + sig, err := decodeKeyText(signature, ed25519.SignatureSize) + if err != nil { + return fmt.Errorf("decode Ed25519 signature: %w", err) + } + key, err := decodeKeyText(publicKey, ed25519.PublicKeySize) + if err != nil { + return fmt.Errorf("decode Ed25519 public key: %w", err) + } + if !ed25519.Verify(ed25519.PublicKey(key), message, sig) { + return errors.New("Ed25519 signature verification failed") + } + return nil +} diff --git a/internal/cryptokit/openpgp.go b/internal/cryptokit/openpgp.go new file mode 100644 index 0000000..bf766b5 --- /dev/null +++ b/internal/cryptokit/openpgp.go @@ -0,0 +1,240 @@ +package cryptokit + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const maxOpenPGPMessageBytes = 64 << 20 + +type limitedBuffer struct { + buffer bytes.Buffer + limit int +} + +func (b *limitedBuffer) Write(value []byte) (int, error) { + if b.buffer.Len() < b.limit { + remaining := b.limit - b.buffer.Len() + if len(value) > remaining { + _, _ = b.buffer.Write(value[:remaining]) + } else { + _, _ = b.buffer.Write(value) + } + } + return len(value), nil +} + +// OpenPGP support is delegated to the system GnuPG binary. This keeps Aegis +// compatible with the user's existing OpenPGP installation, including RSA, +// Ed25519 and X25519 keys, without embedding another keyring. Key material is +// imported into a temporary 0700 GNUPGHOME and removed after each operation. + +func gpgBinary() (string, error) { + path, err := exec.LookPath("gpg") + if err != nil { + return "", errors.New("gpg is required for OpenPGP operations") + } + return path, nil +} + +func withGPG(operation func(home string) error) error { + if _, err := gpgBinary(); err != nil { + return err + } + home, err := os.MkdirTemp("", "aegis-gpg-") + if err != nil { + return fmt.Errorf("create temporary OpenPGP home: %w", err) + } + defer os.RemoveAll(home) + if err := os.Chmod(home, 0o700); err != nil { + return fmt.Errorf("protect temporary OpenPGP home: %w", err) + } + return operation(home) +} + +func runGPG(home string, args []string, input []byte, outputLimit int) ([]byte, string, error) { + binary, err := gpgBinary() + if err != nil { + return nil, "", err + } + base := []string{ + "--batch", "--no-tty", "--no-options", "--no-auto-check-trustdb", + "--homedir", home, + } + cmd := exec.Command(binary, append(base, args...)...) + cmd.Stdin = bytes.NewReader(input) + var stdout limitedBuffer + var stderr bytes.Buffer + if outputLimit <= 0 { + outputLimit = maxOpenPGPMessageBytes + } + stdout.limit = outputLimit + 1 + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return stdout.buffer.Bytes(), stderr.String(), fmt.Errorf("gpg %s: %w: %s", args[0], err, cleanGPGError(stderr.String())) + } + if stdout.buffer.Len() > outputLimit { + return nil, stderr.String(), fmt.Errorf("OpenPGP output exceeds the %d MiB limit", outputLimit/(1<<20)) + } + return stdout.buffer.Bytes(), stderr.String(), nil +} + +func cleanGPGError(value string) string { + value = strings.TrimSpace(value) + if len(value) > 1000 { + return value[:1000] + "..." + } + return value +} + +func importOpenPGP(home, keyMaterial string) error { + if strings.TrimSpace(keyMaterial) == "" { + return errors.New("OpenPGP key material is required") + } + _, stderr, err := runGPG(home, []string{"--import"}, []byte(keyMaterial), 1<<20) + if err != nil { + return fmt.Errorf("import OpenPGP key: %w", err) + } + if strings.Contains(stderr, "no valid OpenPGP data found") { + return errors.New("no valid OpenPGP key found") + } + return nil +} + +func listFingerprints(home string) ([]string, error) { + output, _, err := runGPG(home, []string{"--with-colons", "--list-keys"}, nil, 1<<20) + if err != nil { + return nil, fmt.Errorf("list OpenPGP keys: %w", err) + } + var fingerprints []string + for _, line := range strings.Split(string(output), "\n") { + fields := strings.Split(line, ":") + if len(fields) > 9 && fields[0] == "fpr" && fields[9] != "" { + fingerprints = append(fingerprints, fields[9]) + } + } + if len(fingerprints) == 0 { + return nil, errors.New("no usable OpenPGP key found") + } + return fingerprints, nil +} + +func writeGPGFile(home, name string, content []byte) (string, error) { + path := filepath.Join(home, name) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return "", fmt.Errorf("create temporary OpenPGP file: %w", err) + } + if _, err := file.Write(content); err != nil { + _ = file.Close() + return "", fmt.Errorf("write temporary OpenPGP file: %w", err) + } + if err := file.Close(); err != nil { + return "", fmt.Errorf("close temporary OpenPGP file: %w", err) + } + return path, nil +} + +// SignOpenPGPDetached returns an ASCII-armored detached signature. The input +// must contain a private signing key supplied by the user. +func SignOpenPGPDetached(message []byte, privateKeyArmor string) (string, error) { + var signature []byte + err := withGPG(func(home string) error { + if err := importOpenPGP(home, privateKeyArmor); err != nil { + return err + } + output, _, err := runGPG(home, []string{"--armor", "--detach-sign", "--output", "-"}, message, 1<<20) + if err != nil { + return fmt.Errorf("sign OpenPGP message: %w", err) + } + signature = output + return nil + }) + return string(signature), err +} + +// VerifyOpenPGPDetached verifies an ASCII-armored or binary detached +// signature using public or private OpenPGP key material supplied by the user. +func VerifyOpenPGPDetached(message []byte, signature, publicKeyArmor string) error { + return withGPG(func(home string) error { + if err := importOpenPGP(home, publicKeyArmor); err != nil { + return err + } + signaturePath, err := writeGPGFile(home, "signature.asc", []byte(signature)) + if err != nil { + return err + } + messagePath, err := writeGPGFile(home, "message.bin", message) + if err != nil { + return err + } + if _, _, err := runGPG(home, []string{"--verify", signaturePath, messagePath}, nil, 1<<20); err != nil { + return fmt.Errorf("verify OpenPGP signature: %w", err) + } + return nil + }) +} + +// EncryptOpenPGP encrypts a message to every entity in the supplied armored +// public key ring. If privateSignerArmor is non-empty, it also signs the +// message with the supplied private key. The result is ASCII armored. +func EncryptOpenPGP(message []byte, recipientKeyArmor, privateSignerArmor string) (string, error) { + var encrypted []byte + err := withGPG(func(home string) error { + if err := importOpenPGP(home, recipientKeyArmor); err != nil { + return err + } + recipientFingerprints, err := listFingerprints(home) + if err != nil { + return err + } + args := []string{"--armor", "--trust-model", "always", "--encrypt", "--output", "-"} + for _, fingerprint := range recipientFingerprints { + args = append(args, "--recipient", fingerprint) + } + if strings.TrimSpace(privateSignerArmor) != "" { + if err := importOpenPGP(home, privateSignerArmor); err != nil { + return err + } + signerFingerprints, err := listFingerprints(home) + if err != nil { + return err + } + args = append(args, "--sign", "--local-user", signerFingerprints[0]) + } + output, _, err := runGPG(home, args, message, maxOpenPGPMessageBytes) + if err != nil { + return fmt.Errorf("encrypt OpenPGP message: %w", err) + } + encrypted = output + return nil + }) + return string(encrypted), err +} + +// DecryptOpenPGP decrypts an ASCII-armored or binary OpenPGP message with a +// user-provided private key. A bad embedded signature is rejected. +func DecryptOpenPGP(message []byte, privateKeyArmor string) ([]byte, error) { + var plaintext []byte + err := withGPG(func(home string) error { + if err := importOpenPGP(home, privateKeyArmor); err != nil { + return err + } + output, status, err := runGPG(home, []string{"--status-fd", "2", "--decrypt", "--output", "-"}, message, maxOpenPGPMessageBytes) + if strings.Contains(status, "[GNUPG:] BADSIG") || strings.Contains(status, "[GNUPG:] ERRSIG") { + return errors.New("authenticate OpenPGP message: invalid signature") + } + if err != nil { + return fmt.Errorf("decrypt OpenPGP message: %w", err) + } + plaintext = output + return nil + }) + return plaintext, err +} diff --git a/internal/cryptokit/yubicrypt.go b/internal/cryptokit/yubicrypt.go new file mode 100644 index 0000000..851d564 --- /dev/null +++ b/internal/cryptokit/yubicrypt.go @@ -0,0 +1,237 @@ +package cryptokit + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const maxYubiCryptMessageBytes = 64 << 20 + +var yubiCryptCandidates = []string{ + "/home/gabriel1/bin/yubicrypt", + "/home/gabriel1/bin/yubicrpt-cli", + "/home/gabriel1/Projects/yubicrpt-cli/yubicrypt", + "/home/gabriel1/Projects/yubicrpt-cli/yubicrpt-cli", + "/usr/local/bin/yubicrypt", + "/usr/bin/yubicrypt", +} + +// FindYubiCryptCLI returns the optional yubicrypt-cli executable. An explicit +// AEGIS_YUBICRYPT_CLI path takes precedence over the standard locations. +func FindYubiCryptCLI() (string, error) { + if configured := strings.TrimSpace(os.Getenv("AEGIS_YUBICRYPT_CLI")); configured != "" { + if isExecutableBinary(configured) { + return configured, nil + } + return "", fmt.Errorf("AEGIS_YUBICRYPT_CLI is not executable: %s", configured) + } + for _, candidate := range yubiCryptCandidates { + if isExecutableBinary(candidate) { + return candidate, nil + } + } + if path, err := exec.LookPath("yubicrypt"); err == nil { + return path, nil + } + return "", errors.New("yubicrypt-cli executable not found; set AEGIS_YUBICRYPT_CLI or install yubicrypt") +} + +func isExecutableBinary(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0 +} + +func runYubiCrypt(args []string, input []byte) ([]byte, error) { + executable, err := FindYubiCryptCLI() + if err != nil { + return nil, err + } + command := exec.Command(executable, args...) + command.Stdin = bytes.NewReader(input) + var stdout limitedBuffer + var stderr bytes.Buffer + stdout.limit = maxYubiCryptMessageBytes + 1 + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + return nil, fmt.Errorf("yubicrypt %s: %w: %s", firstCommandArg(args), err, cleanCommandError(stderr.String())) + } + if stdout.buffer.Len() > maxYubiCryptMessageBytes { + return nil, errors.New("yubicrypt output exceeds the 64 MiB limit") + } + return stdout.buffer.Bytes(), nil +} + +func firstCommandArg(args []string) string { + if len(args) == 0 { + return "operation" + } + return args[0] +} + +func cleanCommandError(value string) string { + value = strings.TrimSpace(value) + if len(value) > 1000 { + return value[:1000] + "..." + } + if value == "" { + return "command failed" + } + return value +} + +func withYubiTemp(operation func(directory string) error) error { + directory, err := os.MkdirTemp("", "aegis-yubicrypt-") + if err != nil { + return fmt.Errorf("create temporary YubiCrypt directory: %w", err) + } + defer os.RemoveAll(directory) + if err := os.Chmod(directory, 0o700); err != nil { + return fmt.Errorf("protect temporary YubiCrypt directory: %w", err) + } + return operation(directory) +} + +func writeYubiTemp(directory, name string, data []byte) (string, error) { + path := filepath.Join(directory, name) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return "", fmt.Errorf("create temporary YubiCrypt input: %w", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return "", fmt.Errorf("write temporary YubiCrypt input: %w", err) + } + if err := file.Close(); err != nil { + return "", fmt.Errorf("close temporary YubiCrypt input: %w", err) + } + return path, nil +} + +func checkYubiMessageSize(message []byte) error { + if len(message) > maxYubiCryptMessageBytes { + return errors.New("message exceeds the 64 MiB limit") + } + return nil +} + +// EncryptYubiCrypt encrypts with an RSA public certificate/key accepted by +// yubicrypt-cli. The private decryption key remains inside the YubiKey PIV +// slot 9d and is never read by Aegis. +func EncryptYubiCrypt(message []byte, recipientKeyPEM string) ([]byte, error) { + if err := checkYubiMessageSize(message); err != nil { + return nil, err + } + if strings.TrimSpace(recipientKeyPEM) == "" { + return nil, errors.New("YubiCrypt RSA recipient certificate is required") + } + var result []byte + err := withYubiTemp(func(directory string) error { + messagePath, err := writeYubiTemp(directory, "message.bin", message) + if err != nil { + return err + } + keyPath, err := writeYubiTemp(directory, "recipient.pem", []byte(recipientKeyPEM)) + if err != nil { + return err + } + result, err = runYubiCrypt([]string{ + "encrypt", "--quiet", "--key", keyPath, "--input", messagePath, "--output", "-", + }, nil) + if err != nil { + return fmt.Errorf("YubiCrypt encryption failed: %w", err) + } + return nil + }) + return result, err +} + +func requireYubiPIN(pin string) ([]byte, error) { + pin = strings.TrimRight(pin, "\r\n") + if pin == "" { + return nil, errors.New("YubiKey PIV PIN is required") + } + return []byte(pin + "\n"), nil +} + +// SignYubiCrypt signs with the YubiKey PIV slot 9c. The PIN is sent through +// stdin to yubicrypt-cli and is not placed in command arguments or logs. +func SignYubiCrypt(message []byte, pin string) ([]byte, error) { + if err := checkYubiMessageSize(message); err != nil { + return nil, err + } + pinInput, err := requireYubiPIN(pin) + if err != nil { + return nil, err + } + var result []byte + err = withYubiTemp(func(directory string) error { + messagePath, err := writeYubiTemp(directory, "message.bin", message) + if err != nil { + return err + } + result, err = runYubiCrypt([]string{ + "sign", "--quiet", "--pin-stdin", "--input", messagePath, "--output", "-", + }, pinInput) + if err != nil { + return fmt.Errorf("YubiCrypt signing failed: %w", err) + } + return nil + }) + return result, err +} + +// DecryptYubiCrypt decrypts with the YubiKey PIV slot 9d. The ciphertext is +// kept in a temporary file while the PIN is supplied only on stdin. +func DecryptYubiCrypt(ciphertext []byte, pin string) ([]byte, error) { + if err := checkYubiMessageSize(ciphertext); err != nil { + return nil, err + } + pinInput, err := requireYubiPIN(pin) + if err != nil { + return nil, err + } + var result []byte + err = withYubiTemp(func(directory string) error { + ciphertextPath, err := writeYubiTemp(directory, "ciphertext.yc", ciphertext) + if err != nil { + return err + } + result, err = runYubiCrypt([]string{ + "decrypt", "--quiet", "--pin-stdin", "--input", ciphertextPath, "--output", "-", + }, pinInput) + if err != nil { + return fmt.Errorf("YubiCrypt decryption failed: %w", err) + } + return nil + }) + return result, err +} + +// VerifyYubiCrypt verifies a yubicrypt signature block and returns the +// original message only after the CLI has authenticated it. +func VerifyYubiCrypt(signedMessage []byte) ([]byte, error) { + if err := checkYubiMessageSize(signedMessage); err != nil { + return nil, err + } + var result []byte + err := withYubiTemp(func(directory string) error { + signedPath, err := writeYubiTemp(directory, "signed.yc", signedMessage) + if err != nil { + return err + } + result, err = runYubiCrypt([]string{ + "verify", "--quiet", "--input", signedPath, "--message-output", "-", + }, nil) + if err != nil { + return fmt.Errorf("YubiCrypt verification failed: %w", err) + } + return nil + }) + return result, err +} diff --git a/internal/cryptokit/yubicrypt_test.go b/internal/cryptokit/yubicrypt_test.go new file mode 100644 index 0000000..5adaf40 --- /dev/null +++ b/internal/cryptokit/yubicrypt_test.go @@ -0,0 +1,36 @@ +package cryptokit + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindYubiCryptCLIUsesExplicitExecutable(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "yubicrypt") + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("AEGIS_YUBICRYPT_CLI", path) + found, err := FindYubiCryptCLI() + if err != nil { + t.Fatal(err) + } + if found != path { + t.Fatalf("found %q, want %q", found, path) + } +} + +func TestRequireYubiPIN(t *testing.T) { + input, err := requireYubiPIN("123456\n") + if err != nil { + t.Fatal(err) + } + if string(input) != "123456\n" { + t.Fatalf("unexpected PIN input %q", input) + } + if _, err := requireYubiPIN(""); err == nil { + t.Fatal("empty PIN accepted") + } +} diff --git a/internal/filter/filter.go b/internal/filter/filter.go new file mode 100644 index 0000000..ba1f40b --- /dev/null +++ b/internal/filter/filter.go @@ -0,0 +1,245 @@ +// Package filter evaluates deterministic, local-only Usenet filters. +package filter + +import ( + "fmt" + "path" + "regexp" + "strings" + "time" +) + +type Field string + +const ( + FieldAny Field = "any" + FieldFrom Field = "from" + FieldSubject Field = "subject" + FieldNewsgroups Field = "newsgroups" + FieldMessageID Field = "message-id" + FieldReferences Field = "references" + FieldDate Field = "date" + FieldBody Field = "body" + FieldHeader Field = "header" + FieldVFaceHash Field = "vface-hash" + FieldRead Field = "read" +) + +type Operator string + +const ( + OperatorContains Operator = "contains" + OperatorExact Operator = "exact" + OperatorRegexp Operator = "regexp" + OperatorGlob Operator = "glob" +) + +type Action string + +const ( + ActionKeep Action = "keep" + ActionHide Action = "hide" + ActionMarkRead Action = "mark-read" + ActionHighlight Action = "highlight" + ActionTag Action = "tag" + ActionMuteThread Action = "mute-thread" +) + +type Rule struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Field Field `json:"field"` + Header string `json:"header,omitempty"` + Operator Operator `json:"operator"` + Pattern string `json:"pattern"` + Action Action `json:"action"` + Tag string `json:"tag,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type Article struct { + From string + Subject string + Newsgroups string + MessageID string + References string + Date string + Body string + Read bool + Headers map[string]string +} + +type Result struct { + Hidden bool + MarkRead bool + Highlight bool + MuteThread bool + Tags []string + MatchedRules []string +} + +type InvalidRuleError struct{ Message string } + +func (e *InvalidRuleError) Error() string { return e.Message } + +func Evaluate(article Article, rules []Rule) (Result, error) { + var result Result + for _, rule := range rules { + if !rule.Enabled { + continue + } + if err := validateRule(rule); err != nil { + return Result{}, err + } + values := fieldValues(article, rule) + matched, err := match(values, rule.Operator, rule.Pattern) + if err != nil { + return Result{}, fmt.Errorf("rule %q: %w", rule.ID, err) + } + if !matched { + continue + } + result.MatchedRules = append(result.MatchedRules, rule.ID) + switch rule.Action { + case ActionHide: + result.Hidden = true + case ActionMarkRead: + result.MarkRead = true + case ActionHighlight: + result.Highlight = true + case ActionTag: + if rule.Tag != "" && !contains(result.Tags, rule.Tag) { + result.Tags = append(result.Tags, rule.Tag) + } + case ActionMuteThread: + result.MuteThread = true + case ActionKeep: + default: + return Result{}, &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported action %q", rule.ID, rule.Action)} + } + } + return result, nil +} + +func validateRule(rule Rule) error { + if strings.TrimSpace(rule.Pattern) == "" { + return &InvalidRuleError{Message: fmt.Sprintf("rule %q has an empty pattern", rule.ID)} + } + switch rule.Field { + case FieldAny, FieldFrom, FieldSubject, FieldNewsgroups, FieldMessageID, + FieldReferences, FieldDate, FieldBody, FieldHeader, FieldVFaceHash, FieldRead: + default: + return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported field %q", rule.ID, rule.Field)} + } + if rule.Field == FieldHeader && strings.TrimSpace(rule.Header) == "" { + return &InvalidRuleError{Message: fmt.Sprintf("rule %q needs a header name", rule.ID)} + } + switch rule.Operator { + case OperatorContains, OperatorExact, OperatorRegexp, OperatorGlob: + default: + return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported operator %q", rule.ID, rule.Operator)} + } + switch rule.Action { + case ActionKeep, ActionHide, ActionMarkRead, ActionHighlight, ActionTag, ActionMuteThread: + default: + return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported action %q", rule.ID, rule.Action)} + } + return nil +} + +func fieldValues(article Article, rule Rule) []string { + if rule.Field == FieldAny { + return []string{article.From, article.Subject, article.Newsgroups, article.MessageID, article.References, article.Date, article.Body, vfaceValue(article.Headers)} + } + if rule.Field == FieldHeader { + for key, value := range article.Headers { + if strings.EqualFold(key, rule.Header) { + return []string{value} + } + } + return nil + } + switch rule.Field { + case FieldFrom: + return []string{article.From} + case FieldSubject: + return []string{article.Subject} + case FieldNewsgroups: + return []string{article.Newsgroups} + case FieldMessageID: + return []string{article.MessageID} + case FieldReferences: + return []string{article.References} + case FieldDate: + return []string{article.Date} + case FieldBody: + return []string{article.Body} + case FieldVFaceHash: + return []string{vfaceValue(article.Headers)} + case FieldRead: + return []string{fmt.Sprintf("%t", article.Read)} + default: + return nil + } +} + +func vfaceValue(headers map[string]string) string { + for _, key := range []string{"Identity-Hash", "X-VFace-Hash", "X-VFace-PNG-SHA256"} { + for header, value := range headers { + if strings.EqualFold(header, key) { + return value + } + } + } + return "" +} + +func match(values []string, operator Operator, pattern string) (bool, error) { + for _, value := range values { + switch operator { + case OperatorContains: + if strings.Contains(strings.ToLower(value), strings.ToLower(pattern)) { + return true, nil + } + case OperatorExact: + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(pattern)) { + return true, nil + } + case OperatorRegexp: + re, err := regexp.Compile(pattern) + if err != nil { + return false, err + } + if re.MatchString(value) { + return true, nil + } + case OperatorGlob: + matched, err := path.Match(strings.ToLower(pattern), strings.ToLower(value)) + if err != nil { + return false, err + } + if matched { + return true, nil + } + } + } + return false, nil +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +// ParseDate is kept here so callers can validate date filters without adding +// parser logic to the UI. It accepts the common RFC 5322 form and RFC3339. +func ParseDate(value string) (time.Time, error) { + if parsed, err := time.Parse(time.RFC1123Z, value); err == nil { + return parsed, nil + } + return time.Parse(time.RFC3339, value) +} diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go new file mode 100644 index 0000000..ab35ee5 --- /dev/null +++ b/internal/filter/filter_test.go @@ -0,0 +1,24 @@ +package filter + +import "testing" + +func TestEvaluateClassicAndVFaceFields(t *testing.T) { + article := Article{ + From: "Alice ", Subject: "Important announcement", Newsgroups: "comp.test", + Headers: map[string]string{"Identity-Hash": "abc123"}, + } + result, err := Evaluate(article, []Rule{ + {ID: "subject", Enabled: true, Field: FieldSubject, Operator: OperatorContains, Pattern: "important", Action: ActionHighlight}, + {ID: "identity", Enabled: true, Field: FieldVFaceHash, Operator: OperatorExact, Pattern: "abc123", Action: ActionTag, Tag: "trusted"}, + }) + if err != nil || !result.Highlight || len(result.Tags) != 1 || result.Tags[0] != "trusted" { + t.Fatalf("unexpected result: %#v, %v", result, err) + } +} + +func TestEvaluateRejectsInvalidRegexp(t *testing.T) { + _, err := Evaluate(Article{Subject: "x"}, []Rule{{ID: "bad", Enabled: true, Field: FieldSubject, Operator: OperatorRegexp, Pattern: "[", Action: ActionHide}}) + if err == nil { + t.Fatal("expected invalid regexp error") + } +} 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) +} diff --git a/internal/nntp/client.go b/internal/nntp/client.go new file mode 100644 index 0000000..5958628 --- /dev/null +++ b/internal/nntp/client.go @@ -0,0 +1,983 @@ +package nntp + +import ( + "bufio" + "compress/flate" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "golang.org/x/net/proxy" +) + +const ( + defaultTimeout = 30 * time.Second + maxResponseLine = 1 << 20 + maxMultilineBytes = 64 << 20 + maxMultilineLines = 1_000_000 + maxPostBytes = 10 << 20 + maxPostLineBytes = 998 +) + +var tls12AESGCMSuites = []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, +} + +type DialConfig struct { + Host string + Port string + UseTLS bool + StartTLS bool + InsecureSkipVerify bool + Username string + Password string + SASLMechanism string + UseCompression bool + ProxyType string + ProxyAddress string + Timeout time.Duration + tlsConfig *tls.Config +} + +type GroupInfo struct { + Name string + Low int64 + High int64 + EstimatedPost int64 + Posting string +} + +type GroupStatus struct { + Name string + Count int64 + Low int64 + High int64 +} + +type ArticleHeader struct { + Number int64 + Subject string + From string + Date string + MessageID string + References string + Bytes int64 + Lines int64 +} + +type HeaderValue struct { + Number int64 + Value string +} + +type GroupDescription struct { + Name string + Description string +} + +type ServerDate struct { + Date string + Time string +} + +type ResponseError struct { + Code int + Message string +} + +func (e *ResponseError) Error() string { + return fmt.Sprintf("NNTP response %d: %s", e.Code, e.Message) +} + +type Client struct { + mu sync.Mutex + conn net.Conn + reader *bufio.Reader + writer *bufio.Writer + timeout time.Duration + closed bool +} + +func Dial(ctx context.Context, cfg DialConfig) (*Client, error) { + if err := validateDialConfig(cfg); err != nil { + return nil, err + } + if cfg.UseCompression && cfg.UseTLS { + return nil, errors.New("COMPRESS DEFLATE cannot be used with TLS") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + target := net.JoinHostPort(cfg.Host, cfg.Port) + netDialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second} + var raw net.Conn + var err error + if strings.EqualFold(cfg.ProxyType, "SOCKS5") { + proxyDialer, proxyErr := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, netDialer) + if proxyErr != nil { + return nil, fmt.Errorf("configure SOCKS5 proxy: %w", proxyErr) + } + raw, err = proxyDialer.Dial("tcp", target) + } else { + raw, err = netDialer.DialContext(ctx, "tcp", target) + } + if err != nil { + return nil, fmt.Errorf("connect to NNTP server: %w", err) + } + conn := raw + if cfg.UseTLS && !cfg.StartTLS { + tlsConfig := makeTLSConfig(cfg) + tlsConn := tls.Client(raw, tlsConfig) + if err := tlsConn.HandshakeContext(ctx); err != nil { + raw.Close() + return nil, fmt.Errorf("complete TLS handshake: %w", err) + } + conn = tlsConn + } + client := &Client{ + conn: conn, + reader: bufio.NewReaderSize(conn, 64*1024), + writer: bufio.NewWriterSize(conn, 64*1024), + timeout: timeout, + } + if err := client.setDeadline(); err != nil { + client.Close() + return nil, err + } + code, message, err := client.readResponse() + if err != nil { + client.Close() + return nil, fmt.Errorf("read NNTP greeting: %w", err) + } + if code != 200 && code != 201 { + client.Close() + return nil, &ResponseError{Code: code, Message: message} + } + if cfg.StartTLS { + code, message, err = client.command("STARTTLS") + if err != nil { + client.Close() + return nil, err + } + if code != 382 { + client.Close() + return nil, &ResponseError{Code: code, Message: message} + } + tlsConn := tls.Client(client.conn, makeTLSConfig(cfg)) + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = client.conn.Close() + return nil, fmt.Errorf("complete STARTTLS handshake: %w", err) + } + client.conn = tlsConn + client.reader = bufio.NewReaderSize(tlsConn, 64*1024) + client.writer = bufio.NewWriterSize(tlsConn, 64*1024) + if err := client.setDeadline(); err != nil { + client.Close() + return nil, err + } + } + if cfg.Username != "" { + if err := client.authenticateWithConfig(cfg); err != nil { + client.Close() + return nil, err + } + } + if cfg.UseCompression { + if err := client.enableCompression(); err != nil { + client.Close() + return nil, err + } + } + if code, message, err = client.command("MODE READER"); err != nil { + client.Close() + return nil, err + } + if code != 200 && code != 201 && code != 500 && code != 501 { + client.Close() + return nil, &ResponseError{Code: code, Message: message} + } + _ = client.conn.SetDeadline(time.Time{}) + return client, nil +} + +func (c *Client) Capabilities() ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.capabilitiesUnlocked() + if responseCode(err) == 500 || responseCode(err) == 501 { + return nil, nil + } + return lines, err +} + +func (c *Client) capabilitiesUnlocked() ([]string, error) { + return c.multilineCommand("CAPABILITIES", 101) +} + +func (c *Client) Help() ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.multilineCommand("HELP", 100) +} + +func (c *Client) Date() (ServerDate, error) { + c.mu.Lock() + defer c.mu.Unlock() + code, message, err := c.commandUnlocked("DATE") + if err != nil { + return ServerDate{}, err + } + if code != 111 { + return ServerDate{}, &ResponseError{Code: code, Message: message} + } + fields := strings.Fields(message) + if len(fields) < 2 { + return ServerDate{}, errors.New("malformed DATE response") + } + return ServerDate{Date: fields[0], Time: fields[1]}, nil +} + +func (c *Client) ListOverviewFormat() ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.multilineCommand("LIST OVERVIEW.FMT", 215) +} + +func (c *Client) ListNewsGroups(pattern string) ([]GroupDescription, error) { + if strings.ContainsAny(pattern, "\r\n") { + return nil, errors.New("invalid LIST NEWSGROUPS pattern") + } + command := "LIST NEWSGROUPS" + if strings.TrimSpace(pattern) != "" { + command += " " + strings.TrimSpace(pattern) + } + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand(command, 215) + if err != nil { + return nil, err + } + groups := make([]GroupDescription, 0, len(lines)) + for _, line := range lines { + fields := strings.SplitN(line, " ", 2) + if len(fields) == 0 || !validAtom(fields[0]) { + continue + } + description := "" + if len(fields) == 2 { + description = strings.TrimSpace(fields[1]) + } + groups = append(groups, GroupDescription{Name: fields[0], Description: description}) + } + return groups, nil +} + +func (c *Client) NewGroups(date, clock, timezone string) ([]string, error) { + if !validCommandValue(date) || !validCommandValue(clock) || !validCommandValue(timezone) { + return nil, errors.New("invalid NEWGROUPS arguments") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.multilineCommand("NEWGROUPS "+date+" "+clock+" "+timezone, 231) +} + +func (c *Client) NewNews(groups, date, clock, timezone string) ([]string, error) { + if !validCommandValue(groups) || !validCommandValue(date) || !validCommandValue(clock) || !validCommandValue(timezone) { + return nil, errors.New("invalid NEWNEWS arguments") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.multilineCommand("NEWNEWS "+groups+" "+date+" "+clock+" "+timezone, 230) +} + +func (c *Client) ListGroup(group string) ([]int64, error) { + if !validAtom(group) { + return nil, errors.New("invalid newsgroup name") + } + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand("LISTGROUP "+group, 211) + if err != nil { + return nil, err + } + articles := make([]int64, 0, len(lines)) + for _, line := range lines { + for _, token := range strings.Fields(line) { + parts := strings.SplitN(token, "-", 2) + first, parseErr := strconv.ParseInt(parts[0], 10, 64) + if parseErr != nil || first < 1 { + continue + } + last := first + if len(parts) == 2 { + last, parseErr = strconv.ParseInt(parts[1], 10, 64) + if parseErr != nil || last < first || last-first > 100000 { + continue + } + } + for number := first; number <= last; number++ { + articles = append(articles, number) + } + } + } + return articles, nil +} + +func (c *Client) Head(number int64) (string, error) { + return c.singleArticlePart("HEAD", number, 221) +} + +func (c *Client) Body(number int64) (string, error) { + return c.singleArticlePart("BODY", number, 222) +} + +func (c *Client) Stat(number int64) (string, error) { + if number < 1 { + return "", errors.New("invalid article number") + } + c.mu.Lock() + defer c.mu.Unlock() + code, message, err := c.commandUnlocked("STAT " + strconv.FormatInt(number, 10)) + if err != nil { + return "", err + } + if code != 223 { + return "", &ResponseError{Code: code, Message: message} + } + return message, nil +} + +func (c *Client) Header(field string, first, last int64) ([]HeaderValue, error) { + if !validAtom(field) || first < 1 || last < first || last-first > 10000 { + return nil, errors.New("invalid HDR request") + } + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand("HDR "+field+" "+strconv.FormatInt(first, 10)+"-"+strconv.FormatInt(last, 10), 225) + if err != nil { + return nil, err + } + values := make([]HeaderValue, 0, len(lines)) + for _, line := range lines { + fields := strings.SplitN(line, "\t", 2) + if len(fields) != 2 { + continue + } + number, parseErr := strconv.ParseInt(fields[0], 10, 64) + if parseErr != nil || number < 1 { + continue + } + values = append(values, HeaderValue{Number: number, Value: fields[1]}) + } + return values, nil +} + +func (c *Client) singleArticlePart(command string, number int64, expected int) (string, error) { + if number < 1 { + return "", errors.New("invalid article number") + } + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand(command+" "+strconv.FormatInt(number, 10), expected) + if err != nil { + return "", err + } + return strings.Join(lines, "\n"), nil +} + +func (c *Client) ListActive() ([]GroupInfo, error) { + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand("LIST ACTIVE", 215) + if err != nil { + return nil, err + } + groups := make([]GroupInfo, 0, len(lines)) + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 4 || !validAtom(fields[0]) { + continue + } + high, highErr := strconv.ParseInt(fields[1], 10, 64) + low, lowErr := strconv.ParseInt(fields[2], 10, 64) + if highErr != nil || lowErr != nil || high < 0 || low < 0 { + continue + } + groups = append(groups, GroupInfo{ + Name: fields[0], + Low: low, + High: high, + EstimatedPost: estimatePopulation(low, high), + Posting: fields[3], + }) + } + return groups, nil +} + +func (c *Client) SelectGroup(group string) (GroupStatus, error) { + if !validAtom(group) { + return GroupStatus{}, errors.New("invalid newsgroup name") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.selectGroupUnlocked(group) +} + +func (c *Client) selectGroupUnlocked(group string) (GroupStatus, error) { + code, message, err := c.commandUnlocked("GROUP " + group) + if err != nil { + return GroupStatus{}, err + } + if code != 211 { + return GroupStatus{}, &ResponseError{Code: code, Message: message} + } + fields := strings.Fields(message) + if len(fields) < 4 { + return GroupStatus{}, errors.New("malformed GROUP response") + } + count, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return GroupStatus{}, errors.New("malformed GROUP article count") + } + low, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return GroupStatus{}, errors.New("malformed GROUP low article number") + } + high, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil { + return GroupStatus{}, errors.New("malformed GROUP high article number") + } + return GroupStatus{Name: fields[3], Count: count, Low: low, High: high}, nil +} + +func (c *Client) Overview(first, last int64) ([]ArticleHeader, error) { + if first < 1 || last < first || last-first > 10_000 { + return nil, errors.New("invalid overview range") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.overviewUnlocked(first, last) +} + +func (c *Client) overviewUnlocked(first, last int64) ([]ArticleHeader, error) { + rangeArg := strconv.FormatInt(first, 10) + "-" + strconv.FormatInt(last, 10) + lines, err := c.multilineCommand("OVER "+rangeArg, 224) + if code := responseCode(err); code == 500 || code == 501 { + lines, err = c.multilineCommand("XOVER "+rangeArg, 224) + } + if err != nil { + return nil, err + } + headers := make([]ArticleHeader, 0, len(lines)) + for _, line := range lines { + fields := strings.Split(line, "\t") + if len(fields) < 5 { + continue + } + number, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + header := ArticleHeader{ + Number: number, + Subject: fields[1], + From: fields[2], + Date: fields[3], + MessageID: fields[4], + } + if len(fields) > 5 { + header.References = fields[5] + } + if len(fields) > 6 { + header.Bytes, _ = strconv.ParseInt(fields[6], 10, 64) + } + if len(fields) > 7 { + header.Lines, _ = strconv.ParseInt(fields[7], 10, 64) + } + headers = append(headers, header) + } + return headers, nil +} + +func (c *Client) LatestOverview(group string, limit int64) (GroupStatus, []ArticleHeader, error) { + if !validAtom(group) { + return GroupStatus{}, nil, errors.New("invalid newsgroup name") + } + if limit < 1 || limit > 10_000 { + return GroupStatus{}, nil, errors.New("overview limit must be between 1 and 10000") + } + c.mu.Lock() + defer c.mu.Unlock() + status, err := c.selectGroupUnlocked(group) + if err != nil { + return GroupStatus{}, nil, err + } + if status.Count == 0 || status.High < status.Low || status.High < 1 { + return status, nil, nil + } + first := status.High - limit + 1 + if first < status.Low { + first = status.Low + } + headers, err := c.overviewUnlocked(first, status.High) + return status, headers, err +} + +func (c *Client) Article(number int64) (string, error) { + if number < 1 { + return "", errors.New("invalid article number") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.articleUnlocked(number) +} + +func (c *Client) ArticleInGroup(group string, number int64) (string, error) { + if !validAtom(group) { + return "", errors.New("invalid newsgroup name") + } + if number < 1 { + return "", errors.New("invalid article number") + } + c.mu.Lock() + defer c.mu.Unlock() + if _, err := c.selectGroupUnlocked(group); err != nil { + return "", err + } + return c.articleUnlocked(number) +} + +func (c *Client) articleUnlocked(number int64) (string, error) { + lines, err := c.multilineCommand("ARTICLE "+strconv.FormatInt(number, 10), 220) + if err != nil { + return "", err + } + return strings.Join(lines, "\n"), nil +} + +func (c *Client) Post(article string) error { + lines, err := normalizedArticleLines(article) + if err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + code, message, err := c.commandUnlocked("POST") + if err != nil { + return err + } + if code != 340 { + return &ResponseError{Code: code, Message: message} + } + if err := c.setDeadline(); err != nil { + return err + } + for _, line := range lines { + if strings.HasPrefix(line, ".") { + line = "." + line + } + if _, err := c.writer.WriteString(line + "\r\n"); err != nil { + return fmt.Errorf("write article: %w", err) + } + } + if _, err := c.writer.WriteString(".\r\n"); err != nil { + return fmt.Errorf("finish article: %w", err) + } + if err := c.writer.Flush(); err != nil { + return fmt.Errorf("flush article: %w", err) + } + code, message, err = c.readResponse() + if err != nil { + return err + } + if code != 240 { + return &ResponseError{Code: code, Message: message} + } + return nil +} + +// ValidateArticle checks the NNTP/MIME-safe text constraints used by Post. +// It accepts either LF or CRLF input and does not modify the article. +func ValidateArticle(article string) error { + _, err := normalizedArticleLines(article) + return err +} + +func normalizedArticleLines(article string) ([]string, error) { + if len(article) == 0 || len(article) > maxPostBytes { + return nil, fmt.Errorf("article must contain between 1 and %d bytes", maxPostBytes) + } + if !utf8.ValidString(article) { + return nil, errors.New("article is not valid UTF-8") + } + if strings.ContainsRune(article, '\x00') { + return nil, errors.New("article contains a NUL byte") + } + normalized := strings.ReplaceAll(article, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + lines := strings.Split(normalized, "\n") + for _, line := range lines { + wireBytes := len(line) + if strings.HasPrefix(line, ".") { + wireBytes++ // NNTP dot-stuffing adds one byte on the wire. + } + if wireBytes > maxPostLineBytes { + return nil, fmt.Errorf("article contains a line longer than %d octets", maxPostLineBytes) + } + } + return lines, nil +} + +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil + } + c.closed = true + _ = c.setDeadline() + _, _, _ = c.commandUnlocked("QUIT") + return c.conn.Close() +} + +func (c *Client) authenticate(username, password string) error { + if !validCommandValue(username) || !validCommandValue(password) { + return errors.New("credentials contain invalid control characters") + } + code, message, err := c.command("AUTHINFO USER " + username) + if err != nil { + return err + } + if code == 281 { + return nil + } + if code != 381 { + return &ResponseError{Code: code, Message: message} + } + if password == "" { + return errors.New("server requires a password") + } + code, message, err = c.command("AUTHINFO PASS " + password) + if err != nil { + return err + } + if code != 281 { + return &ResponseError{Code: code, Message: message} + } + return nil +} + +func (c *Client) authenticateWithConfig(cfg DialConfig) error { + if strings.TrimSpace(cfg.SASLMechanism) == "" { + return c.authenticate(cfg.Username, cfg.Password) + } + mechanism := strings.ToUpper(strings.TrimSpace(cfg.SASLMechanism)) + if mechanism != "PLAIN" { + return fmt.Errorf("unsupported NNTP SASL mechanism %q", cfg.SASLMechanism) + } + capabilities, err := c.capabilitiesUnlocked() + if err != nil { + return fmt.Errorf("query NNTP capabilities for SASL: %w", err) + } + if !hasCapability(capabilities, "SASL", "PLAIN") { + return errors.New("NNTP server does not advertise SASL PLAIN") + } + code, message, err := c.commandUnlocked("AUTHINFO SASL PLAIN") + if err != nil { + return err + } + if code != 383 { + if code == 281 { + return nil + } + return &ResponseError{Code: code, Message: message} + } + response := base64.StdEncoding.EncodeToString([]byte("\x00" + cfg.Username + "\x00" + cfg.Password)) + if err := c.writeLineUnlocked(response); err != nil { + return err + } + code, message, err = c.readResponse() + if err != nil { + return err + } + if code != 281 { + return &ResponseError{Code: code, Message: message} + } + return nil +} + +func (c *Client) enableCompression() error { + capabilities, err := c.capabilitiesUnlocked() + if err != nil { + return fmt.Errorf("query NNTP capabilities for compression: %w", err) + } + if !hasCapability(capabilities, "COMPRESS", "DEFLATE") { + return errors.New("NNTP server does not advertise COMPRESS DEFLATE") + } + code, message, err := c.commandUnlocked("COMPRESS DEFLATE") + if err != nil { + return err + } + if code != 206 { + return &ResponseError{Code: code, Message: message} + } + compressed := newCompressedConn(c.conn) + c.conn = compressed + c.reader = bufio.NewReaderSize(compressed, 64*1024) + c.writer = bufio.NewWriterSize(compressed, 64*1024) + return nil +} + +func hasCapability(lines []string, name string, value string) bool { + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) == 0 || !strings.EqualFold(fields[0], name) { + continue + } + if value == "" { + return true + } + for _, field := range fields[1:] { + if strings.EqualFold(field, value) { + return true + } + } + } + return false +} + +func (c *Client) command(line string) (int, string, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.commandUnlocked(line) +} + +func (c *Client) commandUnlocked(line string) (int, string, error) { + if c.closed && line != "QUIT" { + return 0, "", errors.New("NNTP connection is closed") + } + if !validCommandValue(line) { + return 0, "", errors.New("NNTP command contains invalid control characters") + } + if err := c.setDeadline(); err != nil { + return 0, "", err + } + if err := c.writeLineUnlocked(line); err != nil { + return 0, "", err + } + return c.readResponse() +} + +func (c *Client) writeLineUnlocked(line string) error { + if _, err := c.writer.WriteString(line + "\r\n"); err != nil { + return fmt.Errorf("write NNTP command: %w", err) + } + if err := c.writer.Flush(); err != nil { + return fmt.Errorf("flush NNTP command: %w", err) + } + return nil +} + +type compressedConn struct { + net.Conn + reader io.ReadCloser + writer *flate.Writer +} + +func newCompressedConn(conn net.Conn) *compressedConn { + writer, _ := flate.NewWriter(conn, flate.DefaultCompression) + return &compressedConn{Conn: conn, reader: flate.NewReader(conn), writer: writer} +} + +func (c *compressedConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *compressedConn) Write(p []byte) (int, error) { + n, err := c.writer.Write(p) + if flushErr := c.writer.Flush(); err == nil { + err = flushErr + } + return n, err +} + +func (c *compressedConn) Close() error { + _ = c.writer.Close() + _ = c.reader.Close() + return c.Conn.Close() +} + +func (c *Client) multilineCommand(command string, expectedCode int) ([]string, error) { + code, message, err := c.commandUnlocked(command) + if err != nil { + return nil, err + } + if code != expectedCode { + return nil, &ResponseError{Code: code, Message: message} + } + return c.readMultiline() +} + +func (c *Client) readResponse() (int, string, error) { + line, err := readLimitedLine(c.reader) + if err != nil { + return 0, "", err + } + if len(line) < 3 { + return 0, "", errors.New("malformed NNTP response") + } + code, err := strconv.Atoi(line[:3]) + if err != nil { + return 0, "", errors.New("malformed NNTP response code") + } + message := "" + if len(line) > 4 { + message = line[4:] + } + return code, message, nil +} + +func (c *Client) readMultiline() ([]string, error) { + lines := make([]string, 0, 1024) + total := 0 + for len(lines) < maxMultilineLines { + line, err := readLimitedLine(c.reader) + if err != nil { + return nil, err + } + if line == "." { + return lines, nil + } + if strings.HasPrefix(line, "..") { + line = line[1:] + } + total += len(line) + if total > maxMultilineBytes { + return nil, errors.New("NNTP multiline response exceeds size limit") + } + lines = append(lines, line) + } + return nil, errors.New("NNTP multiline response exceeds line limit") +} + +func (c *Client) setDeadline() error { + if err := c.conn.SetDeadline(time.Now().Add(c.timeout)); err != nil { + return fmt.Errorf("set NNTP deadline: %w", err) + } + return nil +} + +func readLimitedLine(reader *bufio.Reader) (string, error) { + buffer := make([]byte, 0, 4096) + for { + fragment, err := reader.ReadSlice('\n') + if len(buffer)+len(fragment) > maxResponseLine { + return "", errors.New("NNTP response line exceeds size limit") + } + buffer = append(buffer, fragment...) + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if err != nil { + if errors.Is(err, io.EOF) { + return "", io.ErrUnexpectedEOF + } + return "", err + } + break + } + line := string(buffer) + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + return line, nil +} + +func validateDialConfig(cfg DialConfig) error { + if cfg.Host == "" || !validCommandValue(cfg.Host) { + return errors.New("invalid NNTP host") + } + port, err := strconv.Atoi(cfg.Port) + if err != nil || port < 1 || port > 65535 { + return errors.New("invalid NNTP port") + } + if cfg.ProxyType != "" && !strings.EqualFold(cfg.ProxyType, "DIRECT") && !strings.EqualFold(cfg.ProxyType, "SOCKS5") { + return errors.New("unsupported proxy type") + } + if strings.EqualFold(cfg.ProxyType, "SOCKS5") { + if _, _, err := net.SplitHostPort(cfg.ProxyAddress); err != nil { + return fmt.Errorf("invalid SOCKS5 address: %w", err) + } + } + if cfg.Username != "" && !cfg.UseTLS { + return errors.New("NNTP authentication requires TLS") + } + if cfg.StartTLS && !cfg.UseTLS { + return errors.New("STARTTLS requires TLS to be enabled") + } + if cfg.SASLMechanism != "" && cfg.Username == "" { + return errors.New("SASL authentication requires a username") + } + return nil +} + +func makeTLSConfig(cfg DialConfig) *tls.Config { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + CipherSuites: append([]uint16(nil), tls12AESGCMSuites...), + ServerName: cfg.Host, + InsecureSkipVerify: cfg.InsecureSkipVerify, + } //nolint:gosec // explicit user opt-in for local or pinned deployments + if cfg.tlsConfig != nil { + tlsConfig = cfg.tlsConfig.Clone() + if tlsConfig.MinVersion < tls.VersionTLS12 { + tlsConfig.MinVersion = tls.VersionTLS12 + } + if tlsConfig.MaxVersion < tls.VersionTLS12 || tlsConfig.MaxVersion > tls.VersionTLS13 { + tlsConfig.MaxVersion = tls.VersionTLS13 + } + if tlsConfig.CipherSuites == nil { + tlsConfig.CipherSuites = append([]uint16(nil), tls12AESGCMSuites...) + } + if tlsConfig.ServerName == "" { + tlsConfig.ServerName = cfg.Host + } + } + return tlsConfig +} + +func estimatePopulation(low, high int64) int64 { + if low <= 0 || high < low { + return 0 + } + return high - low + 1 +} + +func validAtom(value string) bool { + return value != "" && validCommandValue(value) && !strings.ContainsAny(value, " ") +} + +func validCommandValue(value string) bool { + return !strings.ContainsAny(value, "\x00\r\n") +} + +func responseCode(err error) int { + var responseErr *ResponseError + if errors.As(err, &responseErr) { + return responseErr.Code + } + return 0 +} diff --git a/internal/nntp/client_test.go b/internal/nntp/client_test.go new file mode 100644 index 0000000..26354ed --- /dev/null +++ b/internal/nntp/client_test.go @@ -0,0 +1,357 @@ +package nntp + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "fmt" + "io" + "math/big" + "net" + "strings" + "sync" + "testing" + "time" +) + +type fakeServer struct { + listener net.Listener + posted string + mu sync.Mutex +} + +func startFakeServer(t *testing.T) *fakeServer { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + return startFakeServerOn(t, listener) +} + +func startFakeTLSServer(t *testing.T) (*fakeServer, *tls.Config) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Aegis NNTP test"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatal(err) + } + certificate := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey} + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + }) + if err != nil { + t.Fatal(err) + } + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + roots.AddCert(parsed) + return startFakeServerOn(t, listener), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12} +} + +func startFakeServerOn(t *testing.T, listener net.Listener) *fakeServer { + t.Helper() + server := &fakeServer{listener: listener} + go server.serve(t) + t.Cleanup(func() { listener.Close() }) + return server +} + +func (s *fakeServer) address() (string, string) { + host, port, _ := net.SplitHostPort(s.listener.Addr().String()) + return host, port +} + +func (s *fakeServer) serve(t *testing.T) { + conn, err := s.listener.Accept() + if err != nil { + return + } + defer conn.Close() + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + write := func(value string) { + _, _ = writer.WriteString(value) + _ = writer.Flush() + } + write("200 fake server ready\r\n") + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSpace(line) + switch { + case line == "AUTHINFO USER reader": + write("381 password required\r\n") + case line == "AUTHINFO PASS secret": + write("281 authentication accepted\r\n") + case line == "MODE READER": + write("200 reader mode\r\n") + case line == "CAPABILITIES": + write("101 capabilities follow\r\nVERSION 2\r\nOVER\r\nPOST\r\n.\r\n") + case line == "LIST ACTIVE": + write("215 list follows\r\ncomp.lang.go 120 101 y\r\nempty.group 0 1 n\r\n.\r\n") + case line == "GROUP comp.lang.go": + write("211 19 101 120 comp.lang.go\r\n") + case line == "OVER 119-120": + write("224 overview follows\r\n119\tFirst\tAlice \tMon, 1 Jan 2024 00:00:00 +0000\t<119@example>\t\t100\t5\r\n120\tSecond\tBob \tMon, 1 Jan 2024 01:00:00 +0000\t<120@example>\t<119@example>\t120\t6\r\n.\r\n") + case line == "ARTICLE 120": + write("220 120 <120@example> article follows\r\nSubject: Second\r\n\r\nHello\r\n..dot line\r\n.\r\n") + case line == "POST": + write("340 send article\r\n") + var article strings.Builder + for { + postedLine, readErr := reader.ReadString('\n') + if readErr != nil { + return + } + if postedLine == ".\r\n" { + break + } + article.WriteString(postedLine) + } + s.mu.Lock() + s.posted = article.String() + s.mu.Unlock() + write("240 article received\r\n") + case line == "QUIT": + write("205 closing connection\r\n") + return + default: + write(fmt.Sprintf("500 unsupported: %s\r\n", line)) + } + } +} + +func startSOCKSProxy(t *testing.T, upstreamAddress string) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + hello := make([]byte, 3) + if _, err := io.ReadFull(connection, hello); err != nil || hello[0] != 5 { + return + } + if _, err := connection.Write([]byte{5, 0}); err != nil { + return + } + header := make([]byte, 4) + if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 1 { + return + } + switch header[3] { + case 1: + _, err = io.ReadFull(connection, make([]byte, 4)) + case 3: + length := make([]byte, 1) + if _, err = io.ReadFull(connection, length); err == nil { + _, err = io.ReadFull(connection, make([]byte, int(length[0]))) + } + case 4: + _, err = io.ReadFull(connection, make([]byte, 16)) + default: + return + } + if err != nil { + return + } + port := make([]byte, 2) + if _, err := io.ReadFull(connection, port); err != nil || binary.BigEndian.Uint16(port) == 0 { + return + } + upstream, err := net.DialTimeout("tcp", upstreamAddress, 2*time.Second) + if err != nil { + _, _ = connection.Write([]byte{5, 1, 0, 1, 0, 0, 0, 0, 0, 0}) + return + } + defer upstream.Close() + if _, err := connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 1}); err != nil { + return + } + done := make(chan struct{}) + go func() { + _, _ = io.Copy(upstream, connection) + _ = upstream.Close() + close(done) + }() + _, _ = io.Copy(connection, upstream) + <-done + }() + return listener.Addr().String() +} + +func TestClientWorkflow(t *testing.T) { + server, tlsConfig := startFakeTLSServer(t) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{ + Host: host, + Port: port, + UseTLS: true, + Username: "reader", + Password: "secret", + ProxyType: "DIRECT", + tlsConfig: tlsConfig, + }) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer client.Close() + + capabilities, err := client.Capabilities() + if err != nil || len(capabilities) != 3 { + t.Fatalf("Capabilities() = %v, %v", capabilities, err) + } + groups, err := client.ListActive() + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 || groups[0].EstimatedPost != 20 || groups[1].EstimatedPost != 0 { + t.Fatalf("ListActive() = %#v", groups) + } + status, headers, err := client.LatestOverview("comp.lang.go", 2) + if err != nil || status.Count != 19 || status.High != 120 { + t.Fatalf("LatestOverview() status = %#v, error = %v", status, err) + } + if err != nil || len(headers) != 2 || headers[1].Subject != "Second" { + t.Fatalf("LatestOverview() headers = %#v, error = %v", headers, err) + } + article, err := client.ArticleInGroup("comp.lang.go", 120) + if err != nil || !strings.Contains(article, "\n.dot line") { + t.Fatalf("Article() = %q, %v", article, err) + } + if err := client.Post("From: reader@example.org\nNewsgroups: comp.lang.go\nSubject: Test\n\n.line"); err != nil { + t.Fatal(err) + } + server.mu.Lock() + posted := server.posted + server.mu.Unlock() + if !strings.Contains(posted, "\r\n..line\r\n") { + t.Fatalf("posted article was not dot-stuffed: %q", posted) + } +} + +func TestTLSCertificateVerificationCannotBeSkipped(t *testing.T) { + server, _ := startFakeTLSServer(t) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{Host: host, Port: port, UseTLS: true, ProxyType: "DIRECT"}) + if client != nil { + client.Close() + } + if err == nil { + t.Fatal("Dial() trusted an unknown TLS certificate") + } +} + +func TestRejectsCommandInjection(t *testing.T) { + if _, err := Dial(context.Background(), DialConfig{Host: "example.org\r\nQUIT", Port: "119"}); err == nil { + t.Fatal("Dial() accepted command injection in host") + } + client := &Client{} + if _, err := client.SelectGroup("comp.lang.go\r\nPOST"); err == nil { + t.Fatal("SelectGroup() accepted command injection") + } +} + +func TestValidateArticleTextConstraints(t *testing.T) { + tests := []struct { + name string + article string + wantErr bool + }{ + {name: "valid UTF-8 and CRLF", article: "Subject: café\r\n\r\ncorps UTF-8\r\n"}, + {name: "valid LF normalized by Post", article: "Subject: test\n\nbody"}, + {name: "998 octets", article: "Subject: " + strings.Repeat("a", 989)}, + {name: "oversized line", article: strings.Repeat("a", maxPostLineBytes+1), wantErr: true}, + {name: "dot-stuffed oversized line", article: "." + strings.Repeat("a", maxPostLineBytes-1), wantErr: true}, + {name: "NUL", article: "Subject: test\x00", wantErr: true}, + {name: "invalid UTF-8", article: string([]byte{'a', 0xff}), wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateArticle(test.article) + if (err != nil) != test.wantErr { + t.Fatalf("ValidateArticle() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestRejectsAuthenticationWithoutTLS(t *testing.T) { + if _, err := Dial(context.Background(), DialConfig{ + Host: "127.0.0.1", Port: "119", Username: "reader", Password: "secret", + }); err == nil { + t.Fatal("Dial() accepted authentication without TLS") + } +} + +func TestSOCKS5Connection(t *testing.T) { + server := startFakeServer(t) + proxyAddress := startSOCKSProxy(t, server.listener.Addr().String()) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{ + Host: host, Port: port, ProxyType: "SOCKS5", ProxyAddress: proxyAddress, + }) + if err != nil { + t.Fatalf("Dial() through SOCKS5 error = %v", err) + } + defer client.Close() + groups, err := client.ListActive() + if err != nil || len(groups) != 2 { + t.Fatalf("ListActive() through SOCKS5 = %#v, %v", groups, err) + } +} + +func TestPopulationEstimate(t *testing.T) { + tests := []struct { + low, high int64 + want int64 + }{ + {101, 120, 20}, + {1, 1, 1}, + {1, 0, 0}, + {0, 0, 0}, + } + for _, test := range tests { + if got := estimatePopulation(test.low, test.high); got != test.want { + t.Errorf("estimatePopulation(%d, %d) = %d, want %d", test.low, test.high, got, test.want) + } + } +} diff --git a/internal/profile/vault.go b/internal/profile/vault.go new file mode 100644 index 0000000..554d01b --- /dev/null +++ b/internal/profile/vault.go @@ -0,0 +1,235 @@ +// Package profile stores the optional VFace identity in an age-encrypted vault. +package profile + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "aegis/internal/cryptokit" + "filippo.io/age" + agearmor "filippo.io/age/armor" +) + +const ( + Version = 1 + MinPasswordLength = 12 + maxVaultBytes = 1 << 20 +) + +type Profile struct { + Version int `json:"version"` + Username string `json:"username"` + Email string `json:"email"` + PublicKey string `json:"ed25519_public_key"` + PrivateKey string `json:"ed25519_private_key"` + AgeIdentity string `json:"age_identity,omitempty"` + YubiKeyFingerprint string `json:"yubikey_openpgp_fingerprint,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func DefaultPath() (string, error) { + directory, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate profile directory: %w", err) + } + return filepath.Join(directory, "aegis", "profile.json.age"), nil +} + +func Generate(username, email string) (Profile, error) { + username = strings.TrimSpace(username) + email = strings.TrimSpace(email) + if username == "" || email == "" { + return Profile{}, errors.New("VFace username and email are required") + } + if strings.ContainsAny(username+email, "\x00\r\n|") { + return Profile{}, errors.New("VFace username and email contain forbidden characters") + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return Profile{}, fmt.Errorf("generate VFace Ed25519 key pair: %w", err) + } + return Profile{ + Version: Version, + Username: username, + Email: email, + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + PrivateKey: base64.StdEncoding.EncodeToString(privateKey), + CreatedAt: time.Now().UTC(), + }, nil +} + +func Save(path string, value Profile, password string) error { + if err := validatePassword(password); err != nil { + return err + } + if err := Validate(value); err != nil { + return err + } + plaintext, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("encode VFace profile: %w", err) + } + recipient, err := age.NewScryptRecipient(password) + if err != nil { + return fmt.Errorf("create profile password recipient: %w", err) + } + var encrypted bytes.Buffer + armor := agearmor.NewWriter(&encrypted) + writer, err := age.Encrypt(armor, recipient) + if err != nil { + _ = armor.Close() + return fmt.Errorf("create profile vault: %w", err) + } + if _, err := writer.Write(plaintext); err != nil { + _ = writer.Close() + _ = armor.Close() + return fmt.Errorf("write profile vault: %w", err) + } + if err := writer.Close(); err != nil { + _ = armor.Close() + return fmt.Errorf("close profile vault: %w", err) + } + if err := armor.Close(); err != nil { + return fmt.Errorf("close profile armor: %w", err) + } + return atomicWrite(path, encrypted.Bytes()) +} + +func Load(path string, password string) (Profile, error) { + if err := validatePassword(password); err != nil { + return Profile{}, err + } + ciphertext, err := os.ReadFile(path) + if err != nil { + return Profile{}, fmt.Errorf("read profile vault: %w", err) + } + if len(ciphertext) > maxVaultBytes { + return Profile{}, errors.New("profile vault is too large") + } + identity, err := age.NewScryptIdentity(password) + if err != nil { + return Profile{}, fmt.Errorf("create profile password identity: %w", err) + } + reader, err := age.Decrypt(agearmor.NewReader(bytes.NewReader(ciphertext)), identity) + if err != nil { + return Profile{}, errors.New("cannot unlock profile vault") + } + plaintext, err := io.ReadAll(io.LimitReader(reader, maxVaultBytes+1)) + if err != nil { + return Profile{}, fmt.Errorf("read profile vault: %w", err) + } + if len(plaintext) > maxVaultBytes { + return Profile{}, errors.New("profile vault contents are too large") + } + var value Profile + if err := json.Unmarshal(plaintext, &value); err != nil { + return Profile{}, errors.New("profile vault contains invalid data") + } + if err := Validate(value); err != nil { + return Profile{}, fmt.Errorf("validate profile vault: %w", err) + } + return value, nil +} + +func Validate(value Profile) error { + if value.Version != Version { + return fmt.Errorf("unsupported VFace profile version %d", value.Version) + } + if value.Username == "" || value.Email == "" || strings.ContainsAny(value.Username+value.Email, "\x00\r\n|") { + return errors.New("invalid VFace username or email") + } + publicKey, err := decodePublicKey(value.PublicKey) + if err != nil { + return err + } + if strings.TrimSpace(value.PrivateKey) == "" { + if strings.TrimSpace(value.YubiKeyFingerprint) == "" { + return errors.New("profile needs an encrypted private key or a YubiKey OpenPGP fingerprint") + } + return nil + } + privateKey, err := decodePrivateKey(value.PrivateKey) + if err != nil { + return err + } + derived := privateKey.Public().(ed25519.PublicKey) + if !bytes.Equal(publicKey, derived) { + return errors.New("VFace public and private keys do not match") + } + return nil +} + +func validatePassword(password string) error { + if len([]rune(password)) < MinPasswordLength { + return fmt.Errorf("profile password must contain at least %d characters", MinPasswordLength) + } + return nil +} + +func decodePublicKey(value string) (ed25519.PublicKey, error) { + canonical, err := cryptokit.CanonicalEd25519PublicKey(value) + if err != nil { + return nil, fmt.Errorf("invalid VFace public key: %w", err) + } + decoded, err := base64.StdEncoding.DecodeString(canonical) + if err != nil { + return nil, errors.New("invalid VFace public key encoding") + } + return ed25519.PublicKey(decoded), nil +} + +func decodePrivateKey(value string) (ed25519.PrivateKey, error) { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil || len(decoded) != ed25519.PrivateKeySize { + return nil, errors.New("invalid VFace private key") + } + return ed25519.PrivateKey(decoded), nil +} + +func atomicWrite(path string, data []byte) error { + if path == "" { + return errors.New("profile vault path is required") + } + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return fmt.Errorf("create profile directory: %w", err) + } + if err := os.Chmod(directory, 0o700); err != nil { + return fmt.Errorf("protect profile directory: %w", err) + } + temporary, err := os.CreateTemp(directory, ".profile-*.tmp") + if err != nil { + return fmt.Errorf("create profile temporary file: %w", err) + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return fmt.Errorf("write profile vault: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("sync profile vault: %w", err) + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryName, path); err != nil { + return fmt.Errorf("commit profile vault: %w", err) + } + return nil +} diff --git a/internal/profile/vault_test.go b/internal/profile/vault_test.go new file mode 100644 index 0000000..a726c5f --- /dev/null +++ b/internal/profile/vault_test.go @@ -0,0 +1,45 @@ +package profile + +import ( + "os" + "path/filepath" + "testing" +) + +func TestVaultRoundTrip(t *testing.T) { + value, err := Generate("pseudonym", "reader@example.org") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "profile.json.age") + if err := Save(path, value, "correct horse battery staple"); err != nil { + t.Fatal(err) + } + loaded, err := Load(path, "correct horse battery staple") + if err != nil { + t.Fatal(err) + } + if loaded.Username != value.Username || loaded.PublicKey != value.PublicKey || loaded.PrivateKey != value.PrivateKey { + t.Fatal("profile vault round-trip changed identity") + } + if _, err := Load(path, "wrong password"); err == nil { + t.Fatal("wrong password unlocked profile vault") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 || string(data) == "" { + t.Fatal("empty profile vault") + } +} + +func TestPasswordMinimum(t *testing.T) { + value, err := Generate("pseudonym", "reader@example.org") + if err != nil { + t.Fatal(err) + } + if err := Save(filepath.Join(t.TempDir(), "profile.json.age"), value, "short"); err == nil { + t.Fatal("short profile password accepted") + } +} diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go new file mode 100644 index 0000000..e294323 --- /dev/null +++ b/internal/smtpclient/client.go @@ -0,0 +1,139 @@ +// Package smtpclient sends classic RFC 5322 messages to mail2news gateways. +package smtpclient + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "net/mail" + "net/smtp" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +type Config struct { + Host string + Port string + Mode string + Username string + Password string + InsecureSkipVerify bool + ProxyType string + ProxyAddress string + Timeout time.Duration +} + +func Send(cfg Config, from string, recipients []string, message []byte) error { + if err := validate(cfg, from, recipients); err != nil { + return err + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + target := net.JoinHostPort(cfg.Host, cfg.Port) + dialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second} + var conn net.Conn + var err error + if strings.EqualFold(cfg.ProxyType, "SOCKS5") { + socks, proxyErr := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, dialer) + if proxyErr != nil { + return fmt.Errorf("configure SMTP SOCKS5 proxy: %w", proxyErr) + } + conn, err = socks.Dial("tcp", target) + } else { + conn, err = dialer.Dial("tcp", target) + } + if err != nil { + return fmt.Errorf("connect SMTP server: %w", err) + } + defer conn.Close() + tlsConfig := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12, InsecureSkipVerify: cfg.InsecureSkipVerify} // #nosec G402, explicit user opt-in + if strings.EqualFold(cfg.Mode, "TLS") { + tlsConn := tls.Client(conn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return fmt.Errorf("SMTP TLS handshake: %w", err) + } + conn = tlsConn + } + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("initialize SMTP client: %w", err) + } + defer client.Close() + if strings.EqualFold(cfg.Mode, "STARTTLS") { + if ok, _ := client.Extension("STARTTLS"); !ok { + return errors.New("SMTP server does not advertise STARTTLS") + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("SMTP STARTTLS: %w", err) + } + } + if cfg.Username != "" { + state, ok := client.TLSConnectionState() + if !ok || !state.HandshakeComplete { + return errors.New("SMTP authentication requires TLS") + } + if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil { + return fmt.Errorf("SMTP authentication: %w", err) + } + } + if err := client.Mail(from); err != nil { + return fmt.Errorf("SMTP MAIL FROM: %w", err) + } + for _, recipient := range recipients { + if err := client.Rcpt(recipient); err != nil { + return fmt.Errorf("SMTP RCPT TO: %w", err) + } + } + writer, err := client.Data() + if err != nil { + return fmt.Errorf("SMTP DATA: %w", err) + } + if _, err := writer.Write(message); err != nil { + _ = writer.Close() + return fmt.Errorf("write SMTP message: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("finish SMTP message: %w", err) + } + return client.Quit() +} + +func validate(cfg Config, from string, recipients []string) error { + if strings.TrimSpace(cfg.Host) == "" || strings.TrimSpace(cfg.Port) == "" { + return errors.New("SMTP host and port are required") + } + if cfg.Mode != "" && cfg.Mode != "TLS" && cfg.Mode != "STARTTLS" { + return errors.New("SMTP mode must be cleartext, TLS or STARTTLS") + } + if cfg.ProxyType != "DIRECT" && cfg.ProxyType != "SOCKS5" { + return errors.New("SMTP proxy type must be DIRECT or SOCKS5") + } + if strings.HasSuffix(strings.ToLower(cfg.Host), ".onion") && cfg.ProxyType != "SOCKS5" { + return errors.New(".onion SMTP server requires SOCKS5") + } + if cfg.ProxyType == "SOCKS5" { + if _, _, err := net.SplitHostPort(cfg.ProxyAddress); err != nil { + return fmt.Errorf("invalid SMTP SOCKS5 address: %w", err) + } + } + if cfg.Username != "" && cfg.Mode == "" { + return errors.New("SMTP authentication requires TLS or STARTTLS") + } + if _, err := mail.ParseAddress(from); err != nil { + return fmt.Errorf("invalid SMTP sender: %w", err) + } + if len(recipients) == 0 { + return errors.New("at least one SMTP recipient is required") + } + for _, recipient := range recipients { + if _, err := mail.ParseAddress(recipient); err != nil { + return fmt.Errorf("invalid SMTP recipient: %w", err) + } + } + return nil +} diff --git a/internal/smtpclient/client_test.go b/internal/smtpclient/client_test.go new file mode 100644 index 0000000..6572f9a --- /dev/null +++ b/internal/smtpclient/client_test.go @@ -0,0 +1,17 @@ +package smtpclient + +import "testing" + +func TestValidateOnionRequiresProxy(t *testing.T) { + err := validate(Config{Host: "mail.example.onion", Port: "465", Mode: "TLS", ProxyType: "DIRECT"}, "a@example.org", []string{"news@example.org"}) + if err == nil { + t.Fatal("expected onion proxy validation error") + } +} + +func TestValidateAllowsCleartextOnion(t *testing.T) { + err := validate(Config{Host: "mail.example.onion", Port: "25", Mode: "", ProxyType: "SOCKS5", ProxyAddress: "127.0.0.1:9050"}, "a@example.org", []string{"news@example.org"}) + if err != nil { + t.Fatalf("cleartext onion SMTP should be allowed: %v", err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..f851dce --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,232 @@ +// Package store contains the local, non-secret Aegis cache. +package store + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "aegis/internal/filter" +) + +const ( + stateVersion = 1 + maxArticleBytes = 16 << 20 + maxDraftBytes = 2 << 20 +) + +type Article struct { + Key string `json:"key"` + Group string `json:"group"` + Number int64 `json:"number"` + Subject string `json:"subject,omitempty"` + From string `json:"from,omitempty"` + MessageID string `json:"message_id,omitempty"` + Raw string `json:"raw,omitempty"` + Read bool `json:"read"` + Bookmarked bool `json:"bookmarked"` + Tags []string `json:"tags,omitempty"` + FetchedAt time.Time `json:"fetched_at"` +} + +type Draft struct { + ID string `json:"id"` + Groups string `json:"groups,omitempty"` + From string `json:"from,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body,omitempty"` + References string `json:"references,omitempty"` + FollowupTo string `json:"followup_to,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type State struct { + Version int `json:"version"` + Articles map[string]Article `json:"articles"` + Drafts map[string]Draft `json:"drafts"` + Filters []filter.Rule `json:"filters,omitempty"` +} + +type Store struct { + mu sync.RWMutex + path string + state State +} + +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate user configuration directory: %w", err) + } + return filepath.Join(dir, "aegis", "state.json"), nil +} + +func Open(path string) (*Store, error) { + if path == "" { + return nil, errors.New("state path is required") + } + s := &Store{path: path, state: emptyState()} + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("read local state: %w", err) + } + if len(data) > 32<<20 { + return nil, errors.New("local state is too large") + } + if err := json.Unmarshal(data, &s.state); err != nil { + return nil, fmt.Errorf("decode local state: %w", err) + } + if s.state.Version == 0 { + s.state.Version = stateVersion + } + if s.state.Version != stateVersion { + return nil, fmt.Errorf("unsupported local state version %d", s.state.Version) + } + if s.state.Articles == nil { + s.state.Articles = make(map[string]Article) + } + if s.state.Drafts == nil { + s.state.Drafts = make(map[string]Draft) + } + return s, nil +} + +func emptyState() State { + return State{Version: stateVersion, Articles: make(map[string]Article), Drafts: make(map[string]Draft)} +} + +func (s *Store) Save() error { + s.mu.RLock() + data, err := json.MarshalIndent(s.state, "", " ") + s.mu.RUnlock() + if err != nil { + return fmt.Errorf("encode local state: %w", err) + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create local state directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return fmt.Errorf("protect local state directory: %w", err) + } + tmp, err := os.CreateTemp(dir, ".state-*.tmp") + if err != nil { + return fmt.Errorf("create local state temporary file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + _ = tmp.Close() + return fmt.Errorf("write local state: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync local state: %w", err) + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, s.path); err != nil { + return fmt.Errorf("commit local state: %w", err) + } + return nil +} + +func (s *Store) Snapshot() State { + s.mu.RLock() + defer s.mu.RUnlock() + result := emptyState() + result.Filters = append(result.Filters, s.state.Filters...) + for key, article := range s.state.Articles { + article.Tags = append([]string(nil), article.Tags...) + result.Articles[key] = article + } + for key, draft := range s.state.Drafts { + result.Drafts[key] = draft + } + return result +} + +func (s *Store) UpsertArticle(article Article) error { + if article.Key == "" { + return errors.New("article key is required") + } + if len(article.Raw) > maxArticleBytes { + return errors.New("article exceeds local cache limit") + } + s.mu.Lock() + if old, ok := s.state.Articles[article.Key]; ok { + article.Read = old.Read + article.Bookmarked = old.Bookmarked + if len(article.Tags) == 0 { + article.Tags = append([]string(nil), old.Tags...) + } + } + if article.FetchedAt.IsZero() { + article.FetchedAt = time.Now().UTC() + } + s.state.Articles[article.Key] = article + s.mu.Unlock() + return nil +} + +func (s *Store) SetRead(key string, read bool) error { + s.mu.Lock() + defer s.mu.Unlock() + article, ok := s.state.Articles[key] + if !ok { + return errors.New("article not found") + } + article.Read = read + s.state.Articles[key] = article + return nil +} + +func (s *Store) SetBookmarked(key string, bookmarked bool) error { + s.mu.Lock() + defer s.mu.Unlock() + article, ok := s.state.Articles[key] + if !ok { + return errors.New("article not found") + } + article.Bookmarked = bookmarked + s.state.Articles[key] = article + return nil +} + +func (s *Store) SaveDraft(draft Draft) error { + if draft.ID == "" { + return errors.New("draft ID is required") + } + if len(draft.Body) > maxDraftBytes { + return errors.New("draft exceeds local size limit") + } + draft.UpdatedAt = time.Now().UTC() + s.mu.Lock() + s.state.Drafts[draft.ID] = draft + s.mu.Unlock() + return nil +} + +func (s *Store) DeleteDraft(id string) { + s.mu.Lock() + delete(s.state.Drafts, id) + s.mu.Unlock() +} + +func (s *Store) SetFilters(rules []filter.Rule) { + s.mu.Lock() + s.state.Filters = append([]filter.Rule(nil), rules...) + s.mu.Unlock() +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..40efef1 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,34 @@ +package store + +import ( + "path/filepath" + "testing" + + "aegis/internal/filter" +) + +func TestStoreRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err := s.UpsertArticle(Article{Key: "comp.test:1", Group: "comp.test", Number: 1, Raw: "Subject: test\r\n\r\nbody"}); err != nil { + t.Fatal(err) + } + if err := s.SaveDraft(Draft{ID: "draft-1", Body: "hello"}); err != nil { + t.Fatal(err) + } + s.SetFilters([]filter.Rule{{ID: "r", Enabled: true, Field: filter.FieldSubject, Operator: filter.OperatorContains, Pattern: "x", Action: filter.ActionHide}}) + if err := s.Save(); err != nil { + t.Fatal(err) + } + reloaded, err := Open(path) + if err != nil { + t.Fatal(err) + } + snapshot := reloaded.Snapshot() + if len(snapshot.Articles) != 1 || len(snapshot.Drafts) != 1 || len(snapshot.Filters) != 1 { + t.Fatalf("unexpected snapshot: %#v", snapshot) + } +} diff --git a/internal/thread/thread.go b/internal/thread/thread.go new file mode 100644 index 0000000..ed9a47b --- /dev/null +++ b/internal/thread/thread.go @@ -0,0 +1,76 @@ +// Package thread builds local Usenet conversation trees from article headers. +package thread + +import ( + "sort" + "strings" + + "aegis/internal/nntp" +) + +type Node struct { + Header nntp.ArticleHeader + Parent *Node + Children []*Node +} + +func Build(headers []nntp.ArticleHeader) []*Node { + ordered := append([]nntp.ArticleHeader(nil), headers...) + sort.SliceStable(ordered, func(i, j int) bool { + if ordered[i].Number == ordered[j].Number { + return ordered[i].MessageID < ordered[j].MessageID + } + return ordered[i].Number < ordered[j].Number + }) + nodes := make(map[string]*Node, len(ordered)) + for _, header := range ordered { + node := &Node{Header: header} + if key := canonicalID(header.MessageID); key != "" { + nodes[key] = node + } + } + var roots []*Node + for _, node := range nodesInOrder(ordered, nodes) { + parentID := lastReference(node.Header.References) + parent := nodes[canonicalID(parentID)] + if parent == nil || parent == node { + roots = append(roots, node) + continue + } + node.Parent = parent + parent.Children = append(parent.Children, node) + } + for _, node := range nodesInOrder(ordered, nodes) { + sort.SliceStable(node.Children, func(i, j int) bool { return node.Children[i].Header.Number < node.Children[j].Header.Number }) + } + return roots +} + +func nodesInOrder(headers []nntp.ArticleHeader, nodes map[string]*Node) []*Node { + result := make([]*Node, 0, len(headers)) + seen := make(map[*Node]struct{}, len(headers)) + for _, header := range headers { + node := nodes[canonicalID(header.MessageID)] + if node == nil { + continue + } + if _, ok := seen[node]; ok { + continue + } + seen[node] = struct{}{} + result = append(result, node) + } + return result +} + +func lastReference(value string) string { + parts := strings.Fields(value) + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} + +func canonicalID(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} diff --git a/internal/thread/thread_test.go b/internal/thread/thread_test.go new file mode 100644 index 0000000..146ae0c --- /dev/null +++ b/internal/thread/thread_test.go @@ -0,0 +1,18 @@ +package thread + +import ( + "testing" + + "aegis/internal/nntp" +) + +func TestBuildUsesLastReference(t *testing.T) { + roots := Build([]nntp.ArticleHeader{ + {Number: 1, MessageID: "", Subject: "topic"}, + {Number: 2, MessageID: "", References: ""}, + {Number: 3, MessageID: "", References: " "}, + }) + if len(roots) != 1 || len(roots[0].Children) != 1 || len(roots[0].Children[0].Children) != 1 { + t.Fatalf("unexpected thread tree: %#v", roots) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..d193ce1 --- /dev/null +++ b/internal/ui/app.go @@ -0,0 +1,1795 @@ +package ui + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "image" + "io" + "mime" + "net/mail" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "aegis/internal/config" + "aegis/internal/cryptokit" + "aegis/internal/filter" + "aegis/internal/identity" + "aegis/internal/nntp" + vfaceprofile "aegis/internal/profile" + "aegis/internal/smtpclient" + "aegis/internal/store" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +const overviewLimit = int64(500) + +const messageIDDomain = "aegis.virebent.art" + +type application struct { + window fyne.Window + configPath string + settings config.Settings + state *store.Store + filterRules []filter.Rule + vfaceProfile *vfaceprofile.Profile + vfaceVaultPath string + + clientMu sync.RWMutex + client *nntp.Client + + status *widget.Label + progress *widget.ProgressBarInfinite + connect *widget.Button + disconnect *widget.Button + refresh *widget.Button + groupSearch *widget.Entry + groupList *widget.List + groupDetail *widget.Label + subscribe *widget.Button + unsubscribe *widget.Button + loadGroup *widget.Button + + groups []nntp.GroupInfo + visibleGroups []nntp.GroupInfo + selectedGroup string + + headerSearch *widget.Entry + headerList *widget.List + articleHeaders *widget.Label + showAllHeaders *widget.Check + body *widget.Entry + articleText string + headers []nntp.ArticleHeader + visibleHeader []nntp.ArticleHeader + loadedGroup string + selectedHeader nntp.ArticleHeader + hasSelectedHeader bool + markRead *widget.Button + bookmark *widget.Button + reply *widget.Button + + composeGroups *widget.Entry + composeDelivery *widget.Select + composeMail2News *widget.Entry + composeFrom *widget.Entry + composeSubject *widget.Entry + composeReferences *widget.Entry + composeFollowupTo *widget.Entry + composeBody *widget.Entry + composeCryptoMode *widget.Select + composeCryptoKey *widget.Entry + composeCryptoSigningKey *widget.Entry + composeCryptoKeyURL *widget.Entry + + cryptoAlgorithm *widget.Select + cryptoOperation *widget.Select + cryptoMessage *widget.Entry + cryptoPrimary *widget.Entry + cryptoSecret *widget.Entry + cryptoSecondary *widget.Entry + cryptoPrimaryLabel *widget.Label + cryptoSecretLabel *widget.Label + cryptoSecondaryLabel *widget.Label + cryptoPrimaryBox *fyne.Container + cryptoSecretBox *fyne.Container + cryptoSecondaryBox *fyne.Container + cryptoOutput *widget.Label + + hostEntry *widget.Entry + portEntry *widget.Entry + tlsCheck *widget.Check + startTLSCheck *widget.Check + tlsSkipVerify *widget.Check + usernameEntry *widget.Entry + passwordEntry *widget.Entry + saslSelect *widget.Select + compressionCheck *widget.Check + proxySelect *widget.Select + proxyEntry *widget.Entry + smtpHostEntry *widget.Entry + smtpPortEntry *widget.Entry + smtpModeSelect *widget.Select + smtpUserEntry *widget.Entry + smtpEmailEntry *widget.Entry + smtpRecipientEntry *widget.Entry + smtpPasswordEntry *widget.Entry + smtpSkipVerify *widget.Check + displayEntry *widget.Entry + emailEntry *widget.Entry + filterField *widget.Select + filterOperator *widget.Select + filterPattern *widget.Entry + filterAction *widget.Select + filterTag *widget.Entry + vfaceUsernameEntry *widget.Entry + vfaceEmailEntry *widget.Entry + vfacePasswordEntry *widget.Entry + vfaceConfirmEntry *widget.Entry + vfaceStatus *widget.Label + vfaceImage *canvas.Image + vfaceHash *widget.Label +} + +func Run() { + configPath, pathErr := config.DefaultPath() + settings := config.Default() + loadErr := pathErr + if pathErr == nil { + var err error + settings, err = config.Load(configPath) + if err != nil { + loadErr = err + settings = config.Default() + } + } + + fyneApp := app.NewWithID("art.virebent.aegis") + window := fyneApp.NewWindow("Aegis Usenet Client") + window.Resize(fyne.NewSize(1180, 800)) + a := &application{window: window, configPath: configPath, settings: settings} + if statePath, err := store.DefaultPath(); err == nil { + if localState, stateErr := store.Open(statePath); stateErr == nil { + a.state = localState + a.filterRules = localState.Snapshot().Filters + } else if loadErr == nil { + loadErr = stateErr + } + } + window.SetContent(a.build()) + if loadErr != nil { + dialog.ShowError(loadErr, window) + } + window.ShowAndRun() + a.closeClient() +} + +func (a *application) build() fyne.CanvasObject { + a.status = widget.NewLabel("Offline. Configure the server, then connect.") + a.progress = widget.NewProgressBarInfinite() + a.progress.Hide() + + reader := a.buildReader() + composer := a.buildComposer() + settings := a.buildSettings() + tabs := container.NewAppTabs( + container.NewTabItemWithIcon("News Reader", theme.HomeIcon(), reader), + container.NewTabItemWithIcon("Compose", theme.MailComposeIcon(), composer), + container.NewTabItemWithIcon("Profilo e VFace", theme.AccountIcon(), a.buildProfile()), + container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settings), + ) + return container.NewBorder(nil, container.NewVBox(a.progress, a.status), nil, nil, tabs) +} + +func (a *application) buildReader() fyne.CanvasObject { + a.groupSearch = widget.NewEntry() + a.groupSearch.SetPlaceHolder("Filter available newsgroups...") + a.groupSearch.OnChanged = func(string) { a.filterGroups() } + a.groupDetail = widget.NewLabel("Select a group to see its estimated population before subscribing.") + a.groupDetail.Wrapping = fyne.TextWrapWord + a.groupList = widget.NewList( + func() int { return len(a.visibleGroups) }, + func() fyne.CanvasObject { return widget.NewLabel("Newsgroup") }, + func(id widget.ListItemID, object fyne.CanvasObject) { + group := a.visibleGroups[id] + prefix := " " + if a.isSubscribed(group.Name) { + prefix = "✓ " + } + object.(*widget.Label).SetText(fmt.Sprintf("%s%s (≈ %s posts)", prefix, group.Name, formatCount(group.EstimatedPost))) + }, + ) + a.groupList.OnSelected = func(id widget.ListItemID) { + if id < 0 || id >= len(a.visibleGroups) { + return + } + group := a.visibleGroups[id] + a.selectedGroup = group.Name + a.groupDetail.SetText(fmt.Sprintf( + "%s\nEstimated posts: %s (article numbers %d-%d)\nPosting flag: %s\nThe estimate may include gaps on the server.", + group.Name, formatCount(group.EstimatedPost), group.Low, group.High, group.Posting, + )) + } + + a.subscribe = widget.NewButtonWithIcon("Subscribe", theme.ContentAddIcon(), a.subscribeSelected) + a.unsubscribe = widget.NewButtonWithIcon("Unsubscribe", theme.ContentRemoveIcon(), a.unsubscribeSelected) + a.loadGroup = widget.NewButtonWithIcon("Load articles", theme.ViewRefreshIcon(), a.loadSelectedGroup) + groupButtons := container.NewGridWithColumns(3, a.subscribe, a.unsubscribe, a.loadGroup) + groupPane := container.NewBorder( + container.NewVBox(widget.NewLabelWithStyle("Available groups", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), a.groupSearch), + container.NewVBox(a.groupDetail, groupButtons), nil, nil, a.groupList, + ) + + a.body = widget.NewMultiLineEntry() + a.body.SetPlaceHolder("Select an article to download it from the server...") + a.body.TextStyle = fyne.TextStyle{Monospace: true} + a.body.Disable() + a.articleHeaders = widget.NewLabel("No article selected.") + a.articleHeaders.Selectable = true + a.articleHeaders.TextStyle = fyne.TextStyle{Monospace: true} + a.articleHeaders.Wrapping = fyne.TextWrapOff + a.showAllHeaders = widget.NewCheck("Show all headers", func(bool) { a.refreshArticleHeaders() }) + headerDisplay := container.NewBorder(a.showAllHeaders, nil, nil, nil, container.NewVScroll(a.articleHeaders)) + + a.headerSearch = widget.NewEntry() + a.headerSearch.SetPlaceHolder("Search loaded subjects or authors...") + a.headerSearch.OnChanged = func(string) { a.filterHeaders() } + a.headerList = widget.NewList( + func() int { return len(a.visibleHeader) }, + func() fyne.CanvasObject { + return container.NewHBox( + widget.NewIcon(theme.DocumentIcon()), + widget.NewLabel("Subject"), + layout.NewSpacer(), + widget.NewLabel("Author"), + ) + }, + func(id widget.ListItemID, object fyne.CanvasObject) { + header := a.visibleHeader[id] + box := object.(*fyne.Container) + prefix := "" + if a.headerRead(header) { + prefix = "✓ " + } + if a.headerBookmarked(header) { + prefix += "★ " + } + box.Objects[1].(*widget.Label).SetText(prefix + header.Subject) + box.Objects[3].(*widget.Label).SetText(header.From) + }, + ) + a.headerList.OnSelected = func(id widget.ListItemID) { + if id < 0 || id >= len(a.visibleHeader) { + return + } + header := a.visibleHeader[id] + a.selectedHeader = header + a.hasSelectedHeader = true + a.refreshArticleActions() + a.loadArticle(a.loadedGroup, header) + } + headerPane := container.NewBorder(a.headerSearch, nil, nil, nil, a.headerList) + bodyPane := container.NewVSplit(headerDisplay, a.body) + bodyPane.SetOffset(0.24) + rightSplit := container.NewVSplit(headerPane, bodyPane) + rightSplit.SetOffset(0.45) + mainSplit := container.NewHSplit(groupPane, rightSplit) + mainSplit.SetOffset(0.36) + + a.connect = widget.NewButtonWithIcon("Connect", theme.LoginIcon(), a.connectServer) + a.disconnect = widget.NewButtonWithIcon("Disconnect", theme.LogoutIcon(), a.disconnectServer) + a.disconnect.Disable() + a.refresh = widget.NewButtonWithIcon("Refresh groups", theme.ViewRefreshIcon(), a.refreshGroups) + a.refresh.Disable() + a.markRead = widget.NewButton("Mark read", a.toggleRead) + a.bookmark = widget.NewButton("Bookmark", a.toggleBookmark) + a.reply = widget.NewButton("Reply", a.replyToSelected) + a.markRead.Disable() + a.bookmark.Disable() + a.reply.Disable() + toolbar := container.NewHBox(a.connect, a.disconnect, a.refresh, layout.NewSpacer(), a.reply, a.markRead, a.bookmark) + return container.NewBorder(toolbar, nil, nil, nil, mainSplit) +} + +func (a *application) buildComposer() fyne.CanvasObject { + a.composeGroups = widget.NewEntry() + a.composeGroups.SetPlaceHolder("comp.lang.go,example.group") + a.composeDelivery = widget.NewSelect([]string{"NNTP direct posting", "SMTP mail2news"}, nil) + a.composeDelivery.SetSelected("NNTP direct posting") + a.composeMail2News = widget.NewEntry() + a.composeMail2News.SetText(a.settings.SMTPRecipient) + a.composeFrom = widget.NewEntry() + a.composeSubject = widget.NewEntry() + a.composeReferences = widget.NewEntry() + a.composeReferences.SetPlaceHolder("Filled automatically for replies") + a.composeFollowupTo = widget.NewEntry() + a.composeFollowupTo.SetPlaceHolder("Optional Followup-To newsgroup") + a.composeBody = widget.NewMultiLineEntry() + a.composeBody.SetPlaceHolder("Article body...") + a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email)) + a.composeFrom.Disable() + a.composeCryptoMode = widget.NewSelect([]string{ + "Plain", + "Sign with Ed25519", + "Encrypt with age", + "Encrypt with age and sign", + }, nil) + a.composeCryptoMode.SetSelected("Plain") + a.composeCryptoKey = widget.NewMultiLineEntry() + a.composeCryptoKey.SetPlaceHolder("age recipient (native X25519 or SSH Ed25519/RSA)") + a.composeCryptoKey.Wrapping = fyne.TextWrapOff + a.composeCryptoSigningKey = widget.NewMultiLineEntry() + a.composeCryptoSigningKey.SetPlaceHolder("Ed25519 private key, base64 or hexadecimal") + a.composeCryptoSigningKey.Wrapping = fyne.TextWrapOff + a.composeCryptoKeyURL = widget.NewEntry() + a.composeCryptoKeyURL.SetPlaceHolder("Optional HTTPS URL for the public key") + post := widget.NewButtonWithIcon("Post article", theme.MailSendIcon(), a.postArticle) + form := widget.NewForm( + widget.NewFormItem("Newsgroups", a.composeGroups), + widget.NewFormItem("Delivery", a.composeDelivery), + widget.NewFormItem("mail2news recipient", a.composeMail2News), + widget.NewFormItem("From", a.composeFrom), + widget.NewFormItem("Subject", a.composeSubject), + widget.NewFormItem("References", a.composeReferences), + widget.NewFormItem("Followup-To", a.composeFollowupTo), + widget.NewFormItem("Crypto mode", a.composeCryptoMode), + widget.NewFormItem("Age recipient", a.composeCryptoKey), + widget.NewFormItem("Ed25519 signing key", a.composeCryptoSigningKey), + widget.NewFormItem("Public-key URL", a.composeCryptoKeyURL), + ) + return container.NewBorder(form, post, nil, nil, a.composeBody) +} + +func (a *application) buildProfile() fyne.CanvasObject { + a.vfaceUsernameEntry = widget.NewEntry() + a.vfaceUsernameEntry.SetPlaceHolder("Pseudonymous username") + a.vfaceEmailEntry = widget.NewEntry() + a.vfaceEmailEntry.SetPlaceHolder("Pseudonymous email address") + a.vfacePasswordEntry = widget.NewPasswordEntry() + a.vfacePasswordEntry.SetPlaceHolder("Vault password, minimum 12 characters") + a.vfaceConfirmEntry = widget.NewPasswordEntry() + a.vfaceConfirmEntry.SetPlaceHolder("Repeat vault password") + a.vfaceStatus = widget.NewLabel("No VFace identity loaded. VFace is optional.") + a.vfaceStatus.Wrapping = fyne.TextWrapWord + a.vfaceHash = widget.NewLabel("") + a.vfaceHash.Wrapping = fyne.TextWrapWord + a.vfaceImage = canvas.NewImageFromImage(image.NewRGBA(image.Rect(0, 0, 48, 48))) + a.vfaceImage.FillMode = canvas.ImageFillContain + a.vfaceImage.SetMinSize(fyne.NewSize(96, 96)) + a.vfaceImage.Hide() + path, err := vfaceprofile.DefaultPath() + if err == nil { + a.vfaceVaultPath = path + } + pathLabel := widget.NewLabel("Vault: " + a.vfaceVaultPath) + pathLabel.Wrapping = fyne.TextWrapBreak + create := widget.NewButton("Create or replace VFace identity", a.createVFaceProfile) + load := widget.NewButton("Load VFace identity", a.loadVFaceProfile) + lock := widget.NewButton("Lock identity", a.lockVFaceProfile) + form := widget.NewForm( + widget.NewFormItem("VFace username", a.vfaceUsernameEntry), + widget.NewFormItem("VFace email", a.vfaceEmailEntry), + widget.NewFormItem("Vault password", a.vfacePasswordEntry), + widget.NewFormItem("Confirm password", a.vfaceConfirmEntry), + ) + provider := widget.NewLabel("Crypto providers: age for software encryption. OpenPGP is available only through a YubiKey integration and is not exposed as a standalone format.") + provider.Wrapping = fyne.TextWrapWord + return container.NewVScroll(container.NewVBox( + widget.NewLabelWithStyle("Optional pseudonymous identity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + form, + container.NewHBox(create, load, lock), + pathLabel, + a.vfaceStatus, + a.vfaceImage, + a.vfaceHash, + provider, + )) +} + +func (a *application) createVFaceProfile() { + password := a.vfacePasswordEntry.Text + if password != a.vfaceConfirmEntry.Text { + dialog.ShowError(errors.New("VFace passwords do not match"), a.window) + return + } + value, err := vfaceprofile.Generate(a.vfaceUsernameEntry.Text, a.vfaceEmailEntry.Text) + if err != nil { + dialog.ShowError(err, a.window) + return + } + if a.vfaceVaultPath == "" { + dialog.ShowError(errors.New("VFace vault path is unavailable"), a.window) + return + } + a.setBusy(true, "Creating encrypted VFace vault...") + go func() { + saveErr := vfaceprofile.Save(a.vfaceVaultPath, value, password) + fyne.Do(func() { + a.setBusy(false, "") + if saveErr != nil { + dialog.ShowError(saveErr, a.window) + return + } + a.vfaceProfile = &value + a.vfaceConfirmEntry.SetText("") + a.renderVFaceProfile(value) + a.updateComposeIdentity() + a.vfaceStatus.SetText("VFace identity created and encrypted on disk.") + }) + }() +} + +func (a *application) loadVFaceProfile() { + if a.vfaceVaultPath == "" { + dialog.ShowError(errors.New("VFace vault path is unavailable"), a.window) + return + } + password := a.vfacePasswordEntry.Text + a.setBusy(true, "Loading encrypted VFace identity...") + go func() { + value, err := vfaceprofile.Load(a.vfaceVaultPath, password) + fyne.Do(func() { + a.setBusy(false, "") + if err != nil { + dialog.ShowError(err, a.window) + return + } + a.vfaceProfile = &value + a.vfaceUsernameEntry.SetText(value.Username) + a.vfaceEmailEntry.SetText(value.Email) + a.renderVFaceProfile(value) + a.updateComposeIdentity() + a.vfaceStatus.SetText("VFace identity loaded from encrypted disk vault.") + }) + }() +} + +func (a *application) lockVFaceProfile() { + a.vfaceProfile = nil + if a.vfaceImage != nil { + a.vfaceImage.Hide() + } + if a.vfaceStatus != nil { + a.vfaceStatus.SetText("VFace identity locked. VFace is optional.") + } + a.updateComposeIdentity() +} + +func (a *application) renderVFaceProfile(value vfaceprofile.Profile) { + profile, err := identity.GenerateVFace(value.Username, value.Email, value.PublicKey) + if err != nil { + a.vfaceStatus.SetText("VFace profile unavailable: " + err.Error()) + return + } + preview, err := identity.DecodeFacePNG(profile.FaceBase64) + if err == nil { + a.vfaceImage.Image = preview + a.vfaceImage.Show() + a.vfaceImage.Refresh() + } + a.vfaceHash.SetText("Identity SHA-256: " + profile.IdentityHash + "\nPNG SHA-256: " + profile.PNGHash) +} + +func (a *application) updateComposeIdentity() { + if a.composeFrom == nil { + return + } + if a.vfaceProfile != nil { + a.composeFrom.SetText(formatFrom(a.vfaceProfile.Username, a.vfaceProfile.Email)) + } else { + a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email)) + } +} + +func (a *application) buildCrypto() fyne.CanvasObject { + a.cryptoAlgorithm = widget.NewSelect([]string{"OpenPGP", "age", "Ed25519", "YubiCrypt"}, nil) + a.cryptoAlgorithm.SetSelected("OpenPGP") + a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify", "Encrypt", "Decrypt"}, nil) + a.cryptoOperation.SetSelected("Sign") + a.cryptoMessage = widget.NewMultiLineEntry() + a.cryptoMessage.SetPlaceHolder("Message or ciphertext") + a.cryptoMessage.Wrapping = fyne.TextWrapOff + a.cryptoPrimary = widget.NewMultiLineEntry() + a.cryptoPrimary.SetPlaceHolder("Key material supplied by you") + a.cryptoPrimary.Wrapping = fyne.TextWrapOff + a.cryptoSecret = widget.NewPasswordEntry() + a.cryptoSecret.SetPlaceHolder("YubiKey PIV PIN, session only") + a.cryptoSecondary = widget.NewMultiLineEntry() + a.cryptoSecondary.SetPlaceHolder("Optional second key or signature") + a.cryptoSecondary.Wrapping = fyne.TextWrapOff + a.cryptoPrimaryLabel = widget.NewLabel("Private key / identity") + a.cryptoSecretLabel = widget.NewLabel("Secret") + a.cryptoSecondaryLabel = widget.NewLabel("Public key / recipient") + a.cryptoOutput = widget.NewLabel("No result yet.") + a.cryptoOutput.Selectable = true + a.cryptoOutput.Wrapping = fyne.TextWrapOff + a.cryptoOutput.TextStyle = fyne.TextStyle{Monospace: true} + a.cryptoAlgorithm.OnChanged = func(string) { a.refreshCryptoFields() } + a.cryptoOperation.OnChanged = func(string) { a.refreshCryptoFields() } + run := widget.NewButtonWithIcon("Run operation", theme.MediaPlayIcon(), a.runCryptoOperation) + clear := widget.NewButtonWithIcon("Clear", theme.DeleteIcon(), func() { + a.cryptoMessage.SetText("") + a.cryptoPrimary.SetText("") + a.cryptoSecret.SetText("") + a.cryptoSecondary.SetText("") + a.cryptoOutput.SetText("No result yet.") + }) + note := widget.NewLabel("Aegis does not generate or save keys. Key fields are used only for this session. YubiCrypt requires the optional yubicrypt executable, a YubiKey, pcscd and the PIV PIN.") + note.Wrapping = fyne.TextWrapWord + form := widget.NewForm( + widget.NewFormItem("Format", a.cryptoAlgorithm), + widget.NewFormItem("Operation", a.cryptoOperation), + ) + messageBox := container.NewVBox(widget.NewLabel("Message / ciphertext"), a.cryptoMessage) + a.cryptoPrimaryBox = container.NewVBox(a.cryptoPrimaryLabel, a.cryptoPrimary) + a.cryptoSecretBox = container.NewVBox(a.cryptoSecretLabel, a.cryptoSecret) + a.cryptoSecondaryBox = container.NewVBox(a.cryptoSecondaryLabel, a.cryptoSecondary) + a.refreshCryptoFields() + keys := container.NewVBox(a.cryptoPrimaryBox, a.cryptoSecretBox, a.cryptoSecondaryBox) + input := container.NewVSplit(messageBox, keys) + input.SetOffset(0.42) + result := container.NewBorder(widget.NewLabel("Result, selectable for copy"), nil, nil, nil, container.NewVScroll(a.cryptoOutput)) + main := container.NewVSplit(container.NewVSplit(form, input), result) + main.SetOffset(0.34) + return container.NewBorder(nil, container.NewVBox(note, container.NewHBox(run, clear)), nil, nil, main) +} + +func (a *application) refreshCryptoFields() { + if a.cryptoAlgorithm == nil || a.cryptoOperation == nil || a.cryptoPrimaryBox == nil || a.cryptoSecretBox == nil || a.cryptoSecondaryBox == nil { + return + } + algorithm := a.cryptoAlgorithm.Selected + operation := a.cryptoOperation.Selected + a.cryptoPrimaryBox.Show() + a.cryptoSecretBox.Hide() + a.cryptoSecondaryBox.Show() + switch operation { + case "Verify": + if algorithm == "YubiCrypt" { + a.cryptoPrimaryBox.Hide() + a.cryptoSecondaryBox.Hide() + } else { + a.cryptoPrimaryLabel.SetText("Signature") + a.cryptoSecondaryLabel.SetText("Public key") + } + case "Encrypt": + if algorithm == "OpenPGP" { + a.cryptoPrimaryLabel.SetText("Recipient public key") + a.cryptoSecondaryLabel.SetText("Optional signer private key") + } else if algorithm == "YubiCrypt" { + a.cryptoPrimaryLabel.SetText("RSA recipient certificate/key (PEM)") + a.cryptoSecondaryBox.Hide() + } else { + a.cryptoPrimaryLabel.SetText("Recipient key") + a.cryptoSecondaryLabel.SetText("Not used") + } + case "Decrypt": + if algorithm == "YubiCrypt" { + a.cryptoPrimaryBox.Hide() + a.cryptoSecretBox.Show() + a.cryptoSecretLabel.SetText("YubiKey PIV PIN") + a.cryptoSecondaryBox.Hide() + } else { + a.cryptoPrimaryLabel.SetText("Private key / identity") + a.cryptoSecondaryLabel.SetText("Not used") + } + default: + if algorithm == "YubiCrypt" { + a.cryptoPrimaryBox.Hide() + a.cryptoSecretBox.Show() + a.cryptoSecretLabel.SetText("YubiKey PIV PIN") + a.cryptoSecondaryBox.Hide() + } else { + a.cryptoPrimaryLabel.SetText("Private key") + a.cryptoSecondaryLabel.SetText("Not used") + } + } + a.cryptoSecondary.Disable() + if operation == "Verify" || (operation == "Encrypt" && algorithm == "OpenPGP") { + a.cryptoSecondary.Enable() + } + a.cryptoPrimaryBox.Refresh() + a.cryptoSecretBox.Refresh() + a.cryptoSecondaryBox.Refresh() +} + +func (a *application) runCryptoOperation() { + algorithm := a.cryptoAlgorithm.Selected + operation := a.cryptoOperation.Selected + message := []byte(a.cryptoMessage.Text) + primary := a.cryptoPrimary.Text + secret := a.cryptoSecret.Text + secondary := a.cryptoSecondary.Text + if len(strings.TrimSpace(string(message))) == 0 { + dialog.ShowError(errors.New("message or ciphertext is required"), a.window) + return + } + if algorithm == "YubiCrypt" && operation != "Verify" && operation != "Encrypt" && strings.TrimSpace(secret) == "" { + dialog.ShowError(errors.New("YubiKey PIV PIN is required"), a.window) + return + } + if algorithm == "YubiCrypt" && operation == "Encrypt" && strings.TrimSpace(primary) == "" { + dialog.ShowError(errors.New("RSA recipient certificate/key is required"), a.window) + return + } + if algorithm != "YubiCrypt" && strings.TrimSpace(primary) == "" { + dialog.ShowError(errors.New("primary key material is required"), a.window) + return + } + a.setBusy(true, "Running "+algorithm+" "+operation+"...") + go func() { + var result string + var err error + switch algorithm { + case "OpenPGP": + switch operation { + case "Sign": + result, err = cryptokit.SignOpenPGPDetached(message, primary) + case "Verify": + err = cryptokit.VerifyOpenPGPDetached(message, primary, secondary) + result = "OpenPGP signature verified." + case "Encrypt": + result, err = cryptokit.EncryptOpenPGP(message, primary, secondary) + case "Decrypt": + var plaintext []byte + plaintext, err = cryptokit.DecryptOpenPGP(message, primary) + result = string(plaintext) + } + case "age": + switch operation { + case "Encrypt": + var ciphertext []byte + ciphertext, err = cryptokit.EncryptAge(message, primary) + result = string(ciphertext) + case "Decrypt": + var plaintext []byte + plaintext, err = cryptokit.DecryptAge(message, primary) + result = string(plaintext) + default: + err = errors.New("age supports Encrypt and Decrypt") + } + case "Ed25519": + switch operation { + case "Sign": + result, err = cryptokit.SignEd25519(message, primary) + case "Verify": + err = cryptokit.VerifyEd25519(message, primary, secondary) + result = "Ed25519 signature verified." + default: + err = errors.New("raw Ed25519 supports Sign and Verify; use age SSH keys for encryption") + } + case "YubiCrypt": + switch operation { + case "Sign": + var signed []byte + signed, err = cryptokit.SignYubiCrypt(message, secret) + result = string(signed) + case "Verify": + var verified []byte + verified, err = cryptokit.VerifyYubiCrypt(message) + result = string(verified) + case "Encrypt": + var ciphertext []byte + ciphertext, err = cryptokit.EncryptYubiCrypt(message, primary) + result = string(ciphertext) + case "Decrypt": + var plaintext []byte + plaintext, err = cryptokit.DecryptYubiCrypt(message, secret) + result = string(plaintext) + } + } + fyne.Do(func() { + a.setBusy(false, "Cryptography operation completed.") + if err != nil { + dialog.ShowError(err, a.window) + return + } + a.cryptoOutput.SetText(result) + }) + }() +} + +func (a *application) buildSettings() fyne.CanvasObject { + a.hostEntry = widget.NewEntry() + a.hostEntry.SetText(a.settings.Host) + a.portEntry = widget.NewEntry() + a.portEntry.SetText(a.settings.Port) + a.tlsCheck = widget.NewCheck("Use TLS", nil) + a.tlsCheck.SetChecked(a.settings.UseTLS) + a.startTLSCheck = widget.NewCheck("Use STARTTLS", nil) + a.startTLSCheck.SetChecked(a.settings.StartTLS) + a.tlsSkipVerify = widget.NewCheck("Do not verify the TLS certificate (unsafe, explicit opt-in)", nil) + a.tlsSkipVerify.SetChecked(a.settings.SkipTLSVerify) + a.usernameEntry = widget.NewEntry() + a.usernameEntry.SetText(a.settings.Username) + a.passwordEntry = widget.NewPasswordEntry() + a.passwordEntry.SetPlaceHolder("Session only, never saved") + a.saslSelect = widget.NewSelect([]string{"None", "PLAIN"}, nil) + if a.settings.SASLMechanism == "PLAIN" { + a.saslSelect.SetSelected("PLAIN") + } else { + a.saslSelect.SetSelected("None") + } + a.compressionCheck = widget.NewCheck("Use COMPRESS DEFLATE when advertised", nil) + a.compressionCheck.SetChecked(a.settings.UseCompression) + a.proxySelect = widget.NewSelect([]string{"DIRECT", "SOCKS5"}, nil) + a.proxySelect.SetSelected(a.settings.ProxyType) + a.proxyEntry = widget.NewEntry() + a.proxyEntry.SetText(a.settings.ProxyAddress) + a.smtpHostEntry = widget.NewEntry() + a.smtpHostEntry.SetText(a.settings.SMTPHost) + a.smtpPortEntry = widget.NewEntry() + a.smtpPortEntry.SetText(a.settings.SMTPPort) + a.smtpModeSelect = widget.NewSelect([]string{"Cleartext (no TLS)", "TLS", "STARTTLS"}, nil) + if a.settings.SMTPMode == "TLS" || a.settings.SMTPMode == "STARTTLS" { + a.smtpModeSelect.SetSelected(a.settings.SMTPMode) + } else { + a.smtpModeSelect.SetSelected("Cleartext (no TLS)") + } + a.smtpUserEntry = widget.NewEntry() + a.smtpUserEntry.SetText(a.settings.SMTPUsername) + a.smtpEmailEntry = widget.NewEntry() + a.smtpEmailEntry.SetText(a.settings.SMTPEmail) + a.smtpRecipientEntry = widget.NewEntry() + a.smtpRecipientEntry.SetText(a.settings.SMTPRecipient) + a.smtpPasswordEntry = widget.NewPasswordEntry() + a.smtpPasswordEntry.SetPlaceHolder("Session only, never saved") + a.smtpSkipVerify = widget.NewCheck("Do not verify SMTP TLS certificate (unsafe)", nil) + a.smtpSkipVerify.SetChecked(a.settings.SMTPSkipVerify) + a.displayEntry = widget.NewEntry() + a.displayEntry.SetText(a.settings.DisplayName) + a.emailEntry = widget.NewEntry() + a.emailEntry.SetText(a.settings.Email) + a.filterField = widget.NewSelect([]string{"subject", "from", "newsgroups", "message-id", "references", "date", "body", "header", "vface-hash", "read", "any"}, nil) + a.filterField.SetSelected("subject") + a.filterOperator = widget.NewSelect([]string{"contains", "exact", "regexp", "glob"}, nil) + a.filterOperator.SetSelected("contains") + a.filterPattern = widget.NewEntry() + a.filterPattern.SetPlaceHolder("es. [spam], example.org, ") + a.filterAction = widget.NewSelect([]string{"hide", "mark-read", "highlight", "tag", "mute-thread", "keep"}, nil) + a.filterAction.SetSelected("hide") + a.filterTag = widget.NewEntry() + a.filterTag.SetPlaceHolder("Tag, se l'azione è tag") + save := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), a.saveSettings) + form := widget.NewForm( + widget.NewFormItem("NNTP host", a.hostEntry), + widget.NewFormItem("Port", a.portEntry), + widget.NewFormItem("Transport", container.NewVBox(a.tlsCheck, a.startTLSCheck, a.tlsSkipVerify, a.compressionCheck)), + widget.NewFormItem("NNTP username", a.usernameEntry), + widget.NewFormItem("NNTP password", a.passwordEntry), + widget.NewFormItem("SASL", a.saslSelect), + widget.NewFormItem("Proxy", a.proxySelect), + widget.NewFormItem("SOCKS5 address", a.proxyEntry), + widget.NewFormItem("SMTP mail2news host", a.smtpHostEntry), + widget.NewFormItem("SMTP port", a.smtpPortEntry), + widget.NewFormItem("SMTP transport", a.smtpModeSelect), + widget.NewFormItem("SMTP username", a.smtpUserEntry), + widget.NewFormItem("SMTP email", a.smtpEmailEntry), + widget.NewFormItem("mail2news recipient", a.smtpRecipientEntry), + widget.NewFormItem("SMTP password", a.smtpPasswordEntry), + widget.NewFormItem("SMTP TLS", a.smtpSkipVerify), + widget.NewFormItem("NNTP display name", a.displayEntry), + widget.NewFormItem("NNTP email", a.emailEntry), + ) + filters := widget.NewButton("Filters", a.showFilterEditor) + content := container.NewVBox(form, filters, save) + return container.NewVScroll(content) +} + +func (a *application) readSettingsForm() config.Settings { + settings := a.settings + settings.Host = strings.TrimSpace(a.hostEntry.Text) + settings.Port = strings.TrimSpace(a.portEntry.Text) + settings.UseTLS = a.tlsCheck.Checked + settings.StartTLS = a.startTLSCheck.Checked + settings.SkipTLSVerify = a.tlsSkipVerify.Checked + settings.Username = strings.TrimSpace(a.usernameEntry.Text) + if a.saslSelect.Selected == "PLAIN" { + settings.SASLMechanism = "PLAIN" + } else { + settings.SASLMechanism = "" + } + settings.UseCompression = a.compressionCheck.Checked + settings.ProxyType = a.proxySelect.Selected + settings.ProxyAddress = strings.TrimSpace(a.proxyEntry.Text) + settings.SMTPHost = strings.TrimSpace(a.smtpHostEntry.Text) + settings.SMTPPort = strings.TrimSpace(a.smtpPortEntry.Text) + if a.smtpModeSelect.Selected == "TLS" || a.smtpModeSelect.Selected == "STARTTLS" { + settings.SMTPMode = a.smtpModeSelect.Selected + } else { + settings.SMTPMode = "" + } + settings.SMTPUsername = strings.TrimSpace(a.smtpUserEntry.Text) + settings.SMTPEmail = strings.TrimSpace(a.smtpEmailEntry.Text) + settings.SMTPRecipient = strings.TrimSpace(a.smtpRecipientEntry.Text) + settings.SMTPSkipVerify = a.smtpSkipVerify.Checked + settings.DisplayName = strings.TrimSpace(a.displayEntry.Text) + settings.Email = strings.TrimSpace(a.emailEntry.Text) + return settings +} + +func (a *application) saveSettings() { + settings := a.readSettingsForm() + if err := settings.Validate(); err != nil { + dialog.ShowError(err, a.window) + return + } + if a.configPath == "" { + dialog.ShowError(errors.New("configuration path is unavailable"), a.window) + return + } + if err := config.Save(a.configPath, settings); err != nil { + dialog.ShowError(err, a.window) + return + } + a.settings = settings + a.updateComposeIdentity() + a.status.SetText("Settings saved. Password retained only for this session.") +} + +func (a *application) addFilterRule() { + if a.state == nil { + dialog.ShowError(errors.New("local state is unavailable"), a.window) + return + } + rule := filter.Rule{ + ID: fmt.Sprintf("rule-%d", time.Now().UnixNano()), Enabled: true, + Field: filter.Field(a.filterField.Selected), Operator: filter.Operator(a.filterOperator.Selected), + Pattern: strings.TrimSpace(a.filterPattern.Text), Action: filter.Action(a.filterAction.Selected), Tag: strings.TrimSpace(a.filterTag.Text), + } + if _, err := filter.Evaluate(filter.Article{Headers: map[string]string{}}, []filter.Rule{rule}); err != nil { + dialog.ShowError(err, a.window) + return + } + a.filterRules = append(a.filterRules, rule) + a.state.SetFilters(a.filterRules) + if err := a.state.Save(); err != nil { + dialog.ShowError(err, a.window) + return + } + a.filterPattern.SetText("") + a.filterTag.SetText("") + a.filterHeaders() + a.status.SetText("Filtro locale aggiunto.") +} + +func (a *application) showFilterEditor() { + form := widget.NewForm( + widget.NewFormItem("Field", a.filterField), + widget.NewFormItem("Operator", a.filterOperator), + widget.NewFormItem("Pattern", a.filterPattern), + widget.NewFormItem("Action", a.filterAction), + widget.NewFormItem("Tag", a.filterTag), + ) + note := widget.NewLabel("Local filter only. It never deletes or rewrites server articles.") + note.Wrapping = fyne.TextWrapWord + content := container.NewVBox(note, form) + dialog.ShowCustomConfirm("Filters", "Add filter", "Close", content, func(confirmed bool) { + if confirmed { + a.addFilterRule() + } + }, a.window) +} + +func (a *application) connectServer() { + settings := a.readSettingsForm() + if err := settings.Validate(); err != nil { + dialog.ShowError(err, a.window) + return + } + password := a.passwordEntry.Text + a.settings = settings + a.setBusy(true, "Connecting securely to "+settings.Host+"...") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) + defer cancel() + client, err := nntp.Dial(ctx, nntp.DialConfig{ + Host: settings.Host, + Port: settings.Port, + UseTLS: settings.UseTLS, + StartTLS: settings.StartTLS, + InsecureSkipVerify: settings.SkipTLSVerify, + Username: settings.Username, + Password: password, + SASLMechanism: settings.SASLMechanism, + UseCompression: settings.UseCompression, + ProxyType: settings.ProxyType, + ProxyAddress: settings.ProxyAddress, + }) + if err != nil { + a.asyncError("Connection failed", err) + return + } + groups, err := client.ListActive() + if err != nil { + client.Close() + a.asyncError("Connected, but LIST ACTIVE failed", err) + return + } + a.replaceClient(client) + fyne.Do(func() { + a.groups = groups + a.filterGroups() + a.connect.Disable() + a.disconnect.Enable() + a.refresh.Enable() + a.setBusy(false, fmt.Sprintf("Connected. %s available newsgroups loaded.", formatCount(int64(len(groups))))) + }) + }() +} + +func (a *application) disconnectServer() { + a.closeClient() + a.groups = nil + a.visibleGroups = nil + a.headers = nil + a.visibleHeader = nil + a.articleText = "" + a.selectedGroup = "" + a.loadedGroup = "" + a.groupList.Refresh() + a.headerList.Refresh() + a.body.SetText("") + a.articleHeaders.SetText("No article selected.") + a.connect.Enable() + a.disconnect.Disable() + a.refresh.Disable() + a.status.SetText("Disconnected.") +} + +func (a *application) refreshGroups() { + client := a.currentClient() + if client == nil { + dialog.ShowError(errors.New("connect to a server first"), a.window) + return + } + a.setBusy(true, "Refreshing available newsgroups...") + go func() { + groups, err := client.ListActive() + if err != nil { + a.asyncError("Cannot refresh groups", err) + return + } + fyne.Do(func() { + a.groups = groups + a.filterGroups() + a.setBusy(false, fmt.Sprintf("%s available newsgroups loaded.", formatCount(int64(len(groups))))) + }) + }() +} + +func (a *application) subscribeSelected() { + if a.selectedGroup == "" { + dialog.ShowError(errors.New("select a newsgroup first"), a.window) + return + } + if !a.isSubscribed(a.selectedGroup) { + a.settings.Subscriptions = append(a.settings.Subscriptions, a.selectedGroup) + sort.Strings(a.settings.Subscriptions) + if err := a.persistSubscriptions(); err != nil { + dialog.ShowError(err, a.window) + return + } + } + a.composeGroups.SetText(a.selectedGroup) + a.groupList.Refresh() + a.status.SetText("Subscribed to " + a.selectedGroup + ".") +} + +func (a *application) unsubscribeSelected() { + if a.selectedGroup == "" { + dialog.ShowError(errors.New("select a newsgroup first"), a.window) + return + } + groups := a.settings.Subscriptions[:0] + for _, group := range a.settings.Subscriptions { + if group != a.selectedGroup { + groups = append(groups, group) + } + } + a.settings.Subscriptions = groups + if err := a.persistSubscriptions(); err != nil { + dialog.ShowError(err, a.window) + return + } + a.groupList.Refresh() + a.status.SetText("Unsubscribed from " + a.selectedGroup + ".") +} + +func (a *application) persistSubscriptions() error { + if a.configPath == "" { + return errors.New("configuration path is unavailable") + } + settings := a.readSettingsForm() + settings.Subscriptions = append([]string(nil), a.settings.Subscriptions...) + if err := config.Save(a.configPath, settings); err != nil { + return err + } + a.settings = settings + return nil +} + +func (a *application) loadSelectedGroup() { + group := a.selectedGroup + if group == "" { + dialog.ShowError(errors.New("select a newsgroup first"), a.window) + return + } + if !a.isSubscribed(group) { + dialog.ShowError(errors.New("subscribe to the newsgroup before loading its articles"), a.window) + return + } + client := a.currentClient() + if client == nil { + dialog.ShowError(errors.New("connect to a server first"), a.window) + return + } + a.setBusy(true, "Loading recent headers from "+group+"...") + go func() { + status, headers, err := client.LatestOverview(group, overviewLimit) + if err != nil { + a.asyncError("Cannot load article overview", err) + return + } + fyne.Do(func() { + a.loadedGroup = group + a.headers = headers + if a.state != nil { + for _, header := range headers { + _ = a.state.UpsertArticle(store.Article{Key: articleKey(group, header), Group: group, Number: header.Number, Subject: header.Subject, From: header.From, MessageID: header.MessageID}) + } + _ = a.state.Save() + } + a.filterHeaders() + a.body.SetText("") + a.composeGroups.SetText(group) + a.setBusy(false, fmt.Sprintf("%s: loaded %d recent headers, server reports %d articles.", group, len(headers), status.Count)) + }) + }() +} + +func (a *application) loadArticle(group string, header nntp.ArticleHeader) { + client := a.currentClient() + if client == nil || group == "" { + return + } + a.setBusy(true, fmt.Sprintf("Downloading article %d from %s...", header.Number, group)) + go func() { + article, err := client.ArticleInGroup(group, header.Number) + if err != nil { + a.asyncError("Cannot download article", err) + return + } + fyne.Do(func() { + a.body.SetText(article) + a.articleText = article + if a.state != nil { + key := articleKey(group, header) + _ = a.state.UpsertArticle(store.Article{Key: key, Group: group, Number: header.Number, Subject: header.Subject, From: header.From, MessageID: header.MessageID, Raw: article}) + _ = a.state.SetRead(key, true) + _ = a.state.Save() + } + a.refreshArticleHeaders() + a.refreshArticleActions() + a.headerList.Refresh() + a.setBusy(false, fmt.Sprintf("Article %d downloaded from %s.", header.Number, group)) + }) + }() +} + +func (a *application) replyToSelected() { + if !a.hasSelectedHeader || strings.TrimSpace(a.articleText) == "" { + return + } + message, err := mail.ReadMessage(strings.NewReader(a.articleText)) + if err != nil { + dialog.ShowError(fmt.Errorf("cannot parse selected article: %w", err), a.window) + return + } + body, err := io.ReadAll(io.LimitReader(message.Body, 2<<20)) + if err != nil { + dialog.ShowError(fmt.Errorf("cannot read selected article: %w", err), a.window) + return + } + groups := strings.TrimSpace(message.Header.Get("Followup-To")) + if groups == "" || strings.EqualFold(groups, "poster") { + groups = strings.TrimSpace(message.Header.Get("Newsgroups")) + } + a.composeGroups.SetText(groups) + subject := strings.TrimSpace(message.Header.Get("Subject")) + if !strings.HasPrefix(strings.ToLower(subject), "re:") { + subject = "Re: " + subject + } + a.composeSubject.SetText(subject) + references := strings.TrimSpace(message.Header.Get("References")) + messageID := strings.TrimSpace(message.Header.Get("Message-ID")) + if messageID != "" { + if references != "" { + references += " " + } + references += messageID + } + a.composeReferences.SetText(references) + a.composeFollowupTo.SetText(strings.TrimSpace(message.Header.Get("Followup-To"))) + a.composeBody.SetText(quoteBody(string(body), message.Header.Get("From"))) + a.status.SetText("Reply preparata con quoting e References. Controlla Followup-To prima dell'invio.") +} + +func quoteBody(body, from string) string { + body = normalizeCRLF(body) + lines := strings.Split(strings.TrimSuffix(body, "\r\n"), "\r\n") + var builder strings.Builder + if strings.TrimSpace(from) != "" { + builder.WriteString("On behalf of ") + builder.WriteString(strings.TrimSpace(from)) + builder.WriteString(" wrote:\r\n") + } + for _, line := range lines { + builder.WriteString("> ") + builder.WriteString(line) + builder.WriteString("\r\n") + } + return builder.String() +} + +func (a *application) refreshArticleHeaders() { + if a.articleHeaders == nil { + return + } + text := formatArticleHeaders(a.articleText, a.showAllHeaders != nil && a.showAllHeaders.Checked) + verification := identity.VerifyArticle(a.articleText) + if verification.SignaturePresent || verification.PublicKey != "" { + status := "VFace invalid" + if verification.VFaceValid { + status = "VFace valid" + } + if verification.SignaturePresent { + if verification.SignatureValid { + status += "; Ed25519 signature valid" + } else { + status += "; Ed25519 signature invalid" + } + } + text += "\n\nVerification: " + status + if verification.Error != nil { + text += " (" + verification.Error.Error() + ")" + } + } + a.articleHeaders.SetText(text) +} + +func formatArticleHeaders(article string, showAll bool) string { + if strings.TrimSpace(article) == "" { + return "No article selected." + } + if showAll { + raw := articleHeaderBlock(article) + if raw != "" { + return raw + } + } + message, err := mail.ReadMessage(strings.NewReader(article)) + if err != nil { + raw := articleHeaderBlock(article) + if raw == "" { + return "The article headers could not be parsed." + } + return raw + } + important := []string{ + "From", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To", + "Reply-To", "Organization", "User-Agent", "MIME-Version", "Content-Type", + "Content-Transfer-Encoding", "Face", "OpenPGP", "X-OpenPGP", "X-Signature", + "X-Aegis-Crypto-Version", "X-Aegis-Encryption", "X-Aegis-Signature", + "X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint", "X-Aegis-Public-Key-URL", + "X-VFace-Version", "X-Ed25519-Pub", "X-Ed25519-Sig", "Identity-Hash", + "X-VFace-Hash", "X-VFace-PNG-SHA256", "X-VFace-Verify", + } + var lines []string + for _, name := range important { + if value := strings.TrimSpace(message.Header.Get(name)); value != "" { + lines = append(lines, name+": "+value) + } + } + if len(lines) == 0 { + return "No recognized headers in this article." + } + return strings.Join(lines, "\n") +} + +func articleHeaderBlock(article string) string { + article = strings.ReplaceAll(article, "\r\n", "\n") + article = strings.ReplaceAll(article, "\r", "\n") + if separator := strings.Index(article, "\n\n"); separator >= 0 { + return strings.TrimSpace(article[:separator]) + } + return "" +} + +func (a *application) postArticle() { + delivery := a.composeDelivery.Selected + settings := a.readSettingsForm() + if settings.SMTPHost == "" { + delivery = "NNTP direct posting" + } + client := a.currentClient() + if delivery != "SMTP mail2news" && client == nil { + dialog.ShowError(errors.New("connect to a server first"), a.window) + return + } + if err := settings.Validate(); err != nil { + dialog.ShowError(err, a.window) + return + } + groups, err := normalizeGroups(a.composeGroups.Text) + if err != nil { + dialog.ShowError(err, a.window) + return + } + from := formatFrom(settings.DisplayName, settings.Email) + identityHeaders := []string(nil) + expectedPublicKey := "" + signingKey := strings.TrimSpace(a.composeCryptoSigningKey.Text) + if a.vfaceProfile != nil { + profile, profileErr := identity.GenerateVFace(a.vfaceProfile.Username, a.vfaceProfile.Email, a.vfaceProfile.PublicKey) + if profileErr != nil { + dialog.ShowError(fmt.Errorf("invalid loaded VFace identity: %w", profileErr), a.window) + return + } + from = formatFrom(a.vfaceProfile.Username, a.vfaceProfile.Email) + identityHeaders = profile.Headers() + expectedPublicKey = profile.PublicKey + if signingKey == "" { + signingKey = a.vfaceProfile.PrivateKey + } + } + subject := strings.TrimSpace(a.composeSubject.Text) + if from == "" || subject == "" || strings.ContainsAny(from+subject, "\r\n") { + dialog.ShowError(errors.New("From and Subject are required and must be one line"), a.window) + return + } + if _, err := mail.ParseAddress(from); err != nil { + dialog.ShowError(fmt.Errorf("invalid From address: %w", err), a.window) + return + } + var recipients []string + if delivery == "SMTP mail2news" { + recipient := strings.TrimSpace(a.composeMail2News.Text) + if recipient == "" { + recipient = strings.TrimSpace(settings.SMTPRecipient) + } + if recipient == "" { + dialog.ShowError(errors.New("mail2news recipient is required for SMTP delivery"), a.window) + return + } + if _, err := mail.ParseAddress(recipient); err != nil { + dialog.ShowError(fmt.Errorf("invalid mail2news recipient: %w", err), a.window) + return + } + recipients = []string{recipient} + } + article, err := buildArticleWithIdentityHeaders(groups, from, subject, a.composeBody.Text, identityHeaders, articleCryptoOptions{ + Mode: a.composeCryptoMode.Selected, + EncryptionKey: a.composeCryptoKey.Text, + SigningKey: signingKey, + PublicKeyURL: a.composeCryptoKeyURL.Text, + ExpectedPublicKey: expectedPublicKey, + References: strings.TrimSpace(a.composeReferences.Text), + FollowupTo: strings.TrimSpace(a.composeFollowupTo.Text), + }) + if err != nil { + dialog.ShowError(err, a.window) + return + } + a.setBusy(true, "Posting article...") + go func() { + var postErr error + if delivery == "SMTP mail2news" { + smtpFrom := strings.TrimSpace(settings.SMTPEmail) + if smtpFrom == "" { + smtpFrom = from + } + postErr = smtpclient.Send(smtpclient.Config{ + Host: settings.SMTPHost, Port: settings.SMTPPort, Mode: settings.SMTPMode, + Username: settings.SMTPUsername, Password: a.smtpPasswordEntry.Text, + InsecureSkipVerify: settings.SMTPSkipVerify, + ProxyType: settings.ProxyType, ProxyAddress: settings.ProxyAddress, + }, smtpFrom, recipients, []byte(article)) + } else { + postErr = client.Post(article) + } + if postErr != nil { + a.asyncError("Posting failed", postErr) + return + } + fyne.Do(func() { + a.composeSubject.SetText("") + a.composeBody.SetText("") + a.composeReferences.SetText("") + a.composeFollowupTo.SetText("") + a.composeMail2News.SetText(settings.SMTPRecipient) + a.composeCryptoKey.SetText("") + a.composeCryptoSigningKey.SetText("") + a.composeCryptoKeyURL.SetText("") + a.composeCryptoMode.SetSelected("Plain") + a.setBusy(false, "Article accepted by the NNTP server.") + dialog.ShowInformation("Article posted", "The NNTP server accepted the article.", a.window) + }) + }() +} + +func buildTextArticle(groups []string, from, subject, body string) (string, error) { + return buildTextArticleWithCrypto(groups, from, subject, body, articleCryptoOptions{Mode: "Plain"}) +} + +type articleCryptoOptions struct { + Mode string + EncryptionKey string + SigningKey string + PublicKeyURL string + ExpectedPublicKey string + References string + FollowupTo string +} + +func buildTextArticleWithCrypto(groups []string, from, subject, body string, options articleCryptoOptions) (string, error) { + face, err := identity.GenerateFace(from) + if err != nil { + return "", fmt.Errorf("generate Face header: %w", err) + } + return buildArticleWithIdentityHeaders(groups, from, subject, body, []string{identity.FormatFaceHeader(face)}, options) +} + +func buildTextArticleWithVFace(groups []string, from, subject, body string, profile identity.VFace, options articleCryptoOptions) (string, error) { + if profile.IdentityHash == "" { + return buildArticleWithIdentityHeaders(groups, from, subject, body, nil, options) + } + return buildArticleWithIdentityHeaders(groups, from, subject, body, profile.Headers(), options) +} + +func buildArticleWithIdentityHeaders(groups []string, from, subject, body string, identityHeaders []string, options articleCryptoOptions) (string, error) { + fromHeader, err := formatFromHeader(from) + if err != nil { + return "", err + } + if subject == "" || strings.ContainsAny(subject, "\r\n") { + return "", errors.New("Subject is required and must be one line") + } + if !utf8.ValidString(body) { + return "", errors.New("article body is not valid UTF-8") + } + messageID, err := generateMessageID() + if err != nil { + return "", err + } + body = strings.ReplaceAll(body, "\r\n", "\n") + body = strings.ReplaceAll(body, "\r", "\n") + body = strings.ReplaceAll(body, "\n", "\r\n") + mode := strings.TrimSpace(options.Mode) + if mode == "" { + mode = "Plain" + } + cryptoHeaders, contentType, contentTransferEncoding, wireBody, err := prepareArticleCrypto(body, mode, options) + if err != nil { + return "", err + } + headers := []string{ + "From: " + fromHeader, + "Message-ID: " + messageID, + "Newsgroups: " + strings.Join(groups, ","), + "Subject: " + mime.QEncoding.Encode("UTF-8", subject), + "Date: " + time.Now().Format(time.RFC1123Z), + "User-Agent: Aegis/0.1", + "MIME-Version: 1.0", + "Content-Type: " + contentType, + "Content-Transfer-Encoding: " + contentTransferEncoding, + } + if options.References != "" { + if strings.ContainsAny(options.References, "\r\n") { + return "", errors.New("References must not contain line breaks") + } + headers = append(headers, foldHeader("References", options.References)) + } + if options.FollowupTo != "" { + if !validFollowupTo(options.FollowupTo) { + return "", errors.New("Followup-To must be poster or valid newsgroup names") + } + headers = append(headers, foldHeader("Followup-To", options.FollowupTo)) + } + headers = append(headers, identityHeaders...) + headers = append(headers, cryptoHeaders...) + article := strings.Join([]string{ + strings.Join(headers, "\r\n"), + "", + wireBody, + }, "\r\n") + if err := nntp.ValidateArticle(article); err != nil { + return "", fmt.Errorf("article format is not Usenet-safe: %w", err) + } + return article, nil +} + +func validFollowupTo(value string) bool { + if strings.EqualFold(strings.TrimSpace(value), "poster") { + return true + } + _, err := normalizeGroups(value) + return err == nil +} + +func generateMessageID() (string, error) { + randomPart := make([]byte, 16) + if _, err := rand.Read(randomPart); err != nil { + return "", fmt.Errorf("generate Message-ID: %w", err) + } + return "<" + hex.EncodeToString(randomPart) + "@" + messageIDDomain + ">", nil +} + +func prepareArticleCrypto(body, mode string, options articleCryptoOptions) (headers []string, contentType, transferEncoding, wireBody string, err error) { + wireBody = body + contentType = "text/plain; charset=UTF-8" + transferEncoding = "8bit" + signingKey := strings.TrimSpace(options.SigningKey) + encryptionKey := strings.TrimSpace(options.EncryptionKey) + publicKeyURL := strings.TrimSpace(options.PublicKeyURL) + if publicKeyURL != "" { + parsed, parseErr := url.Parse(publicKeyURL) + if parseErr != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || strings.ContainsAny(publicKeyURL, "\r\n") { + return nil, "", "", "", errors.New("public-key URL must be a valid HTTPS URL without line breaks") + } + } + + switch mode { + case "Plain": + if signingKey != "" || encryptionKey != "" || publicKeyURL != "" { + return nil, "", "", "", errors.New("plain mode does not accept cryptographic key fields") + } + case "Sign with Ed25519": + if signingKey == "" { + return nil, "", "", "", errors.New("an Ed25519 private key is required for signing") + } + case "Encrypt with age": + if encryptionKey == "" { + return nil, "", "", "", errors.New("an age recipient is required for encryption") + } + ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey) + if encryptErr != nil { + return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr) + } + wireBody = normalizeCRLF(string(ciphertext)) + contentType = "application/vnd.aegis.age" + transferEncoding = "7bit" + case "Encrypt with age and sign": + if encryptionKey == "" || signingKey == "" { + return nil, "", "", "", errors.New("an age recipient and an Ed25519 private key are required") + } + ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey) + if encryptErr != nil { + return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr) + } + wireBody = normalizeCRLF(string(ciphertext)) + contentType = "application/vnd.aegis.age" + transferEncoding = "7bit" + default: + return nil, "", "", "", fmt.Errorf("unsupported article crypto mode %q", mode) + } + + if mode == "Sign with Ed25519" || mode == "Encrypt with age and sign" { + if signingKey == "" { + return nil, "", "", "", errors.New("an Ed25519 private key is required for signing") + } + signature, signErr := cryptokit.SignEd25519([]byte(wireBody), signingKey) + if signErr != nil { + return nil, "", "", "", fmt.Errorf("sign article body: %w", signErr) + } + publicKey, publicErr := cryptokit.Ed25519PublicKey(signingKey) + if publicErr != nil { + return nil, "", "", "", fmt.Errorf("derive Ed25519 public key: %w", publicErr) + } + fingerprint, fingerprintErr := cryptokit.Ed25519PublicKeyFingerprint(publicKey) + if fingerprintErr != nil { + return nil, "", "", "", fmt.Errorf("fingerprint Ed25519 public key: %w", fingerprintErr) + } + if options.ExpectedPublicKey != "" { + expectedKey, expectedErr := cryptokit.CanonicalEd25519PublicKey(options.ExpectedPublicKey) + if expectedErr != nil { + return nil, "", "", "", fmt.Errorf("canonicalize profile Ed25519 public key: %w", expectedErr) + } + if publicKey != expectedKey { + return nil, "", "", "", errors.New("signing key does not match the VFace profile public key") + } + } + headers = append(headers, + "X-Aegis-Crypto-Version: 1", + "X-Aegis-Signature: ed25519; "+signature, + "X-Aegis-Public-Key: "+publicKey, + "X-Ed25519-Sig: "+signature, + "X-Aegis-Key-Fingerprint: "+fingerprint, + ) + if publicKeyURL != "" { + headers = append(headers, foldHeader("X-Aegis-Public-Key-URL", publicKeyURL)) + } + } else if publicKeyURL != "" { + return nil, "", "", "", errors.New("public-key URL requires an Ed25519 signature mode") + } + if mode == "Encrypt with age" || mode == "Encrypt with age and sign" { + headers = append([]string{"X-Aegis-Crypto-Version: 1", "X-Aegis-Encryption: age"}, headers...) + } + return headers, contentType, transferEncoding, wireBody, nil +} + +func normalizeCRLF(value string) string { + value = strings.ReplaceAll(value, "\r\n", "\n") + value = strings.ReplaceAll(value, "\r", "\n") + return strings.ReplaceAll(value, "\n", "\r\n") +} + +func foldHeader(name, value string) string { + const maxHeaderValue = 76 + if len(name)+2+len(value) <= 998 { + return name + ": " + value + } + var out strings.Builder + out.WriteString(name) + out.WriteString(":") + for len(value) > 0 { + out.WriteString("\r\n ") + limit := maxHeaderValue + if len(value) < limit { + limit = len(value) + } + out.WriteString(value[:limit]) + value = value[limit:] + } + return out.String() +} + +func formatFromHeader(value string) (string, error) { + address, err := mail.ParseAddress(strings.TrimSpace(value)) + if err != nil { + return "", fmt.Errorf("invalid From address: %w", err) + } + if address.Name == "" { + return address.Address, nil + } + if isASCII(address.Name) { + return (&mail.Address{Name: address.Name, Address: address.Address}).String(), nil + } + return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">", nil +} + +func isASCII(value string) bool { + for i := 0; i < len(value); i++ { + if value[i] > 0x7f { + return false + } + } + return true +} + +func (a *application) filterGroups() { + query := strings.ToLower(strings.TrimSpace(a.groupSearch.Text)) + a.visibleGroups = a.visibleGroups[:0] + for _, group := range a.groups { + if query == "" || strings.Contains(strings.ToLower(group.Name), query) { + a.visibleGroups = append(a.visibleGroups, group) + } + } + if a.groupList != nil { + a.groupList.Refresh() + } +} + +func (a *application) filterHeaders() { + query := strings.ToLower(strings.TrimSpace(a.headerSearch.Text)) + a.visibleHeader = a.visibleHeader[:0] + for _, header := range a.headers { + if query != "" && !strings.Contains(strings.ToLower(header.Subject), query) && !strings.Contains(strings.ToLower(header.From), query) && !strings.Contains(strings.ToLower(header.MessageID), query) && !strings.Contains(strings.ToLower(header.References), query) { + continue + } + article := filter.Article{From: header.From, Subject: header.Subject, Newsgroups: a.loadedGroup, MessageID: header.MessageID, References: header.References, Read: a.headerRead(header)} + result, err := filter.Evaluate(article, a.filterRules) + if err != nil { + a.status.SetText("Filter error: " + err.Error()) + continue + } + if result.Hidden || result.MuteThread { + continue + } + a.visibleHeader = append(a.visibleHeader, header) + } + if a.headerList != nil { + a.headerList.Refresh() + } +} + +func articleKey(group string, header nntp.ArticleHeader) string { + if header.MessageID != "" { + return group + ":" + header.MessageID + } + return fmt.Sprintf("%s:%d", group, header.Number) +} + +func (a *application) headerRead(header nntp.ArticleHeader) bool { + if a.state == nil { + return false + } + article, ok := a.state.Snapshot().Articles[articleKey(a.loadedGroup, header)] + return ok && article.Read +} + +func (a *application) headerBookmarked(header nntp.ArticleHeader) bool { + if a.state == nil { + return false + } + article, ok := a.state.Snapshot().Articles[articleKey(a.loadedGroup, header)] + return ok && article.Bookmarked +} + +func (a *application) refreshArticleActions() { + if a.markRead == nil || a.bookmark == nil || a.reply == nil { + return + } + if !a.hasSelectedHeader { + a.markRead.Disable() + a.bookmark.Disable() + a.reply.Disable() + return + } + a.markRead.Enable() + a.bookmark.Enable() + if strings.TrimSpace(a.articleText) == "" { + a.reply.Disable() + } else { + a.reply.Enable() + } + if a.headerRead(a.selectedHeader) { + a.markRead.SetText("Mark unread") + } else { + a.markRead.SetText("Mark read") + } + if a.headerBookmarked(a.selectedHeader) { + a.bookmark.SetText("Remove bookmark") + } else { + a.bookmark.SetText("Bookmark") + } +} + +func (a *application) toggleRead() { + if a.state == nil || !a.hasSelectedHeader { + return + } + key := articleKey(a.loadedGroup, a.selectedHeader) + _ = a.state.SetRead(key, !a.headerRead(a.selectedHeader)) + _ = a.state.Save() + a.refreshArticleActions() + a.headerList.Refresh() +} + +func (a *application) toggleBookmark() { + if a.state == nil || !a.hasSelectedHeader { + return + } + key := articleKey(a.loadedGroup, a.selectedHeader) + _ = a.state.SetBookmarked(key, !a.headerBookmarked(a.selectedHeader)) + _ = a.state.Save() + a.refreshArticleActions() + a.headerList.Refresh() +} + +func (a *application) isSubscribed(group string) bool { + for _, subscribed := range a.settings.Subscriptions { + if subscribed == group { + return true + } + } + return false +} + +func (a *application) setBusy(busy bool, message string) { + if busy { + a.progress.Show() + } else { + a.progress.Hide() + } + a.status.SetText(message) +} + +func (a *application) asyncError(title string, err error) { + fyne.Do(func() { + a.setBusy(false, title+".") + dialog.ShowError(fmt.Errorf("%s: %w", title, err), a.window) + }) +} + +func (a *application) currentClient() *nntp.Client { + a.clientMu.RLock() + defer a.clientMu.RUnlock() + return a.client +} + +func (a *application) replaceClient(client *nntp.Client) { + a.clientMu.Lock() + old := a.client + a.client = client + a.clientMu.Unlock() + if old != nil { + _ = old.Close() + } +} + +func (a *application) closeClient() { + a.clientMu.Lock() + client := a.client + a.client = nil + a.clientMu.Unlock() + if client != nil { + _ = client.Close() + } +} + +func normalizeGroups(value string) ([]string, error) { + parts := strings.Split(value, ",") + groups := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + group := strings.TrimSpace(part) + if group == "" { + continue + } + if err := config.ValidateGroupName(group); err != nil { + return nil, err + } + if _, ok := seen[group]; ok { + continue + } + seen[group] = struct{}{} + groups = append(groups, group) + } + if len(groups) == 0 { + return nil, errors.New("at least one newsgroup is required") + } + return groups, nil +} + +func formatFrom(name, email string) string { + if email == "" { + return "" + } + return (&mail.Address{Name: name, Address: email}).String() +} + +func formatCount(value int64) string { + if value < 1000 { + return strconv.FormatInt(value, 10) + } + parts := make([]string, 0, 4) + for value > 0 { + part := value % 1000 + value /= 1000 + if value > 0 { + parts = append(parts, fmt.Sprintf("%03d", part)) + } else { + parts = append(parts, strconv.FormatInt(part, 10)) + } + } + for left, right := 0, len(parts)-1; left < right; left, right = left+1, right-1 { + parts[left], parts[right] = parts[right], parts[left] + } + return strings.Join(parts, " ") +} diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go new file mode 100644 index 0000000..0483bdb --- /dev/null +++ b/internal/ui/app_test.go @@ -0,0 +1,201 @@ +package ui + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "reflect" + "strings" + "testing" + + "aegis/internal/cryptokit" + "aegis/internal/identity" + + "filippo.io/age" +) + +func TestNormalizeGroups(t *testing.T) { + got, err := normalizeGroups(" comp.lang.go, sci.crypt,comp.lang.go ") + if err != nil { + t.Fatal(err) + } + want := []string{"comp.lang.go", "sci.crypt"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalizeGroups() = %v, want %v", got, want) + } +} + +func TestNormalizeGroupsRejectsInjection(t *testing.T) { + if _, err := normalizeGroups("comp.lang.go\r\nPOST"); err == nil { + t.Fatal("normalizeGroups() accepted protocol injection") + } +} + +func TestFormatCount(t *testing.T) { + for _, test := range []struct { + value int64 + want string + }{{0, "0"}, {999, "999"}, {1000, "1 000"}, {1234567, "1 234 567"}} { + if got := formatCount(test.value); got != test.want { + t.Errorf("formatCount(%d) = %q, want %q", test.value, got, test.want) + } + } +} + +func TestBuildTextArticleUsesMIMEAndEncodesUTF8Headers(t *testing.T) { + article, err := buildTextArticle( + []string{"alt.test"}, + "Élodie ", + "Caffè e privacy", + "Corpo UTF-8: café\nseconda riga", + ) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "MIME-Version: 1.0\r\n", + "Content-Type: text/plain; charset=UTF-8\r\n", + "Content-Transfer-Encoding: 8bit\r\n", + "Face: ", + "Subject: =?UTF-8?q?Caff=C3=A8_e_privacy?=\r\n", + "From: =?UTF-8?q?=C3=89lodie?= \r\n", + "Corpo UTF-8: café\r\nseconda riga", + } { + if !strings.Contains(article, want) { + t.Fatalf("article missing %q:\n%s", want, article) + } + } + for _, line := range strings.Split(article, "\r\n") { + if strings.Contains(line, "\n") { + t.Fatalf("article contains bare LF: %q", line) + } + } +} + +func TestBuildTextArticleRejectsInvalidBody(t *testing.T) { + if _, err := buildTextArticle([]string{"alt.test"}, "reader@example.org", "Test", string([]byte{'x', 0xff})); err == nil { + t.Fatal("buildTextArticle accepted invalid UTF-8") + } +} + +func TestBuildArticleWithoutVFaceIsAllowed(t *testing.T) { + article, err := buildArticleWithIdentityHeaders( + []string{"alt.test"}, "reader@example.org", "Plain post", "body", nil, + articleCryptoOptions{Mode: "Plain"}, + ) + if err != nil { + t.Fatal(err) + } + if strings.Contains(article, "X-VFace-") || strings.Contains(article, "X-Ed25519-Pub:") { + t.Fatalf("plain article unexpectedly contains VFace headers:\n%s", article) + } +} + +func TestPrepareArticleCryptoSignsCanonicalBody(t *testing.T) { + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateSeed := base64.StdEncoding.EncodeToString(private.Seed()) + headers, contentType, transferEncoding, wireBody, err := prepareArticleCrypto( + "hello\r\nworld", + "Sign with Ed25519", + articleCryptoOptions{SigningKey: privateSeed, PublicKeyURL: "https://keys.example.invalid/aegis.pub"}, + ) + if err != nil { + t.Fatal(err) + } + if contentType != "text/plain; charset=UTF-8" || transferEncoding != "8bit" { + t.Fatalf("unexpected MIME metadata: %q, %q", contentType, transferEncoding) + } + joined := strings.Join(headers, "\r\n") + if !strings.Contains(joined, "X-Aegis-Public-Key-URL: https://keys.example.invalid/aegis.pub") { + t.Fatalf("missing public-key URL: %s", joined) + } + const signaturePrefix = "X-Aegis-Signature: ed25519; " + var signature string + for _, header := range headers { + if strings.HasPrefix(header, signaturePrefix) { + signature = strings.TrimPrefix(header, signaturePrefix) + } + } + if signature == "" { + t.Fatal("missing Ed25519 signature") + } + if err := cryptokit.VerifyEd25519([]byte(wireBody), signature, base64.StdEncoding.EncodeToString(public)); err != nil { + t.Fatal(err) + } +} + +func TestPrepareArticleCryptoEncryptsAndSigns(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + _, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + headers, contentType, transferEncoding, wireBody, err := prepareArticleCrypto( + "secret body", + "Encrypt with age and sign", + articleCryptoOptions{ + EncryptionKey: identity.Recipient().String(), + SigningKey: base64.StdEncoding.EncodeToString(private.Seed()), + }, + ) + if err != nil { + t.Fatal(err) + } + if contentType != "application/vnd.aegis.age" || transferEncoding != "7bit" { + t.Fatalf("unexpected encrypted MIME metadata: %q, %q", contentType, transferEncoding) + } + if !strings.Contains(strings.Join(headers, "\r\n"), "X-Aegis-Encryption: age") { + t.Fatal("missing age encryption header") + } + plaintext, err := cryptokit.DecryptAge([]byte(strings.ReplaceAll(wireBody, "\r\n", "\n")), identity.String()) + if err != nil { + t.Fatal(err) + } + if string(plaintext) != "secret body" { + t.Fatalf("decrypted body = %q", plaintext) + } +} + +func TestBuildTextArticleWithVFaceIncludesMandatoryIdentityHeaders(t *testing.T) { + if _, err := identity.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 := identity.GenerateVFace("pseudonym", "reader@example.org", publicKey) + if err != nil { + t.Fatal(err) + } + article, err := buildTextArticleWithVFace( + []string{"alt.test"}, + "pseudonym ", + "VFace test", + "body", + profile, + articleCryptoOptions{Mode: "Plain", ExpectedPublicKey: profile.PublicKey}, + ) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "X-VFace-Version: 1\r\n", + "X-Ed25519-Pub: " + publicKey + "\r\n", + "Identity-Hash: " + profile.IdentityHash + "\r\n", + "X-VFace-Hash: sha256:" + profile.IdentityHash + "\r\n", + "X-VFace-PNG-SHA256: " + profile.PNGHash + "\r\n", + "X-VFace-Verify: " + identity.VFaceVerificationURL + "\r\n", + } { + if !strings.Contains(article, want) { + t.Fatalf("article missing %q:\n%s", want, article) + } + } +} -- cgit v1.2.3