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)
|
||||
}
|
||||
}
|
||||
+86
-18
@@ -2,10 +2,22 @@ package main
|
||||
|
||||
// Golden-audio STT tests (Vikunja #288).
|
||||
//
|
||||
// These push real audio through the real whisper.cpp binding, so a bad model
|
||||
// path, a wrong language hint, a broken resample or a regressed silence gate
|
||||
// is caught by `make test` rather than by the owner talking to a daemon that
|
||||
// mishears him.
|
||||
// These push real audio through the real whisper.cpp binding. What that
|
||||
// covers, precisely, is two things: the model still transcribes known speech
|
||||
// well enough for the router to act on it, and the silence gate still lets real
|
||||
// speech through. A regression in either shows up in `make test` rather than in
|
||||
// the owner talking to a daemon that mishears him.
|
||||
//
|
||||
// It is worth being exact about what is NOT covered, because this comment used
|
||||
// to claim more. Nothing here resamples: audio.PCMFromWAV refuses anything that
|
||||
// is not 16 kHz mono s16, the fixtures arrive at 16 kHz from ffmpeg, and there
|
||||
// is no conversion step between the WAV and whisper_full. Nothing here
|
||||
// exercises language selection either: the hint comes out of the manifest
|
||||
// already correct and goes straight into the request, so how mavsttd chooses a
|
||||
// language is untested. And a wrong model path is not caught when it is the
|
||||
// default one, because a box without the model skips; an explicitly set
|
||||
// MAVEN_WHISPER_MODEL that does not exist is a failure, since that is a
|
||||
// mistake and not an absence.
|
||||
//
|
||||
// The fixtures are piper-synthesised, not recorded — see
|
||||
// scripts/gen-stt-fixtures.sh. Nothing of the owner's voice is committed, and
|
||||
@@ -47,7 +59,10 @@ type goldenCase struct {
|
||||
Lang string `json:"lang"`
|
||||
Text string `json:"text"`
|
||||
Keywords []string `json:"keywords"`
|
||||
MaxWER float64 `json:"max_wer"`
|
||||
// MeasuredWER is what this case scored when the ceiling was last set, so
|
||||
// a model swap is a diff to a recorded number rather than silence.
|
||||
MeasuredWER float64 `json:"measured_wer"`
|
||||
MaxWER float64 `json:"max_wer"`
|
||||
}
|
||||
|
||||
type goldenManifest struct {
|
||||
@@ -152,6 +167,14 @@ func containsSeq(hyp, want []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// looseWordMatch reports whether got is want, or an inflection of it.
|
||||
//
|
||||
// A shared prefix alone is not enough. "воды" retains three runes, so "водка"
|
||||
// used to satisfy the ru_fact keyword and the test passed on whisper hearing
|
||||
// "выпил водки". "disk" retains "dis", which "display", "distance" and
|
||||
// "discuss" all match. So the hypothesis is also capped in length: a case
|
||||
// ending adds a rune or two, it does not add a syllable. Short words get no
|
||||
// slack at all, because there is nothing left of them after a prefix cut.
|
||||
func looseWordMatch(got, want string) bool {
|
||||
if got == want {
|
||||
return true
|
||||
@@ -166,6 +189,13 @@ func looseWordMatch(got, want string) bool {
|
||||
if n < 3 || len(g) < n {
|
||||
return false
|
||||
}
|
||||
extra := 2
|
||||
if len(w) <= 4 {
|
||||
extra = 0
|
||||
}
|
||||
if len(g) > len(w)+extra {
|
||||
return false
|
||||
}
|
||||
return string(g[:n]) == string(w[:n])
|
||||
}
|
||||
|
||||
@@ -176,6 +206,11 @@ func TestGoldenAudioTranscription(t *testing.T) {
|
||||
|
||||
model := goldenModelPath()
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
// An explicit override that points at nothing is a mistake, not a box
|
||||
// without the model. Skipping there made a typo look like a pass.
|
||||
if os.Getenv("MAVEN_WHISPER_MODEL") != "" {
|
||||
t.Fatalf("MAVEN_WHISPER_MODEL=%s does not exist: %v", model, err)
|
||||
}
|
||||
t.Skipf("whisper model %s absent (%v) — set MAVEN_WHISPER_MODEL or see AGENTS.md", model, err)
|
||||
}
|
||||
|
||||
@@ -192,7 +227,9 @@ func TestGoldenAudioTranscription(t *testing.T) {
|
||||
path := filepath.Join("testdata", c.WAV)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Skipf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
|
||||
// Not a skip. A fixture the generator failed to write is a
|
||||
// broken checkout, and skipping made `make test` green on one.
|
||||
t.Fatalf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
|
||||
}
|
||||
format, pcm, err := audio.PCMFromWAV(raw)
|
||||
if err != nil {
|
||||
@@ -221,9 +258,12 @@ func TestGoldenAudioTranscription(t *testing.T) {
|
||||
if missing := missingKeywords(c.Keywords, hyp); len(missing) > 0 {
|
||||
t.Errorf("%s: missing keywords %v in %q", c.WAV, missing, resp.Text)
|
||||
}
|
||||
if wer := wordErrorRate(ref, hyp); wer > c.MaxWER {
|
||||
t.Errorf("%s: WER %.2f > %.2f\n want: %q\n got: %q", c.WAV, wer, c.MaxWER, c.Text, resp.Text)
|
||||
wer := wordErrorRate(ref, hyp)
|
||||
if wer > c.MaxWER {
|
||||
t.Errorf("%s: WER %.2f > %.2f (measured %.2f when the ceiling was set)\n want: %q\n got: %q",
|
||||
c.WAV, wer, c.MaxWER, c.MeasuredWER, c.Text, resp.Text)
|
||||
}
|
||||
t.Logf("%s: WER %.2f (ceiling %.2f, was %.2f)", c.WAV, wer, c.MaxWER, c.MeasuredWER)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -253,27 +293,29 @@ func TestGoldenFixturesAreCanonical(t *testing.T) {
|
||||
}
|
||||
// The fixture must clear mavsttd's own silence gate, otherwise the
|
||||
// model test below would be asserting on a gated empty string.
|
||||
if reason := gateReason(pcmToF32(pcm), whisperSampleRate, 300, 0.01); reason != "" {
|
||||
if reason := gateReason(pcmSamples(pcm), whisperSampleRate, 300, 0.01); reason != "" {
|
||||
t.Errorf("%s: would be gated as %s", path, reason)
|
||||
}
|
||||
if len(c.Keywords) == 0 {
|
||||
t.Errorf("%s: manifest case has no keywords", c.Name)
|
||||
}
|
||||
// An empty reference makes wordErrorRate return 1 for every
|
||||
// hypothesis, so the WER assertion fires with nothing useful to say.
|
||||
if len(normalizeTranscript(c.Text)) == 0 {
|
||||
t.Errorf("%s: manifest case has no reference text", c.Name)
|
||||
}
|
||||
if c.Lang != "ru" && c.Lang != "en" {
|
||||
t.Errorf("%s: lang %q is not one of the two languages mavsttd is run with", c.Name, c.Lang)
|
||||
}
|
||||
if c.MaxWER <= 0 || c.MaxWER > 1 {
|
||||
t.Errorf("%s: max_wer %v outside (0,1]", c.Name, c.MaxWER)
|
||||
}
|
||||
if c.MeasuredWER > c.MaxWER {
|
||||
t.Errorf("%s: measured_wer %v is above max_wer %v, so the ceiling was never met", c.Name, c.MeasuredWER, c.MaxWER)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pcmToF32(b []byte) []float32 {
|
||||
out := make([]float32, len(b)/2)
|
||||
for i := range out {
|
||||
s := int16(b[i*2]) | int16(b[i*2+1])<<8
|
||||
out[i] = float32(s) / 32768.0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- matcher unit tests (no model, no fixtures) ----------------------------
|
||||
|
||||
func TestNormalizeTranscript(t *testing.T) {
|
||||
@@ -320,4 +362,30 @@ func TestMissingKeywords(t *testing.T) {
|
||||
if got := missingKeywords([]string{"часть"}, hyp2); len(got) != 1 {
|
||||
t.Fatalf("missingKeywords = %v, want %q reported missing", got, "часть")
|
||||
}
|
||||
// A prefix is not a word. These are different words that share one, and
|
||||
// each of them used to satisfy the keyword it is paired with.
|
||||
different := [][2]string{
|
||||
{"воды", "Я выпил водки."},
|
||||
{"disk", "check the display"},
|
||||
{"disk", "we should discuss it"},
|
||||
{"server", "a serverless function"},
|
||||
}
|
||||
for _, d := range different {
|
||||
if got := missingKeywords([]string{d[0]}, normalizeTranscript(d[1])); len(got) != 1 {
|
||||
t.Errorf("keyword %q was satisfied by %q", d[0], d[1])
|
||||
}
|
||||
}
|
||||
// And the inflections still pass, which is the whole point of the loose
|
||||
// match.
|
||||
same := [][2]string{
|
||||
{"воды", "выпил воду"},
|
||||
{"напомни", "напомните мне"},
|
||||
{"календарю", "по календаре"},
|
||||
{"restart", "restarted the server"},
|
||||
}
|
||||
for _, d := range same {
|
||||
if got := missingKeywords([]string{d[0]}, normalizeTranscript(d[1])); len(got) != 0 {
|
||||
t.Errorf("keyword %q was not matched by %q", d[0], d[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+10
-5
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh. Regenerate with that script; do not hand-edit `wav`.",
|
||||
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh, which reads `text` from this file and synthesises from it. This is the only source of the spoken words; regenerate with that script and do not hand-edit `wav`.",
|
||||
"wer_note": "max_wer is set just above what each case actually measures against ggml-small, recorded in `measured_wer` on 2026-08-01. A flat 0.34 over a five-word reference tolerated two wrong words and left most of the range unguarded. A model swap should show up as a diff to these numbers, not as silence: rerun `make test-stt-golden`, read the logged transcript, and move both fields together.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "ru_reminder",
|
||||
@@ -7,7 +8,8 @@
|
||||
"lang": "ru",
|
||||
"text": "напомни мне через час позвонить маме",
|
||||
"keywords": ["напомни", "час", "позвонить"],
|
||||
"max_wer": 0.34
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
},
|
||||
{
|
||||
"name": "ru_fact",
|
||||
@@ -15,7 +17,8 @@
|
||||
"lang": "ru",
|
||||
"text": "отметь что я выпил воды",
|
||||
"keywords": ["отметь", "воды"],
|
||||
"max_wer": 0.34
|
||||
"measured_wer": 0.2,
|
||||
"max_wer": 0.25
|
||||
},
|
||||
{
|
||||
"name": "ru_query",
|
||||
@@ -23,7 +26,8 @@
|
||||
"lang": "ru",
|
||||
"text": "что у меня сегодня по календарю",
|
||||
"keywords": ["сегодня", "календарю"],
|
||||
"max_wer": 0.34
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
},
|
||||
{
|
||||
"name": "en_act",
|
||||
@@ -31,7 +35,8 @@
|
||||
"lang": "en",
|
||||
"text": "restart the web server and check the disk space",
|
||||
"keywords": ["restart", "server", "disk"],
|
||||
"max_wer": 0.34
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -66,6 +66,19 @@ func gateReason(samples []float32, rate, minMs int, minRMS float64) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// pcmSamples converts canonical s16le little-endian PCM to the float32 range
|
||||
// whisper wants. Shared with the golden tests: they used to carry their own
|
||||
// copy, so a regression here (a /32767 divisor, a byte order slip) left the
|
||||
// assertion that the fixtures clear the silence gate green.
|
||||
func pcmSamples(b []byte) []float32 {
|
||||
out := make([]float32, len(b)/2)
|
||||
for i := range out {
|
||||
s := int16(b[i*2]) | int16(b[i*2+1])<<8
|
||||
out[i] = float32(s) / 32768.0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return worker.TranscribeResp{}, fmt.Errorf("whisper: context done before transcribe: %w", err)
|
||||
@@ -75,12 +88,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
return worker.TranscribeResp{}, fmt.Errorf("whisper: empty audio")
|
||||
}
|
||||
|
||||
nSamples := len(a.Bytes) / 2
|
||||
samples := make([]float32, nSamples)
|
||||
for i := 0; i < nSamples; i++ {
|
||||
s := int16(a.Bytes[i*2]) | int16(a.Bytes[i*2+1])<<8
|
||||
samples[i] = float32(s) / 32768.0
|
||||
}
|
||||
samples := pcmSamples(a.Bytes)
|
||||
|
||||
// Silence gate: drop non-speech before whisper hallucinates on it.
|
||||
if reason := gateReason(samples, whisperSampleRate, h.minMs, h.minRMS); reason != "" {
|
||||
@@ -111,7 +119,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
ch := make(chan result, 1)
|
||||
cSamples := (*C.float)(unsafe.Pointer(&samples[0]))
|
||||
go func() {
|
||||
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(nSamples)))}
|
||||
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(len(samples))))}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
|
||||
+15
-4
@@ -14,9 +14,12 @@
|
||||
//
|
||||
// While a reply is playing the capture side is muted (half-duplex): without
|
||||
// it, Maven's own voice comes back in through the mic and she answers
|
||||
// herself. -barge-in punches one hole in that gate — sustained energy well
|
||||
// above the speaker's leak level cuts playback so he can talk over her. It is
|
||||
// off by default because the threshold is room-specific; see playback.go.
|
||||
// herself. -barge-in punches one hole in that gate — sustained energy above
|
||||
// -barge-in-rms cuts playback so he can talk over her. It is off by default
|
||||
// because the threshold is room-specific; see playback.go. The threshold is a
|
||||
// raw frame RMS and has no reference to what the speaker actually leaks, so
|
||||
// the daemon logs the mean energy of the frames it suppressed while speaking.
|
||||
// Set -barge-in-rms from those numbers rather than by guessing.
|
||||
//
|
||||
// usage:
|
||||
// mavwaked # default ALSA device, 127.0.0.1:9100
|
||||
@@ -130,7 +133,15 @@ func run(args []string) error {
|
||||
var barge bargeInConfig
|
||||
if *bargeIn {
|
||||
barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames}
|
||||
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
|
||||
if barge.Enabled() {
|
||||
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
|
||||
} else {
|
||||
// The log used to say "barge-in on (rms 0.0000 x 5)" here and then
|
||||
// nothing happened, because Enabled needs a positive threshold.
|
||||
log.Printf("mavwaked: -barge-in was passed but rms %.4f x %d frames disables it; "+
|
||||
"both must be above zero, so barge-in is OFF",
|
||||
barge.RMS, barge.Frames)
|
||||
}
|
||||
}
|
||||
sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge)
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ import (
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// playbackMargin is the slack over the reply's own duration before a stuck
|
||||
// aplay is killed. Enough for ALSA to open the device and drain its buffer,
|
||||
// short enough that a busy device does not cost her a turn.
|
||||
const playbackMargin = 2 * time.Second
|
||||
|
||||
// player plays one reply at a time and can be cut off mid-utterance.
|
||||
type player interface {
|
||||
// Play starts playback of a, replacing anything already playing, and
|
||||
@@ -87,6 +92,13 @@ func (p *aplayPlayer) Play(a audio.Audio) {
|
||||
p.playing = true
|
||||
p.mu.Unlock()
|
||||
|
||||
// Bound the mute window by the reply itself. Playing() gates all capture
|
||||
// now, so a wedged aplay does not merely go silent, it makes her deaf for
|
||||
// as long as the flag is set. The old ceiling was a flat 30s inherited
|
||||
// from the fire-and-forget version, where it only bounded a leaked
|
||||
// goroutine. A reply cannot legitimately take longer than it lasts.
|
||||
limit := time.Duration(a.Duration()*float64(time.Second)) + playbackMargin
|
||||
|
||||
go func() {
|
||||
if _, err := stdin.Write(a.Bytes); err != nil {
|
||||
// Broken pipe is the expected outcome of Stop().
|
||||
@@ -101,8 +113,9 @@ func (p *aplayPlayer) Play(a audio.Audio) {
|
||||
if err != nil {
|
||||
log.Printf("mavwaked: aplay: %v", err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
log.Printf("mavwaked: aplay timeout, killing")
|
||||
case <-time.After(limit):
|
||||
log.Printf("mavwaked: aplay did not finish %.1fs of audio within %s, killing (capture was muted the whole time)",
|
||||
a.Duration(), limit)
|
||||
if pr := cmd.Process; pr != nil {
|
||||
_ = pr.Kill()
|
||||
}
|
||||
|
||||
+118
-8
@@ -7,6 +7,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
@@ -42,6 +43,20 @@ type session struct {
|
||||
lang string
|
||||
barge bargeInConfig
|
||||
|
||||
// now is the clock, swapped in tests. The round-trip backlog is measured
|
||||
// in wall time, because that is the only thing that says how much room
|
||||
// went into the pipe while the daemon was thinking.
|
||||
now func() time.Time
|
||||
|
||||
// discard is how many buffered frames still have to be thrown away
|
||||
// before capture means anything again. See dispatch.
|
||||
discard int
|
||||
|
||||
// recent holds the last few frames seen during playback, so the ones
|
||||
// that proved he was interrupting can be replayed into the VAD after the
|
||||
// barge-in reset instead of being clipped off the front of his sentence.
|
||||
recent [][]byte
|
||||
|
||||
// loudFrames counts consecutive over-threshold frames seen while she is
|
||||
// speaking. Reset whenever a frame falls back under the threshold, and
|
||||
// whenever playback ends.
|
||||
@@ -49,14 +64,28 @@ type session struct {
|
||||
|
||||
// counters, read by tests and logged on the way out.
|
||||
suppressed int // frames dropped because she was speaking
|
||||
dropped int // frames dropped as round-trip backlog
|
||||
bargeIns int // times playback was cut because he spoke over her
|
||||
sent int // utterances shipped to the daemon
|
||||
|
||||
// loudSum and loudSeen accumulate the energy of suppressed frames, so
|
||||
// the operator can read what the room actually measures and set
|
||||
// -barge-in-rms from data instead of guessing.
|
||||
loudSum float64
|
||||
loudSeen int
|
||||
}
|
||||
|
||||
func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeInConfig) *session {
|
||||
return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge}
|
||||
return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge, now: time.Now}
|
||||
}
|
||||
|
||||
// frameDuration is the wall time one captured frame represents.
|
||||
const frameDuration = defaultFrameMs * time.Millisecond
|
||||
|
||||
// suppressLogEvery — how many suppressed frames between energy reports. 200
|
||||
// frames is six seconds of her talking, so this is roughly one line per reply.
|
||||
const suppressLogEvery = 200
|
||||
|
||||
// feed processes one 30ms PCM frame.
|
||||
//
|
||||
// While the player is running the capture side is muted: the VAD is not fed
|
||||
@@ -65,26 +94,52 @@ func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeI
|
||||
// energy well above the speaker's leak level cuts playback, and capture
|
||||
// resumes on the very next frame with a clean VAD.
|
||||
func (s *session) feed(ctx context.Context, frame []byte) error {
|
||||
// Backlog first, before anything looks at this frame. These are frames
|
||||
// the microphone captured while the round-trip blocked; they arrive in a
|
||||
// burst at pipe speed and they are not a command, not an answer and not
|
||||
// an interruption.
|
||||
if s.discard > 0 {
|
||||
s.discard--
|
||||
s.dropped++
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.player.Playing() {
|
||||
s.suppressed++
|
||||
rms := frameRMS(PCMToI16(frame))
|
||||
s.loudSum += rms
|
||||
s.loudSeen++
|
||||
if s.loudSeen >= suppressLogEvery {
|
||||
// The doc comment asks for energy "well above the speaker's leak
|
||||
// level" and never says what that is. This is what it is.
|
||||
log.Printf("mavwaked: suppressed %d frames while speaking, mean rms %.4f (barge-in threshold %.4f)",
|
||||
s.loudSeen, s.loudSum/float64(s.loudSeen), s.barge.RMS)
|
||||
s.loudSum, s.loudSeen = 0, 0
|
||||
}
|
||||
if !s.barge.Enabled() {
|
||||
return nil
|
||||
}
|
||||
if frameRMS(PCMToI16(frame)) < s.barge.RMS {
|
||||
if rms < s.barge.RMS {
|
||||
s.loudFrames = 0
|
||||
s.recent = s.recent[:0]
|
||||
return nil
|
||||
}
|
||||
s.loudFrames++
|
||||
s.keepRecent(frame)
|
||||
if s.loudFrames < s.barge.Frames {
|
||||
return nil
|
||||
}
|
||||
// He is talking over her. Cut her off, drop the VAD state that
|
||||
// accumulated from the echo, and start listening for real.
|
||||
// accumulated from the echo, and start listening for real — starting
|
||||
// with the frames that proved he was talking. Those used to be
|
||||
// thrown away, which clipped the first 150ms off his interruption,
|
||||
// and on a short one that is the whole first word.
|
||||
s.player.Stop()
|
||||
s.bargeIns++
|
||||
s.loudFrames = 0
|
||||
s.vad.Reset()
|
||||
log.Printf("mavwaked: barge-in — stopped playback")
|
||||
s.replayRecent()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -102,22 +157,77 @@ func (s *session) feed(ctx context.Context, frame []byte) error {
|
||||
return s.dispatch(ctx, utt)
|
||||
}
|
||||
|
||||
// keepRecent stores a copy of one barge-in trigger frame, keeping at most
|
||||
// barge.Frames of them.
|
||||
func (s *session) keepRecent(frame []byte) {
|
||||
if len(s.recent) >= s.barge.Frames {
|
||||
copy(s.recent, s.recent[1:])
|
||||
s.recent = s.recent[:len(s.recent)-1]
|
||||
}
|
||||
s.recent = append(s.recent, append([]byte(nil), frame...))
|
||||
}
|
||||
|
||||
// replayRecent feeds the trigger frames back into the freshly reset VAD, so
|
||||
// his interruption starts where he started it.
|
||||
//
|
||||
// Feed cannot complete an utterance here: closing one needs silenceMs of
|
||||
// trailing quiet and these frames are all above the barge-in threshold, which
|
||||
// is far above the VAD floor. Any utterance it did return would be a fragment
|
||||
// of a sentence he is still speaking, so it is not dispatched.
|
||||
func (s *session) replayRecent() {
|
||||
for _, f := range s.recent {
|
||||
s.vad.Feed(PCMToI16(f))
|
||||
}
|
||||
s.recent = s.recent[:0]
|
||||
}
|
||||
|
||||
// dispatch ships a complete utterance and plays whatever comes back.
|
||||
//
|
||||
// Every return path here has to deal with the backlog. Nothing reads the
|
||||
// microphone while Send is in flight, so the audio piles up in arecord's pipe
|
||||
// and the kernel buffer, and it arrives in a burst the moment this returns. A
|
||||
// round-trip is p50 2.7s through the LLM router, which is around 90 frames of
|
||||
// room, of him finishing his sentence, of the television.
|
||||
//
|
||||
// This used to reset the VAD on the reply path only, and for the wrong reason:
|
||||
// the comment said the VAD had been accumulating during the round-trip, when
|
||||
// in fact its state is exactly what Feed left it as. The two paths that had no
|
||||
// reset are the ones that mattered, because neither of them starts playback
|
||||
// and so neither is covered by the half-duplex gate. A text-only turn fed the
|
||||
// whole backlog straight into the VAD, and a Send error did the same on every
|
||||
// failed turn, so a dead socket drove a retry loop off nothing but backlog.
|
||||
func (s *session) dispatch(ctx context.Context, utt audio.Audio) error {
|
||||
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes))
|
||||
start := s.now()
|
||||
reply, err := s.sender.Send(ctx, utt, s.lang)
|
||||
s.sent++
|
||||
defer s.dropBacklog(start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Count what was shipped, not what was attempted. This used to run
|
||||
// before the error check, so failed round-trips counted as sent.
|
||||
s.sent++
|
||||
if len(reply.Bytes) == 0 {
|
||||
log.Printf("mavwaked: empty reply audio (text only)")
|
||||
return nil
|
||||
}
|
||||
// The VAD has been accumulating from the buffered mic stream while the
|
||||
// round-trip blocked. None of it is a command — reset before the
|
||||
// speaker opens, so the first post-reply frame starts clean.
|
||||
s.vad.Reset()
|
||||
s.player.Play(reply)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropBacklog resets the VAD and arranges for the frames captured during the
|
||||
// round-trip to be thrown away as they arrive.
|
||||
//
|
||||
// Discarding them is also what keeps barge-in honest. The Frames guard is
|
||||
// documented as "long enough that a door or a cough does not cut her off",
|
||||
// which assumes the frames are real time. Draining a backlog delivers five
|
||||
// frames in microseconds, so without this she could be cut off by audio
|
||||
// recorded before she started speaking.
|
||||
func (s *session) dropBacklog(start time.Time) {
|
||||
s.vad.Reset()
|
||||
s.loudFrames = 0
|
||||
s.recent = s.recent[:0]
|
||||
if elapsed := s.now().Sub(start); elapsed > 0 {
|
||||
s.discard = int(elapsed / frameDuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
@@ -280,3 +281,147 @@ func TestBargeInConfigEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slowSender models the real thing: a round-trip takes wall-clock time, and
|
||||
// the microphone keeps recording into a pipe nobody is reading.
|
||||
type slowSender struct {
|
||||
fakeSender
|
||||
clock *time.Time
|
||||
took time.Duration
|
||||
}
|
||||
|
||||
func (s *slowSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) {
|
||||
*s.clock = s.clock.Add(s.took)
|
||||
return s.fakeSender.Send(ctx, utt, lang)
|
||||
}
|
||||
|
||||
// newSlowSession wires a session whose round-trip takes took of wall time.
|
||||
func newSlowSession(barge bargeInConfig, reply audio.Audio, err error, took time.Duration) (*session, *fakePlayer, *slowSender) {
|
||||
now := time.Unix(0, 0)
|
||||
p := &fakePlayer{}
|
||||
snd := &slowSender{fakeSender: fakeSender{reply: reply, err: err}, clock: &now, took: took}
|
||||
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", barge)
|
||||
sess.now = func() time.Time { return now }
|
||||
return sess, p, snd
|
||||
}
|
||||
|
||||
// A text-only turn starts no playback, so the half-duplex gate does not cover
|
||||
// it. The backlog captured during the round-trip has to be dropped anyway, or
|
||||
// three seconds of room arrives at pipe speed and becomes a command.
|
||||
func TestSessionDropsBacklogAfterAnEmptyReply(t *testing.T) {
|
||||
sess, p, snd := newSlowSession(bargeInConfig{}, audio.Audio{Format: audio.PCM16kMono}, nil, 3*time.Second)
|
||||
|
||||
speakThenPause(t, sess)
|
||||
if p.plays != 0 || len(snd.sent) != 1 {
|
||||
t.Fatalf("plays = %d, sent = %d; want one text-only turn", p.plays, len(snd.sent))
|
||||
}
|
||||
// The tail of speakThenPause already spent a couple of them.
|
||||
if want := int(3 * time.Second / frameDuration); sess.discard+sess.dropped != want {
|
||||
t.Fatalf("discard %d + dropped %d frames, want %d (3s of backlog)", sess.discard, sess.dropped, want)
|
||||
}
|
||||
|
||||
// The burst: the whole backlog, all of it him still talking.
|
||||
loud := frameAt(0.35)
|
||||
for i := 0; i < sess.discard; i++ {
|
||||
if err := sess.feed(context.Background(), loud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(snd.sent) != 1 {
|
||||
t.Errorf("the backlog was sent as a second utterance (sent = %d)", len(snd.sent))
|
||||
}
|
||||
if sess.dropped == 0 {
|
||||
t.Error("no frames were counted as backlog")
|
||||
}
|
||||
}
|
||||
|
||||
// Same on the error path. A dead daemon used to seed the next spurious trigger
|
||||
// on every failed turn, so a dead socket drove a retry loop off backlog alone.
|
||||
func TestSessionDropsBacklogAfterASendError(t *testing.T) {
|
||||
sess, _, _ := newSlowSession(bargeInConfig{}, audio.Audio{}, errors.New("boom"), 3*time.Second)
|
||||
|
||||
// Not speakThenPause: the dispatch returns the send error, which that
|
||||
// helper treats as fatal.
|
||||
loud := frameAt(0.35)
|
||||
for i := 0; i < (defaultSpeechMs+defaultFrameMs-1)/defaultFrameMs+5; i++ {
|
||||
_ = sess.feed(context.Background(), loud)
|
||||
}
|
||||
for i := 0; i < (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs+2; i++ {
|
||||
_ = sess.feed(context.Background(), silentBytes())
|
||||
}
|
||||
if sess.discard == 0 {
|
||||
t.Fatal("a failed round-trip left the backlog to be fed into the VAD")
|
||||
}
|
||||
}
|
||||
|
||||
// Barge-in must not be triggerable by the backlog. Those frames are him
|
||||
// finishing the sentence he started before she answered, delivered in
|
||||
// microseconds, and the five-frame guard assumes real time.
|
||||
func TestSessionBacklogCannotBargeIn(t *testing.T) {
|
||||
sess, p, _ := newSlowSession(bargeInConfig{RMS: 0.12, Frames: 5}, replyAudio(), nil, 3*time.Second)
|
||||
|
||||
speakThenPause(t, sess)
|
||||
if p.plays != 1 || !p.Playing() {
|
||||
t.Fatalf("plays = %d, playing = %v; want the reply playing", p.plays, p.Playing())
|
||||
}
|
||||
|
||||
loud := frameAt(0.35)
|
||||
backlog := sess.discard
|
||||
if backlog < 5 {
|
||||
t.Fatalf("discard = %d, want a real backlog", backlog)
|
||||
}
|
||||
for i := 0; i < backlog; i++ {
|
||||
if err := sess.feed(context.Background(), loud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if p.stops != 0 {
|
||||
t.Fatalf("she was cut off by audio recorded before she started speaking (stops = %d)", p.stops)
|
||||
}
|
||||
|
||||
// Real-time speech after the backlog still interrupts her.
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := sess.feed(context.Background(), loud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if p.stops != 1 {
|
||||
t.Fatalf("stops = %d, want 1 — barge-in must still work after the backlog", p.stops)
|
||||
}
|
||||
}
|
||||
|
||||
// The frames that proved he was interrupting are replayed into the VAD, so his
|
||||
// first word is not clipped. Five trigger frames plus five real ones reach the
|
||||
// 300ms speech threshold; without the replay the first five are lost and no
|
||||
// utterance is produced at all.
|
||||
func TestSessionReplaysTheBargeInTriggerFrames(t *testing.T) {
|
||||
sess, p, snd := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||
speakThenPause(t, sess)
|
||||
|
||||
veryLoud := frameAt(0.35)
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = sess.feed(context.Background(), veryLoud)
|
||||
}
|
||||
if p.stops != 1 {
|
||||
t.Fatalf("expected barge-in, stops = %d", p.stops)
|
||||
}
|
||||
if p.Playing() {
|
||||
t.Fatal("fake player still playing after Stop")
|
||||
}
|
||||
|
||||
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
||||
for i := 0; i < speechFrames-5; i++ {
|
||||
if err := sess.feed(context.Background(), veryLoud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
||||
for i := 0; i < silenceFrames; i++ {
|
||||
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(snd.sent) != 2 {
|
||||
t.Fatalf("sent %d utterances, want 2 — the 150ms that triggered barge-in was clipped", len(snd.sent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,17 +32,28 @@ type fakeKeyIPC struct {
|
||||
unlockCalls int
|
||||
wrapCalls int
|
||||
unlockErr error
|
||||
wrapExplicit bool
|
||||
// opensWith, when set, is the only secret Unlock accepts. It stands in
|
||||
// for a wrapped blob on disk: everything else gets unlockErr.
|
||||
opensWith []byte
|
||||
}
|
||||
|
||||
func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error {
|
||||
f.unlockCalls++
|
||||
f.unlockSecret = bytes.Clone(secret)
|
||||
if f.opensWith != nil {
|
||||
if bytes.Equal(secret, f.opensWith) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("unwrap key: decrypt failed (wrong credential?)")
|
||||
}
|
||||
return f.unlockErr
|
||||
}
|
||||
|
||||
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte) error {
|
||||
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte, explicit bool) error {
|
||||
f.wrapCalls++
|
||||
f.wrapSecret = bytes.Clone(secret)
|
||||
f.wrapExplicit = explicit
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -137,6 +148,13 @@ func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) {
|
||||
// assert drives POST /assert/finish with a valid assertion and the given
|
||||
// base64url PRF result.
|
||||
func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return a.assertExplicit(t, h, prf, false)
|
||||
}
|
||||
|
||||
// assertExplicit is assert with control over the explicit flag the rewrite
|
||||
// button sets.
|
||||
func (a *prfAuthenticator) assertExplicit(t *testing.T, h *PasskeyHandle, prf string, explicit bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
_, chal, err := h.rp.AssertionOptions()
|
||||
if err != nil {
|
||||
@@ -152,6 +170,7 @@ func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *h
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"challenge": chal,
|
||||
"prf": prf,
|
||||
"explicit": explicit,
|
||||
"credential": map[string]any{
|
||||
"id": b64u(a.credID),
|
||||
"type": "public-key",
|
||||
@@ -253,8 +272,8 @@ func TestAssertSucceedsWhenUnlockFails(t *testing.T) {
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 1 {
|
||||
t.Errorf("unlock attempted %d times, want 1", key.unlockCalls)
|
||||
if key.unlockCalls == 0 {
|
||||
t.Error("unlock was never attempted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,3 +310,100 @@ func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A box enrolled before Vikunja #14 has a v1 blob wrapped under the credential
|
||||
// PUBLIC key. The PRF secret cannot open it, and this handler is the only
|
||||
// caller of Unlock, so without the legacy retry that box stays locked forever
|
||||
// while a perfectly good passkey is asserted at it.
|
||||
func TestLegacyV1BlobStillColdStarts(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
pub, _, err := h.store.Lookup(b64u(auth.credID))
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
// The daemon only opens under the public key — a v1 blob.
|
||||
key.opensWith = pub
|
||||
|
||||
secret := bytes.Repeat([]byte{9}, 32)
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 2 {
|
||||
t.Fatalf("unlock attempted %d times, want 2 (PRF, then the legacy public key)", key.unlockCalls)
|
||||
}
|
||||
if !bytes.Equal(key.unlockSecret, pub) {
|
||||
t.Fatal("the legacy retry did not send the credential public key, so a v1 box can never cold-start again")
|
||||
}
|
||||
}
|
||||
|
||||
// The PRF secret is tried first and, when it works, the public key is never
|
||||
// sent. The legacy retry is a one-way door out of v1, not a fallback offered
|
||||
// to every assertion.
|
||||
func TestPRFUnlockNeverFallsBackWhenItWorks(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{7}, 32)
|
||||
key := &fakeKeyIPC{opensWith: secret}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 1 {
|
||||
t.Fatalf("unlock attempted %d times, want 1", key.unlockCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapping the at-rest key is an explicit act, never a side effect of a
|
||||
// step-up. A page POSTing a substituted prf on a routine assertion must not
|
||||
// make the daemon re-wrap the database key under it.
|
||||
func TestPlainAssertionAsksForNoRewrite(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assert(t, h, b64u(bytes.Repeat([]byte{5}, 32))); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.wrapCalls != 1 {
|
||||
t.Fatalf("wrapCalls = %d, want 1", key.wrapCalls)
|
||||
}
|
||||
if key.wrapExplicit {
|
||||
t.Fatal("a plain step-up asked the daemon to rewrite the cold-start key")
|
||||
}
|
||||
}
|
||||
|
||||
// The rewrite button, and only the rewrite button, sets explicit.
|
||||
func TestRewriteButtonAsksForAnExplicitWrap(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assertExplicit(t, h, b64u(bytes.Repeat([]byte{6}, 32)), true); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !key.wrapExplicit {
|
||||
t.Fatal("the explicit flag did not reach the daemon, so the rewrite button cannot work")
|
||||
}
|
||||
}
|
||||
|
||||
// The page is the only place the explicit flag originates. If the button or
|
||||
// the field goes away, rewriting a cold-start key becomes impossible with
|
||||
// nothing failing.
|
||||
func TestPasskeyPageHasTheRewriteButton(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"rewrite cold-start key",
|
||||
"explicit:!!explicit",
|
||||
"async function rewrapKey()",
|
||||
} {
|
||||
if !strings.Contains(passkeyPageHTML, want) {
|
||||
t.Errorf("the passkey page no longer contains %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-25
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -23,7 +24,7 @@ type assertIPC interface {
|
||||
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
|
||||
// StoreEncryptionKey and Unlock are silently skipped.
|
||||
type keyIPC interface {
|
||||
StoreEncryptionKey(ctx context.Context, secret []byte) error
|
||||
StoreEncryptionKey(ctx context.Context, secret []byte, explicit bool) error
|
||||
Unlock(ctx context.Context, secret []byte) error
|
||||
}
|
||||
|
||||
@@ -86,8 +87,10 @@ const passkeyPageHTML = `{{template "shellTop" "passkey"}}
|
||||
<div class=flex gap-2>
|
||||
<button class=btn onclick=enroll()>enroll passkey</button>
|
||||
<button class=btn onclick=assert()>assert (step-up)</button>
|
||||
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
|
||||
<a href=/tools><button class=btn-primary>→ tools</button></a>
|
||||
</div>
|
||||
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
|
||||
<div id=msg></div>
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
@@ -112,7 +115,7 @@ async function enroll(){try{
|
||||
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
|
||||
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
|
||||
}catch(e){say('enroll error: '+e,false);}}
|
||||
async function assert(){try{
|
||||
async function assert(explicit){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
const c=await navigator.credentials.get({publicKey:options});
|
||||
@@ -121,13 +124,20 @@ async function assert(){try{
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
|
||||
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,prf,credential:{id:c.id,type:c.type,response:{
|
||||
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||
signature:b64u(c.response.signature)}}})});
|
||||
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
|
||||
say(prf?'stepped up ✓ — enable tools now':
|
||||
'stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);
|
||||
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
|
||||
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
|
||||
'stepped up ✓ — enable tools now',true);
|
||||
}catch(e){say('assert error: '+e,false);}}
|
||||
// Rewriting the wrapped key is a separate gesture, never a side effect of a
|
||||
// step-up. Only this button sets explicit, and only explicit lets the daemon
|
||||
// replace a blob that already exists.
|
||||
async function rewrapKey(){
|
||||
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
|
||||
await assert(true);}
|
||||
</script>`
|
||||
|
||||
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -201,7 +211,20 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
// getClientExtensionResults(). Empty when the authenticator has no
|
||||
// PRF extension: cold-start unlock is then unavailable and we say so
|
||||
// rather than falling back to something weaker.
|
||||
//
|
||||
// Known property, accepted deliberately: this value is supplied by
|
||||
// the client and is NOT covered by the assertion signature. WebAuthn
|
||||
// client extension outputs never are, and binding one would need a
|
||||
// per-assertion salt, which would make the wrapped blob unopenable on
|
||||
// the next boot. Nothing here can tell a real PRF output from 32
|
||||
// bytes a compromised page chose. What limits the damage is that the
|
||||
// daemon refuses to rewrite an existing blob unless the operator
|
||||
// asked for it — see Explicit below and cmd/mavend/keyfile.go.
|
||||
PRF string `json:"prf"`
|
||||
// Explicit marks the "rewrite cold-start key" button rather than a
|
||||
// plain step-up. Only then may the daemon replace a blob that is
|
||||
// already on disk.
|
||||
Explicit bool `json:"explicit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -235,32 +258,22 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cold-start unlock and key wrapping, both keyed on the PRF secret that
|
||||
// this assertion just produced. The secret is used here and dropped; it is
|
||||
// never stored on this side.
|
||||
// Cold-start unlock and key wrapping, both keyed on the PRF secret this
|
||||
// assertion just produced. The secret is used here and dropped; it is
|
||||
// never stored on this side, and it must never be logged — unlike a
|
||||
// signature it does not expire, so one copy in a proxy log or a HAR file
|
||||
// is permanent access to the wrapped blob.
|
||||
//
|
||||
// Order matters: unlock first (if the daemon is locked there is nothing to
|
||||
// wrap yet), then re-wrap, which writes the blob on the first assertion
|
||||
// after enrolment and is a harmless rewrite afterwards. Both are
|
||||
// best-effort — the assertion itself is valid either way.
|
||||
// wrap yet), then wrap. Both are best-effort, because the assertion itself
|
||||
// is valid either way.
|
||||
if h.encryptFn != nil {
|
||||
secret, err := webauthn.DecodePRFResult(body.PRF)
|
||||
switch {
|
||||
case err != nil:
|
||||
if secret, err := webauthn.DecodePRFResult(body.PRF); err != nil {
|
||||
log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err)
|
||||
default:
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.encryptFn.Unlock(ctx, secret); err != nil {
|
||||
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
|
||||
} else {
|
||||
log.Printf("webauthn: daemon unlocked via credential %s", credID)
|
||||
}
|
||||
if err := h.encryptFn.StoreEncryptionKey(ctx, secret); err != nil {
|
||||
log.Printf("webauthn: wrap encryption key: %v", err)
|
||||
} else {
|
||||
log.Printf("webauthn: encryption key wrapped for credential %s", credID)
|
||||
}
|
||||
h.coldStart(ctx, credID, secret, body.Explicit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,3 +285,56 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("webauthn: asserted credential %s", credID)
|
||||
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
||||
}
|
||||
|
||||
// coldStart unlocks a locked daemon with this assertion's PRF output and then
|
||||
// asks it to wrap the at-rest key. Never fatal: a locked or unreachable daemon
|
||||
// does not invalidate the step-up.
|
||||
//
|
||||
// # The legacy retry
|
||||
//
|
||||
// A box enrolled before Vikunja #14 has a v1 blob, wrapped under the
|
||||
// credential PUBLIC key. The PRF secret cannot open it, and this handler is
|
||||
// the only caller of Unlock, so without a second attempt that box could never
|
||||
// cold-start again: it would sit locked while a perfectly good passkey was
|
||||
// asserted, and the only way back in would be putting MAVEN_DB_KEY into the
|
||||
// environment — the exact thing cold-start unlock exists to avoid.
|
||||
//
|
||||
// So a failed PRF unlock is retried with the public key from the credential
|
||||
// store. That is not a weaker fallback being offered to new deployments:
|
||||
// nothing writes v1 any more, and a v2 blob does not open under a public key
|
||||
// either. It is a one-way door out of the old format, and the operator is told
|
||||
// to walk through it.
|
||||
func (h *PasskeyHandle) coldStart(ctx context.Context, credID string, secret []byte, explicit bool) {
|
||||
legacy := false
|
||||
err := h.encryptFn.Unlock(ctx, secret)
|
||||
if err != nil && !errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
if pub, _, lerr := h.store.Lookup(credID); lerr == nil && len(pub) > 0 {
|
||||
if err2 := h.encryptFn.Unlock(ctx, pub); err2 == nil {
|
||||
err, legacy = nil, true
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
// Env-key mode: the daemon was never locked and has no UnlockFn. Not
|
||||
// a failure, and the old code logged it as one on every assertion.
|
||||
case err != nil:
|
||||
log.Printf("webauthn: unlock via credential %s failed: %v", credID, err)
|
||||
case legacy:
|
||||
log.Printf("SECURITY: webauthn: daemon unlocked from a LEGACY v1 wrapped key using credential %s. That blob is derived from the credential public key, which sits in passkeys.json beside it, so it protects nothing. Press \"rewrite cold-start key\" on this page to replace it with a v2 blob.", credID)
|
||||
default:
|
||||
log.Printf("webauthn: daemon reports unlocked, credential %s", credID)
|
||||
}
|
||||
|
||||
// explicit=false means "write the blob only if there is none". The daemon
|
||||
// enforces that; sending the flag is the whole of this side's part in it.
|
||||
switch err := h.encryptFn.StoreEncryptionKey(ctx, secret, explicit); {
|
||||
case err == nil && explicit:
|
||||
log.Printf("webauthn: cold-start key rewritten under credential %s", credID)
|
||||
case err == nil:
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
// No key to wrap: a plaintext dev store, or a daemon still locked.
|
||||
default:
|
||||
log.Printf("webauthn: wrap encryption key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user