Merge branch 'fix/g09' into fix/integrated
This commit is contained in:
+16
-2
@@ -362,6 +362,12 @@ type Speaker struct {
|
||||
Name string `json:"name"`
|
||||
Enrolled time.Time `json:"enrolled"`
|
||||
Samples int `json:"samples"`
|
||||
// Damaged — the stored row's metadata did not read back cleanly. The
|
||||
// voiceprint is still there; the name, sample count or enrolment time is
|
||||
// not trustworthy. A surface should say so rather than render a corrupt
|
||||
// row as a profile enrolled from zero samples, which is what a real
|
||||
// minimal enrolment looks like.
|
||||
Damaged bool `json:"damaged,omitempty"`
|
||||
}
|
||||
|
||||
// EnrollSpeakerResp — the profile that was written.
|
||||
@@ -804,14 +810,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
|
||||
|
||||
@@ -413,9 +413,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
|
||||
|
||||
@@ -529,7 +529,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.
|
||||
@@ -996,7 +1000,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,13 @@ 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 +55,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 +95,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 +117,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 +125,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.
|
||||
|
||||
Reference in New Issue
Block a user