coldstart: recover v1 boxes, and make key wrapping an explicit act

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.
This commit is contained in:
kami
2026-08-01 14:05:13 +04:00
parent 7f42cc73be
commit 7ab9b48259
13 changed files with 654 additions and 65 deletions
+10 -2
View File
@@ -755,14 +755,22 @@ type DayPlan struct {
}
// storeEncryptionKeyReq — the passkey-derived secret used to wrap the store
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
// encryption key. Called by mavweb after a verified assertion.
//
// Secret is the 32-byte WebAuthn PRF output, NOT the credential public key.
// The field used to carry the public key and that was the bug: a public key
// sits in passkeys.json next to the wrapped blob, so the blob protected
// nothing. See internal/webauthn/keywrap.go.
//
// Explicit says the operator asked for the cold-start key to be written, as
// opposed to it being a side effect of asserting a passkey. Without the flag
// the daemon writes only when no blob exists yet. Rewriting on every assertion
// is what let a page-level compromise substitute its own PRF value and have
// the daemon re-wrap the real database key under it, and what let a second
// authenticator silently replace the first one's blob.
type storeEncryptionKeyReq struct {
Secret []byte `json:"secret"`
Secret []byte `json:"secret"`
Explicit bool `json:"explicit,omitempty"`
}
// unlockReq — the passkey-derived secret for unwrapping the store encryption
+8 -3
View File
@@ -395,9 +395,14 @@ func (c *Client) AssertStepUp(ctx context.Context) error {
}
// StoreEncryptionKey wraps the daemon's at-rest key under secret, the 32-byte
// WebAuthn PRF output for the freshly enrolled credential.
func (c *Client) StoreEncryptionKey(ctx context.Context, secret []byte) error {
return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{Secret: secret}, nil)
// WebAuthn PRF output for the asserted credential.
//
// explicit marks an operator-requested write. False means "write it only if
// there is nothing there yet": a blob already on disk is left alone, because
// rewriting it on every assertion is how an attacker-chosen PRF value, or a
// second authenticator, replaces the one thing that opens the database.
func (c *Client) StoreEncryptionKey(ctx context.Context, secret []byte, explicit bool) error {
return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{Secret: secret, Explicit: explicit}, nil)
}
// Unlock hands the daemon the PRF secret so it can unwrap its at-rest key and
+6 -2
View File
@@ -498,7 +498,11 @@ type Server struct {
// WrapKeyFunc — wraps the store encryption key under the passkey-derived
// secret (a 32-byte WebAuthn PRF output) and persists the wrapped blob.
type WrapKeyFunc func(ctx context.Context, secret []byte) error
//
// explicit distinguishes "the operator asked for the cold-start key to be
// written" from "a passkey was asserted". Only the first may overwrite a blob
// that is already there; see cmd/mavend/keyfile.go.
type WrapKeyFunc func(ctx context.Context, secret []byte, explicit bool) error
// UnlockFunc — unwraps the store encryption key using the passkey-derived
// secret and completes daemon initialization.
@@ -945,7 +949,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err
}
return marshalResult(nil), s.WrapKeyFn(ctx, p.Secret)
return marshalResult(nil), s.WrapKeyFn(ctx, p.Secret, p.Explicit)
}
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
+18 -5
View File
@@ -40,8 +40,9 @@ func TestUnlockDeliversSecretToHook(t *testing.T) {
secret[i] = byte(i + 1)
}
var gotUnlock, gotWrap []byte
var gotExplicit bool
srv.UnlockFn = func(_ context.Context, s []byte) error { gotUnlock = bytes.Clone(s); return nil }
srv.WrapKeyFn = func(_ context.Context, s []byte) error { gotWrap = bytes.Clone(s); return nil }
srv.WrapKeyFn = func(_ context.Context, s []byte, explicit bool) error { gotWrap = bytes.Clone(s); gotExplicit = explicit; return nil }
ctx := context.Background()
if err := cli.Unlock(ctx, secret); err != nil {
@@ -50,12 +51,24 @@ func TestUnlockDeliversSecretToHook(t *testing.T) {
if !bytes.Equal(gotUnlock, secret) {
t.Errorf("UnlockFn got %x, want %x", gotUnlock, secret)
}
if err := cli.StoreEncryptionKey(ctx, secret); err != nil {
if err := cli.StoreEncryptionKey(ctx, secret, true); err != nil {
t.Fatalf("StoreEncryptionKey: %v", err)
}
if !bytes.Equal(gotWrap, secret) {
t.Errorf("WrapKeyFn got %x, want %x", gotWrap, secret)
}
// The explicit flag rides the same request. Without it the daemon cannot
// tell "he asked for the cold-start key to be rewritten" from "a passkey
// was asserted", and rewrites the blob on every step-up.
if !gotExplicit {
t.Error("WrapKeyFn got explicit=false, want the flag to cross the wire")
}
if err := cli.StoreEncryptionKey(ctx, secret, false); err != nil {
t.Fatalf("StoreEncryptionKey: %v", err)
}
if gotExplicit {
t.Error("WrapKeyFn got explicit=true for an implicit wrap")
}
}
// A refusal from the daemon hook — a wrong passkey, or no prior assertion —
@@ -78,7 +91,7 @@ func TestUnlockUnwiredIsUnknownMethod(t *testing.T) {
if err := cli.Unlock(ctx, bytes.Repeat([]byte{1}, 32)); err == nil {
t.Error("Unlock succeeded with no UnlockFn wired")
}
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{1}, 32)); err == nil {
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{1}, 32), false); err == nil {
t.Error("StoreEncryptionKey succeeded with no WrapKeyFn wired")
}
}
@@ -100,7 +113,7 @@ func TestLockedCheckDefaultDenies(t *testing.T) {
unlocked := false
srv.UnlockFn = func(context.Context, []byte) error { unlocked = true; return nil }
srv.StepUp = func(context.Context) error { return nil }
srv.WrapKeyFn = func(context.Context, []byte) error { return nil }
srv.WrapKeyFn = func(context.Context, []byte, bool) error { return nil }
ctx := context.Background()
// A store method must be refused while locked.
@@ -108,7 +121,7 @@ func TestLockedCheckDefaultDenies(t *testing.T) {
t.Error("a store read went through while locked")
}
// Key wrapping is NOT on the allowlist: a locked daemon has no key to wrap.
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{2}, 32)); err == nil {
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{2}, 32), false); err == nil {
t.Error("StoreEncryptionKey was allowed while locked")
}
// The unlock flow itself must still work.
+22 -3
View File
@@ -24,7 +24,18 @@
//
// v1 blobs are still readable, so an existing deployment opens and can be
// re-wrapped, and UnwrapKey reports which format it read so the caller can
// say so out loud. Nothing writes v1 any more.
// say so out loud. Nothing writes v1 any more. Reading one needs the
// credential public key, which only cmd/mavweb still has: its assertion
// handler retries a failed PRF unwrap with it, because otherwise a box
// enrolled before v2 could never cold-start again.
//
// # What the wrapped blob's security rests on
//
// The PRF secret is stable for the lifetime of the credential and it reaches
// mavend inside an HTTP request body. Unlike a signature it does not expire.
// One copy in a proxy log, a devtools HAR, or a crash dump is permanent
// offline access to whatever this blob wraps. Nothing on this path may log the
// secret, and nothing does.
//
// # Blob format
//
@@ -171,8 +182,16 @@ func unwrap(body, secret []byte, info string, aad []byte, wantSecretLen int) ([]
if len(body) < saltLen+nonceLen+1 {
return nil, fmt.Errorf("%w: blob too short (%d)", ErrKeyUnwrap, len(body))
}
if wantSecretLen > 0 && len(secret) != wantSecretLen {
return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, ErrSecretLen)
// The v2 side is held to exactly what WrapKey demands, all-zero included.
// Letting the two ends disagree about what a valid secret is would leave
// a blob that can be opened by material that could never have sealed it.
if wantSecretLen > 0 {
if len(secret) != wantSecretLen {
return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, ErrSecretLen)
}
if err := checkSecret(secret); err != nil {
return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, err)
}
}
salt := body[:saltLen]
+15
View File
@@ -229,3 +229,18 @@ func TestUnwrapRejectsOversizeBlob(t *testing.T) {
t.Fatalf("err = %v, want ErrBlobTooLong", err)
}
}
// WrapKey refuses an all-zero secret because a blob wrapped under one is a
// blob anyone can open. The v2 unwrap side must refuse it for the same reason:
// if the two ends disagree about what a valid secret is, a blob can be opened
// by material that could never have sealed it.
func TestUnwrapV2RefusesAnAllZeroSecret(t *testing.T) {
key := bytes.Repeat([]byte{1}, 32)
blob, err := WrapKey(key, bytes.Repeat([]byte{2}, 32))
if err != nil {
t.Fatalf("WrapKey: %v", err)
}
if _, _, err := UnwrapKey(blob, make([]byte, 32)); !errors.Is(err, ErrKeyUnwrap) {
t.Fatalf("UnwrapKey with an all-zero secret = %v, want refusal", err)
}
}