Files
kami 7ab9b48259 coldstart: recover v1 boxes, and make key wrapping an explicit act
Three ways the cold-start path could lose the database.

A box enrolled before the PRF change could never cold-start again. UnwrapKey
still read v1 blobs, but the only caller stopped supplying the v1 secret: the
assertion handler sends the PRF output and nothing looks up the credential
public key any more. On such a box the daemon read the blob, took the v1
branch, failed to decrypt, and stayed locked while a valid passkey was
asserted at it. The escape hatch was gone too, because WrapKeyFn was wired
only in env-key mode and a locked boot is by definition the mode with no env
key. The recovery was to put MAVEN_DB_KEY back in the environment, which is
the thing cold-start unlock exists to avoid. AssertFinish now retries a failed
PRF unwrap with the credential public key, and WrapKeyFn is wired in locked
mode too, so the box that came up on a v1 blob can be moved to v2.

Wrapping ran on every successful assertion. That made a routine step-up
rewrite the one file that opens the database, under whatever 32 bytes the page
posted. A compromised /auth/webauthn converted one legitimate touch into
permanent offline recovery of the at-rest key, and a second enrolled
authenticator silently locked out the first. Wrapping is now an act of its
own: a plain assertion may write the blob only when none exists, and replacing
one takes the rewrite button, which is the only caller that sets the new
explicit flag. The daemon still refuses to overwrite a v2 blob that does not
open under the presented secret.

The write was os.WriteFile, which truncates in place. A power cut between the
truncate and the write left a zero-length blob and no previous contents, on
the path of every step-up. It is now a temp file in the same directory, fsync,
rename, fsync of the directory.

Two smaller things on the same path. The v2 unwrap checked the secret length
but not the all-zero case the wrap side rejects, so the two ends disagreed
about what a valid secret is. And the handler logged "daemon unlocked via
credential" when an env-key daemon had answered unknown method, and again when
an already-unlocked daemon had done nothing.

Left alone deliberately: the PRF value is client-supplied and not covered by
the assertion signature. That is inherent to PRF key wrapping, since the salt
has to be fixed for the blob to open on the next boot. It is recorded as a
known property where the secret enters the handler.

Found in review of #77.
2026-08-01 14:05:13 +04:00

252 lines
9.1 KiB
Go

