diff options
Diffstat (limited to 'internal/ui')
| -rw-r--r-- | internal/ui/app.go | 1795 | ||||
| -rw-r--r-- | internal/ui/app_test.go | 201 |
2 files changed, 1996 insertions, 0 deletions
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) + } + } +} |
