// 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
}
