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 }