Skip to content

Commit a586588

Browse files
andrinoffsteveevansdevLeaWhoCodes
authored
feat: PGP signing and encryption (#438)
Co-authored-by: Steve Evans <steve@floatpane.com> Co-authored-by: Lea <lea@floatpane.com>
1 parent 60861a1 commit a586588

17 files changed

Lines changed: 1773 additions & 87 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ jobs:
2020
with:
2121
go-version: "1.26.1"
2222

23+
- name: Install system dependencies
24+
if: runner.os == 'Linux'
25+
run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
26+
2327
- name: Tidy modules
2428
run: go mod tidy
2529

backend/backend.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ type Attachment struct {
8282
IsSMIMESignature bool
8383
SMIMEVerified bool
8484
IsSMIMEEncrypted bool
85+
IsPGPSignature bool
86+
PGPVerified bool
87+
IsPGPEncrypted bool
8588
}
8689

8790
// Folder represents a mailbox/folder.
@@ -105,6 +108,8 @@ type OutgoingEmail struct {
105108
References []string
106109
SignSMIME bool
107110
EncryptSMIME bool
111+
SignPGP bool
112+
EncryptPGP bool
108113
}
109114

110115
// NotifyType indicates the kind of notification event.

backend/imap/imap.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro
7070
msg.Images, msg.Attachments,
7171
msg.InReplyTo, msg.References,
7272
msg.SignSMIME, msg.EncryptSMIME,
73+
msg.SignPGP, msg.EncryptPGP,
7374
)
7475
}
7576

backend/pop3/pop3.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro
212212
msg.Images, msg.Attachments,
213213
msg.InReplyTo, msg.References,
214214
msg.SignSMIME, msg.EncryptSMIME,
215+
msg.SignPGP, msg.EncryptPGP,
215216
)
216217
}
217218

