CalAnonSync/src/calanonsync/settings.go

284 lines
6.8 KiB
Go

package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh/terminal"
"io"
"io/ioutil"
"log"
"os"
"sort"
"strings"
"syscall"
)
type ServerSettings struct {
URL string
Username string
Password string
}
type StringAnonSettings struct {
ReplaceWith string
Whitelist []string
}
type Settings struct {
EWS ServerSettings
CalDAV ServerSettings
Anonymize struct {
Title *StringAnonSettings
}
}
const settingsName = "calanonsync.json"
const keyName = ".calanonsync.key"
func LoadSettings() Settings {
f, err := os.Open(settingsName)
if err != nil {
panic(err)
}
settings := Settings{}
err = json.NewDecoder(f).Decode(&settings)
if err != nil {
panic(err)
}
if settings.CalDAV.URL != "" && !strings.HasSuffix(settings.CalDAV.URL, "/") {
settings.CalDAV.URL += "/"
}
// Load a key if possible.
var key []byte = nil
keyString, err := ioutil.ReadFile(keyName)
if err == nil {
key, err = base64.StdEncoding.DecodeString(string(keyString))
if err != nil {
log.Fatalf("Could not load encryption key: %s\n", err)
}
} else if !os.IsNotExist(err) {
log.Fatalf("Could not load encryption key: %s\n", err)
}
ensurePassword := func(password *string, name string) {
if *password == "" {
print(name + " password: ")
b, err := terminal.ReadPassword(int(syscall.Stdin))
println()
if err != nil {
panic(err)
}
*password = string(b)
} else if key != nil {
// Password already set. Since we have an encryption key, try to
// decrypt the password.
pwbytes, err := base64.StdEncoding.DecodeString(*password)
if err != nil {
log.Fatalf("Could not decode password: %s\n", err)
}
block, err := aes.NewCipher(key)
if err != nil {
log.Fatalf("Could not create cipher: %s\n", err)
}
if len(pwbytes) < block.BlockSize() {
log.Fatalln("Could not decrypt password. Encrypted stream is too short.")
}
iv := pwbytes[:aes.BlockSize]
result := pwbytes[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(result, result)
*password = string(result)
}
}
ensurePassword(&settings.EWS.Password, "EWS")
ensurePassword(&settings.CalDAV.Password, "CalDAV")
return settings
}
func InitSettingsCmd() *cobra.Command {
settingsCmd := &cobra.Command{
Use: "settings",
Short: "Manage settings.",
}
encryptCmd := &cobra.Command{
Use: "encrypt",
Short: "Encrypt the passwords in the settings file.",
Long: `This will encrypt the passwords in the settings file that are
not empty. It will generate a new "master" password and store that alongside
the settings file. This is NOT secure, it just helps to prevent
over-the-shoulder "attacks".`,
Run: runSettingsEncryption,
}
decryptCmd := &cobra.Command{
Use: "decrypt",
Short: "Decrypt a previously encrypted settings file.",
Run: runSettingsDecryption,
}
settingsCmd.AddCommand(encryptCmd, decryptCmd)
return settingsCmd
}
func runSettingsEncryption(cmd *cobra.Command, args []string) {
s := LoadSettings()
if _, err := os.Stat(keyName); err == nil || os.IsExist(err) {
log.Fatalln("Cannot encrypt an (apparently) already encrypted settings file. If this is an error, please remove .calanonsync.key and try again.")
}
// Generate a secure 256 bit key.
key := make([]byte, 32)
if n, err := rand.Read(key); n != 32 || err != nil {
log.Fatalf("Could not get random 256 bit key: %s (%d)\n", err, n)
}
block, err := aes.NewCipher(key)
if err != nil {
log.Fatalf("Could not create cipher: %s\n", err)
}
doEncrypt := func(pwd *string) {
if *pwd != "" {
result := make([]byte, aes.BlockSize+len(*pwd))
// Prepare the initialization vector
iv := result[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
panic(err)
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(result[aes.BlockSize:], []byte(*pwd))
*pwd = base64.StdEncoding.EncodeToString(result)
}
}
doEncrypt(&s.EWS.Password)
doEncrypt(&s.CalDAV.Password)
if s.EWS.Password == "" && s.CalDAV.Password == "" {
log.Fatalf("No passwords found. Nothing to encrypt.")
}
// Rewrite the settings file.
f, err := os.OpenFile(settingsName, os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
log.Fatalf("Could not rewrite settings: %s\n", err)
}
defer f.Close()
e := json.NewEncoder(f)
e.SetIndent("", " ")
err = e.Encode(&s)
if err != nil {
panic(err)
}
f, err = os.OpenFile(keyName, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
log.Fatalf("Could not write keyfile: %s\n", err)
}
defer f.Close()
ks := base64.StdEncoding.EncodeToString(key)
_, err = f.WriteString(ks)
if err != nil {
panic(err)
}
log.Println("Settings encrypted")
}
func runSettingsDecryption(cmd *cobra.Command, args []string) {
s := LoadSettings()
// Rewrite the settings file.
f, err := os.OpenFile(settingsName, os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
log.Fatalf("Could not rewrite settings: %s\n", err)
}
defer f.Close()
e := json.NewEncoder(f)
e.SetIndent("", " ")
err = e.Encode(&s)
if err != nil {
panic(err)
}
err = os.Remove(keyName)
if err != nil {
log.Fatalf("Could not remove key file: %s\n", err)
}
log.Println("Settings decrypted")
}
// Apply the anonymization rule to the given string, returning the
// anonymized version.
// If the anonymization is nil or empty, the original string will
// be returned (since no anonymization is wanted, apparently).
// If no whitelist is given or nothing within the whitelist is
// found inside the string, the ReplaceWith string is returned.
//
// If a whitelist is used, ALL entries that were found in the original
// string will be concatenated and returned as the result. The order
// as found in the original string is kept.
//
// The whitelist is considered case insensitive!
//
func (settings *StringAnonSettings) Apply(s string) string {
if settings == nil || settings.ReplaceWith == "" {
return s
}
if settings.Whitelist != nil {
// We have a whitelist. Try to find all matches in appropriate order.
type match struct {
index int
entry int
}
lowerString := strings.ToLower(s)
var matches []match
for i := range settings.Whitelist {
m := match{entry: i}
m.index = strings.Index(lowerString, strings.ToLower(settings.Whitelist[i]))
if m.index > -1 {
matches = append(matches, m)
}
}
if matches != nil {
// Oh, we have matches. Good. Sort them by original
// index within the string so we have a chance of a
// meaningful title.
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].index < matches[j].index
})
sb := &strings.Builder{}
for i := range matches {
if i > 0 {
sb.WriteString(" ")
}
sb.WriteString(settings.Whitelist[matches[i].entry])
}
return sb.String()
}
}
return settings.ReplaceWith
}