4eca20bd94
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
115 lines
3.1 KiB
Go
115 lines
3.1 KiB
Go
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
|
|
}
|