Derive the cold-start unlock key from the passkey PRF, not the public key (#14)

Cold-start unlock wrapped the database key under the credential *public* key.
A public key is public: mavweb writes it verbatim to passkeys.json, normally in
the same state dir as db_key.wrapped, so anyone holding both files recovered the
database key offline with no authenticator involved. The wrapped blob was a
plaintext key with extra steps.

The secret is now the WebAuthn PRF extension output — 32 bytes the authenticator
computes over a fixed salt and never stores anywhere. The blob gains a version:

  v2:  "MVNKW2\x00" || salt || nonce || AES-256-GCM(key), magic as AAD
  v1:  salt || nonce || AES-256-GCM(key)                  (read-only)

v1 still opens so an existing deployment is not bricked, and reports itself so
the daemon can log a SECURITY line telling him to re-enroll. Nothing writes v1.
The magic is authenticated, so a v2 blob cannot be stripped and re-read as v1.

Four other defects on the same path:

  - The locked-boot store was opened on an IPC goroutine inside UnlockFn and
    never closed. Close is what re-encrypts the tmpfs working copy back over
    the ciphertext, so every write of a cold-started session was lost silently
    on the next boot. daemonLock now owns the store and seals it at shutdown.
  - MethodUnlock was reachable by anything on the box; the socket is same-uid
    and cannot authenticate its caller. It now requires a passkey assertion
    that mavweb verified first.
  - Concurrent unlocks would each open a store and wire a daemon. One at a
    time, and never a second one.
  - The hand-rolled HKDF keyed the expand step with the salt instead of the
    PRK. Replaced with crypto/hkdf.

Key wrapping moves from enrolment to the first assertion, because create() does
not produce a PRF result on most authenticators — only a support flag. An
authenticator without PRF now writes no wrapped file at all rather than one
that looks protected and is not, and the page says so.

Verified: make build, make test. New tests cover the v2 round trip, a wrong
secret, every single-bit tamper, truncation, the v1 downgrade attempt, legacy
v1 reads, non-32-byte and all-zero secrets, the ipc wire field, locked-mode
default-deny, a forged assertion never reaching the unlock path, seal-on-
shutdown after a cold start, and that nothing in the state dir contains the
plaintext key. The PRF round trip against real hardware is a QA step.

Vikunja #14
This commit is contained in:
kami
2026-08-01 05:49:27 +04:00
parent fed33a4e16
commit 4eca20bd94
13 changed files with 1349 additions and 168 deletions
+159 -100
View File
@@ -1,24 +1,48 @@
// Key wrapping for cold-start unlock.
// Key wrapping for cold-start unlock (Vikunja #14).
//
// The at-rest AES-256 key is wrapped with a key derived from the passkey
// credential public key (stable across assertions) via HKDF-SHA256, then
// AES-256-GCM. The wrapped blob is stored on disk; at cold-start the passkey
// assertion provides the credential public key to unwrap it.
// 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.
//
// The passkey credential is a P-256 ECDSA public key. Its raw uncompressed
// bytes (65 bytes, 0x04 || X || Y) are the HKDF input — high-entropy, stable.
// # What the secret must be
//
// Blob format: salt (16) || nonce (12) || AES-256-GCM ciphertext.
// No file magic — the caller (mavend) owns the file path.
// 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.
//
// # 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/hmac"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"crypto/subtle"
"errors"
"fmt"
"io"
@@ -31,143 +55,178 @@ const (
nonceLen = 12
// keyLen — AES-256 key length.
keyLen = 32
// wrapInfo — HKDF info string for domain separation.
wrapInfo = "maven-passkey-keywrap-v1"
// 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")
)
// WrapKey derives a wrapping key from credPublicKey via HKDF-SHA256 and
// AES-GCM-wraps plaintextKey. Returns the blob: salt || nonce || ciphertext.
// plaintextKey must be exactly 32 bytes (AES-256).
func WrapKey(plaintextKey, credPublicKey []byte) ([]byte, error) {
// 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 len(credPublicKey) == 0 {
return nil, fmt.Errorf("%w: empty credential public key", ErrKeyWrap)
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)
}
wrapKey := hkdfSHA256(credPublicKey, salt, []byte(wrapInfo), keyLen)
nonce := make([]byte, nonceLen)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("%w: nonce: %v", ErrKeyWrap, err)
}
block, err := aes.NewCipher(wrapKey)
gcm, err := gcmFor(secret, salt, wrapInfoV2)
if err != nil {
return nil, fmt.Errorf("%w: aes: %v", ErrKeyWrap, err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("%w: gcm: %v", ErrKeyWrap, err)
return nil, fmt.Errorf("%w: %v", ErrKeyWrap, err)
}
// Seal appends ciphertext+tag to nonce (which becomes nonce||ct).
ct := gcm.Seal(nil, nonce, plaintextKey, nil)
// 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, saltLen+nonceLen+len(ct))
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 extracts the salt from blob, re-derives the wrapping key from
// credPublicKey, and AES-GCM-unwraps. Returns the plaintext 32-byte AES key.
func UnwrapKey(blob, credPublicKey []byte) ([]byte, error) {
if len(blob) < saltLen+nonceLen+1 {
return nil, fmt.Errorf("%w: blob too short (%d)", ErrKeyUnwrap, len(blob))
// 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(blob) > 1<<20 { // 1MB sanity limit
return nil, ErrBlobTooLong
}
if len(credPublicKey) == 0 {
return nil, fmt.Errorf("%w: empty credential public key", ErrKeyUnwrap)
if len(secret) == 0 {
return nil, 0, fmt.Errorf("%w: empty secret", ErrKeyUnwrap)
}
salt := blob[:saltLen]
nonce := blob[saltLen : saltLen+nonceLen]
ct := blob[saltLen+nonceLen:]
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
}
wrapKey := hkdfSHA256(credPublicKey, salt, []byte(wrapInfo), keyLen)
// 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))
}
if wantSecretLen > 0 && len(secret) != wantSecretLen {
return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, ErrSecretLen)
}
block, err := aes.NewCipher(wrapKey)
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: aes: %v", ErrKeyUnwrap, err)
return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("%w: gcm: %v", ErrKeyUnwrap, err)
}
plain, err := gcm.Open(nil, nonce, ct, nil)
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
}
// hkdfSHA256 implements HKDF-SHA256 (RFC 5869) using only stdlib.
// gcmFor derives the wrapping key with HKDF-SHA256 and returns a GCM AEAD.
//
// Input:
// - secret: the input key material (credential public key bytes)
// - salt: random salt (16 bytes)
// - info: optional context string for domain separation
// - length: desired output length in bytes
//
// Output: length bytes of derived key material.
//
// HKDF is extract-then-expand. We use HMAC-SHA256 for both steps. This avoids
// importing golang.org/x/crypto/hkdf — a ~30-line function vs a new dep. The
// tradeoff is no constant-time guarantees on the extract step beyond HMAC's;
// acceptable here because the input is already high-entropy key material (a
// P-256 public key), not a low-entropy passphrase.
func hkdfSHA256(secret, salt, info []byte, length int) []byte {
// Step 1: Extract — PRK = HMAC-SHA256(salt, secret)
// If salt is nil/empty, use a zero-filled block (RFC 5869 §2.2).
if salt == nil {
salt = make([]byte, sha256.Size)
// 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)
}
mac := hmac.New(sha256.New, salt)
mac.Write(secret)
prk := mac.Sum(nil)
// Step 2: Expand — produce length bytes via T(i) = HMAC-SHA256(PRK, T(i-1) || info || i)
// Where T(0) = empty, i is a byte counter starting at 1.
out := make([]byte, 0, length)
block := make([]byte, 0, sha256.Size+len(info)+1)
var t []byte // T(i-1)
for counter := byte(1); len(out) < length; counter++ {
block = block[:0]
block = append(block, t...)
block = append(block, info...)
block = append(block, counter)
mac.Reset()
mac.Write(block)
t = mac.Sum(prk[:0]) // reuse prk buffer — mac.Sum appends to its arg
// t now starts with prk[:0] (empty) followed by the HMAC result.
// Since we need just the HMAC result (sha256.Size bytes), re-slice.
t = t[len(t)-sha256.Size:]
out = append(out, t...)
block, err := aes.NewCipher(wrapKey)
if err != nil {
return nil, fmt.Errorf("aes: %v", err)
}
return out[:length]
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %v", err)
}
return gcm, nil
}
// encodeUint32 — big-endian uint32 for the blob format header, if needed.
func encodeUint32(v uint32) []byte {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
return b[:]
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
}