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:
+10
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user