Merge branch 'fix/g09' into fix/integrated

This commit is contained in:
kami
2026-08-01 14:22:24 +04:00
31 changed files with 1431 additions and 159 deletions
+65 -22
View File
@@ -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 {