7ab9b48259
Three ways the cold-start path could lose the database. A box enrolled before the PRF change could never cold-start again. UnwrapKey still read v1 blobs, but the only caller stopped supplying the v1 secret: the assertion handler sends the PRF output and nothing looks up the credential public key any more. On such a box the daemon read the blob, took the v1 branch, failed to decrypt, and stayed locked while a valid passkey was asserted at it. The escape hatch was gone too, because WrapKeyFn was wired only in env-key mode and a locked boot is by definition the mode with no env key. The recovery was to put MAVEN_DB_KEY back in the environment, which is the thing cold-start unlock exists to avoid. AssertFinish now retries a failed PRF unwrap with the credential public key, and WrapKeyFn is wired in locked mode too, so the box that came up on a v1 blob can be moved to v2. Wrapping ran on every successful assertion. That made a routine step-up rewrite the one file that opens the database, under whatever 32 bytes the page posted. A compromised /auth/webauthn converted one legitimate touch into permanent offline recovery of the at-rest key, and a second enrolled authenticator silently locked out the first. Wrapping is now an act of its own: a plain assertion may write the blob only when none exists, and replacing one takes the rewrite button, which is the only caller that sets the new explicit flag. The daemon still refuses to overwrite a v2 blob that does not open under the presented secret. The write was os.WriteFile, which truncates in place. A power cut between the truncate and the write left a zero-length blob and no previous contents, on the path of every step-up. It is now a temp file in the same directory, fsync, rename, fsync of the directory. Two smaller things on the same path. The v2 unwrap checked the secret length but not the all-zero case the wrap side rejects, so the two ends disagreed about what a valid secret is. And the handler logged "daemon unlocked via credential" when an env-key daemon had answered unknown method, and again when an already-unlocked daemon had done nothing. Left alone deliberately: the PRF value is client-supplied and not covered by the assertion signature. That is inherent to PRF key wrapping, since the salt has to be fixed for the blob to open on the next boot. It is recorded as a known property where the secret enters the handler. Found in review of #77.
410 lines
13 KiB
Go
410 lines
13 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
|
|
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":<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()
|
|
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)
|
|
}
|
|
}
|
|
}
|