mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
527fa47e0f
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) models.User no longer carries the secret. GetUserQ and its siblings, the member and backlink reads and the mention-recipient check therefore cannot fail on a secret that will not decrypt. checkSecondFactor loads it through the new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP branch (an empty one never verifies), a decrypt failure logs once without secret material and fails closed, and the recovery-code branch is untouched. Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD * fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1) - checkSecondFactor writes its 500 directly: writeInternalError logged the same error a second time. T2 counts every ERROR record except the request logger's per-request line. - T1's Stripe lookup now matches the undecryptable user (it looked up an unknown customer and could not go red), and ListUsers and SearchUsers are pinned to return the row with its 2FA flag. - The appstore boundary allowlist no longer says GetUserQ decrypts, and drops the deleted decryptUserTOTP. - Two comments no longer claim a single reader of the column. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2) An enabled row whose secret is empty cannot verify a code, so the user is held to recovery codes; checkSecondFactor now logs one warning naming the user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and the test placeholder helper drops its unused argument. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3) user.TOTPEnabled is the request's snapshot, and a disable landing after it also reads as no secret, so the warning no longer says 2FA is enabled; it says there was no secret to verify a code against. T5 asserts the new text. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019) The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty case wrote '' and returned at stored=="" first, and encrypt("") returns "" rather than sealing it, so dropping the guard left every test green. The store test now writes a hand-sealed empty plaintext to an enabled row and expects ErrNoTOTPSecret; a server test refuses a code computed from "" on that row. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8 * test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5) EnableTOTP also decrypts the stored secret (to compare) and fails on one that will not decrypt, so the header no longer says the second-factor check is the only thing that fails. TOTPSecretForVerify remains the only reader that returns the plaintext. Comment only. Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
142 lines
3.1 KiB
Go
142 lines
3.1 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
func TestEncryptDecrypt(t *testing.T) {
|
|
t.Parallel()
|
|
s := testStore(t)
|
|
|
|
// Generate a random 32-byte key
|
|
key := make([]byte, 32)
|
|
if _, err := rand.Read(key); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.SetEncryptionKey(key)
|
|
|
|
// Test basic encrypt/decrypt
|
|
original := "JBSWY3DPEHPK3PXP"
|
|
encrypted, err := s.encrypt(original)
|
|
if err != nil {
|
|
t.Fatalf("encrypt: %v", err)
|
|
}
|
|
if encrypted == original {
|
|
t.Error("encrypted value should differ from original")
|
|
}
|
|
if encrypted[:4] != "enc:" {
|
|
t.Errorf("encrypted value should start with 'enc:', got %q", encrypted[:4])
|
|
}
|
|
|
|
decrypted, err := s.decrypt(encrypted)
|
|
if err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
if decrypted != original {
|
|
t.Errorf("expected %q, got %q", original, decrypted)
|
|
}
|
|
}
|
|
|
|
func TestDecryptPlaintext(t *testing.T) {
|
|
t.Parallel()
|
|
s := testStore(t)
|
|
|
|
key := make([]byte, 32)
|
|
rand.Read(key)
|
|
s.SetEncryptionKey(key)
|
|
|
|
// Plaintext values (no "enc:" prefix) should pass through unchanged
|
|
plain := "JBSWY3DPEHPK3PXP"
|
|
result, err := s.decrypt(plain)
|
|
if err != nil {
|
|
t.Fatalf("decrypt plaintext: %v", err)
|
|
}
|
|
if result != plain {
|
|
t.Errorf("expected %q, got %q", plain, result)
|
|
}
|
|
}
|
|
|
|
func TestEncryptWithoutKey(t *testing.T) {
|
|
t.Parallel()
|
|
s := testStore(t)
|
|
|
|
// No encryption key — encrypt should return plaintext
|
|
original := "JBSWY3DPEHPK3PXP"
|
|
result, err := s.encrypt(original)
|
|
if err != nil {
|
|
t.Fatalf("encrypt without key: %v", err)
|
|
}
|
|
if result != original {
|
|
t.Errorf("without key, encrypt should return plaintext, got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestEncryptDecryptEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
s := testStore(t)
|
|
|
|
key := make([]byte, 32)
|
|
rand.Read(key)
|
|
s.SetEncryptionKey(key)
|
|
|
|
// Empty strings should pass through
|
|
encrypted, err := s.encrypt("")
|
|
if err != nil {
|
|
t.Fatalf("encrypt empty: %v", err)
|
|
}
|
|
if encrypted != "" {
|
|
t.Errorf("expected empty, got %q", encrypted)
|
|
}
|
|
|
|
decrypted, err := s.decrypt("")
|
|
if err != nil {
|
|
t.Fatalf("decrypt empty: %v", err)
|
|
}
|
|
if decrypted != "" {
|
|
t.Errorf("expected empty, got %q", decrypted)
|
|
}
|
|
}
|
|
|
|
func TestTOTPEncryptionRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
s := testStore(t)
|
|
|
|
key := make([]byte, 32)
|
|
rand.Read(key)
|
|
s.SetEncryptionKey(key)
|
|
|
|
// Create a user
|
|
u, err := s.CreateUser(models.UserCreate{
|
|
Email: "totp@test.com",
|
|
Name: "TOTP Tester",
|
|
Password: "password123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
// Set TOTP secret (should be encrypted in DB)
|
|
secret := "JBSWY3DPEHPK3PXP"
|
|
if err := s.SetTOTPSecret(u.ID, secret); err != nil {
|
|
t.Fatalf("set totp secret: %v", err)
|
|
}
|
|
|
|
// Read it back — should be decrypted automatically
|
|
if got := storedTOTPSecret(t, s, u.ID); got != secret {
|
|
t.Errorf("expected %q, got %q", secret, got)
|
|
}
|
|
|
|
// Verify the raw value in DB is encrypted
|
|
var rawSecret string
|
|
s.db.QueryRow(s.q("SELECT totp_secret FROM users WHERE id = ?"), u.ID).Scan(&rawSecret)
|
|
if rawSecret == secret {
|
|
t.Error("raw DB value should be encrypted, not plaintext")
|
|
}
|
|
if rawSecret[:4] != "enc:" {
|
|
t.Errorf("raw DB value should start with 'enc:', got %q", rawSecret[:10])
|
|
}
|
|
}
|