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:
+11
-6
@@ -729,17 +729,22 @@ type DayPlan struct {
|
||||
Spoken string `json:"spoken"`
|
||||
}
|
||||
|
||||
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
||||
// storeEncryptionKeyReq — the passkey-derived secret used to wrap the store
|
||||
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
||||
//
|
||||
// Secret is the 32-byte WebAuthn PRF output, NOT the credential public key.
|
||||
// The field used to carry the public key and that was the bug: a public key
|
||||
// sits in passkeys.json next to the wrapped blob, so the blob protected
|
||||
// nothing. See internal/webauthn/keywrap.go.
|
||||
type storeEncryptionKeyReq struct {
|
||||
PublicKey []byte `json:"public_key"`
|
||||
Secret []byte `json:"secret"`
|
||||
}
|
||||
|
||||
// unlockReq — passkey credential public key for unwrapping the store
|
||||
// encryption key at cold-start. mavend reads the wrapped blob from its own
|
||||
// configured path; the public key is the other half needed for unwrapping.
|
||||
// unlockReq — the passkey-derived secret for unwrapping the store encryption
|
||||
// key at cold-start. mavend reads the wrapped blob from its own configured
|
||||
// path; this is the other half. Same PRF-output contract as above.
|
||||
type unlockReq struct {
|
||||
PublicKey []byte `json:"public_key"`
|
||||
Secret []byte `json:"secret"`
|
||||
}
|
||||
|
||||
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
|
||||
|
||||
@@ -393,12 +393,16 @@ func (c *Client) AssertStepUp(ctx context.Context) error {
|
||||
return c.call(ctx, MethodAssertStepUp, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) StoreEncryptionKey(ctx context.Context, publicKey []byte) error {
|
||||
return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{PublicKey: publicKey}, nil)
|
||||
// StoreEncryptionKey wraps the daemon's at-rest key under secret, the 32-byte
|
||||
// WebAuthn PRF output for the freshly enrolled credential.
|
||||
func (c *Client) StoreEncryptionKey(ctx context.Context, secret []byte) error {
|
||||
return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{Secret: secret}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Unlock(ctx context.Context, publicKey []byte) error {
|
||||
return c.call(ctx, MethodUnlock, unlockReq{PublicKey: publicKey}, nil)
|
||||
// Unlock hands the daemon the PRF secret so it can unwrap its at-rest key and
|
||||
// open the store. Refused unless a passkey assertion was verified first.
|
||||
func (c *Client) Unlock(ctx context.Context, secret []byte) error {
|
||||
return c.call(ctx, MethodUnlock, unlockReq{Secret: secret}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||
|
||||
+11
-11
@@ -420,8 +420,8 @@ type Server struct {
|
||||
// MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor).
|
||||
StepUp StepUpFunc
|
||||
|
||||
// WrapKeyFn — wraps the in-memory store encryption key with a passkey
|
||||
// credential public key (HKDF-AESGCM) and writes the wrapped blob to disk.
|
||||
// WrapKeyFn — wraps the in-memory store encryption key under the passkey
|
||||
// PRF secret (HKDF-AESGCM) and writes the wrapped blob to disk.
|
||||
// Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod.
|
||||
WrapKeyFn WrapKeyFunc
|
||||
|
||||
@@ -481,7 +481,7 @@ type Server struct {
|
||||
ForgetSpeakerFn ForgetSpeakerFunc
|
||||
|
||||
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
||||
// the passkey credential public key, opens the encrypted store, and wires
|
||||
// the passkey PRF secret, opens the encrypted store, and wires
|
||||
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
||||
// in locked mode; nil ⇒ MethodUnlock returns ErrUnknownMethod.
|
||||
UnlockFn UnlockFunc
|
||||
@@ -490,13 +490,13 @@ type Server struct {
|
||||
// absolute ts supplied by callers, so this isn't load-bearing for live ops.
|
||||
}
|
||||
|
||||
// WrapKeyFunc — wraps the store encryption key with the given credential
|
||||
// public key and persists the wrapped blob.
|
||||
type WrapKeyFunc func(ctx context.Context, publicKey []byte) error
|
||||
// WrapKeyFunc — wraps the store encryption key under the passkey-derived
|
||||
// secret (a 32-byte WebAuthn PRF output) and persists the wrapped blob.
|
||||
type WrapKeyFunc func(ctx context.Context, secret []byte) error
|
||||
|
||||
// UnlockFunc — unwraps the store encryption key using the given credential
|
||||
// public key and completes daemon initialization.
|
||||
type UnlockFunc func(ctx context.Context, publicKey []byte) error
|
||||
// UnlockFunc — unwraps the store encryption key using the passkey-derived
|
||||
// secret and completes daemon initialization.
|
||||
type UnlockFunc func(ctx context.Context, secret []byte) error
|
||||
|
||||
// SwapModelFunc — loads another resident model in place of the live one.
|
||||
type SwapModelFunc func(ctx context.Context, req SwapModelReq) (SwapModelResp, error)
|
||||
@@ -929,7 +929,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), s.WrapKeyFn(ctx, p.PublicKey)
|
||||
return marshalResult(nil), s.WrapKeyFn(ctx, p.Secret)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
@@ -939,7 +939,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey)
|
||||
return marshalResult(nil), s.UnlockFn(ctx, p.Secret)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The wire must carry the PRF secret, not the credential public key. This is
|
||||
// the field rename that fixes Vikunja #14: a v1 deployment sent "public_key",
|
||||
// and the value it sent was in passkeys.json next to the wrapped blob.
|
||||
func TestUnlockWireCarriesSecret(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{7}, 32)
|
||||
for _, p := range []any{unlockReq{Secret: secret}, storeEncryptionKeyReq{Secret: secret}} {
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %T: %v", p, err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("unmarshal %T: %v", p, err)
|
||||
}
|
||||
if _, ok := m["secret"]; !ok {
|
||||
t.Errorf("%T has no \"secret\" field: %s", p, b)
|
||||
}
|
||||
if _, ok := m["public_key"]; ok {
|
||||
t.Errorf("%T still sends \"public_key\": %s", p, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The secret must reach the daemon hook byte-for-byte through the socket.
|
||||
func TestUnlockDeliversSecretToHook(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
|
||||
secret := make([]byte, 32)
|
||||
for i := range secret {
|
||||
secret[i] = byte(i + 1)
|
||||
}
|
||||
var gotUnlock, gotWrap []byte
|
||||
srv.UnlockFn = func(_ context.Context, s []byte) error { gotUnlock = bytes.Clone(s); return nil }
|
||||
srv.WrapKeyFn = func(_ context.Context, s []byte) error { gotWrap = bytes.Clone(s); return nil }
|
||||
|
||||
ctx := context.Background()
|
||||
if err := cli.Unlock(ctx, secret); err != nil {
|
||||
t.Fatalf("Unlock: %v", err)
|
||||
}
|
||||
if !bytes.Equal(gotUnlock, secret) {
|
||||
t.Errorf("UnlockFn got %x, want %x", gotUnlock, secret)
|
||||
}
|
||||
if err := cli.StoreEncryptionKey(ctx, secret); err != nil {
|
||||
t.Fatalf("StoreEncryptionKey: %v", err)
|
||||
}
|
||||
if !bytes.Equal(gotWrap, secret) {
|
||||
t.Errorf("WrapKeyFn got %x, want %x", gotWrap, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal from the daemon hook — a wrong passkey, or no prior assertion —
|
||||
// must surface to the caller as an error, never be swallowed into success.
|
||||
func TestUnlockPropagatesRefusal(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
srv.UnlockFn = func(context.Context, []byte) error {
|
||||
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||
}
|
||||
if err := cli.Unlock(context.Background(), bytes.Repeat([]byte{9}, 32)); err == nil {
|
||||
t.Fatal("a refused unlock reported success")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the hooks wired — the normal, unencrypted deployment — both methods
|
||||
// answer ErrUnknownMethod rather than pretending to have done something.
|
||||
func TestUnlockUnwiredIsUnknownMethod(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
if err := cli.Unlock(ctx, bytes.Repeat([]byte{1}, 32)); err == nil {
|
||||
t.Error("Unlock succeeded with no UnlockFn wired")
|
||||
}
|
||||
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{1}, 32)); err == nil {
|
||||
t.Error("StoreEncryptionKey succeeded with no WrapKeyFn wired")
|
||||
}
|
||||
}
|
||||
|
||||
// Locked mode: Server.Check is the whole authorization surface, and it must
|
||||
// default-deny everything except the two methods the unlock flow needs.
|
||||
func TestLockedCheckDefaultDenies(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
|
||||
locked := errors.New("locked")
|
||||
srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
|
||||
switch m {
|
||||
case MethodAssertStepUp, MethodUnlock:
|
||||
return nil
|
||||
default:
|
||||
return locked
|
||||
}
|
||||
}
|
||||
unlocked := false
|
||||
srv.UnlockFn = func(context.Context, []byte) error { unlocked = true; return nil }
|
||||
srv.StepUp = func(context.Context) error { return nil }
|
||||
srv.WrapKeyFn = func(context.Context, []byte) error { return nil }
|
||||
|
||||
ctx := context.Background()
|
||||
// A store method must be refused while locked.
|
||||
if _, err := cli.RecentNotes(ctx, 5); err == nil {
|
||||
t.Error("a store read went through while locked")
|
||||
}
|
||||
// Key wrapping is NOT on the allowlist: a locked daemon has no key to wrap.
|
||||
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{2}, 32)); err == nil {
|
||||
t.Error("StoreEncryptionKey was allowed while locked")
|
||||
}
|
||||
// The unlock flow itself must still work.
|
||||
if err := cli.AssertStepUp(ctx); err != nil {
|
||||
t.Errorf("AssertStepUp refused while locked: %v", err)
|
||||
}
|
||||
if err := cli.Unlock(ctx, bytes.Repeat([]byte{3}, 32)); err != nil {
|
||||
t.Errorf("Unlock refused while locked: %v", err)
|
||||
}
|
||||
if !unlocked {
|
||||
t.Error("UnlockFn never ran")
|
||||
}
|
||||
}
|
||||
+159
-100
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testSecret(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
s := make([]byte, secretLen)
|
||||
if _, err := io.ReadFull(rand.Reader, s); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
s[0] |= 1 // never all-zero
|
||||
return s
|
||||
}
|
||||
|
||||
func testKey(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
k := make([]byte, keyLen)
|
||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func TestWrapUnwrapRoundTrip(t *testing.T) {
|
||||
key, secret := testKey(t), testSecret(t)
|
||||
|
||||
blob, err := WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
if !bytes.HasPrefix(blob, blobMagicV2) {
|
||||
t.Fatalf("blob does not start with the v2 magic: %x", blob[:8])
|
||||
}
|
||||
// The plaintext key must not be recoverable by reading the file.
|
||||
if bytes.Contains(blob, key) {
|
||||
t.Fatal("the wrapped blob contains the plaintext key verbatim")
|
||||
}
|
||||
|
||||
got, version, err := UnwrapKey(blob, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("UnwrapKey: %v", err)
|
||||
}
|
||||
if version != BlobV2 {
|
||||
t.Errorf("version = %v, want v2", version)
|
||||
}
|
||||
if !bytes.Equal(got, key) {
|
||||
t.Errorf("unwrapped key differs from the wrapped one")
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh salt and nonce per wrap: two blobs of the same key under the same
|
||||
// secret must not be byte-identical, or the file leaks that nothing changed.
|
||||
func TestWrapKeyIsNotDeterministic(t *testing.T) {
|
||||
key, secret := testKey(t), testSecret(t)
|
||||
a, err := WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
b, err := WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("two wraps of the same key produced identical blobs")
|
||||
}
|
||||
}
|
||||
|
||||
// The failure mode that matters most: a wrong passkey must not unlock.
|
||||
func TestUnwrapWithWrongSecretFails(t *testing.T) {
|
||||
key := testKey(t)
|
||||
blob, err := WrapKey(key, testSecret(t))
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
got, _, err := UnwrapKey(blob, testSecret(t))
|
||||
if err == nil {
|
||||
t.Fatal("a different secret unwrapped the blob")
|
||||
}
|
||||
if !errors.Is(err, ErrKeyUnwrap) {
|
||||
t.Errorf("err = %v, want ErrKeyUnwrap", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Error("key material returned alongside an error")
|
||||
}
|
||||
}
|
||||
|
||||
// One flipped bit anywhere must fail the GCM tag, including in the salt and
|
||||
// nonce — those are not authenticated by the tag but they change the
|
||||
// derivation, so the tag fails anyway.
|
||||
func TestUnwrapRejectsTamperedBlob(t *testing.T) {
|
||||
key, secret := testKey(t), testSecret(t)
|
||||
blob, err := WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
for i := range blob {
|
||||
bad := bytes.Clone(blob)
|
||||
bad[i] ^= 0x01
|
||||
if _, _, err := UnwrapKey(bad, secret); err == nil {
|
||||
t.Fatalf("byte %d of %d could be flipped and the blob still opened", i, len(blob))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapRejectsTruncatedBlob(t *testing.T) {
|
||||
key, secret := testKey(t), testSecret(t)
|
||||
blob, err := WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
for _, n := range []int{0, 1, len(blobMagicV2), len(blobMagicV2) + saltLen, len(blob) - 1} {
|
||||
if _, _, err := UnwrapKey(blob[:n], secret); err == nil {
|
||||
t.Errorf("a %d-byte blob unwrapped", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A v2 blob must not be downgradeable to v1 by stripping its header: the magic
|
||||
// is GCM additional data, so the tag fails once it is gone.
|
||||
func TestV2BlobCannotBeStrippedToV1(t *testing.T) {
|
||||
key, secret := testKey(t), testSecret(t)
|
||||
blob, err := WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
if _, _, err := UnwrapKey(blob[len(blobMagicV2):], secret); err == nil {
|
||||
t.Fatal("a header-stripped v2 blob was accepted as v1")
|
||||
}
|
||||
}
|
||||
|
||||
// v1 blobs still open, and report themselves as v1 so the daemon can warn.
|
||||
// wrapV1 reproduces the legacy writer this file no longer has.
|
||||
func wrapV1(t *testing.T, key, secret []byte) []byte {
|
||||
t.Helper()
|
||||
salt := make([]byte, saltLen)
|
||||
nonce := make([]byte, nonceLen)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
gcm, err := gcmFor(secret, salt, wrapInfoV1)
|
||||
if err != nil {
|
||||
t.Fatalf("gcmFor: %v", err)
|
||||
}
|
||||
out := append([]byte{}, salt...)
|
||||
out = append(out, nonce...)
|
||||
return append(out, gcm.Seal(nil, nonce, key, nil)...)
|
||||
}
|
||||
|
||||
func TestUnwrapReadsLegacyV1(t *testing.T) {
|
||||
key := testKey(t)
|
||||
// v1 was keyed on the credential public key: not 32 bytes, and that is
|
||||
// deliberately still accepted on the read path.
|
||||
pub := make([]byte, 77)
|
||||
if _, err := io.ReadFull(rand.Reader, pub); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
blob := wrapV1(t, key, pub)
|
||||
|
||||
got, version, err := UnwrapKey(blob, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("UnwrapKey(v1): %v", err)
|
||||
}
|
||||
if version != BlobV1 {
|
||||
t.Errorf("version = %v, want v1", version)
|
||||
}
|
||||
if !bytes.Equal(got, key) {
|
||||
t.Error("v1 round-trip lost the key")
|
||||
}
|
||||
if _, _, err := UnwrapKey(blob, pub[:76]); err == nil {
|
||||
t.Error("a truncated public key opened the v1 blob")
|
||||
}
|
||||
}
|
||||
|
||||
// The structural guard against the bug this replaces: a COSE public key is not
|
||||
// 32 bytes, so it can never be used to write a new blob.
|
||||
func TestWrapKeyRefusesNonPRFSecret(t *testing.T) {
|
||||
key := testKey(t)
|
||||
cases := map[string][]byte{
|
||||
"nil": nil,
|
||||
"empty": {},
|
||||
"short": make([]byte, 16),
|
||||
"cose public key": make([]byte, 77),
|
||||
"all-zero 32 byte": make([]byte, 32),
|
||||
}
|
||||
for name, secret := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := WrapKey(key, secret); err == nil {
|
||||
t.Fatalf("WrapKey accepted a %s secret", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapKeyRefusesWrongKeyLength(t *testing.T) {
|
||||
secret := testSecret(t)
|
||||
for _, n := range []int{0, 16, 31, 33, 64} {
|
||||
if _, err := WrapKey(make([]byte, n), secret); err == nil {
|
||||
t.Errorf("WrapKey accepted a %d-byte plaintext key", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A v2 blob demands exactly 32 bytes on the read path too, so a caller cannot
|
||||
// go back to passing a public key.
|
||||
func TestUnwrapV2RefusesNonPRFSecret(t *testing.T) {
|
||||
blob, err := WrapKey(testKey(t), testSecret(t))
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
if _, _, err := UnwrapKey(blob, make([]byte, 77)); !errors.Is(err, ErrKeyUnwrap) {
|
||||
t.Fatalf("err = %v, want ErrKeyUnwrap for a 77-byte secret", err)
|
||||
}
|
||||
if _, _, err := UnwrapKey(blob, nil); err == nil {
|
||||
t.Fatal("an empty secret unwrapped a v2 blob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapRejectsOversizeBlob(t *testing.T) {
|
||||
if _, _, err := UnwrapKey(make([]byte, maxBlobLen+1), testSecret(t)); !errors.Is(err, ErrBlobTooLong) {
|
||||
t.Fatalf("err = %v, want ErrBlobTooLong", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The WebAuthn PRF extension is where cold-start unlock gets its secret
|
||||
// (Vikunja #14). The authenticator evaluates a keyed PRF over a salt we
|
||||
// choose and returns 32 bytes that are:
|
||||
//
|
||||
// - stable — the same credential and the same salt always give the same
|
||||
// bytes, which is what lets a blob wrapped today be opened tomorrow;
|
||||
// - secret — they never leave the authenticator except as this output, so
|
||||
// unlike the credential public key they are not sitting in passkeys.json;
|
||||
// - bound to user verification — the assertion that produces them required
|
||||
// a gesture, so the bytes cannot be harvested silently.
|
||||
//
|
||||
// The salt is fixed and public. It is a domain separator, not a secret: it
|
||||
// makes maven's PRF output different from any other relying party's use of
|
||||
// the same credential.
|
||||
|
||||
// prfSaltInput — the string hashed into the 32-byte evaluation salt. Changing
|
||||
// it invalidates every wrapped key file in existence, which is why it is a
|
||||
// constant and not configuration.
|
||||
const prfSaltInput = "maven-coldstart-unlock-v1"
|
||||
|
||||
// PRFSalt returns the fixed 32-byte PRF evaluation salt.
|
||||
func PRFSalt() []byte {
|
||||
sum := sha256.Sum256([]byte(prfSaltInput))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// ErrNoPRF is returned when a browser reports no PRF result — either the
|
||||
// authenticator does not implement the extension, or the platform stripped
|
||||
// it. Cold-start unlock is unavailable for that credential, and the correct
|
||||
// response is to say so rather than to fall back to something weaker.
|
||||
var ErrNoPRF = errors.New("webauthn: authenticator returned no PRF result (cold-start unlock unavailable)")
|
||||
|
||||
// DecodePRFResult parses the base64url PRF output the browser read out of
|
||||
// getClientExtensionResults().prf.results.first and checks it is usable as
|
||||
// wrapping key material.
|
||||
//
|
||||
// The browser is not trusted to send something sensible: a short, empty, or
|
||||
// all-zero result would silently produce a blob that anyone can open, so all
|
||||
// three are refused here rather than at the crypto layer.
|
||||
func DecodePRFResult(b64 string) ([]byte, error) {
|
||||
if b64 == "" {
|
||||
return nil, ErrNoPRF
|
||||
}
|
||||
secret, err := decodeB64Any(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: prf result: %w", err)
|
||||
}
|
||||
if err := checkSecret(secret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// decodeB64Any accepts padded or unpadded base64url — browsers differ, and
|
||||
// the JS helper on the passkey page strips padding.
|
||||
func decodeB64Any(s string) ([]byte, error) {
|
||||
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The salt is the identity of every wrapped key file ever written. If it
|
||||
// changes, every deployment's blob becomes unopenable, so it is pinned here.
|
||||
func TestPRFSaltIsStable(t *testing.T) {
|
||||
salt := PRFSalt()
|
||||
if len(salt) != 32 {
|
||||
t.Fatalf("salt is %d bytes, want 32", len(salt))
|
||||
}
|
||||
if got := base64.RawURLEncoding.EncodeToString(salt); got != base64.RawURLEncoding.EncodeToString(PRFSalt()) {
|
||||
t.Fatal("PRFSalt is not deterministic")
|
||||
}
|
||||
// Mutating the returned slice must not affect the next caller.
|
||||
salt[0] ^= 0xff
|
||||
if bytes.Equal(salt, PRFSalt()) {
|
||||
t.Fatal("PRFSalt returned shared backing state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePRFResult(t *testing.T) {
|
||||
raw := make([]byte, 32)
|
||||
for i := range raw {
|
||||
raw[i] = byte(i + 1)
|
||||
}
|
||||
for _, enc := range []string{
|
||||
base64.RawURLEncoding.EncodeToString(raw),
|
||||
base64.URLEncoding.EncodeToString(raw),
|
||||
} {
|
||||
got, err := DecodePRFResult(enc)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePRFResult(%q): %v", enc, err)
|
||||
}
|
||||
if !bytes.Equal(got, raw) {
|
||||
t.Errorf("decoded %x, want %x", got, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No PRF must be a distinguishable, named failure — never a silent fallback to
|
||||
// some other secret.
|
||||
func TestDecodePRFResultNoPRF(t *testing.T) {
|
||||
if _, err := DecodePRFResult(""); !errors.Is(err, ErrNoPRF) {
|
||||
t.Fatalf("err = %v, want ErrNoPRF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePRFResultRejectsUnusable(t *testing.T) {
|
||||
zeros := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
short := base64.RawURLEncoding.EncodeToString(make([]byte, 16))
|
||||
long := base64.RawURLEncoding.EncodeToString(make([]byte, 64))
|
||||
for name, in := range map[string]string{
|
||||
"not base64": "!!!!",
|
||||
"all zero": zeros,
|
||||
"too short": short,
|
||||
"too long": long,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := DecodePRFResult(in); err == nil {
|
||||
t.Fatalf("accepted a %s PRF result", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Both option builders must ask for PRF, or the browser never produces a
|
||||
// secret and cold-start unlock silently never works.
|
||||
func TestOptionsRequestPRF(t *testing.T) {
|
||||
rp := NewRP(Config{Origin: "http://localhost:8080", RPID: "localhost", RPName: "maven"})
|
||||
|
||||
create, _, err := rp.CreationOptions([]byte("u"), "u")
|
||||
if err != nil {
|
||||
t.Fatalf("CreationOptions: %v", err)
|
||||
}
|
||||
if _, ok := extPRF(t, create)["prf"]; !ok {
|
||||
t.Error("creation options do not request the prf extension")
|
||||
}
|
||||
|
||||
assert, _, err := rp.AssertionOptions()
|
||||
if err != nil {
|
||||
t.Fatalf("AssertionOptions: %v", err)
|
||||
}
|
||||
prf, ok := extPRF(t, assert)["prf"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("assertion options do not request the prf extension")
|
||||
}
|
||||
eval, _ := prf["eval"].(map[string]any)
|
||||
first, _ := eval["first"].(string)
|
||||
if first != base64.RawURLEncoding.EncodeToString(PRFSalt()) {
|
||||
t.Errorf("prf.eval.first = %q, want the fixed salt", first)
|
||||
}
|
||||
}
|
||||
|
||||
func extPRF(t *testing.T, opts any) map[string]any {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal options: %v", err)
|
||||
}
|
||||
var m struct {
|
||||
Extensions map[string]any `json:"extensions"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("unmarshal options: %v", err)
|
||||
}
|
||||
return m.Extensions
|
||||
}
|
||||
@@ -124,6 +124,13 @@ func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, s
|
||||
"timeout": 60000,
|
||||
"attestation": "none",
|
||||
"excludeCredentials": []any{},
|
||||
// PRF: ask the authenticator at enrollment time whether it can
|
||||
// produce a per-credential secret. Nothing is wrapped here — the
|
||||
// browser reports support back and mavweb decides whether cold-start
|
||||
// unlock is available for this credential. See internal/webauthn/prf.go.
|
||||
"extensions": map[string]any{
|
||||
"prf": map[string]any{},
|
||||
},
|
||||
}, challengeB64, nil
|
||||
}
|
||||
|
||||
@@ -193,6 +200,15 @@ func (rp *RP) AssertionOptions() (map[string]any, string, error) {
|
||||
"rpId": rp.cfg.RPID,
|
||||
"allowCredentials": []any{},
|
||||
"userVerification": "required",
|
||||
// PRF evaluation over the fixed cold-start salt. The 32 bytes that
|
||||
// come back are the ONLY thing that can unwrap the database key.
|
||||
"extensions": map[string]any{
|
||||
"prf": map[string]any{
|
||||
"eval": map[string]any{
|
||||
"first": base64.RawURLEncoding.EncodeToString(PRFSalt()),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, challengeB64, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user