package main import ( "bytes" "errors" "os" "path/filepath" "testing" "github.com/kami/maven/internal/webauthn" ) func wrapPath(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "db_key.wrapped") } // The first wrap writes a v2 blob that opens under the same secret. func TestWrapKeyToFileWritesAnOpenableBlob(t *testing.T) { path := wrapPath(t) key := bytes.Repeat([]byte{1}, 32) secret := bytes.Repeat([]byte{2}, 32) wrote, err := wrapKeyToFile(path, key, secret) if err != nil || !wrote { t.Fatalf("wrapKeyToFile = %v, %v; want a write", wrote, err) } blob, err := os.ReadFile(path) if err != nil { t.Fatalf("read blob: %v", err) } plain, version, err := webauthn.UnwrapKey(blob, secret) if err != nil || version != webauthn.BlobV2 || !bytes.Equal(plain, key) { t.Fatalf("UnwrapKey = %x, %v, %v", plain, version, err) } if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 { t.Fatalf("mode = %v (%v), want 0600", fi.Mode().Perm(), err) } } // A blob that already wraps this key under this secret is left alone. Without // this every assertion rewrote the one file that opens the database. func TestWrapKeyToFileSkipsAnIdenticalBlob(t *testing.T) { path := wrapPath(t) key := bytes.Repeat([]byte{3}, 32) secret := bytes.Repeat([]byte{4}, 32) if _, err := wrapKeyToFile(path, key, secret); err != nil { t.Fatalf("first wrap: %v", err) } before, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } wrote, err := wrapKeyToFile(path, key, secret) if err != nil { t.Fatalf("second wrap: %v", err) } if wrote { t.Error("rewrote a blob that already opens under this secret") } after, _ := os.ReadFile(path) if !bytes.Equal(before, after) { t.Error("the blob changed on a no-op wrap") } } // Two enrolled authenticators, two PRF secrets, one blob. The second must not // silently lock the first one out — the backup passkey enrolled for exactly // the cold-start case is the one thing that used to stop working. func TestWrapKeyToFileRefusesAnotherCredentialsBlob(t *testing.T) { path := wrapPath(t) key := bytes.Repeat([]byte{5}, 32) phone := bytes.Repeat([]byte{6}, 32) yubikey := bytes.Repeat([]byte{7}, 32) if _, err := wrapKeyToFile(path, key, phone); err != nil { t.Fatalf("first wrap: %v", err) } before, _ := os.ReadFile(path) wrote, err := wrapKeyToFile(path, key, yubikey) if !errors.Is(err, errForeignBlob) { t.Fatalf("wrapKeyToFile = %v, %v; want errForeignBlob", wrote, err) } after, _ := os.ReadFile(path) if !bytes.Equal(before, after) { t.Fatal("the second authenticator overwrote the first one's blob") } if _, _, err := webauthn.UnwrapKey(after, phone); err != nil { t.Fatalf("the first authenticator can no longer open the blob: %v", err) } } // A v1 blob is the pre-#14 format. It is upgraded in place rather than // refused, because that is the only way off a format that protects nothing. func TestWrapKeyToFileUpgradesALegacyBlob(t *testing.T) { path := wrapPath(t) key := bytes.Repeat([]byte{8}, 32) secret := bytes.Repeat([]byte{9}, 32) // A v1 blob is a v2 blob with the magic stripped and the v1 info string; // the package writes no v1, so build one the only way a test can: wrap // v2 under a public key, then hand the file a body with no magic. What // matters here is only that UnwrapKey classifies it as v1. v2, err := webauthn.WrapKey(key, secret) if err != nil { t.Fatalf("WrapKey: %v", err) } legacy := v2[7:] // drop the magic if err := os.WriteFile(path, legacy, 0o600); err != nil { t.Fatalf("write legacy blob: %v", err) } if _, version, _ := webauthn.UnwrapKey(legacy, secret); version != webauthn.BlobV1 { t.Fatalf("fixture is not read as v1 (got %v)", version) } wrote, err := wrapKeyToFile(path, key, secret) if err != nil || !wrote { t.Fatalf("wrapKeyToFile = %v, %v; want the legacy blob upgraded", wrote, err) } blob, _ := os.ReadFile(path) if _, version, err := webauthn.UnwrapKey(blob, secret); err != nil || version != webauthn.BlobV2 { t.Fatalf("after upgrade: version %v, err %v", version, err) } } // A rotated at-rest key under the same credential is a rewrite, not a no-op. func TestWrapKeyToFileRewritesARotatedKey(t *testing.T) { path := wrapPath(t) secret := bytes.Repeat([]byte{10}, 32) old := bytes.Repeat([]byte{11}, 32) fresh := bytes.Repeat([]byte{12}, 32) if _, err := wrapKeyToFile(path, old, secret); err != nil { t.Fatalf("first wrap: %v", err) } wrote, err := wrapKeyToFile(path, fresh, secret) if err != nil || !wrote { t.Fatalf("wrapKeyToFile = %v, %v; want the rotated key written", wrote, err) } blob, _ := os.ReadFile(path) plain, _, err := webauthn.UnwrapKey(blob, secret) if err != nil || !bytes.Equal(plain, fresh) { t.Fatalf("blob still wraps the old key (%v)", err) } } // The write never truncates the target in place, so a crash mid-write cannot // leave a zero-length blob where the only copy of the wrapped key was. func TestWriteFileAtomicLeavesNoTempFilesAndReplacesWhole(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "db_key.wrapped") if err := os.WriteFile(path, bytes.Repeat([]byte{0xaa}, 67), 0o600); err != nil { t.Fatalf("seed: %v", err) } // Hold the old inode. A rename gives it a new one; a truncating write // would keep it. oldInfo, err := os.Stat(path) if err != nil { t.Fatalf("stat: %v", err) } want := bytes.Repeat([]byte{0xbb}, 67) if err := writeFileAtomic(path, want, 0o600); err != nil { t.Fatalf("writeFileAtomic: %v", err) } got, err := os.ReadFile(path) if err != nil || !bytes.Equal(got, want) { t.Fatalf("content = %x (%v)", got, err) } newInfo, err := os.Stat(path) if err != nil { t.Fatalf("stat: %v", err) } if os.SameFile(oldInfo, newInfo) { t.Error("the target was written in place, not renamed over") } entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("readdir: %v", err) } if len(entries) != 1 { t.Errorf("directory holds %d entries, want just the blob (a temp file leaked)", len(entries)) } }