summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore14
-rw-r--r--README.md103
-rw-r--r--TODO.md10
-rw-r--r--aegis.go7
-rw-r--r--docs/client-feature-audit.md123
-rw-r--r--go.mod46
-rw-r--r--go.sum92
-rw-r--r--internal/compose/mime.go147
-rw-r--r--internal/compose/mime_test.go31
-rw-r--r--internal/config/config.go260
-rw-r--r--internal/config/config_test.go113
-rw-r--r--internal/cryptokit/age.go91
-rw-r--r--internal/cryptokit/cryptokit_test.go156
-rw-r--r--internal/cryptokit/ed25519.go102
-rw-r--r--internal/cryptokit/openpgp.go240
-rw-r--r--internal/cryptokit/yubicrypt.go237
-rw-r--r--internal/cryptokit/yubicrypt_test.go36
-rw-r--r--internal/filter/filter.go245
-rw-r--r--internal/filter/filter_test.go24
-rw-r--r--internal/identity/cli.go147
-rw-r--r--internal/identity/cli_test.go62
-rw-r--r--internal/identity/verify.go92
-rw-r--r--internal/identity/vface.go94
-rw-r--r--internal/nntp/client.go983
-rw-r--r--internal/nntp/client_test.go357
-rw-r--r--internal/profile/vault.go235
-rw-r--r--internal/profile/vault_test.go45
-rw-r--r--internal/smtpclient/client.go139
-rw-r--r--internal/smtpclient/client_test.go17
-rw-r--r--internal/store/store.go232
-rw-r--r--internal/store/store_test.go34
-rw-r--r--internal/thread/thread.go76
-rw-r--r--internal/thread/thread_test.go18
-rw-r--r--internal/ui/app.go1795
-rw-r--r--internal/ui/app_test.go201
35 files changed, 6604 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1b0729d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+# Local build output
+/aegis
+/aegis-build
+
+# Local configuration and encrypted identity material
+*.age
+config.json
+profile.json
+*.tmp
+
+# Editor and OS files
+.DS_Store
+*.swp
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ac1d12b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,103 @@
+# Aegis
+
+Aegis is a desktop Usenet reader written in Go with Fyne. It connects to an
+NNTP server directly or through SOCKS5, supports implicit TLS, optional
+`AUTHINFO USER/PASS` authentication, group discovery, local subscriptions,
+overview search, article retrieval, and text article posting.
+
+## Group population
+
+Before subscribing, Aegis displays an estimated post count for every group
+returned by `LIST ACTIVE`. The estimate is `high - low + 1`. NNTP article
+numbers can contain gaps, so this is an upper-bound style estimate rather than
+a guaranteed count. After selecting a group, the server's `GROUP` response is
+used for the current article count.
+
+## Build and test
+
+```sh
+gofmt -w .
+go test ./...
+go test -race ./...
+go vet -buildvcs=false ./...
+go build -buildvcs=false -o aegis .
+```
+
+The `-buildvcs=false` option is useful when the source directory is not a Git
+working tree.
+
+## Configuration and credentials
+
+Settings are stored in the user configuration directory as
+`aegis/config.json` with mode `0600`. The NNTP password is never written to
+that file and remains only in memory for the current application run.
+
+TLS certificate validation is enabled by default. The Settings screen has an
+explicit `Do not verify the TLS certificate` opt-in for local testing or a
+pinned/trusted connection that cannot present a verifiable certificate. It can
+expose account credentials and article traffic to a man-in-the-middle and
+should not be enabled on an untrusted network.
+
+## Identity and message cryptography
+
+The posting profile is VFace-compatible but optional for publishing. Its
+username, email address, and canonical Ed25519 public key are joined as
+`username|email|public-key`. That exact string produces the 48x48 transparent
+PNG identicon through the existing Ch1ffr3punk `identicon-cli` executable.
+Aegis accepts no uploaded or alternate profile image and no image format other
+than the validated PNG. It publishes the SHA-256 identity hash, the SHA-256
+hash of the PNG, and the verification link
+`https://identicons.virebent.art` together with `Face:` and the interoperable
+`X-Ed25519-Pub` header. Aegis looks first at
+`/home/gabriel1/Projects/identicons/identicons-cli`, then at the configured
+`AEGIS_IDENTICON_CLI` path and other standard locations. The executable must
+return a 48x48 PNG as base64. Aegis does not embed the identicon GUI.
+
+Age is the software encryption format. OpenPGP is not exposed as a standalone
+software format: it is reserved for the YubiKey OpenPGP integration. The
+optional VFace profile generates an Ed25519 identity and stores it in an
+age-encrypted local vault protected by a user password of at least 12
+characters.
+
+The composer keeps the classic Usenet path as the default. It sends RFC 5322
+and MIME-compatible text articles directly to NNTP, with UTF-8 `8bit` text,
+canonical CRLF line endings, and a generated `Message-ID`. Optional composer
+modes can sign the canonical body with an Ed25519 key, encrypt the body with
+age (native X25519 or SSH Ed25519/RSA recipients), or do both. These modes add
+`X-Aegis-*` metadata,
+including the inline verification key, its SHA-256 fingerprint, and an
+optional HTTPS key URL. The URL is metadata only and is never fetched
+automatically.
+
+Ed25519 is used for signatures, not encryption. The age recipient must be an
+encryption key, and private keys are supplied for the current operation only.
+The optional SURB integration remains a separate future task and is not part
+of classic Usenet posting.
+
+TLS connections require TLS 1.2 or 1.3. For TLS 1.2, Aegis offers only
+ECDHE-AES-GCM suites. Go does not expose TLS 1.3 cipher-suite selection, so a
+TLS 1.3 connection can still negotiate the platform's ChaCha20-Poly1305
+suite. Deployments that require GCM exclusively must cap the NNTP server at
+TLS 1.2.
+
+## Current limits
+
+- implicit TLS and STARTTLS are supported; COMPRESS DEFLATE is deliberately
+ rejected whenever TLS is enabled and is available only for cleartext NNTP;
+- SOCKS5 username/password authentication is not exposed in the interface;
+- at most the latest 500 overview records are loaded per group;
+- posting creates MIME 1.0 text articles with UTF-8 and `8bit` by default;
+ attachment file selection remains pending; article lines are validated at a maximum of 998
+ octets and sent with canonical CRLF line endings;
+- composer supports age and optional VFace Ed25519 signing; OpenPGP remains
+ reserved for the YubiKey integration;
+- SMTP mail2news delivery supports cleartext, implicit TLS or STARTTLS. It
+ reuses the single NNTP proxy configuration, and SOCKS5 routing is required
+ for `.onion` hosts; the SMTP password is session-only;
+- subscriptions are local Aegis preferences, because NNTP itself has no
+ standard server-side subscription command.
+
+The complete-client audit and prioritized roadmap are in
+[`docs/client-feature-audit.md`](docs/client-feature-audit.md). Optional
+features remain part of the main Aegis binary for now, without a runtime
+plugin directory.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..f5ad308
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,10 @@
+# Aegis TODO
+
+- P0: Add purge controls and a full UI for browsing cached drafts/articles.
+- P0: Add an explicit thread-tree view; the local References model is present.
+- Add clearer SMTP authentication guidance when credentials are requested over
+ cleartext; anonymous cleartext SMTP is supported for onion gateways.
+- P1: Add multipart attachment file selection and presentation in the reader.
+- Define a protocol-specific SURB adapter for anonymous reply routing. Keep
+ SURB separate from subject/body encryption and choose the target protocol
+ (Nym or Katzenpost) before implementation.
diff --git a/aegis.go b/aegis.go
new file mode 100644
index 0000000..6249b12
--- /dev/null
+++ b/aegis.go
@@ -0,0 +1,7 @@
+package main
+
+import "aegis/internal/ui"
+
+func main() {
+ ui.Run()
+}
diff --git a/docs/client-feature-audit.md b/docs/client-feature-audit.md
new file mode 100644
index 0000000..38d710e
--- /dev/null
+++ b/docs/client-feature-audit.md
@@ -0,0 +1,123 @@
+# Aegis Usenet client feature audit
+
+This audit separates a complete NNTP reader/poster from optional features that
+would conflict with Aegis privacy and VFace goals. It is based on RFC 3977,
+RFC 4642, RFC 4643, RFC 5536, RFC 5537, RFC 6048 and RFC 8054.
+
+## Current state
+
+| Area | Status | Notes |
+| --- | --- | --- |
+| Implicit TLS | Present | TLS 1.2 and 1.3, certificate validation by default. |
+| STARTTLS | Present | Explicit TLS upgrade is available on servers exposing port 119. |
+| AUTHINFO USER/PASS | Present | Password remains in memory and requires TLS. |
+| AUTHINFO SASL | Present | PLAIN is used only after capability negotiation and TLS. |
+| CAPABILITIES | Partial | Parsed, but capability-specific behavior is limited. |
+| Group discovery | Present | LIST NEWSGROUPS, OVERVIEW.FMT, NEWGROUPS and NEWNEWS are available. |
+| Overview | Present | `OVER` with `XOVER` fallback. |
+| Article retrieval | Present | ARTICLE, HEAD, BODY, STAT, LISTGROUP and HDR are available. |
+| Posting | Present | Direct NNTP posting with MIME, VFace and optional age/Ed25519 metadata. |
+| Threading | Partial | Local thread trees and References-aware replies are available; tree UI remains. |
+| Search | Partial | Basic overview filtering only; no indexed local full-text search. |
+| Offline cache | Present | Local article metadata/raw cache and drafts use atomic 0600 JSON state. |
+| Filters and scoring | Present | Generic deterministic local rules support classic headers, body, VFace and tags. |
+| MIME and attachments | Partial | Text MIME works; attachment file selection/presentation remains. |
+| Identity | Present | VFace profile, deterministic PNG, SHA-256 metadata and public verification link. |
+| Identity verification | Present | Reader verifies VFace hashes, Face and Ed25519 body signatures locally. |
+| OpenPGP/MIME posting | Deferred | OpenPGP is reserved for the YubiKey integration, not a standalone composer format. |
+| SURB | Deferred | Kept separate from classic Usenet and body encryption. |
+
+## Complete client scope
+
+### 1. NNTP transport and account handling
+
+- server profiles with separate credentials, proxy and TLS policies;
+- implicit TLS and STARTTLS with capability checks;
+- AUTHINFO USER/PASS and capability-negotiated SASL;
+- IPv4/IPv6, reconnect with bounded backoff and cancellation;
+- capability cache scoped to a server profile;
+- `COMPRESS DEFLATE` when advertised, with a user-visible opt-in only on
+ cleartext NNTP; it is rejected on every TLS connection;
+- exact response-code handling and a diagnostic transcript that redacts
+ credentials, public-key URLs and article bodies.
+
+### 2. Group and article navigation
+
+- `LIST ACTIVE`, `LIST NEWSGROUPS`, `LIST OVERVIEW.FMT`, moderation metadata and
+ RFC 6048 list additions;
+- `NEWGROUPS` and `NEWNEWS` for incremental discovery;
+- `GROUP`, `LISTGROUP`, `OVER`, `HDR`, `ARTICLE`, `HEAD`, `BODY` and `STAT`;
+- paging, first/last/next navigation, column sorting and cross-post awareness;
+- thread trees from `References`, `In-Reply-To` and `Message-ID`;
+- local read/unread state, bookmarks, tags, mute and collapse controls;
+- raw article view plus normalized text view, always retaining original headers.
+
+### 3. Composer and posting
+
+- reply and follow-up generation with quoted text and correct references;
+- draft storage and a recoverable outbox, never automatic duplicate reposting;
+- explicit cross-post confirmation and `Followup-To` support;
+- moderated-group detection and an `Approved` workflow only when the server
+ or moderator policy authorizes it;
+- MIME multipart text and attachments, with strict size and line limits;
+- age as the software encryption format; OpenPGP only through YubiKey;
+- post result showing the server response and assigned article information.
+
+### 4. Local filters and scoring
+
+Filters should be local-only and deterministic. They should match headers,
+VFace identity hash, public-key fingerprint, newsgroups, dates and optionally
+body text. Actions should be hide, mark read, highlight, tag, mute thread and
+save locally. They must never silently delete server articles, send network
+requests, or rewrite a user's identity.
+
+Use RE2 regular expressions, bounded body reads and an explicit rule order.
+Filter files should be portable JSON or TOML, with a schema version and a
+dry-run preview. A filter that references a stable VFace key is intentionally
+linkable and must be shown as such in the UI.
+
+### 5. Pseudonymity and privacy invariants
+
+- VFace is the only profile image mechanism: no upload, replacement or
+ alternate image format;
+- private keys never enter settings, drafts, logs or crash
+ reports;
+- `Path`, `Injection-Info` and `Xref` remain server-controlled trace fields;
+- no local hostname, IP address, proxy address or real name is added to posts;
+- `Message-ID` uses the neutral Aegis domain, not the From address domain;
+- the configured SOCKS/Nym route is visible as a transport policy, not embedded
+ in article metadata;
+- URL verification is opt-in metadata and is never fetched automatically;
+- local caches and filter databases need an explicit location and retention
+ policy, with an option to purge them;
+- optional features remain inside the audited main binary so their access to
+ network, files, article bodies and identity material is explicit.
+
+## Priority order
+
+### P0, remaining for a usable complete text client
+
+1. Add a visible thread-tree view and cache purge controls.
+2. Add offline browsing of cached articles and drafts.
+
+### P1, interoperability and daily usability
+
+1. Add attachment file selection and reader presentation.
+2. Add moderated-posting warnings and cross-post confirmation.
+
+### P2, advanced features
+
+1. Moderated posting workflows and control-message handling with explicit
+ warnings.
+2. Multiple server profiles, synchronization policies and import/export.
+3. SURB adapters, only after a concrete target protocol is selected.
+
+## References
+
+- [RFC 3977, NNTP](https://www.rfc-editor.org/rfc/rfc3977.html)
+- [RFC 4642, NNTP STARTTLS](https://www.rfc-editor.org/rfc/rfc4642.html)
+- [RFC 4643, NNTP authentication](https://www.rfc-editor.org/rfc/rfc4643.html)
+- [RFC 5536, Netnews article format](https://www.rfc-editor.org/rfc/rfc5536.html)
+- [RFC 5537, Netnews architecture and protocols](https://www.rfc-editor.org/rfc/rfc5537.html)
+- [RFC 6048, NNTP LIST additions](https://www.rfc-editor.org/rfc/rfc6048.html)
+- [RFC 8054, NNTP compression](https://www.rfc-editor.org/rfc/rfc8054.html)
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..89c041e
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,46 @@
+module aegis
+
+go 1.24.0
+
+require (
+ filippo.io/age v1.3.1
+ fyne.io/fyne/v2 v2.7.1
+ golang.org/x/net v0.47.0
+)
+
+require (
+ filippo.io/edwards25519 v1.1.0 // indirect
+ filippo.io/hpke v0.4.0 // indirect
+ fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect
+ github.com/BurntSushi/toml v1.5.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/fredbi/uri v1.1.1 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/fyne-io/gl-js v0.2.0 // indirect
+ github.com/fyne-io/glfw-js v0.3.0 // indirect
+ github.com/fyne-io/image v0.1.1 // indirect
+ github.com/fyne-io/oksvg v0.2.0 // indirect
+ github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
+ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
+ github.com/go-text/render v0.2.0 // indirect
+ github.com/go-text/typesetting v0.2.1 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/hack-pad/go-indexeddb v0.3.2 // indirect
+ github.com/hack-pad/safejs v0.1.0 // indirect
+ github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
+ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
+ github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rymdport/portal v0.4.2 // indirect
+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ github.com/yuin/goldmark v1.7.8 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/image v0.24.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..3151226
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,92 @@
+c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
+c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
+filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
+filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
+filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
+fyne.io/fyne/v2 v2.7.1 h1:ja7rNHWWEooha4XBIZNnPP8tVFwmTfwMJdpZmLxm2Zc=
+fyne.io/fyne/v2 v2.7.1/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE=
+fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 h1:eA5/u2XRd8OUkoMqEv3IBlFYSruNlXD8bRHDiqm0VNI=
+fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
+github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
+github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
+github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
+github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
+github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
+github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
+github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
+github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
+github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
+github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
+github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
+github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
+github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
+github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
+github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
+github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
+github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
+github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
+github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
+github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
+github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
+github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
+github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
+github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
+github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
+github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
+github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
+golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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 <aegis@example.invalid>", "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 <alice@example.net>", 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 <a@example.org>\tMon, 1 Jan 2024 00:00:00 +0000\t<119@example>\t\t100\t5\r\n120\tSecond\tBob <b@example.org>\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: "<root@example>", Subject: "topic"},
+ {Number: 2, MessageID: "<reply@example>", References: "<root@example>"},
+ {Number: 3, MessageID: "<nested@example>", References: "<root@example> <reply@example>"},
+ })
+ 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, <user@...>")
+ 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 <elodie@example.org>",
+ "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?= <elodie@example.org>\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 <reader@example.org>",
+ "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)
+ }
+ }
+}