// 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. // // # 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)) } if wantSecretLen > 0 && len(secret) != wantSecretLen { return nil, fmt.Errorf("%w: %v", ErrKeyUnwrap, ErrSecretLen) } 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 }