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:
@@ -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