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() }