Files
secure-editor-go/main.go
T

330 lines
7.6 KiB
Go

package main
import (
"crypto/aes"
"crypto/cipher"
"encoding/hex"
"encoding/json"
"fmt"
"image/color"
"os"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
const (
Version = "0.1.1"
Title = "Secure Editor"
AlgorithmAes256Gcm = "aes-256-gcm"
DefaultWindowWidth = 800.0
DefaultWindowHeight = 600.0
ConfigFileName = "secure-editor-go-config.json"
// Exit codes:
// 0 -> file was saved and the editor exited
// 2 -> editor closed without saving
ExitCodeOK = 0
ExitCodeNoSave = 2
)
func decrypt(ciphertext, key, nonce []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
func encrypt(plaintext, key, nonce []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil)
return ciphertext, nil
}
func writeFilePreserveMode(filename string, data []byte) error {
fi, err := os.Stat(filename)
if err != nil {
if os.IsNotExist(err) {
return os.WriteFile(filename, data, 0666)
}
return err
}
err = os.WriteFile(filename, data, 0666)
if err != nil {
return err
}
return os.Chmod(filename, fi.Mode())
}
func showErrorMessage(err error, parent fyne.Window) {
dialog.ShowError(err, parent)
}
func isReadonly() bool {
return os.Getenv("READONLY") == "true"
}
// fixedPitchEnabled reports whether the user opted into a monospace
// (programmer) font. The value comes from the TINY_ENCRYPT_ENABLE_FIXED_PITCH
// environment variable; when that is unset it falls back to the file at
// ~/.config/envs/TINY_ENCRYPT_ENABLE_FIXED_PITCH (content trimmed). The values
// true/on/yes/1 are accepted, case-insensitively.
func fixedPitchEnabled() bool {
v := os.Getenv("TINY_ENCRYPT_ENABLE_FIXED_PITCH")
if v == "" {
home, err := os.UserHomeDir()
if err != nil {
return false
}
data, err := os.ReadFile(filepath.Join(home, ".config", "envs", "TINY_ENCRYPT_ENABLE_FIXED_PITCH"))
if err != nil {
return false
}
v = strings.TrimSpace(string(data))
}
switch strings.ToLower(v) {
case "true", "on", "yes", "1":
return true
}
return false
}
// windowConfig is persisted to ~/.cache/secure-editor-go-config.json so the
// editor remembers its window size across runs.
type windowConfig struct {
Width float32 `json:"width"`
Height float32 `json:"height"`
}
func configPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".cache", ConfigFileName), nil
}
func defaultConfig() windowConfig {
return windowConfig{Width: DefaultWindowWidth, Height: DefaultWindowHeight}
}
func loadConfig() windowConfig {
p, err := configPath()
if err != nil {
return defaultConfig()
}
data, err := os.ReadFile(p)
if err != nil {
return defaultConfig()
}
var c windowConfig
if err := json.Unmarshal(data, &c); err != nil {
return defaultConfig()
}
if c.Width <= 0 || c.Height <= 0 {
return defaultConfig()
}
return c
}
func saveConfig(size fyne.Size) {
p, err := configPath()
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return
}
c := windowConfig{Width: size.Width, Height: size.Height}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return
}
_ = os.WriteFile(p, data, 0644)
}
// clampToScreen shrinks size so it never exceeds the visible desktop area.
// When the desktop size can't be determined it returns size unchanged.
func clampToScreen(size fyne.Size) fyne.Size {
sw, sh := desktopSize()
if sw <= 0 || sh <= 0 {
return size
}
w, h := size.Width, size.Height
if w > sw {
w = sw
}
if h > sh {
h = sh
}
return fyne.NewSize(w, h)
}
// lightEditorTheme forces a light variant with a pure white input background
// so the editable text area reads as a white editing surface regardless of
// the system appearance setting. Readonly mode keeps the default theme (grey).
type lightEditorTheme struct {
fyne.Theme
}
func (t *lightEditorTheme) Color(name fyne.ThemeColorName, _ fyne.ThemeVariant) color.Color {
if name == theme.ColorNameInputBackground {
return color.White
}
// Always resolve against the light variant so white input background
// stays paired with dark, readable text.
return t.Theme.Color(name, theme.VariantLight)
}
func main() {
args := os.Args
if len(args) == 2 && args[1] == "version" {
fmt.Printf("secure-editor-go version: %s\n", Version)
os.Exit(0)
}
if len(args) != 5 {
fmt.Printf("Bad encrypt args: %v\n", args)
os.Exit(1)
}
fileName := args[1]
algorithm := args[2]
if algorithm != AlgorithmAes256Gcm {
fmt.Printf("Bad algorithm: %s\n", algorithm)
os.Exit(1)
}
keyBytes, err := hex.DecodeString(args[3])
if err != nil {
fmt.Printf("Bad key: %s, failed: %s\n", args[3], err.Error())
os.Exit(1)
}
if len(keyBytes) != 32 {
fmt.Printf("Bad key length: %d\n", len(keyBytes))
os.Exit(1)
}
nonceBytes, err := hex.DecodeString(args[4])
if err != nil {
fmt.Printf("Bad nonce: %s, failed: %s\n", args[4], err.Error())
os.Exit(1)
}
if len(nonceBytes) != 12 {
fmt.Printf("Bad nonce length: %d\n", len(nonceBytes))
os.Exit(1)
}
fileData, err := os.ReadFile(fileName)
if err != nil {
fmt.Printf("Read file: %s, failed: %s\n", fileName, err.Error())
os.Exit(1)
}
plaintext, err := decrypt(fileData, keyBytes, nonceBytes)
if err != nil {
fmt.Printf("Decrypt file: %s, failed: %s\n", fileName, err.Error())
os.Exit(1)
}
isReadonly := isReadonly()
secureEditorApp := app.New()
if !isReadonly {
secureEditorApp.Settings().SetTheme(&lightEditorTheme{Theme: theme.DefaultTheme()})
}
window := secureEditorApp.NewWindow(Title)
cfg := loadConfig()
window.Resize(clampToScreen(fyne.NewSize(cfg.Width, cfg.Height)))
// exitCode defaults to "closed without saving". The Save button raises
// it to ExitCodeOK. It is read after the event loop ends.
exitCode := ExitCodeNoSave
window.SetOnClosed(func() {
saveConfig(window.Canvas().Size())
secureEditorApp.Quit()
})
textEntry := widget.NewMultiLineEntry()
textEntry.Wrapping = fyne.TextWrapWord
if fixedPitchEnabled() {
// Use Fyne's bundled monospace font for a programmer-style editor.
textEntry.TextStyle = fyne.TextStyle{Monospace: true}
}
initialText := string(plaintext)
textEntry.SetText(initialText)
if isReadonly {
// textEntry.Disable() // TODO for font is grey
textEntry.OnChanged = func(text string) {
if text != initialText {
textEntry.SetText(initialText)
}
}
}
exitButton := widget.NewButton(" [ Exit ] ", func() {
exitCode = ExitCodeNoSave
secureEditorApp.Quit()
})
saveButton := widget.NewButton(" [ Save ] ", func() {
plaintext := textEntry.Text
ciphertext, err := encrypt([]byte(plaintext), keyBytes, nonceBytes)
if err != nil {
showErrorMessage(err, window)
return
}
err = writeFilePreserveMode(fileName, ciphertext)
if err != nil {
showErrorMessage(err, window)
return
}
exitCode = ExitCodeOK
secureEditorApp.Quit()
})
buttons := container.NewHBox(exitButton, saveButton)
if isReadonly {
buttons = container.NewHBox(exitButton)
}
content := container.NewBorder(
nil, // top
container.NewBorder(nil, nil, nil, buttons, nil), // bottom
nil, // left
nil, // right
textEntry, // center
)
window.SetContent(content)
window.CenterOnScreen()
window.ShowAndRun()
os.Exit(exitCode)
}