// Key wrapping for cold-start unlock (Vikunja #14).
//
// The at-rest AES-256 key is never on disk in the clear. It is wrapped with a
// key derived from a secret only the authenticator can produce, so a cold boot
// needs the physical passkey and nothing else opens the store.
//
// # What the secret must be
//
// The WebAuthn PRF extension. On assertion, the authenticator evaluates a
// keyed pseudo-random function over a fixed salt and hands back 32 bytes that
// are stable for the credential, unpredictable to everyone else, and never
// leave the device except as that output. That is the only thing in WebAuthn
// that yields a *secret* rather than a signature, and it is what makes the
// wrapped blob worth wrapping.
//
// # What it must NOT be, and used to be
//
// v1 of this file derived the wrapping key from the credential *public* key,
// on the reasoning that it is high-entropy and stable across assertions. Both
// are true and neither matters: a public key is public. mavweb writes it
// verbatim to passkeys.json, normally in the same state dir as the wrapped
// blob, so anyone holding both files recovered the database key offline with
// no authenticator involved. A v1 blob is a plaintext key with extra steps.
//
// v1 blobs are still readable, so an existing deployment opens and can be
// re-wrapped, and UnwrapKey reports which format it read so the caller can
// say so out loud. Nothing writes v1 any more. Reading one needs the
// credential public key, which only cmd/mavweb still has: its assertion
// handler retries a failed PRF unwrap with it, because otherwise a box
// enrolled before v2 could never cold-start again.
//
// # What the wrapped blob's security rests on
//
// The PRF secret is stable for the lifetime of the credential and it reaches
// mavend inside an HTTP request body. Unlike a signature it does not expire.
// One copy in a proxy log, a devtools HAR, or a crash dump is permanent
// offline access to whatever this blob wraps. Nothing on this path may log the
// secret, and nothing does.
//
// # Blob format
//
// v2: "MVNKW2\x00" (7) || salt (16) || nonce (12) || AES-256-GCM ciphertext
// v1: salt (16) || nonce (12) || AES-256-GCM ciphertext (legacy, read-only)
//
// The magic doubles as the version discriminator: v1 had none, so anything
// that does not start with it is v1 by elimination. A random 16-byte v1 salt
// colliding with the magic is a 2^-56 event, and the GCM tag catches it.
package webauthn
import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"errors"
"fmt"
"io"
)
const (
// saltLen — HKDF salt length. 16 bytes is standard.
saltLen = 16
// nonceLen — AES-GCM standard nonce length.
nonceLen = 12
// keyLen — AES-256 key length.
keyLen = 32
// secretLen — required length of the PRF output used as key material.
// WebAuthn PRF results are 32 bytes. Requiring exactly that is not
// pedantry: it is the structural guard that stops a COSE credential
// public key (77+ bytes) being passed here again by accident.
secretLen = 32
// wrapInfoV2 — HKDF info string. Carries the version so a v1 and a v2
// derivation can never collide even given the same input.
wrapInfoV2 = "maven-passkey-keywrap-v2"
// wrapInfoV1 — the legacy info string, kept only to read old blobs.
wrapInfoV1 = "maven-passkey-keywrap-v1"
)
// blobMagicV2 prefixes every v2 blob.
var blobMagicV2 = []byte("MVNKW2\x00")
var (
ErrKeyWrap = errors.New("webauthn: key wrap failed")
ErrKeyUnwrap = errors.New("webauthn: key unwrap failed (wrong credential?)")
ErrBlobTooLong = errors.New("webauthn: wrapped blob too long")
// ErrSecretLen is returned when the caller passes something that is not a
// 32-byte PRF output — most likely a credential public key.
ErrSecretLen = errors.New("webauthn: wrapping secret must be a 32-byte PRF output")
)
// BlobVersion identifies which format a blob was read as.
type BlobVersion int
const (
// BlobV1 is the legacy public-key-derived format. Readable, never written.
BlobV1 BlobVersion = 1
// BlobV2 is the PRF-derived format.
BlobV2 BlobVersion = 2
)
func (v BlobVersion) String() string {
switch v {
case BlobV1:
return "v1 (legacy, public-key derived — NOT SECRET)"
case BlobV2:
return "v2 (PRF derived)"
}
return "unknown"
}
// maxBlobLen — sanity limit; a real blob is 67 bytes.
const maxBlobLen = 1 << 20
// WrapKey wraps plaintextKey (32 bytes, AES-256) under a key derived from
// secret via HKDF-SHA256, and returns a v2 blob.
//
// secret must be the 32-byte WebAuthn PRF output for the enrolled credential.
// Anything else is refused — see the file header for why passing a credential
// public key here is the bug this replaces.
func WrapKey(plaintextKey, secret []byte) ([]byte, error) {
if len(plaintextKey) != keyLen {
return nil, fmt.Errorf("%w: plaintext key must be %d bytes", ErrKeyWrap, keyLen)
}
if err := checkSecret(secret); err != nil {
return nil, fmt.Errorf("%w: %v", ErrKeyWrap, err)
}
salt := make([]byte, saltLen)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("%w: salt: %v", ErrKeyWrap, err)
}
nonce := make([]byte, nonceLen)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("%w: nonce: %v", ErrKeyWrap, err)
}
gcm, err := gcmFor(secret, salt, wrapInfoV2)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrKeyWrap, err)
}
// The magic is authenticated as additional data, so a v2 blob cannot be
// stripped of its header and re-read as a v1 blob.
ct := gcm.Seal(nil, nonce, plaintextKey, blobMagicV2)
out := make([]byte, 0, len(blobMagicV2)+saltLen+nonceLen+len(ct))
out = append(out, blobMagicV2...)
out = append(out, salt...)
out = append(out, nonce...)
out = append(out, ct...)
return out, nil
}
// UnwrapKey recovers the plaintext AES-256 key from blob.
//
// It reads both formats and reports which one it got, so the caller can warn
// that a v1 blob offers no real protection. For a v2 blob, secret must be the
// 32-byte PRF output; for a v1 blob it is the credential public key, whatever
// length that happens to be.
func UnwrapKey(blob, secret []byte) ([]byte, BlobVersion, error) {
if len(blob) > maxBlobLen {
return nil, 0, ErrBlobTooLong
}
if len(secret) == 0 {
return nil, 0, fmt.Errorf("%w: empty secret", ErrKeyUnwrap)
}
if len(blob) >= len(blobMagicV2) && subtle.ConstantTimeCompare(blob[:len(blobMagicV2)], blobMagicV2) == 1 {
key, err := unwrap(blob[len(blobMagicV2):], secret, wrapInfoV2, blobMagicV2, secretLen)
return key, BlobV2, err
}
key, err := unwrap(blob, secret, wrapInfoV1, nil, 0)
return key, BlobV1, err
}
// unwrap does the shared salt||nonce||ct work. wantSecretLen of 0 means any
// non-empty secret is accepted (the v1 case, where it is a public key).
func unwrap(body, secret []byte, info string, aad []byte, wantSecretLen int) ([]byte, error) {
if len(body) < saltLen+nonceLen+1 {
return nil, fmt.Errorf("%w: blob too short (%d)", ErrKeyUnwrap, len(body))
}
// The v2 side is held to exactly what WrapKey demands, all-zero included.
// Letting the two ends disagree about what a valid secret is would leave
// a blob that can be opened by material that could never have sealed it.
if wantSecretLen > 0 {
if len(secret) != wantSecretLen {
return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, ErrSecretLen)
}
if err := checkSecret(secret); err != nil {
return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, err)
}
}
salt := body[:saltLen]
nonce := body[saltLen : saltLen+nonceLen]
ct := body[saltLen+nonceLen:]
gcm, err := gcmFor(secret, salt, info)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, err)
}
plain, err := gcm.Open(nil, nonce, ct, aad)
if err != nil {
return nil, fmt.Errorf("%w: decrypt failed (wrong credential?)", ErrKeyUnwrap)
}
if len(plain) != keyLen {
return nil, fmt.Errorf("%w: unwrapped key is %d bytes, want %d", ErrKeyUnwrap, len(plain), keyLen)
}
return plain, nil
}
// gcmFor derives the wrapping key with HKDF-SHA256 and returns a GCM AEAD.
//
// This uses the standard library's crypto/hkdf rather than the hand-rolled
// HKDF this file used to carry. That implementation keyed the expand step with
// the salt instead of the PRK — self-consistent, so wrap and unwrap agreed,
// but not RFC 5869 and not the domain separation it claimed to provide.
func gcmFor(secret, salt []byte, info string) (cipher.AEAD, error) {
wrapKey, err := hkdf.Key(sha256.New, secret, salt, info, keyLen)
if err != nil {
return nil, fmt.Errorf("hkdf: %v", err)
}
block, err := aes.NewCipher(wrapKey)
if err != nil {
return nil, fmt.Errorf("aes: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %v", err)
}
return gcm, nil
}
func checkSecret(secret []byte) error {
if len(secret) != secretLen {
return fmt.Errorf("%w (got %d bytes)", ErrSecretLen, len(secret))
}
// An all-zero PRF result means the authenticator returned nothing useful;
// wrapping under it would produce a blob anyone can open.
var acc byte
for _, b := range secret {
acc |= b
}
if acc == 0 {
return fmt.Errorf("%w (all zero)", ErrSecretLen)
}
return nil
}