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.
247 lines
7.1 KiB
Go
247 lines
7.1 KiB
Go
package webauthn
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
)
|
|
|
|
func testSecret(t *testing.T) []byte {
|
|
t.Helper()
|
|
s := make([]byte, secretLen)
|
|
if _, err := io.ReadFull(rand.Reader, s); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
s[0] |= 1 // never all-zero
|
|
return s
|
|
}
|
|
|
|
func testKey(t *testing.T) []byte {
|
|
t.Helper()
|
|
k := make([]byte, keyLen)
|
|
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
return k
|
|
}
|
|
|
|
func TestWrapUnwrapRoundTrip(t *testing.T) {
|
|
key, secret := testKey(t), testSecret(t)
|
|
|
|
blob, err := WrapKey(key, secret)
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
if !bytes.HasPrefix(blob, blobMagicV2) {
|
|
t.Fatalf("blob does not start with the v2 magic: %x", blob[:8])
|
|
}
|
|
// The plaintext key must not be recoverable by reading the file.
|
|
if bytes.Contains(blob, key) {
|
|
t.Fatal("the wrapped blob contains the plaintext key verbatim")
|
|
}
|
|
|
|
got, version, err := UnwrapKey(blob, secret)
|
|
if err != nil {
|
|
t.Fatalf("UnwrapKey: %v", err)
|
|
}
|
|
if version != BlobV2 {
|
|
t.Errorf("version = %v, want v2", version)
|
|
}
|
|
if !bytes.Equal(got, key) {
|
|
t.Errorf("unwrapped key differs from the wrapped one")
|
|
}
|
|
}
|
|
|
|
// Fresh salt and nonce per wrap: two blobs of the same key under the same
|
|
// secret must not be byte-identical, or the file leaks that nothing changed.
|
|
func TestWrapKeyIsNotDeterministic(t *testing.T) {
|
|
key, secret := testKey(t), testSecret(t)
|
|
a, err := WrapKey(key, secret)
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
b, err := WrapKey(key, secret)
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
if bytes.Equal(a, b) {
|
|
t.Fatal("two wraps of the same key produced identical blobs")
|
|
}
|
|
}
|
|
|
|
// The failure mode that matters most: a wrong passkey must not unlock.
|
|
func TestUnwrapWithWrongSecretFails(t *testing.T) {
|
|
key := testKey(t)
|
|
blob, err := WrapKey(key, testSecret(t))
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
got, _, err := UnwrapKey(blob, testSecret(t))
|
|
if err == nil {
|
|
t.Fatal("a different secret unwrapped the blob")
|
|
}
|
|
if !errors.Is(err, ErrKeyUnwrap) {
|
|
t.Errorf("err = %v, want ErrKeyUnwrap", err)
|
|
}
|
|
if got != nil {
|
|
t.Error("key material returned alongside an error")
|
|
}
|
|
}
|
|
|
|
// One flipped bit anywhere must fail the GCM tag, including in the salt and
|
|
// nonce — those are not authenticated by the tag but they change the
|
|
// derivation, so the tag fails anyway.
|
|
func TestUnwrapRejectsTamperedBlob(t *testing.T) {
|
|
key, secret := testKey(t), testSecret(t)
|
|
blob, err := WrapKey(key, secret)
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
for i := range blob {
|
|
bad := bytes.Clone(blob)
|
|
bad[i] ^= 0x01
|
|
if _, _, err := UnwrapKey(bad, secret); err == nil {
|
|
t.Fatalf("byte %d of %d could be flipped and the blob still opened", i, len(blob))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnwrapRejectsTruncatedBlob(t *testing.T) {
|
|
key, secret := testKey(t), testSecret(t)
|
|
blob, err := WrapKey(key, secret)
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
for _, n := range []int{0, 1, len(blobMagicV2), len(blobMagicV2) + saltLen, len(blob) - 1} {
|
|
if _, _, err := UnwrapKey(blob[:n], secret); err == nil {
|
|
t.Errorf("a %d-byte blob unwrapped", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A v2 blob must not be downgradeable to v1 by stripping its header: the magic
|
|
// is GCM additional data, so the tag fails once it is gone.
|
|
func TestV2BlobCannotBeStrippedToV1(t *testing.T) {
|
|
key, secret := testKey(t), testSecret(t)
|
|
blob, err := WrapKey(key, secret)
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
if _, _, err := UnwrapKey(blob[len(blobMagicV2):], secret); err == nil {
|
|
t.Fatal("a header-stripped v2 blob was accepted as v1")
|
|
}
|
|
}
|
|
|
|
// v1 blobs still open, and report themselves as v1 so the daemon can warn.
|
|
// wrapV1 reproduces the legacy writer this file no longer has.
|
|
func wrapV1(t *testing.T, key, secret []byte) []byte {
|
|
t.Helper()
|
|
salt := make([]byte, saltLen)
|
|
nonce := make([]byte, nonceLen)
|
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
gcm, err := gcmFor(secret, salt, wrapInfoV1)
|
|
if err != nil {
|
|
t.Fatalf("gcmFor: %v", err)
|
|
}
|
|
out := append([]byte{}, salt...)
|
|
out = append(out, nonce...)
|
|
return append(out, gcm.Seal(nil, nonce, key, nil)...)
|
|
}
|
|
|
|
func TestUnwrapReadsLegacyV1(t *testing.T) {
|
|
key := testKey(t)
|
|
// v1 was keyed on the credential public key: not 32 bytes, and that is
|
|
// deliberately still accepted on the read path.
|
|
pub := make([]byte, 77)
|
|
if _, err := io.ReadFull(rand.Reader, pub); err != nil {
|
|
t.Fatalf("rand: %v", err)
|
|
}
|
|
blob := wrapV1(t, key, pub)
|
|
|
|
got, version, err := UnwrapKey(blob, pub)
|
|
if err != nil {
|
|
t.Fatalf("UnwrapKey(v1): %v", err)
|
|
}
|
|
if version != BlobV1 {
|
|
t.Errorf("version = %v, want v1", version)
|
|
}
|
|
if !bytes.Equal(got, key) {
|
|
t.Error("v1 round-trip lost the key")
|
|
}
|
|
if _, _, err := UnwrapKey(blob, pub[:76]); err == nil {
|
|
t.Error("a truncated public key opened the v1 blob")
|
|
}
|
|
}
|
|
|
|
// The structural guard against the bug this replaces: a COSE public key is not
|
|
// 32 bytes, so it can never be used to write a new blob.
|
|
func TestWrapKeyRefusesNonPRFSecret(t *testing.T) {
|
|
key := testKey(t)
|
|
cases := map[string][]byte{
|
|
"nil": nil,
|
|
"empty": {},
|
|
"short": make([]byte, 16),
|
|
"cose public key": make([]byte, 77),
|
|
"all-zero 32 byte": make([]byte, 32),
|
|
}
|
|
for name, secret := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := WrapKey(key, secret); err == nil {
|
|
t.Fatalf("WrapKey accepted a %s secret", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWrapKeyRefusesWrongKeyLength(t *testing.T) {
|
|
secret := testSecret(t)
|
|
for _, n := range []int{0, 16, 31, 33, 64} {
|
|
if _, err := WrapKey(make([]byte, n), secret); err == nil {
|
|
t.Errorf("WrapKey accepted a %d-byte plaintext key", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A v2 blob demands exactly 32 bytes on the read path too, so a caller cannot
|
|
// go back to passing a public key.
|
|
func TestUnwrapV2RefusesNonPRFSecret(t *testing.T) {
|
|
blob, err := WrapKey(testKey(t), testSecret(t))
|
|
if err != nil {
|
|
t.Fatalf("WrapKey: %v", err)
|
|
}
|
|
if _, _, err := UnwrapKey(blob, make([]byte, 77)); !errors.Is(err, ErrKeyUnwrap) {
|
|
t.Fatalf("err = %v, want ErrKeyUnwrap for a 77-byte secret", err)
|
|
}
|
|
if _, _, err := UnwrapKey(blob, nil); err == nil {
|
|
t.Fatal("an empty secret unwrapped a v2 blob")
|
|
}
|
|
}
|
|
|
|
func TestUnwrapRejectsOversizeBlob(t *testing.T) {
|
|
if _, _, err := UnwrapKey(make([]byte, maxBlobLen+1), testSecret(t)); !errors.Is(err, ErrBlobTooLong) {
|
|
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)
|
|
}
|
|
}
|