From bdb8a02bbb3dc0d7c39f5b3ec451abb68e9d093d Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sun, 23 Aug 2026 00:26:27 +0200 Subject: Simplify Usenet posting to signing only --- internal/ui/app.go | 227 +++++++++++++++++------------------------------------ 1 file changed, 72 insertions(+), 155 deletions(-) (limited to 'internal/ui/app.go') diff --git a/internal/ui/app.go b/internal/ui/app.go index d193ce1..f7e7cac 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -10,7 +10,6 @@ import ( "io" "mime" "net/mail" - "net/url" "sort" "strconv" "strings" @@ -86,16 +85,14 @@ type application struct { composeGroups *widget.Entry composeDelivery *widget.Select - composeMail2News *widget.Entry + composeTo *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 @@ -144,6 +141,8 @@ type application struct { vfaceStatus *widget.Label vfaceImage *canvas.Image vfaceHash *widget.Label + vfacePublicKey *widget.Label + vfaceKeyStatus *widget.Label } func Run() { @@ -311,8 +310,8 @@ func (a *application) buildComposer() fyne.CanvasObject { 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.composeTo = widget.NewEntry() + a.composeTo.SetText(a.settings.SMTPRecipient) a.composeFrom = widget.NewEntry() a.composeSubject = widget.NewEntry() a.composeReferences = widget.NewEntry() @@ -322,35 +321,27 @@ func (a *application) buildComposer() fyne.CanvasObject { a.composeBody = widget.NewMultiLineEntry() a.composeBody.SetPlaceHolder("Article body...") a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email)) - a.composeFrom.Disable() + // Keep the identity visible in the normal foreground color. VFace, when + // unlocked, still replaces this value before posting. 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.SetPlaceHolder("Optional Ed25519 private key; VFace supplies it automatically") 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("To", a.composeTo), 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("Mode", a.composeCryptoMode), widget.NewFormItem("Ed25519 signing key", a.composeCryptoSigningKey), - widget.NewFormItem("Public-key URL", a.composeCryptoKeyURL), ) return container.NewBorder(form, post, nil, nil, a.composeBody) } @@ -368,6 +359,11 @@ func (a *application) buildProfile() fyne.CanvasObject { a.vfaceStatus.Wrapping = fyne.TextWrapWord a.vfaceHash = widget.NewLabel("") a.vfaceHash.Wrapping = fyne.TextWrapWord + a.vfacePublicKey = widget.NewLabel("") + a.vfacePublicKey.Wrapping = fyne.TextWrapBreak + a.vfacePublicKey.Selectable = true + a.vfaceKeyStatus = widget.NewLabel("") + a.vfaceKeyStatus.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)) @@ -387,7 +383,7 @@ func (a *application) buildProfile() fyne.CanvasObject { 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 := widget.NewLabel("VFace creates an Ed25519 key pair. The public key is part of the identity; the private key remains encrypted in the local vault and is used for signing. Message encryption is intentionally not part of the Usenet client.") provider.Wrapping = fyne.TextWrapWord return container.NewVScroll(container.NewVBox( widget.NewLabelWithStyle("Optional pseudonymous identity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), @@ -397,6 +393,8 @@ func (a *application) buildProfile() fyne.CanvasObject { a.vfaceStatus, a.vfaceImage, a.vfaceHash, + a.vfacePublicKey, + a.vfaceKeyStatus, provider, )) } @@ -429,6 +427,7 @@ func (a *application) createVFaceProfile() { a.vfaceConfirmEntry.SetText("") a.renderVFaceProfile(value) a.updateComposeIdentity() + a.composeCryptoMode.SetSelected("Sign with Ed25519") a.vfaceStatus.SetText("VFace identity created and encrypted on disk.") }) }() @@ -454,6 +453,7 @@ func (a *application) loadVFaceProfile() { a.vfaceEmailEntry.SetText(value.Email) a.renderVFaceProfile(value) a.updateComposeIdentity() + a.composeCryptoMode.SetSelected("Sign with Ed25519") a.vfaceStatus.SetText("VFace identity loaded from encrypted disk vault.") }) }() @@ -464,6 +464,15 @@ func (a *application) lockVFaceProfile() { if a.vfaceImage != nil { a.vfaceImage.Hide() } + if a.vfacePublicKey != nil { + a.vfacePublicKey.SetText("") + } + if a.vfaceKeyStatus != nil { + a.vfaceKeyStatus.SetText("") + } + if a.composeCryptoMode != nil && a.composeCryptoMode.Selected == "Sign with Ed25519" { + a.composeCryptoMode.SetSelected("Plain") + } if a.vfaceStatus != nil { a.vfaceStatus.SetText("VFace identity locked. VFace is optional.") } @@ -483,6 +492,8 @@ func (a *application) renderVFaceProfile(value vfaceprofile.Profile) { a.vfaceImage.Refresh() } a.vfaceHash.SetText("Identity SHA-256: " + profile.IdentityHash + "\nPNG SHA-256: " + profile.PNGHash) + a.vfacePublicKey.SetText("Ed25519 public key (selectable):\n" + value.PublicKey) + a.vfaceKeyStatus.SetText("Ed25519 key pair ready. Private key is encrypted in the local vault and available for signing.") } func (a *application) updateComposeIdentity() { @@ -497,12 +508,12 @@ func (a *application) updateComposeIdentity() { } 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.cryptoAlgorithm = widget.NewSelect([]string{"Ed25519", "YubiCrypt"}, nil) + a.cryptoAlgorithm.SetSelected("Ed25519") + a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify"}, nil) a.cryptoOperation.SetSelected("Sign") a.cryptoMessage = widget.NewMultiLineEntry() - a.cryptoMessage.SetPlaceHolder("Message or ciphertext") + a.cryptoMessage.SetPlaceHolder("Message") a.cryptoMessage.Wrapping = fyne.TextWrapOff a.cryptoPrimary = widget.NewMultiLineEntry() a.cryptoPrimary.SetPlaceHolder("Key material supplied by you") @@ -529,13 +540,13 @@ func (a *application) buildCrypto() fyne.CanvasObject { 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 := widget.NewLabel("This panel is limited to signing and verification. 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) + messageBox := container.NewVBox(widget.NewLabel("Message"), 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) @@ -567,27 +578,6 @@ func (a *application) refreshCryptoFields() { 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() @@ -600,7 +590,7 @@ func (a *application) refreshCryptoFields() { } } a.cryptoSecondary.Disable() - if operation == "Verify" || (operation == "Encrypt" && algorithm == "OpenPGP") { + if operation == "Verify" { a.cryptoSecondary.Enable() } a.cryptoPrimaryBox.Refresh() @@ -616,17 +606,13 @@ func (a *application) runCryptoOperation() { 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) + dialog.ShowError(errors.New("message is required"), a.window) return } - if algorithm == "YubiCrypt" && operation != "Verify" && operation != "Encrypt" && strings.TrimSpace(secret) == "" { + if algorithm == "YubiCrypt" && operation == "Sign" && 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 @@ -636,33 +622,6 @@ func (a *application) runCryptoOperation() { 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": @@ -670,8 +629,6 @@ func (a *application) runCryptoOperation() { 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 { @@ -683,18 +640,10 @@ func (a *application) runCryptoOperation() { 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.") + a.setBusy(false, "Signing operation completed.") if err != nil { dialog.ShowError(err, a.window) return @@ -780,7 +729,7 @@ func (a *application) buildSettings() fyne.CanvasObject { 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("Default To", a.smtpRecipientEntry), widget.NewFormItem("SMTP password", a.smtpPasswordEntry), widget.NewFormItem("SMTP TLS", a.smtpSkipVerify), widget.NewFormItem("NNTP display name", a.displayEntry), @@ -1192,11 +1141,10 @@ func formatArticleHeaders(article string, showAll bool) string { return raw } important := []string{ - "From", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To", + "From", "To", "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", + "Content-Transfer-Encoding", "Face", "X-Signature", + "X-Aegis-Signature", "X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint", "X-VFace-Version", "X-Ed25519-Pub", "X-Ed25519-Sig", "Identity-Hash", "X-VFace-Hash", "X-VFace-PNG-SHA256", "X-VFace-Verify", } @@ -1268,26 +1216,30 @@ func (a *application) postArticle() { return } var recipients []string + to := strings.TrimSpace(a.composeTo.Text) if delivery == "SMTP mail2news" { - recipient := strings.TrimSpace(a.composeMail2News.Text) - if recipient == "" { - recipient = strings.TrimSpace(settings.SMTPRecipient) + if to == "" { + to = strings.TrimSpace(settings.SMTPRecipient) } - if recipient == "" { - dialog.ShowError(errors.New("mail2news recipient is required for SMTP delivery"), a.window) + if to == "" { + dialog.ShowError(errors.New("To address 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) + if _, err := mail.ParseAddress(to); err != nil { + dialog.ShowError(fmt.Errorf("invalid To address: %w", err), a.window) + return + } + recipients = []string{to} + } else if to != "" { + if _, err := mail.ParseAddress(to); err != nil { + dialog.ShowError(fmt.Errorf("invalid To address: %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, + To: to, SigningKey: signingKey, - PublicKeyURL: a.composeCryptoKeyURL.Text, ExpectedPublicKey: expectedPublicKey, References: strings.TrimSpace(a.composeReferences.Text), FollowupTo: strings.TrimSpace(a.composeFollowupTo.Text), @@ -1322,10 +1274,8 @@ func (a *application) postArticle() { a.composeBody.SetText("") a.composeReferences.SetText("") a.composeFollowupTo.SetText("") - a.composeMail2News.SetText(settings.SMTPRecipient) - a.composeCryptoKey.SetText("") + a.composeTo.SetText(settings.SMTPRecipient) 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) @@ -1339,9 +1289,8 @@ func buildTextArticle(groups []string, from, subject, body string) (string, erro type articleCryptoOptions struct { Mode string - EncryptionKey string + To string SigningKey string - PublicKeyURL string ExpectedPublicKey string References string FollowupTo string @@ -1399,6 +1348,15 @@ func buildArticleWithIdentityHeaders(groups []string, from, subject, body string "Content-Type: " + contentType, "Content-Transfer-Encoding: " + contentTransferEncoding, } + if to := strings.TrimSpace(options.To); to != "" { + if strings.ContainsAny(to, "\r\n") { + return "", errors.New("To must not contain line breaks") + } + if _, err := mail.ParseAddress(to); err != nil { + return "", fmt.Errorf("invalid To address: %w", err) + } + headers = append(headers, foldHeader("To", to)) + } if options.References != "" { if strings.ContainsAny(options.References, "\r\n") { return "", errors.New("References must not contain line breaks") @@ -1445,51 +1403,18 @@ func prepareArticleCrypto(body, mode string, options articleCryptoOptions) (head 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) + return nil, "", "", "", fmt.Errorf("unsupported article signing mode %q", mode) } - if mode == "Sign with Ed25519" || mode == "Encrypt with age and sign" { + if mode == "Sign with Ed25519" { if signingKey == "" { return nil, "", "", "", errors.New("an Ed25519 private key is required for signing") } @@ -1521,14 +1446,6 @@ func prepareArticleCrypto(body, mode string, options articleCryptoOptions) (head "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 } -- cgit v1.2.3