330 lines
7.6 KiB
Go
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)
|
|
}
|