config/config.go

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ type Account struct {
3434
SMIMEKey string `json:"smime_key,omitempty"` // Path to the private key PEM
3535
SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default
3636

37+
// PGP settings
38+
PGPPublicKey string `json:"pgp_public_key,omitempty"` // Path to public key (.asc or .gpg)
39+
PGPPrivateKey string `json:"pgp_private_key,omitempty"` // Path to private key (.asc or .gpg)
40+
PGPKeySource string `json:"pgp_key_source,omitempty"` // "file" (default) or "yubikey" for hardware key
41+
PGPPIN string `json:"-"` // YubiKey PIN (stored in keyring, not JSON)
42+
PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` // Auto-sign outgoing emails
43+
PGPEncryptByDefault bool `json:"pgp_encrypt_by_default,omitempty"` // Auto-encrypt when recipient keys available
44+
3745
// OAuth2 settings
3846
AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
3947

@@ -159,13 +167,17 @@ func configFile() (string, error) {
159167

160168
// SaveConfig saves the given configuration to the config file and passwords to the keyring.
161169
func SaveConfig(config *Config) error {
162-
// Save passwords to the OS keyring before writing the JSON file
170+
// Save passwords and PGP PINs to the OS keyring before writing the JSON file
163171
for _, acc := range config.Accounts {
164172
if acc.Password != "" {
165173
// We ignore the error here because some environments (like headless CI)
166174
// might not have a keyring service, but we still want to save the rest of the config.
167175
_ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
168176
}
177+
// Save YubiKey PIN if present
178+
if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
179+
_ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
180+
}
169181
}
170182

171183
path, err := configFile()
@@ -198,25 +210,30 @@ func LoadConfig() (*Config, error) {
198210
var needsMigration bool
199211

200212
type rawAccount struct {
201-
ID string `json:"id"`
202-
Name string `json:"name"`
203-
Email string `json:"email"`
204-
Password string `json:"password,omitempty"`
205-
ServiceProvider string `json:"service_provider"`
206-
FetchEmail string `json:"fetch_email,omitempty"`
207-
IMAPServer string `json:"imap_server,omitempty"`
208-
IMAPPort int `json:"imap_port,omitempty"`
209-
SMTPServer string `json:"smtp_server,omitempty"`
210-
SMTPPort int `json:"smtp_port,omitempty"`
211-
Insecure bool `json:"insecure,omitempty"`
212-
SMIMECert string `json:"smime_cert,omitempty"`
213-
SMIMEKey string `json:"smime_key,omitempty"`
214-
SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
215-
AuthMethod string `json:"auth_method,omitempty"`
216-
Protocol string `json:"protocol,omitempty"`
217-
JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
218-
POP3Server string `json:"pop3_server,omitempty"`
219-
POP3Port int `json:"pop3_port,omitempty"`
213+
ID string `json:"id"`
214+
Name string `json:"name"`
215+
Email string `json:"email"`
216+
Password string `json:"password,omitempty"`
217+
ServiceProvider string `json:"service_provider"`
218+
FetchEmail string `json:"fetch_email,omitempty"`
219+
IMAPServer string `json:"imap_server,omitempty"`
220+
IMAPPort int `json:"imap_port,omitempty"`
221+
SMTPServer string `json:"smtp_server,omitempty"`
222+
SMTPPort int `json:"smtp_port,omitempty"`
223+
Insecure bool `json:"insecure,omitempty"`
224+
SMIMECert string `json:"smime_cert,omitempty"`
225+
SMIMEKey string `json:"smime_key,omitempty"`
226+
SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
227+
PGPPublicKey string `json:"pgp_public_key,omitempty"`
228+
PGPPrivateKey string `json:"pgp_private_key,omitempty"`
229+
PGPKeySource string `json:"pgp_key_source,omitempty"`
230+
PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
231+
PGPEncryptByDefault bool `json:"pgp_encrypt_by_default,omitempty"`
232+
AuthMethod string `json:"auth_method,omitempty"`
233+
Protocol string `json:"protocol,omitempty"`
234+
JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
235+
POP3Server string `json:"pop3_server,omitempty"`
236+
POP3Port int `json:"pop3_port,omitempty"`
220237
}
221238
type diskConfig struct {
222239
Accounts []rawAccount `json:"accounts"`
@@ -259,24 +276,29 @@ func LoadConfig() (*Config, error) {
259276
config.MailingLists = raw.MailingLists
260277
for _, rawAcc := range raw.Accounts {
261278
acc := Account{
262-
ID: rawAcc.ID,
263-
Name: rawAcc.Name,
264-
Email: rawAcc.Email,
265-
ServiceProvider: rawAcc.ServiceProvider,
266-
FetchEmail: rawAcc.FetchEmail,
267-
IMAPServer: rawAcc.IMAPServer,
268-
IMAPPort: rawAcc.IMAPPort,
269-
SMTPServer: rawAcc.SMTPServer,
270-
SMTPPort: rawAcc.SMTPPort,
271-
Insecure: rawAcc.Insecure,
272-
SMIMECert: rawAcc.SMIMECert,
273-
SMIMEKey: rawAcc.SMIMEKey,
274-
SMIMESignByDefault: rawAcc.SMIMESignByDefault,
275-
AuthMethod: rawAcc.AuthMethod,
276-
Protocol: rawAcc.Protocol,
277-
JMAPEndpoint: rawAcc.JMAPEndpoint,
278-
POP3Server: rawAcc.POP3Server,
279-
POP3Port: rawAcc.POP3Port,
279+
ID: rawAcc.ID,
280+
Name: rawAcc.Name,
281+
Email: rawAcc.Email,
282+
ServiceProvider: rawAcc.ServiceProvider,
283+
FetchEmail: rawAcc.FetchEmail,
284+
IMAPServer: rawAcc.IMAPServer,
285+
IMAPPort: rawAcc.IMAPPort,
286+
SMTPServer: rawAcc.SMTPServer,
287+
SMTPPort: rawAcc.SMTPPort,
288+
Insecure: rawAcc.Insecure,
289+
SMIMECert: rawAcc.SMIMECert,
290+
SMIMEKey: rawAcc.SMIMEKey,
291+
SMIMESignByDefault: rawAcc.SMIMESignByDefault,
292+
PGPPublicKey: rawAcc.PGPPublicKey,
293+
PGPPrivateKey: rawAcc.PGPPrivateKey,
294+
PGPKeySource: rawAcc.PGPKeySource,
295+
PGPSignByDefault: rawAcc.PGPSignByDefault,
296+
PGPEncryptByDefault: rawAcc.PGPEncryptByDefault,
297+
AuthMethod: rawAcc.AuthMethod,
298+
Protocol: rawAcc.Protocol,
299+
JMAPEndpoint: rawAcc.JMAPEndpoint,
300+
POP3Server: rawAcc.POP3Server,
301+
POP3Port: rawAcc.POP3Port,
280302
}
281303

282304
if rawAcc.Password != "" {
@@ -291,6 +313,13 @@ func LoadConfig() (*Config, error) {
291313
}
292314
}
293315

316+
// Load YubiKey PIN from keyring if using YubiKey
317+
if acc.PGPKeySource == "yubikey" {
318+
if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
319+
acc.PGPPIN = pin
320+
}
321+
}
322+
294323
config.Accounts = append(config.Accounts, acc)
295324
}
296325

@@ -329,6 +358,8 @@ func (c *Config) RemoveAccount(id string) bool {
329358
if acc.ID == id {
330359
// Delete password from OS Keyring when account is removed
331360
_ = keyring.Delete(keyringServiceName, acc.Email)
361+
// Delete PGP PIN from OS Keyring if present
362+
_ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")
332363

333364
c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
334365
return true
@@ -369,3 +400,13 @@ func (c *Config) GetFirstAccount() *Account {
369400
}
370401
return nil
371402
}
403+
404+
// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
405+
func EnsurePGPDir() error {
406+
dir, err := configDir()
407+
if err != nil {
408+
return err
409+
}
410+
pgpDir := filepath.Join(dir, "pgp")
411+
return os.MkdirAll(pgpDir, 0700)
412+
}

0 commit comments

Comments
 (0)