b0932a19df
- internal/webauthn/keywrap.go: HKDF-SHA256 + AES-256-GCM WrapKey/UnwrapKey - internal/ipc/: MethodStoreEncryptionKey/MethodUnlock wire, api structs, server dispatch callbacks (WrapKeyFn/UnlockFn), client stubs - internal/config/config.go: DefaultWrappedKeyPath() method - cmd/mavend/main.go: locked-mode boot path - detects wrapped key, starts locked with lockedAPI stub, wires UnlockFn that opens store + replaces CoreAPI on passkey assertion. env-key path stores WrapKeyFn for enrollment. make test green (303+, -race)
174 lines
5.6 KiB
Go
174 lines
5.6 KiB
Go
// Key wrapping for cold-start unlock.
|
|
//
|
|
// 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 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.
|
|
//
|
|
// Blob format: salt (16) || nonce (12) || AES-256-GCM ciphertext.
|
|
// No file magic — the caller (mavend) owns the file path.
|
|
package webauthn
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"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
|
|
// wrapInfo — HKDF info string for domain separation.
|
|
wrapInfo = "maven-passkey-keywrap-v1"
|
|
)
|
|
|
|
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")
|
|
)
|
|
|
|
// 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) {
|
|
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)
|
|
}
|
|
|
|
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)
|
|
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)
|
|
}
|
|
|
|
// Seal appends ciphertext+tag to nonce (which becomes nonce||ct).
|
|
ct := gcm.Seal(nil, nonce, plaintextKey, nil)
|
|
|
|
out := make([]byte, 0, saltLen+nonceLen+len(ct))
|
|
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))
|
|
}
|
|
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)
|
|
}
|
|
|
|
salt := blob[:saltLen]
|
|
nonce := blob[saltLen : saltLen+nonceLen]
|
|
ct := blob[saltLen+nonceLen:]
|
|
|
|
wrapKey := hkdfSHA256(credPublicKey, salt, []byte(wrapInfo), keyLen)
|
|
|
|
block, err := aes.NewCipher(wrapKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: aes: %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)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: decrypt failed (wrong credential?)", ErrKeyUnwrap)
|
|
}
|
|
return plain, nil
|
|
}
|
|
|
|
// hkdfSHA256 implements HKDF-SHA256 (RFC 5869) using only stdlib.
|
|
//
|
|
// 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)
|
|
}
|
|
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...)
|
|
}
|
|
return out[:length]
|
|
}
|
|
|
|
// 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[:]
|
|
}
|