forked from aksdb/CalAnonSync
284 lines
6.8 KiB
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
|
|
}
|