7ab9b48259
Three ways the cold-start path could lose the database. A box enrolled before the PRF change could never cold-start again. UnwrapKey still read v1 blobs, but the only caller stopped supplying the v1 secret: the assertion handler sends the PRF output and nothing looks up the credential public key any more. On such a box the daemon read the blob, took the v1 branch, failed to decrypt, and stayed locked while a valid passkey was asserted at it. The escape hatch was gone too, because WrapKeyFn was wired only in env-key mode and a locked boot is by definition the mode with no env key. The recovery was to put MAVEN_DB_KEY back in the environment, which is the thing cold-start unlock exists to avoid. AssertFinish now retries a failed PRF unwrap with the credential public key, and WrapKeyFn is wired in locked mode too, so the box that came up on a v1 blob can be moved to v2. Wrapping ran on every successful assertion. That made a routine step-up rewrite the one file that opens the database, under whatever 32 bytes the page posted. A compromised /auth/webauthn converted one legitimate touch into permanent offline recovery of the at-rest key, and a second enrolled authenticator silently locked out the first. Wrapping is now an act of its own: a plain assertion may write the blob only when none exists, and replacing one takes the rewrite button, which is the only caller that sets the new explicit flag. The daemon still refuses to overwrite a v2 blob that does not open under the presented secret. The write was os.WriteFile, which truncates in place. A power cut between the truncate and the write left a zero-length blob and no previous contents, on the path of every step-up. It is now a temp file in the same directory, fsync, rename, fsync of the directory. Two smaller things on the same path. The v2 unwrap checked the secret length but not the all-zero case the wrap side rejects, so the two ends disagreed about what a valid secret is. And the handler logged "daemon unlocked via credential" when an env-key daemon had answered unknown method, and again when an already-unlocked daemon had done nothing. Left alone deliberately: the PRF value is client-supplied and not covered by the assertion signature. That is inherent to PRF key wrapping, since the salt has to be fixed for the blob to open on the next boot. It is recorded as a known property where the secret enters the handler. Found in review of #77.
188 lines
5.9 KiB
Go
188 lines
5.9 KiB
Go
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))
|
|
}
|
|
}
|