-
Notifications
You must be signed in to change notification settings - Fork 0
Make backups password-encrypted and self-contained #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,132 @@ | ||||||||||||||||||||||||||||
| // Package backupcrypto encrypts panel backups under an admin-chosen password so a | ||||||||||||||||||||||||||||
| // backup file is fully self-contained: it can safely embed the deployment's | ||||||||||||||||||||||||||||
| // ACCOUNT_KEY_ENCRYPTION_KEY and API_HMAC_MASTER_KEY (without which the accounts | ||||||||||||||||||||||||||||
| // and api_keys tables are ciphertext) and still be handed to a fresh server whose | ||||||||||||||||||||||||||||
| // deploy/.env no longer exists - the disaster-recovery case where the original | ||||||||||||||||||||||||||||
| // server is simply gone. The password is the only thing the admin must retain. | ||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||
| // Construction: argon2id (same cost posture as authcrypto's password hashing) | ||||||||||||||||||||||||||||
| // derives a 32-byte key from the password; AES-256-GCM seals the whole backup | ||||||||||||||||||||||||||||
| // JSON. KDF parameters travel inside the envelope so they can be tuned later | ||||||||||||||||||||||||||||
| // without breaking old files. | ||||||||||||||||||||||||||||
| package backupcrypto | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||
| "crypto/aes" | ||||||||||||||||||||||||||||
| "crypto/cipher" | ||||||||||||||||||||||||||||
| "crypto/rand" | ||||||||||||||||||||||||||||
| "encoding/base64" | ||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| "golang.org/x/crypto/argon2" | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Format identifies the envelope; bump the suffix on any incompatible change. | ||||||||||||||||||||||||||||
| const Format = "wgpanel-backup-enc/1" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const ( | ||||||||||||||||||||||||||||
| kdfTimeCost = 1 | ||||||||||||||||||||||||||||
| kdfMemoryKB = 64 * 1024 | ||||||||||||||||||||||||||||
| kdfThreads = 4 | ||||||||||||||||||||||||||||
| keyLenByte = 32 | ||||||||||||||||||||||||||||
| saltLenByte = 16 | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // ErrWrongPassword covers both a wrong password and a corrupted/tampered file - | ||||||||||||||||||||||||||||
| // AES-GCM authentication cannot distinguish the two, and callers shouldn't try. | ||||||||||||||||||||||||||||
| var ErrWrongPassword = errors.New("wrong password or corrupted backup file") | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| type KDFParams struct { | ||||||||||||||||||||||||||||
| Salt string `json:"salt"` // base64 (raw, unpadded) | ||||||||||||||||||||||||||||
| TimeCost uint32 `json:"t"` | ||||||||||||||||||||||||||||
| MemoryKB uint32 `json:"m"` | ||||||||||||||||||||||||||||
| Threads uint8 `json:"p"` | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Envelope is the outer, plaintext-JSON shape of an encrypted backup file. Only | ||||||||||||||||||||||||||||
| // format/created_at are readable without the password. | ||||||||||||||||||||||||||||
| type Envelope struct { | ||||||||||||||||||||||||||||
| Format string `json:"format"` | ||||||||||||||||||||||||||||
| CreatedAt string `json:"created_at"` | ||||||||||||||||||||||||||||
| KDF KDFParams `json:"kdf"` | ||||||||||||||||||||||||||||
| Nonce string `json:"nonce"` // base64 (raw, unpadded) | ||||||||||||||||||||||||||||
| Data string `json:"data"` // base64 (raw, unpadded) AES-256-GCM ciphertext | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Seal encrypts plaintext under password into a ready-to-serialize Envelope. | ||||||||||||||||||||||||||||
| func Seal(password string, plaintext []byte, createdAt string) (Envelope, error) { | ||||||||||||||||||||||||||||
| salt := make([]byte, saltLenByte) | ||||||||||||||||||||||||||||
| if _, err := rand.Read(salt); err != nil { | ||||||||||||||||||||||||||||
| return Envelope{}, fmt.Errorf("generate salt: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| key := argon2.IDKey([]byte(password), salt, kdfTimeCost, kdfMemoryKB, kdfThreads, keyLenByte) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| block, err := aes.NewCipher(key) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return Envelope{}, err | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| gcm, err := cipher.NewGCM(block) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return Envelope{}, err | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| nonce := make([]byte, gcm.NonceSize()) | ||||||||||||||||||||||||||||
| if _, err := rand.Read(nonce); err != nil { | ||||||||||||||||||||||||||||
| return Envelope{}, fmt.Errorf("generate nonce: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| enc := base64.RawStdEncoding | ||||||||||||||||||||||||||||
| return Envelope{ | ||||||||||||||||||||||||||||
| Format: Format, | ||||||||||||||||||||||||||||
| CreatedAt: createdAt, | ||||||||||||||||||||||||||||
| KDF: KDFParams{ | ||||||||||||||||||||||||||||
| Salt: enc.EncodeToString(salt), | ||||||||||||||||||||||||||||
| TimeCost: kdfTimeCost, | ||||||||||||||||||||||||||||
| MemoryKB: kdfMemoryKB, | ||||||||||||||||||||||||||||
| Threads: kdfThreads, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| Nonce: enc.EncodeToString(nonce), | ||||||||||||||||||||||||||||
| Data: enc.EncodeToString(gcm.Seal(nil, nonce, plaintext, nil)), | ||||||||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Open decrypts an Envelope with password, using the KDF parameters the file was | ||||||||||||||||||||||||||||
| // sealed with. Returns ErrWrongPassword on authentication failure. | ||||||||||||||||||||||||||||
| func Open(password string, env Envelope) ([]byte, error) { | ||||||||||||||||||||||||||||
| if env.Format != Format { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("unrecognized backup format %q (expected %q)", env.Format, Format) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| dec := base64.RawStdEncoding | ||||||||||||||||||||||||||||
| salt, err := dec.DecodeString(env.KDF.Salt) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("decode salt: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| nonce, err := dec.DecodeString(env.Nonce) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("decode nonce: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| data, err := dec.DecodeString(env.Data) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("decode data: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| // Guard the KDF cost against a hostile file that would have us allocate | ||||||||||||||||||||||||||||
| // unbounded memory before authentication can reject it. | ||||||||||||||||||||||||||||
| if env.KDF.MemoryKB > 1024*1024 || env.KDF.TimeCost > 16 || env.KDF.Threads == 0 { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("unreasonable KDF parameters in backup file") | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| key := argon2.IDKey([]byte(password), salt, env.KDF.TimeCost, env.KDF.MemoryKB, env.KDF.Threads, keyLenByte) | ||||||||||||||||||||||||||||
| block, err := aes.NewCipher(key) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| gcm, err := cipher.NewGCM(block) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| plaintext, err := gcm.Open(nil, nonce, data, nil) | ||||||||||||||||||||||||||||
|
Comment on lines
+123
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The We should validate the nonce length before calling
Suggested change
|
||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, ErrWrongPassword | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return plaintext, nil | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| package backupcrypto | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/json" | ||
| "errors" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestSealOpenRoundTrip(t *testing.T) { | ||
| plaintext := []byte(`{"tables":{"accounts":[]},"secret":"hunter2"}`) | ||
|
|
||
| env, err := Seal("correct horse battery staple", plaintext, "2026-07-18T00:00:00Z") | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if env.Format != Format { | ||
| t.Errorf("format = %q, want %q", env.Format, Format) | ||
| } | ||
|
|
||
| // The envelope must survive JSON serialization - that's the on-disk shape. | ||
| raw, err := json.Marshal(env) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if bytes.Contains(raw, []byte("hunter2")) { | ||
| t.Fatal("plaintext leaked into the serialized envelope") | ||
| } | ||
| var decoded Envelope | ||
| if err := json.Unmarshal(raw, &decoded); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| got, err := Open("correct horse battery staple", decoded) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if !bytes.Equal(got, plaintext) { | ||
| t.Errorf("round trip mismatch: %q", got) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenWrongPassword(t *testing.T) { | ||
| env, err := Seal("right-password", []byte("data"), "2026-07-18T00:00:00Z") | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if _, err := Open("wrong-password", env); !errors.Is(err, ErrWrongPassword) { | ||
| t.Fatalf("expected ErrWrongPassword, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenTamperedData(t *testing.T) { | ||
| env, err := Seal("pw", []byte("data"), "2026-07-18T00:00:00Z") | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| env.Data = env.Data[:len(env.Data)-2] + "AA" | ||
| if _, err := Open("pw", env); !errors.Is(err, ErrWrongPassword) { | ||
| t.Fatalf("expected ErrWrongPassword for tampered data, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestOpenRejectsHostileKDFParams(t *testing.T) { | ||
| env, err := Seal("pw", []byte("data"), "2026-07-18T00:00:00Z") | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| env.KDF.MemoryKB = 64 * 1024 * 1024 // 64GB - a memory-exhaustion attempt | ||
| if _, err := Open("pw", env); err == nil || errors.Is(err, ErrWrongPassword) { | ||
| t.Fatalf("expected a KDF-parameter rejection, got %v", err) | ||
| } | ||
| } | ||
|
Comment on lines
+64
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To ensure the new panic guards are robust and prevent regressions, we should add test cases verifying that func TestOpenRejectsHostileKDFParams(t *testing.T) {
env, err := Seal("pw", []byte("data"), "2026-07-18T00:00:00Z")
if err != nil {
t.Fatal(err)
}
env.KDF.MemoryKB = 64 * 1024 * 1024 // 64GB - a memory-exhaustion attempt
if _, err := Open("pw", env); err == nil || errors.Is(err, ErrWrongPassword) {
t.Fatalf("expected a KDF-parameter rejection, got %v", err)
}
// Test TimeCost = 0 (panic vector in argon2)
env2, _ := Seal("pw", []byte("data"), "2026-07-18T00:00:00Z")
env2.KDF.TimeCost = 0
if _, err := Open("pw", env2); err == nil || errors.Is(err, ErrWrongPassword) {
t.Fatalf("expected rejection for TimeCost=0, got %v", err)
}
// Test MemoryKB too low for threads (panic vector in argon2)
env3, _ := Seal("pw", []byte("data"), "2026-07-18T00:00:00Z")
env3.KDF.MemoryKB = 1
env3.KDF.Threads = 4
if _, err := Open("pw", env3); err == nil || errors.Is(err, ErrWrongPassword) {
t.Fatalf("expected rejection for low MemoryKB, got %v", err)
}
// Test invalid nonce length (panic vector in gcm.Open)
env4, _ := Seal("pw", []byte("data"), "2026-07-18T00:00:00Z")
env4.Nonce = ""
if _, err := Open("pw", env4); !errors.Is(err, ErrWrongPassword) {
t.Fatalf("expected ErrWrongPassword for invalid nonce length, got %v", err)
}
} |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
argon2.IDKeyfunction ingolang.org/x/crypto/argon2will panic iftime < 1(i.e.,TimeCost == 0) or ifmemory < 8 * threads(i.e.,MemoryKB < 8 * Threads). Since these parameters are read directly from the untrusted backup file envelope, a malicious or malformed backup file can easily trigger a server panic and cause a Denial of Service (DoS).We should explicitly validate these bounds before calling
argon2.IDKey.