Merge branch 'fix/g09' into fix/integrated

This commit is contained in:
kami
2026-08-01 14:22:24 +04:00
31 changed files with 1431 additions and 159 deletions
+16 -2
View File
@@ -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
+8 -3
View File
@@ -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
+6 -2
View File
@@ -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)
+22 -5
View File
@@ -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.