diff --git a/duplicacy/duplicacy_main.go b/duplicacy/duplicacy_main.go index b7004cb..beeee3f 100644 --- a/duplicacy/duplicacy_main.go +++ b/duplicacy/duplicacy_main.go @@ -718,6 +718,10 @@ func backupRepository(context *cli.Context) { backupManager := duplicacy.CreateBackupManager(preference.SnapshotID, storage, repository, password, preference.NobackupFile) duplicacy.SavePassword(*preference, "password", password) + if context.String("key") != "" { + backupManager.SetupRSAPublicKey(context.String("key")) + } + backupManager.SetupSnapshotCache(preference.Name) backupManager.SetDryRun(dryRun) backupManager.Backup(repository, quickMode, threads, context.String("t"), showStatistics, enableVSS, vssTimeout, enumOnly) @@ -783,10 +787,8 @@ func restoreRepository(context *cli.Context) { } patterns = append(patterns, pattern) - - - } + patterns = duplicacy.ProcessFilterLines(patterns, make([]string, 0)) duplicacy.LOG_DEBUG("REGEX_DEBUG", "There are %d compiled regular expressions stored", len(duplicacy.RegexMap)) @@ -797,6 +799,13 @@ func restoreRepository(context *cli.Context) { backupManager := duplicacy.CreateBackupManager(preference.SnapshotID, storage, repository, password, preference.NobackupFile) duplicacy.SavePassword(*preference, "password", password) + if context.String("key") != "" { + prompt := fmt.Sprintf("Enter the passphrase for %s:", context.String("key")) + passphrase := duplicacy.GetPassword(*preference, "rsa_passphrase", prompt, false, false) + backupManager.SetupRSAPrivateKey(context.String("key"), passphrase) + duplicacy.SavePassword(*preference, "rsa_passphrase", passphrase) + } + backupManager.SetupSnapshotCache(preference.Name) backupManager.Restore(repository, revision, true, quickMode, threads, overwrite, deleteMode, setOwner, showStatistics, patterns) @@ -847,6 +856,14 @@ func listSnapshots(context *cli.Context) { showFiles := context.Bool("files") showChunks := context.Bool("chunks") + // list doesn't need to decrypt file chunks; but we need -key here so we can reset the passphrase for the private key + if context.String("key") != "" { + prompt := fmt.Sprintf("Enter the passphrase for %s:", context.String("key")) + passphrase := duplicacy.GetPassword(*preference, "rsa_passphrase", prompt, false, resetPassword) + backupManager.SetupRSAPrivateKey(context.String("key"), passphrase) + duplicacy.SavePassword(*preference, "rsa_passphrase", passphrase) + } + backupManager.SetupSnapshotCache(preference.Name) backupManager.SnapshotManager.ListSnapshots(id, revisions, tag, showFiles, showChunks) @@ -885,6 +902,13 @@ func checkSnapshots(context *cli.Context) { backupManager := duplicacy.CreateBackupManager(preference.SnapshotID, storage, repository, password, preference.NobackupFile) duplicacy.SavePassword(*preference, "password", password) + if context.String("key") != "" { + prompt := fmt.Sprintf("Enter the passphrase for %s:", context.String("key")) + passphrase := duplicacy.GetPassword(*preference, "rsa_passphrase", prompt, false, false) + backupManager.SetupRSAPrivateKey(context.String("key"), passphrase) + duplicacy.SavePassword(*preference, "rsa_passphrase", passphrase) + } + id := preference.SnapshotID if context.Bool("all") { id = "" @@ -940,6 +964,13 @@ func printFile(context *cli.Context) { backupManager := duplicacy.CreateBackupManager(preference.SnapshotID, storage, repository, password, preference.NobackupFile) duplicacy.SavePassword(*preference, "password", password) + if context.String("key") != "" { + prompt := fmt.Sprintf("Enter the passphrase for %s:", context.String("key")) + passphrase := duplicacy.GetPassword(*preference, "rsa_passphrase", prompt, false, false) + backupManager.SetupRSAPrivateKey(context.String("key"), passphrase) + duplicacy.SavePassword(*preference, "rsa_passphrase", passphrase) + } + backupManager.SetupSnapshotCache(preference.Name) file := "" @@ -996,6 +1027,13 @@ func diff(context *cli.Context) { backupManager := duplicacy.CreateBackupManager(preference.SnapshotID, storage, repository, password, preference.NobackupFile) duplicacy.SavePassword(*preference, "password", password) + if context.String("key") != "" { + prompt := fmt.Sprintf("Enter the passphrase for %s:", context.String("key")) + passphrase := duplicacy.GetPassword(*preference, "rsa_passphrase", prompt, false, false) + backupManager.SetupRSAPrivateKey(context.String("key"), passphrase) + duplicacy.SavePassword(*preference, "rsa_passphrase", passphrase) + } + backupManager.SetupSnapshotCache(preference.Name) backupManager.SnapshotManager.Diff(repository, snapshotID, revisions, path, compareByHash, preference.NobackupFile) @@ -1406,6 +1444,11 @@ func main() { Name: "enum-only", Usage: "enumerate the repository recursively and then exit", }, + cli.StringFlag{ + Name: "key", + Usage: "the RSA public key to encrypt file chunks", + Argument: "", + }, }, Usage: "Save a snapshot of the repository to the storage", ArgsUsage: " ", @@ -1457,6 +1500,11 @@ func main() { Usage: "restore from the specified storage instead of the default one", Argument: "", }, + cli.StringFlag{ + Name: "key", + Usage: "the RSA private key to decrypt file chunks", + Argument: "", + }, }, Usage: "Restore the repository to a previously saved snapshot", ArgsUsage: "[--] [pattern] ...", @@ -1502,6 +1550,11 @@ func main() { Usage: "retrieve snapshots from the specified storage", Argument: "", }, + cli.StringFlag{ + Name: "key", + Usage: "the RSA private key to decrypt file chunks", + Argument: "", + }, }, Usage: "List snapshots", ArgsUsage: " ", @@ -1554,6 +1607,11 @@ func main() { Usage: "retrieve snapshots from the specified storage", Argument: "", }, + cli.StringFlag{ + Name: "key", + Usage: "the RSA private key to decrypt file chunks", + Argument: "", + }, }, Usage: "Check the integrity of snapshots", ArgsUsage: " ", @@ -1577,6 +1635,11 @@ func main() { Usage: "retrieve the file from the specified storage", Argument: "", }, + cli.StringFlag{ + Name: "key", + Usage: "the RSA private key to decrypt file chunks", + Argument: "", + }, }, Usage: "Print to stdout the specified file, or the snapshot content if no file is specified", ArgsUsage: "[]", @@ -1605,6 +1668,11 @@ func main() { Usage: "retrieve files from the specified storage", Argument: "", }, + cli.StringFlag{ + Name: "key", + Usage: "the RSA private key to decrypt file chunks", + Argument: "", + }, }, Usage: "Compare two snapshots or two revisions of a file", ArgsUsage: "[]", diff --git a/src/duplicacy_backupmanager.go b/src/duplicacy_backupmanager.go index 42bf281..8643f01 100644 --- a/src/duplicacy_backupmanager.go +++ b/src/duplicacy_backupmanager.go @@ -20,6 +20,11 @@ import ( "sync" "sync/atomic" "time" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "io/ioutil" + "reflect" ) // BackupManager performs the two major operations, backup and restore, and passes other operations, mostly related to @@ -103,6 +108,81 @@ func (manager *BackupManager) SetupSnapshotCache(storageName string) bool { return true } +// SetupRSAPublicKey loads the specifed public key file for encrypting file chunks +func (manager *BackupManager) SetupRSAPublicKey(keyFile string) { + encodedKey, err := ioutil.ReadFile(keyFile) + if err != nil { + LOG_ERROR("BACKUP_KEY", "Failed to read the public key file: %v", err) + return + } + + decodedKey, _ := pem.Decode(encodedKey) + if decodedKey == nil { + LOG_ERROR("RESTORE_KEY", "unrecognized public key in %s", keyFile) + return + } + if decodedKey.Type != "PUBLIC KEY" { + LOG_ERROR("BACKUP_KEY", "Unsupported public key type %s in %s", decodedKey.Type, keyFile) + return + } + + parsedKey, err := x509.ParsePKIXPublicKey(decodedKey.Bytes) + if err != nil { + LOG_ERROR("BACKUP_KEY", "Failed to parse the public key in %s: %v", keyFile, err) + return + } + + key, ok := parsedKey.(*rsa.PublicKey) + if !ok { + LOG_ERROR("BACKUP_KEY", "Unsupported public key type %s in %s", reflect.TypeOf(parsedKey), keyFile) + return + } + + manager.config.rsaPublicKey = key +} + +// SetupRSAPrivateKey loads the specifed private key file for decrypting file chunks +func (manager *BackupManager) SetupRSAPrivateKey(keyFile string, passphrase string) { + + encodedKey, err := ioutil.ReadFile(keyFile) + if err != nil { + LOG_ERROR("RESTORE_KEY", "Failed to read the private key file: %v", err) + } + + decodedKey, _ := pem.Decode(encodedKey) + if decodedKey == nil { + LOG_ERROR("RESTORE_KEY", "unrecognized private key in %s", keyFile) + return + } + if decodedKey.Type != "RSA PRIVATE KEY" { + LOG_ERROR("RESTORE_KEY", "Unsupported private key type %s in %s", decodedKey.Type, keyFile) + return + } + + var decodedKeyBytes []byte + if passphrase != "" { + decodedKeyBytes, err = x509.DecryptPEMBlock(decodedKey, []byte(passphrase)) + } else { + decodedKeyBytes = decodedKey.Bytes + } + + var parsedKey interface{} + if parsedKey, err = x509.ParsePKCS1PrivateKey(decodedKeyBytes); err != nil { + if parsedKey, err = x509.ParsePKCS8PrivateKey(decodedKeyBytes); err != nil { + LOG_ERROR("RESTORE_KEY", "Failed to parse the private key in %s: %v", keyFile, err) + return + } + } + + key, ok := parsedKey.(*rsa.PrivateKey) + if !ok { + LOG_ERROR("RESTORE_KEY", "Unsupported private key type %s in %s", reflect.TypeOf(parsedKey), keyFile) + return + } + + manager.config.rsaPrivateKey = key +} + // setEntryContent sets the 4 content pointers for each entry in 'entries'. 'offset' indicates the value // to be added to the StartChunk and EndChunk points, used when intending to append 'entries' to the // original unchanged entry list. diff --git a/src/duplicacy_benchmark.go b/src/duplicacy_benchmark.go index 22018d9..0851c82 100644 --- a/src/duplicacy_benchmark.go +++ b/src/duplicacy_benchmark.go @@ -41,7 +41,7 @@ func benchmarkSplit(reader *bytes.Reader, fileSize int64, chunkSize int, compres if encryption { key = "0123456789abcdef0123456789abcdef" } - err := chunk.Encrypt([]byte(key), "") + err := chunk.Encrypt([]byte(key), "", false) if err != nil { LOG_ERROR("BENCHMARK_ENCRYPT", "Failed to encrypt the chunk: %v", err) } diff --git a/src/duplicacy_chunk.go b/src/duplicacy_chunk.go index bce5c04..df5d664 100644 --- a/src/duplicacy_chunk.go +++ b/src/duplicacy_chunk.go @@ -8,11 +8,13 @@ import ( "bytes" "compress/zlib" "crypto/aes" + "crypto/rsa" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" + "encoding/binary" "fmt" "hash" "io" @@ -65,6 +67,8 @@ type Chunk struct { // Magic word to identify a duplicacy format encrypted file, plus a version number. var ENCRYPTION_HEADER = "duplicacy\000" +var ENCRYPTION_VERSION_RSA byte = 2 + // CreateChunk creates a new chunk. func CreateChunk(config *Config, bufferNeeded bool) *Chunk { @@ -170,7 +174,7 @@ func (chunk *Chunk) VerifyID() { // Encrypt encrypts the plain data stored in the chunk buffer. If derivationKey is not nil, the actual // encryption key will be HMAC-SHA256(encryptionKey, derivationKey). -func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err error) { +func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string, isSnapshot bool) (err error) { var aesBlock cipher.Block var gcm cipher.AEAD @@ -186,8 +190,17 @@ func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err err if len(encryptionKey) > 0 { key := encryptionKey - - if len(derivationKey) > 0 { + usingRSA := false + if !isSnapshot && chunk.config.rsaPublicKey != nil { + // If the chunk is not a snpashot chunk, we attempt to encrypt it with the RSA publick key if there is one + randomKey := make([]byte, 32) + _, err := rand.Read(randomKey) + if err != nil { + return err + } + key = randomKey + usingRSA = true + } else if len(derivationKey) > 0 { hasher := chunk.config.NewKeyedHasher([]byte(derivationKey)) hasher.Write(encryptionKey) key = hasher.Sum(nil) @@ -204,7 +217,21 @@ func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err err } // Start with the magic number and the version number. - encryptedBuffer.Write([]byte(ENCRYPTION_HEADER)) + if usingRSA { + // RSA encryption starts "duplicacy\002" + encryptedBuffer.Write([]byte(ENCRYPTION_HEADER)[:len(ENCRYPTION_HEADER) - 1]) + encryptedBuffer.Write([]byte{ENCRYPTION_VERSION_RSA}) + + // Then the encrypted key + encryptedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, chunk.config.rsaPublicKey, key, nil) + if err != nil { + return err + } + binary.Write(encryptedBuffer, binary.LittleEndian, uint16(len(encryptedKey))) + encryptedBuffer.Write(encryptedKey) + } else { + encryptedBuffer.Write([]byte(ENCRYPTION_HEADER)) + } // Followed by the nonce nonce = make([]byte, gcm.NonceSize()) @@ -214,7 +241,6 @@ func (chunk *Chunk) Encrypt(encryptionKey []byte, derivationKey string) (err err } encryptedBuffer.Write(nonce) offset = encryptedBuffer.Len() - } // offset is either 0 or the length of header + nonce @@ -291,6 +317,7 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err }() chunk.buffer, encryptedBuffer = encryptedBuffer, chunk.buffer + headerLength := len(ENCRYPTION_HEADER) if len(encryptionKey) > 0 { @@ -308,6 +335,41 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err key = hasher.Sum(nil) } + if len(encryptedBuffer.Bytes()) < headerLength + 12 { + return fmt.Errorf("No enough encrypted data (%d bytes) provided", len(encryptedBuffer.Bytes())) + } + + if string(encryptedBuffer.Bytes()[:headerLength-1]) != ENCRYPTION_HEADER[:headerLength-1] { + return fmt.Errorf("The storage doesn't seem to be encrypted") + } + + version := encryptedBuffer.Bytes()[headerLength-1] + if version != 0 && version != ENCRYPTION_VERSION_RSA { + return fmt.Errorf("Unsupported encryption version %d", version) + } + + if version == ENCRYPTION_VERSION_RSA { + if chunk.config.rsaPrivateKey == nil { + LOG_ERROR("CHUNK_DECRYPT", "An RSA private key is required to decrypt the chunk") + return fmt.Errorf("An RSA private key is required to decrypt the chunk") + } + + encryptedKeyLength := binary.LittleEndian.Uint16(encryptedBuffer.Bytes()[headerLength:headerLength+2]) + + if len(encryptedBuffer.Bytes()) < headerLength + 14 + int(encryptedKeyLength) { + return fmt.Errorf("No enough encrypted data (%d bytes) provided", len(encryptedBuffer.Bytes())) + } + + encryptedKey := encryptedBuffer.Bytes()[headerLength + 2:headerLength + 2 + int(encryptedKeyLength)] + headerLength += 2 + int(encryptedKeyLength) + + decryptedKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, chunk.config.rsaPrivateKey, encryptedKey, nil) + if err != nil { + return err + } + key = decryptedKey + } + aesBlock, err := aes.NewCipher(key) if err != nil { return err @@ -318,21 +380,7 @@ func (chunk *Chunk) Decrypt(encryptionKey []byte, derivationKey string) (err err return err } - headerLength := len(ENCRYPTION_HEADER) offset = headerLength + gcm.NonceSize() - - if len(encryptedBuffer.Bytes()) < offset { - return fmt.Errorf("No enough encrypted data (%d bytes) provided", len(encryptedBuffer.Bytes())) - } - - if string(encryptedBuffer.Bytes()[:headerLength-1]) != ENCRYPTION_HEADER[:headerLength-1] { - return fmt.Errorf("The storage doesn't seem to be encrypted") - } - - if encryptedBuffer.Bytes()[headerLength-1] != 0 { - return fmt.Errorf("Unsupported encryption version %d", encryptedBuffer.Bytes()[headerLength-1]) - } - nonce := encryptedBuffer.Bytes()[headerLength:offset] decryptedBytes, err := gcm.Open(encryptedBuffer.Bytes()[:offset], nonce, diff --git a/src/duplicacy_chunk_test.go b/src/duplicacy_chunk_test.go index 7d09445..8dd80aa 100644 --- a/src/duplicacy_chunk_test.go +++ b/src/duplicacy_chunk_test.go @@ -5,12 +5,22 @@ package duplicacy import ( + "flag" "bytes" crypto_rand "crypto/rand" + "crypto/rsa" "math/rand" "testing" ) +var testRSAEncryption bool + +func init() { + flag.BoolVar(&testRSAEncryption, "rsa", false, "enable RSA encryption") + flag.Parse() +} + + func TestChunk(t *testing.T) { key := []byte("duplicacydefault") @@ -22,6 +32,15 @@ func TestChunk(t *testing.T) { config.CompressionLevel = DEFAULT_COMPRESSION_LEVEL maxSize := 1000000 + if testRSAEncryption { + privateKey, err := rsa.GenerateKey(crypto_rand.Reader, 2048) + if err != nil { + t.Errorf("Failed to generate a random private key: %v", err) + } + config.rsaPrivateKey = privateKey + config.rsaPublicKey = privateKey.Public().(*rsa.PublicKey) + } + remainderLength := -1 for i := 0; i < 500; i++ { @@ -37,7 +56,7 @@ func TestChunk(t *testing.T) { hash := chunk.GetHash() id := chunk.GetID() - err := chunk.Encrypt(key, "") + err := chunk.Encrypt(key, "", false) if err != nil { t.Errorf("Failed to encrypt the data: %v", err) continue diff --git a/src/duplicacy_chunkuploader.go b/src/duplicacy_chunkuploader.go index ea9df85..b983fe0 100644 --- a/src/duplicacy_chunkuploader.go +++ b/src/duplicacy_chunkuploader.go @@ -128,7 +128,7 @@ func (uploader *ChunkUploader) Upload(threadIndex int, task ChunkUploadTask) boo } // Encrypt the chunk only after we know that it must be uploaded. - err = chunk.Encrypt(uploader.config.ChunkKey, chunk.GetHash()) + err = chunk.Encrypt(uploader.config.ChunkKey, chunk.GetHash(), uploader.snapshotCache != nil) if err != nil { LOG_ERROR("UPLOAD_CHUNK", "Failed to encrypt the chunk %s: %v", chunkID, err) return false diff --git a/src/duplicacy_config.go b/src/duplicacy_config.go index cf0536b..00fc13e 100644 --- a/src/duplicacy_config.go +++ b/src/duplicacy_config.go @@ -9,6 +9,7 @@ import ( "crypto/hmac" "crypto/rand" "crypto/sha256" + "crypto/rsa" "encoding/binary" "encoding/hex" "encoding/json" @@ -65,6 +66,10 @@ type Config struct { // for encrypting a non-chunk file FileKey []byte `json:"-"` + // for RSA encryption + rsaPrivateKey *rsa.PrivateKey + rsaPublicKey *rsa.PublicKey + chunkPool chan *Chunk numberOfChunks int32 dryRun bool @@ -430,7 +435,7 @@ func UploadConfig(storage Storage, config *Config, password string, iterations i if len(password) > 0 { // Encrypt the config file with masterKey. If masterKey is nil then no encryption is performed. - err = chunk.Encrypt(masterKey, "") + err = chunk.Encrypt(masterKey, "", true) if err != nil { LOG_ERROR("CONFIG_CREATE", "Failed to create the config file: %v", err) return false diff --git a/src/duplicacy_snapshotmanager.go b/src/duplicacy_snapshotmanager.go index d79ee54..39e8426 100644 --- a/src/duplicacy_snapshotmanager.go +++ b/src/duplicacy_snapshotmanager.go @@ -1858,7 +1858,7 @@ func (manager *SnapshotManager) PruneSnapshots(selfID string, snapshotID string, if _, found := newChunks[chunk]; found { // The fossil is referenced so it can't be deleted. if dryRun { - LOG_INFO("FOSSIL_RESURRECT", "Fossil %s would be resurrected: %v", chunk) + LOG_INFO("FOSSIL_RESURRECT", "Fossil %s would be resurrected", chunk) continue } @@ -2466,7 +2466,7 @@ func (manager *SnapshotManager) UploadFile(path string, derivationKey string, co derivationKey = derivationKey[len(derivationKey)-64:] } - err := manager.fileChunk.Encrypt(manager.config.FileKey, derivationKey) + err := manager.fileChunk.Encrypt(manager.config.FileKey, derivationKey, true) if err != nil { LOG_ERROR("UPLOAD_File", "Failed to encrypt the file %s: %v", path, err) return false