Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions backend/internal/backupcrypto/backupcrypto.go
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")
}
Comment on lines +112 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The argon2.IDKey function in golang.org/x/crypto/argon2 will panic if time < 1 (i.e., TimeCost == 0) or if memory < 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.

Suggested change
// 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")
}
// Guard the KDF cost against a hostile file that would have us allocate
// unbounded memory or trigger a panic in argon2 before authentication can reject it.
if env.KDF.MemoryKB > 1024*1024 || env.KDF.TimeCost > 16 || env.KDF.TimeCost == 0 || env.KDF.Threads == 0 || env.KDF.MemoryKB < 8*uint32(env.KDF.Threads) {
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The gcm.Open function will panic if the provided nonce slice length is not exactly equal to gcm.NonceSize() (which is 12 bytes for AES-GCM). Since the nonce is decoded from the untrusted backup envelope, an invalid nonce length will crash the server.

We should validate the nonce length before calling gcm.Open.

Suggested change
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
plaintext, err := gcm.Open(nil, nonce, data, nil)
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(nonce) != gcm.NonceSize() {
return nil, ErrWrongPassword
}
plaintext, err := gcm.Open(nil, nonce, data, nil)

if err != nil {
return nil, ErrWrongPassword
}
return plaintext, nil
}
73 changes: 73 additions & 0 deletions backend/internal/backupcrypto/backupcrypto_test.go
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure the new panic guards are robust and prevent regressions, we should add test cases verifying that Open gracefully rejects invalid KDF parameters (like TimeCost = 0 or insufficient MemoryKB) and invalid nonce lengths without panicking.

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)
	}
}

Loading
Loading