Merge branch 'fix/g09' into fix/integrated
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
// Writing the wrapped-key blob (Vikunja #14).
|
||||
//
|
||||
// The blob is the only thing that opens the database on a cold-started box, so
|
||||
// the two rules here are about not losing it.
|
||||
//
|
||||
// # It is rewritten on every assertion, so the write must be atomic
|
||||
//
|
||||
// mavweb calls StoreEncryptionKey after every successful assertion, not only
|
||||
// after enrolment. os.WriteFile truncates in place: a power cut or an OOM kill
|
||||
// between the truncate and the write left a zero-length blob and no previous
|
||||
// contents, on the path of every routine step-up. Write to a temp file in the
|
||||
// same directory, fsync it, rename over the target, then fsync the directory.
|
||||
//
|
||||
// # Only one authenticator can hold the cold-start key
|
||||
//
|
||||
// A blob is wrapped under one credential's PRF output and nothing else opens
|
||||
// it. mavweb sends an empty allowCredentials list and the credential store
|
||||
// keeps more than one passkey, so an unconditional rewrite meant the last
|
||||
// authenticator to assert silently locked out every other one — including the
|
||||
// backup hardware key enrolled for exactly the cold-start case. So: a blob
|
||||
// that already opens under this secret and already wraps this key is left
|
||||
// alone, a v1 blob is upgraded in place, and a v2 blob belonging to a
|
||||
// different credential is refused rather than overwritten.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// errForeignBlob — the wrapped key on disk belongs to another credential.
|
||||
// Refusing is the point: overwriting would lock that authenticator out.
|
||||
var errForeignBlob = errors.New("wrapped key belongs to a different credential")
|
||||
|
||||
// wrapKeyToFile wraps key under secret and persists it at path, unless the
|
||||
// blob already there says not to. Reports whether it wrote anything.
|
||||
func wrapKeyToFile(path string, key, secret []byte) (wrote bool, err error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
plain, version, uerr := webauthn.UnwrapKey(existing, secret)
|
||||
switch {
|
||||
case uerr == nil && version == webauthn.BlobV2 && bytes.Equal(plain, key):
|
||||
// Already wrapped under this secret, around this key. The
|
||||
// common case on every assertion after the first.
|
||||
return false, nil
|
||||
case uerr != nil && version == webauthn.BlobV2:
|
||||
return false, fmt.Errorf("%w: %s does not open under this assertion's PRF output, so another passkey holds the cold-start key; delete it deliberately to re-wrap", errForeignBlob, path)
|
||||
}
|
||||
// A v1 blob (upgrade it), or a v2 blob wrapping a stale key under
|
||||
// this same secret (the key was rotated). Both are rewrites.
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
// First wrap.
|
||||
default:
|
||||
return false, fmt.Errorf("read wrapped key: %w", err)
|
||||
}
|
||||
|
||||
blob, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("wrap encryption key: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(path, blob, 0o600); err != nil {
|
||||
return false, fmt.Errorf("write wrapped key: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path so that a reader sees either the whole
|
||||
// new file or the whole old one, never a truncated blob.
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp) // no-op once the rename succeeded
|
||||
|
||||
if err := f.Chmod(perm); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return err
|
||||
}
|
||||
// The rename itself needs to reach the disk, or a crash can resurrect the
|
||||
// old directory entry pointing at a file that is gone.
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
func wrapPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "db_key.wrapped")
|
||||
}
|
||||
|
||||
// The first wrap writes a v2 blob that opens under the same secret.
|
||||
func TestWrapKeyToFileWritesAnOpenableBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{1}, 32)
|
||||
secret := bytes.Repeat([]byte{2}, 32)
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want a write", wrote, err)
|
||||
}
|
||||
blob, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read blob: %v", err)
|
||||
}
|
||||
plain, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil || version != webauthn.BlobV2 || !bytes.Equal(plain, key) {
|
||||
t.Fatalf("UnwrapKey = %x, %v, %v", plain, version, err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode = %v (%v), want 0600", fi.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A blob that already wraps this key under this secret is left alone. Without
|
||||
// this every assertion rewrote the one file that opens the database.
|
||||
func TestWrapKeyToFileSkipsAnIdenticalBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{3}, 32)
|
||||
secret := bytes.Repeat([]byte{4}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, key, secret); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("second wrap: %v", err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("rewrote a blob that already opens under this secret")
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Error("the blob changed on a no-op wrap")
|
||||
}
|
||||
}
|
||||
|
||||
// Two enrolled authenticators, two PRF secrets, one blob. The second must not
|
||||
// silently lock the first one out — the backup passkey enrolled for exactly
|
||||
// the cold-start case is the one thing that used to stop working.
|
||||
func TestWrapKeyToFileRefusesAnotherCredentialsBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{5}, 32)
|
||||
phone := bytes.Repeat([]byte{6}, 32)
|
||||
yubikey := bytes.Repeat([]byte{7}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, key, phone); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
before, _ := os.ReadFile(path)
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, yubikey)
|
||||
if !errors.Is(err, errForeignBlob) {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want errForeignBlob", wrote, err)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("the second authenticator overwrote the first one's blob")
|
||||
}
|
||||
if _, _, err := webauthn.UnwrapKey(after, phone); err != nil {
|
||||
t.Fatalf("the first authenticator can no longer open the blob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A v1 blob is the pre-#14 format. It is upgraded in place rather than
|
||||
// refused, because that is the only way off a format that protects nothing.
|
||||
func TestWrapKeyToFileUpgradesALegacyBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{8}, 32)
|
||||
secret := bytes.Repeat([]byte{9}, 32)
|
||||
|
||||
// A v1 blob is a v2 blob with the magic stripped and the v1 info string;
|
||||
// the package writes no v1, so build one the only way a test can: wrap
|
||||
// v2 under a public key, then hand the file a body with no magic. What
|
||||
// matters here is only that UnwrapKey classifies it as v1.
|
||||
v2, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
legacy := v2[7:] // drop the magic
|
||||
if err := os.WriteFile(path, legacy, 0o600); err != nil {
|
||||
t.Fatalf("write legacy blob: %v", err)
|
||||
}
|
||||
if _, version, _ := webauthn.UnwrapKey(legacy, secret); version != webauthn.BlobV1 {
|
||||
t.Fatalf("fixture is not read as v1 (got %v)", version)
|
||||
}
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want the legacy blob upgraded", wrote, err)
|
||||
}
|
||||
blob, _ := os.ReadFile(path)
|
||||
if _, version, err := webauthn.UnwrapKey(blob, secret); err != nil || version != webauthn.BlobV2 {
|
||||
t.Fatalf("after upgrade: version %v, err %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A rotated at-rest key under the same credential is a rewrite, not a no-op.
|
||||
func TestWrapKeyToFileRewritesARotatedKey(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
secret := bytes.Repeat([]byte{10}, 32)
|
||||
old := bytes.Repeat([]byte{11}, 32)
|
||||
fresh := bytes.Repeat([]byte{12}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, old, secret); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
wrote, err := wrapKeyToFile(path, fresh, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want the rotated key written", wrote, err)
|
||||
}
|
||||
blob, _ := os.ReadFile(path)
|
||||
plain, _, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil || !bytes.Equal(plain, fresh) {
|
||||
t.Fatalf("blob still wraps the old key (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The write never truncates the target in place, so a crash mid-write cannot
|
||||
// leave a zero-length blob where the only copy of the wrapped key was.
|
||||
func TestWriteFileAtomicLeavesNoTempFilesAndReplacesWhole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "db_key.wrapped")
|
||||
|
||||
if err := os.WriteFile(path, bytes.Repeat([]byte{0xaa}, 67), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
// Hold the old inode. A rename gives it a new one; a truncating write
|
||||
// would keep it.
|
||||
oldInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
||||
want := bytes.Repeat([]byte{0xbb}, 67)
|
||||
if err := writeFileAtomic(path, want, 0o600); err != nil {
|
||||
t.Fatalf("writeFileAtomic: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("content = %x (%v)", got, err)
|
||||
}
|
||||
newInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if os.SameFile(oldInfo, newInfo) {
|
||||
t.Error("the target was written in place, not renamed over")
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("directory holds %d entries, want just the blob (a temp file leaked)", len(entries))
|
||||
}
|
||||
}
|
||||
+65
-22
@@ -25,7 +25,7 @@
|
||||
// When a passkey credential is enrolled AND no env key is set, the daemon
|
||||
// starts in LOCKED mode: the IPC server runs but rejects all store methods
|
||||
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
|
||||
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
|
||||
// by MethodUnlock (with that credential's WebAuthn PRF output) unwraps the at-rest
|
||||
// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens
|
||||
// the encrypted store. After unlock, the daemon wires voice, loop, and
|
||||
// delivery and runs normally.
|
||||
@@ -34,10 +34,13 @@
|
||||
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
|
||||
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
|
||||
// persist the wrapped blob — enabling cold-start unlock on the next boot
|
||||
// after the env key is removed.
|
||||
// after the env key is removed. That write happens once, when no blob
|
||||
// exists; replacing an existing one takes an explicit request, see
|
||||
// cmd/mavend/keyfile.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -48,6 +51,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -164,11 +168,28 @@ func run(args []string) error {
|
||||
var st *store.Store
|
||||
var envKeyBytes []byte // kept for WrapKeyFn (enrollment wraps this key)
|
||||
|
||||
// dbKey — the plaintext at-rest key, once the daemon has one. Set at boot
|
||||
// in env-key mode and inside UnlockFn after a cold start. WrapKeyFn reads
|
||||
// it from an IPC goroutine, hence the atomic: srv's function fields are
|
||||
// installed before Serve and must not be reassigned afterwards.
|
||||
var dbKey atomic.Pointer[[]byte]
|
||||
|
||||
// wrappedPath resolves the blob location the same way for both the read
|
||||
// at boot and every write, so a default-path deployment cannot wrap to
|
||||
// one file and unwrap from another.
|
||||
wrappedPath := func() string {
|
||||
if *wrappedKeyPath != "" {
|
||||
return *wrappedKeyPath
|
||||
}
|
||||
return cfg.DefaultWrappedKeyPath()
|
||||
}
|
||||
|
||||
if !locked {
|
||||
// Normal boot: env key or plaintext (dev/CI)
|
||||
if envKey != nil {
|
||||
envKeyBytes = make([]byte, len(envKey))
|
||||
copy(envKeyBytes, envKey)
|
||||
dbKey.Store(&envKeyBytes)
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, envKey)
|
||||
} else {
|
||||
st, err = store.Open(ctx, cfg.DBPath)
|
||||
@@ -387,27 +408,44 @@ func run(args []string) error {
|
||||
wireSpeaker(srv, st, cfg)
|
||||
}
|
||||
|
||||
// WrapKeyFn — wraps the env key under the passkey PRF secret and persists
|
||||
// the wrapped blob. Only wired when the daemon has the key in memory (env
|
||||
// key mode). Called by mavweb after passkey enrollment.
|
||||
// WrapKeyFn — wraps the at-rest key under the passkey PRF secret and
|
||||
// persists the wrapped blob. Called by mavweb after every assertion.
|
||||
//
|
||||
// It is wired in locked mode too, not only in env-key mode, and that is
|
||||
// what makes a v1 blob recoverable. A box enrolled before Vikunja #14
|
||||
// cold-starts through the legacy public-key retry in mavweb, and the
|
||||
// StoreEncryptionKey that follows rewrites the blob as v2. Without this
|
||||
// the only escape from a v1 blob was putting MAVEN_DB_KEY back in the
|
||||
// environment, which is the thing cold-start unlock exists to avoid.
|
||||
//
|
||||
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
|
||||
// an authenticator without PRF support produces no wrapped file at all
|
||||
// rather than a file that looks protected and is not.
|
||||
if envKeyBytes != nil {
|
||||
srv.WrapKeyFn = func(ctx context.Context, secret []byte) error {
|
||||
blob, err := webauthn.WrapKey(envKeyBytes, secret)
|
||||
if envKeyBytes != nil || locked {
|
||||
srv.WrapKeyFn = func(ctx context.Context, secret []byte, explicit bool) error {
|
||||
kp := dbKey.Load()
|
||||
if kp == nil {
|
||||
return errors.New("wrap encryption key: the daemon is locked and has no key yet (unlock first)")
|
||||
}
|
||||
wp := wrappedPath()
|
||||
// Asserting a passkey is not a request to rewrite the cold-start
|
||||
// key. Without this an assertion carrying a substituted PRF value
|
||||
// re-wrapped the real database key under it, and a second
|
||||
// authenticator silently replaced the first one's blob.
|
||||
if !explicit {
|
||||
if _, err := os.Stat(wp); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("check wrapped key: %w", err)
|
||||
}
|
||||
}
|
||||
wrote, err := wrapKeyToFile(wp, *kp, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wrap encryption key: %w", err)
|
||||
return err
|
||||
}
|
||||
wp := *wrappedKeyPath
|
||||
if wp == "" {
|
||||
wp = cfg.DefaultWrappedKeyPath()
|
||||
if wrote {
|
||||
log.Printf("mavend: wrapped encryption key under this passkey's PRF output → %s", wp)
|
||||
}
|
||||
if err := os.WriteFile(wp, blob, 0o600); err != nil {
|
||||
return fmt.Errorf("write wrapped key: %w", err)
|
||||
}
|
||||
log.Printf("mavend: wrapped encryption key with passkey credential (%d bytes)", len(blob))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -428,15 +466,17 @@ func run(args []string) error {
|
||||
return nil // already unlocked; the caller does not need to know
|
||||
}
|
||||
|
||||
// The wire cannot authenticate its caller — the socket is
|
||||
// same-uid — so the unlock path requires a passkey assertion
|
||||
// that mavweb verified cryptographically first. Without this,
|
||||
// MethodUnlock is reachable by anything on the box.
|
||||
// Depth, not a boundary. MethodAssertStepUp is AuthRead, so
|
||||
// anything that can open the same-uid socket can flip the
|
||||
// session and reach MethodUnlock. What actually stops a local
|
||||
// attacker is the 32-byte PRF output they do not have, and that
|
||||
// was true before this check. What this check stops is an
|
||||
// accidental unlock attempt from an unrelated local caller.
|
||||
if !passkeySess.IsStepUp() {
|
||||
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||
}
|
||||
|
||||
wp := *wrappedKeyPath
|
||||
wp := wrappedPath()
|
||||
blob, err := os.ReadFile(wp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read wrapped key: %w", err)
|
||||
@@ -446,8 +486,11 @@ func run(args []string) error {
|
||||
return fmt.Errorf("unwrap key: %w", err)
|
||||
}
|
||||
if version == webauthn.BlobV1 {
|
||||
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Re-enroll the passkey on an authenticator that supports the PRF extension to rewrite it as v2.", wp, version)
|
||||
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Use the \"rewrite cold-start key\" button on /auth/webauthn with a PRF-capable authenticator to replace it with a v2 blob.", wp, version)
|
||||
}
|
||||
// WrapKeyFn needs the key to be able to rewrite the blob later.
|
||||
keyCopy := bytes.Clone(key)
|
||||
dbKey.Store(&keyCopy)
|
||||
// Open the store with the unwrapped key.
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
||||
if err != nil {
|
||||
|
||||
+33
-17
@@ -3,14 +3,17 @@
|
||||
//
|
||||
// # What is actually wired here, and what is not
|
||||
//
|
||||
// The enrolment plumbing is real: profiles are stored, listed and deleted, and
|
||||
// the wire methods exist as soon as a speaker block is configured. The
|
||||
// recognising half is NOT, and cannot be on this box, because there is no
|
||||
// speaker-embedding model on disk — no ECAPA, no x-vector, no titanet, no
|
||||
// wespeaker, nothing in /mnt/hdd1/llms but text ggufs. Until one is downloaded,
|
||||
// newSpeakerEmbedder returns nil, internal/speaker falls back to
|
||||
// speaker.Disabled, and every Identify answers ErrDisabled. The daemon logs
|
||||
// which half is off at startup rather than pretending.
|
||||
// Nothing is, on this box. There is no speaker-embedding model on disk — no
|
||||
// ECAPA, no x-vector, no titanet, no wespeaker, nothing in /mnt/hdd1/llms but
|
||||
// text ggufs. Until one is downloaded, newSpeakerEmbedder returns nil.
|
||||
//
|
||||
// Without an embedder the capability has no runnable half. This comment used to
|
||||
// say enrolment was real and only recognition was blocked, and the startup log
|
||||
// said the same. Both were wrong: Recognizer.Enroll embeds every sample before
|
||||
// it stores anything, so with no model it fails on the first sample and nothing
|
||||
// is ever stored, which leaves List empty forever and Forget with nothing to
|
||||
// delete. So the gate is cfg.Speaker.Recognizes() — enabled AND a model path —
|
||||
// and a box without one gets no speaker methods, not three no-ops.
|
||||
//
|
||||
// This is deliberately not papered over with a hand-rolled MFCC floor. A
|
||||
// biometric that is confidently wrong writes false claims about named people
|
||||
@@ -53,17 +56,25 @@ type speakerWiring struct {
|
||||
// starts working with no change to the store, the protocol, the auth table or
|
||||
// the handlers. See the plan document for what to download.
|
||||
func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder {
|
||||
if cfg == nil || cfg.ModelPath == "" {
|
||||
return nil
|
||||
}
|
||||
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet; "+
|
||||
"enrolment and deletion work, recognition does not (Vikunja #255)", cfg.ModelPath)
|
||||
_ = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSpeakerWiring builds the recognizer, or nil when the capability is off.
|
||||
func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
|
||||
if cfg == nil || cfg.Speaker == nil || !cfg.Speaker.Enabled {
|
||||
if cfg == nil || cfg.Speaker == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Speaker.Recognizes() {
|
||||
// Recognizes() was written as the gate and documented as one, and then
|
||||
// never called. "enabled": true with no model_path used to wire all
|
||||
// three methods and log "enrolment on", which is the one config shape
|
||||
// where the operator most needs to be told otherwise.
|
||||
if cfg.Speaker.Enabled {
|
||||
log.Print("speaker: enabled but no model_path, so there is nothing to embed with; " +
|
||||
"enrol, list and forget would all be no-ops, staying off " +
|
||||
"(see docs/plans/10-speaker-recognition.md)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if st == nil {
|
||||
@@ -81,8 +92,8 @@ func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
|
||||
if rec.Enabled() {
|
||||
log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold())
|
||||
} else {
|
||||
log.Print("speaker: enrolment on, recognition BLOCKED — no speaker-embedding model " +
|
||||
"on this box (see docs/plans/10-speaker-recognition.md)")
|
||||
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet, "+
|
||||
"so enrol, list and forget are all no-ops (Vikunja #255)", cfg.Speaker.ModelPath)
|
||||
}
|
||||
return &speakerWiring{rec: rec}
|
||||
}
|
||||
@@ -114,7 +125,7 @@ func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) er
|
||||
// toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does
|
||||
// not hand the biometric back out over the socket.
|
||||
func toWireSpeaker(p speaker.Profile) ipc.Speaker {
|
||||
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples}
|
||||
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples, Damaged: p.Damaged}
|
||||
}
|
||||
|
||||
// speakerErr maps the package sentinels onto the wire vocabulary so a surface
|
||||
@@ -123,6 +134,11 @@ func speakerErr(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, speaker.ErrDisabled):
|
||||
// Not a core failure. The capability is present on the wire but has no
|
||||
// embedding model behind it, which is the same thing an unconfigured
|
||||
// method says, so say it the same way.
|
||||
return ipc.ErrUnknownMethod
|
||||
case errors.Is(err, speaker.ErrNotFound):
|
||||
return ipc.ErrNoFact
|
||||
case errors.Is(err, speaker.ErrBadID),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/speaker"
|
||||
)
|
||||
|
||||
// "enabled": true with no model_path used to wire all three methods and log
|
||||
// "enrolment on". Nothing behind them works without an embedder, so the
|
||||
// capability stays off and the socket answers "no such method".
|
||||
func TestSpeakerStaysOffWithoutAModelPath(t *testing.T) {
|
||||
srv := &ipc.Server{}
|
||||
cfg := &config.Config{Speaker: &config.SpeakerConfig{Enabled: true}}
|
||||
|
||||
wireSpeaker(srv, nil, cfg)
|
||||
|
||||
if srv.EnrollSpeakerFn != nil || srv.ListSpeakersFn != nil || srv.ForgetSpeakerFn != nil {
|
||||
t.Error("speaker methods were wired with nothing to embed with")
|
||||
}
|
||||
}
|
||||
|
||||
// The gate is Recognizes(), so a disabled block with a model path is off too.
|
||||
func TestSpeakerStaysOffWhenDisabled(t *testing.T) {
|
||||
srv := &ipc.Server{}
|
||||
cfg := &config.Config{Speaker: &config.SpeakerConfig{ModelPath: "/nope/ecapa.onnx"}}
|
||||
|
||||
wireSpeaker(srv, nil, cfg)
|
||||
|
||||
if srv.EnrollSpeakerFn != nil {
|
||||
t.Error("speaker methods were wired for a disabled block")
|
||||
}
|
||||
}
|
||||
|
||||
// ErrDisabled is "this capability is off", not "core broke". It used to fall
|
||||
// through speakerErr's default and reach the surface as an opaque failure.
|
||||
func TestSpeakerErrMapsDisabledToUnknownMethod(t *testing.T) {
|
||||
if got := speakerErr(speaker.ErrDisabled); !errors.Is(got, ipc.ErrUnknownMethod) {
|
||||
t.Errorf("speakerErr(ErrDisabled) = %v, want ErrUnknownMethod", got)
|
||||
}
|
||||
if got := speakerErr(speaker.ErrNotFound); !errors.Is(got, ipc.ErrNoFact) {
|
||||
t.Errorf("speakerErr(ErrNotFound) = %v, want ErrNoFact", got)
|
||||
}
|
||||
if got := speakerErr(nil); got != nil {
|
||||
t.Errorf("speakerErr(nil) = %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user