package main import ( "bytes" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/json" "errors" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/kami/maven/internal/webauthn" ) const prfTestOrigin = "https://maven.test" const prfTestRPID = "maven.test" // fakeKeyIPC stands in for the mavend socket and records exactly what secret // each call received — the point of the whole test file is that it is the PRF // output and never the credential public key. type fakeKeyIPC struct { unlockSecret []byte wrapSecret []byte unlockCalls int wrapCalls int unlockErr error wrapExplicit bool // opensWith, when set, is the only secret Unlock accepts. It stands in // for a wrapped blob on disk: everything else gets unlockErr. opensWith []byte } func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error { f.unlockCalls++ f.unlockSecret = bytes.Clone(secret) if f.opensWith != nil { if bytes.Equal(secret, f.opensWith) { return nil } return errors.New("unwrap key: decrypt failed (wrong credential?)") } return f.unlockErr } func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte, explicit bool) error { f.wrapCalls++ f.wrapSecret = bytes.Clone(secret) f.wrapExplicit = explicit return nil } func b64u(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } // prfAuthenticator is a minimal software authenticator: a P-256 key plus the // COSE encoding of its public half. type prfAuthenticator struct { key *ecdsa.PrivateKey credID []byte cose []byte } func newPRFAuthenticator(t *testing.T) *prfAuthenticator { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) } x := key.PublicKey.X.FillBytes(make([]byte, 32)) y := key.PublicKey.Y.FillBytes(make([]byte, 32)) // COSE_Key: {1: 2 (EC2), 3: -7 (ES256), -1: 1 (P-256), -2: x, -3: y} var c []byte c = append(c, 0xa5) // map(5) c = append(c, 0x01, 0x02) // 1: 2 c = append(c, 0x03, 0x26) // 3: -7 c = append(c, 0x20, 0x01) // -1: 1 c = append(c, 0x21, 0x58, 0x20) // -2: bytes(32) c = append(c, x...) c = append(c, 0x22, 0x58, 0x20) // -3: bytes(32) c = append(c, y...) return &prfAuthenticator{key: key, credID: []byte("prf-cred"), cose: c} } func (a *prfAuthenticator) authData(flags byte, counter uint32, attested bool) []byte { h := sha256.Sum256([]byte(prfTestRPID)) d := append([]byte{}, h[:]...) d = append(d, flags) cb := make([]byte, 4) binary.BigEndian.PutUint32(cb, counter) d = append(d, cb...) if attested { d = append(d, make([]byte, 16)...) // aaguid l := make([]byte, 2) binary.BigEndian.PutUint16(l, uint16(len(a.credID))) d = append(d, l...) d = append(d, a.credID...) d = append(d, a.cose...) } return d } func clientDataJSON(typ, challenge string) []byte { b, _ := json.Marshal(map[string]string{"type": typ, "challenge": challenge, "origin": prfTestOrigin}) return b } // register drives POST /register/finish with a valid attestation. func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) { t.Helper() _, chal, err := h.rp.CreationOptions([]byte("u"), "user") if err != nil { t.Fatalf("CreationOptions: %v", err) } // {"fmt":"none","attStmt":{},"authData":} att := []byte{0xa3} att = append(att, 0x63, 'f', 'm', 't', 0x64, 'n', 'o', 'n', 'e') att = append(att, 0x67, 'a', 't', 't', 'S', 't', 'm', 't', 0xa0) ad := a.authData(1<<6|0x05, 0, true) att = append(att, 0x68, 'a', 'u', 't', 'h', 'D', 'a', 't', 'a') att = append(att, 0x59, byte(len(ad)>>8), byte(len(ad))) att = append(att, ad...) body, _ := json.Marshal(map[string]any{ "challenge": chal, "credential": map[string]any{ "id": b64u(a.credID), "type": "public-key", "response": map[string]any{ "clientDataJSON": b64u(clientDataJSON("webauthn.create", chal)), "attestationObject": b64u(att), }, }, }) w := httptest.NewRecorder() h.RegisterFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/register/finish", bytes.NewReader(body))) if w.Code != http.StatusOK { t.Fatalf("RegisterFinish: %d %s", w.Code, w.Body.String()) } } // assert drives POST /assert/finish with a valid assertion and the given // base64url PRF result. func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder { t.Helper() return a.assertExplicit(t, h, prf, false) } // assertExplicit is assert with control over the explicit flag the rewrite // button sets. func (a *prfAuthenticator) assertExplicit(t *testing.T, h *PasskeyHandle, prf string, explicit bool) *httptest.ResponseRecorder { t.Helper() _, chal, err := h.rp.AssertionOptions() if err != nil { t.Fatalf("AssertionOptions: %v", err) } ad := a.authData(0x05, 7, false) cdj := clientDataJSON("webauthn.get", chal) hash := sha256.Sum256(cdj) sig, err := ecdsa.SignASN1(rand.Reader, a.key, append(append([]byte{}, ad...), hash[:]...)) if err != nil { t.Fatalf("sign: %v", err) } body, _ := json.Marshal(map[string]any{ "challenge": chal, "prf": prf, "explicit": explicit, "credential": map[string]any{ "id": b64u(a.credID), "type": "public-key", "response": map[string]any{ "clientDataJSON": b64u(cdj), "authenticatorData": b64u(ad), "signature": b64u(sig), }, }, }) w := httptest.NewRecorder() h.AssertFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/assert/finish", bytes.NewReader(body))) return w } func newPRFHandle(t *testing.T, key *fakeKeyIPC) *PasskeyHandle { t.Helper() store, err := newCredentialStore(filepath.Join(t.TempDir(), "passkeys.json")) if err != nil { t.Fatalf("credential store: %v", err) } return &PasskeyHandle{ rp: webauthn.NewRP(webauthn.Config{Origin: prfTestOrigin, RPID: prfTestRPID, RPName: "maven"}), encryptFn: key, store: store, session: webauthn.NewPasskeySession(0), } } // The fix for Vikunja #14: what goes over IPC is the PRF secret from the // authenticator, not the credential public key sitting in passkeys.json. func TestAssertSendsPRFSecretNotPublicKey(t *testing.T) { key := &fakeKeyIPC{} h := newPRFHandle(t, key) auth := newPRFAuthenticator(t) auth.register(t, h) // Enrolment must not wrap anything: create() yields no PRF result. if key.wrapCalls != 0 || key.unlockCalls != 0 { t.Fatalf("registration touched the key IPC (wrap=%d unlock=%d)", key.wrapCalls, key.unlockCalls) } secret := make([]byte, 32) for i := range secret { secret[i] = byte(i + 1) } if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK { t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String()) } if key.unlockCalls != 1 || key.wrapCalls != 1 { t.Fatalf("unlock=%d wrap=%d, want 1 and 1", key.unlockCalls, key.wrapCalls) } if !bytes.Equal(key.unlockSecret, secret) { t.Errorf("Unlock got %x, want the PRF secret %x", key.unlockSecret, secret) } if !bytes.Equal(key.wrapSecret, secret) { t.Errorf("StoreEncryptionKey got %x, want the PRF secret %x", key.wrapSecret, secret) } // And explicitly: not the credential public key. pub, _, err := h.store.Lookup(b64u(auth.credID)) if err != nil { t.Fatalf("lookup: %v", err) } if bytes.Equal(key.unlockSecret, pub) { t.Fatal("the credential public key was sent as the unlock secret") } } // An authenticator without PRF must produce no unlock attempt at all — the // assertion still succeeds (step-up works), but cold-start unlock stays off // rather than falling back to something weaker. func TestAssertWithoutPRFDoesNotUnlock(t *testing.T) { for _, prf := range []string{"", "!!!not-base64!!!", b64u(make([]byte, 32)), b64u(make([]byte, 16))} { key := &fakeKeyIPC{} h := newPRFHandle(t, key) auth := newPRFAuthenticator(t) auth.register(t, h) w := auth.assert(t, h, prf) if w.Code != http.StatusOK { t.Fatalf("prf=%q: AssertFinish %d %s", prf, w.Code, w.Body.String()) } if key.unlockCalls != 0 || key.wrapCalls != 0 { t.Errorf("prf=%q: unlock=%d wrap=%d, want no key IPC at all", prf, key.unlockCalls, key.wrapCalls) } } } // A failed unlock must not fail the assertion: step-up is independently valid, // and a locked daemon degrades rather than breaking the login. func TestAssertSucceedsWhenUnlockFails(t *testing.T) { key := &fakeKeyIPC{unlockErr: errors.New("wrong credential")} h := newPRFHandle(t, key) auth := newPRFAuthenticator(t) auth.register(t, h) secret := bytes.Repeat([]byte{3}, 32) if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK { t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String()) } if key.unlockCalls == 0 { t.Error("unlock was never attempted") } } // A forged assertion must never reach the unlock path. func TestForgedAssertionNeverUnlocks(t *testing.T) { key := &fakeKeyIPC{} h := newPRFHandle(t, key) auth := newPRFAuthenticator(t) auth.register(t, h) // A different key signing over the same credential id. attacker := newPRFAuthenticator(t) attacker.credID = auth.credID w := attacker.assert(t, h, b64u(bytes.Repeat([]byte{4}, 32))) if w.Code == http.StatusOK { t.Fatal("an assertion signed by the wrong key was accepted") } if key.unlockCalls != 0 || key.wrapCalls != 0 { t.Fatalf("a forged assertion reached the key IPC (unlock=%d wrap=%d)", key.unlockCalls, key.wrapCalls) } } // The browser side is the only place the PRF result exists. If the page stops // asking for it or stops reading it back, cold-start unlock silently dies with // nothing failing, so the page source is asserted directly. func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) { for _, want := range []string{ "getClientExtensionResults", "ext.prf.results.first", "body:JSON.stringify({challenge,prf,", } { if !strings.Contains(passkeyPageHTML, want) { t.Errorf("the passkey page no longer contains %q", want) } } } // A box enrolled before Vikunja #14 has a v1 blob wrapped under the credential // PUBLIC key. The PRF secret cannot open it, and this handler is the only // caller of Unlock, so without the legacy retry that box stays locked forever // while a perfectly good passkey is asserted at it. func TestLegacyV1BlobStillColdStarts(t *testing.T) { key := &fakeKeyIPC{} h := newPRFHandle(t, key) auth := newPRFAuthenticator(t) auth.register(t, h) pub, _, err := h.store.Lookup(b64u(auth.credID)) if err != nil { t.Fatalf("lookup: %v", err) } // The daemon only opens under the public key — a v1 blob. key.opensWith = pub secret := bytes.Repeat([]byte{9}, 32) if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK { t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String()) } if key.unlockCalls != 2 { t.Fatalf("unlock attempted %d times, want 2 (PRF, then the legacy public key)", key.unlockCalls) } if !bytes.Equal(key.unlockSecret, pub) { t.Fatal("the legacy retry did not send the credential public key, so a v1 box can never cold-start again") } } // The PRF secret is tried first and, when it works, the public key is never // sent. The legacy retry is a one-way door out of v1, not a fallback offered // to every assertion. func TestPRFUnlockNeverFallsBackWhenItWorks(t *testing.T) { secret := bytes.Repeat([]byte{7}, 32) key := &fakeKeyIPC{opensWith: secret} h := newPRFHandle(t, key) auth := newPRFAuthenticator(t) auth.register(t, h) if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK { t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String()) } if key.unlockCalls != 1 { t.Fatalf("unlock attempted %d times, want 1", key.unlockCalls) } } // Wrapping the at-rest key is an explicit act, never a side effect of a // step-up. A page POSTing a substituted prf on a routine assertion must not // make the daemon re-wrap the database key under it. func TestPlainAssertionAsksForNoRewrite(t *testing.T) { key := &fakeKeyIPC{} h := newPRFHandle(t, key) auth := newPRFAuthenticator(t) auth.register(t, h) if w := auth.assert(t, h, b64u(bytes.Repeat([]byte{5}, 32))); w.Code != http.StatusOK { t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String()) } if key.wrapCalls != 1 { t.Fatalf("wrapCalls = %d, want 1", key.wrapCalls) } if key.wrapExplicit { t.Fatal("a plain step-up asked the daemon to rewrite the cold-start key") } } // The rewrite button, and only the rewrite button, sets explicit. func TestRewriteButtonAsksForAnExplicitWrap(t *testing.T) { key := &fakeKeyIPC{} h := newPRFHandle(t, key) auth := newPRFAuthenticator(t) auth.register(t, h) if w := auth.assertExplicit(t, h, b64u(bytes.Repeat([]byte{6}, 32)), true); w.Code != http.StatusOK { t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String()) } if !key.wrapExplicit { t.Fatal("the explicit flag did not reach the daemon, so the rewrite button cannot work") } } // The page is the only place the explicit flag originates. If the button or // the field goes away, rewriting a cold-start key becomes impossible with // nothing failing. func TestPasskeyPageHasTheRewriteButton(t *testing.T) { for _, want := range []string{ "rewrite cold-start key", "explicit:!!explicit", "async function rewrapKey()", } { if !strings.Contains(passkeyPageHTML, want) { t.Errorf("the passkey page no longer contains %q", want) } } }