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
294 lines
9.1 KiB
Go
294 lines
9.1 KiB
Go
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
|
|
}
|
|
|
|
func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error {
|
|
f.unlockCalls++
|
|
f.unlockSecret = bytes.Clone(secret)
|
|
return f.unlockErr
|
|
}
|
|
|
|
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte) error {
|
|
f.wrapCalls++
|
|
f.wrapSecret = bytes.Clone(secret)
|
|
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":<bytes>}
|
|
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()
|
|
_, 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,
|
|
"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 != 1 {
|
|
t.Errorf("unlock attempted %d times, want 1", key.unlockCalls)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|