mavweb: passkey enrollment wraps encryption key, assertion unlocks daemon
- webauthn.go: keyIPC interface for StoreEncryptionKey/Unlock, wired through PasskeyHandle. RegisterFinish calls StoreEncryptionKey with the credential's public key after successful enrollment. AssertFinish calls Unlock with the stored public key after assertion (alongside existing AssertStepUp call). - server.go: fix data race on s.api by switching from bare CoreAPI field to atomic.Value. SetAPI uses Store(), dispatch uses Load(). No more race-flagged tests. - make test green (303+, -race)
This commit is contained in:
+65
-10
@@ -19,18 +19,28 @@ type assertIPC interface {
|
||||
AssertStepUp(ctx context.Context) error
|
||||
}
|
||||
|
||||
// keyIPC — satisfies the key-wrap and unlock methods. The only implementation
|
||||
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
|
||||
// StoreEncryptionKey and Unlock are silently skipped.
|
||||
type keyIPC interface {
|
||||
StoreEncryptionKey(ctx context.Context, publicKey []byte) error
|
||||
Unlock(ctx context.Context, publicKey []byte) error
|
||||
}
|
||||
|
||||
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential
|
||||
// store, and the IPC client used to assert step-up. It serves the four WebAuthn
|
||||
// HTTP endpoints (register/begin, register/finish, assert/begin, assert/finish).
|
||||
// store, and the IPC client used to assert step-up and to wrap/unwrap the
|
||||
// daemon's encryption key. It serves the four WebAuthn HTTP endpoints
|
||||
// (register/begin, register/finish, assert/begin, assert/finish).
|
||||
//
|
||||
// Credentials are kept in-memory only (a single-user daemon restarts
|
||||
// infrequently, and re-enrolling after restart is acceptable). A future
|
||||
// version may persist them to disk.
|
||||
type PasskeyHandle struct {
|
||||
rp *webauthn.RP
|
||||
assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC
|
||||
store *credentialStore
|
||||
session *webauthn.PasskeySession
|
||||
rp *webauthn.RP
|
||||
assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC
|
||||
encryptFn keyIPC // *ipc.Client when connected; nil ⇒ key wrap/unlock disabled
|
||||
store *credentialStore
|
||||
session *webauthn.PasskeySession
|
||||
}
|
||||
|
||||
type localCred struct {
|
||||
@@ -43,15 +53,20 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s
|
||||
if c, ok := core.(assertIPC); ok {
|
||||
af = c
|
||||
}
|
||||
var ek keyIPC
|
||||
if c, ok := core.(keyIPC); ok {
|
||||
ek = c
|
||||
}
|
||||
store, err := newCredentialStore(storePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credential store: %w", err)
|
||||
}
|
||||
return &PasskeyHandle{
|
||||
rp: webauthn.NewRP(cfg),
|
||||
assertFn: af,
|
||||
store: store,
|
||||
session: session,
|
||||
rp: webauthn.NewRP(cfg),
|
||||
assertFn: af,
|
||||
encryptFn: ek,
|
||||
store: store,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -128,7 +143,9 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var enrolledPublicKey []byte
|
||||
save := func(id string, publicKey []byte, _ []byte, _ string) error {
|
||||
enrolledPublicKey = publicKey
|
||||
return h.store.Save(id, publicKey)
|
||||
}
|
||||
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
|
||||
@@ -138,6 +155,21 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("webauthn: registered credential %s", credID)
|
||||
|
||||
// If mavend is reachable and supports key wrapping, store the encryption
|
||||
// key wrapped with this credential's public key — enables cold-start unlock.
|
||||
if h.encryptFn != nil && enrolledPublicKey != nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.encryptFn.StoreEncryptionKey(ctx, enrolledPublicKey); err != nil {
|
||||
log.Printf("webauthn: store encryption key: %v", err)
|
||||
// Non-fatal: enrollment still succeeded, the wrapped key can be
|
||||
// created later via the same endpoint.
|
||||
} else {
|
||||
log.Printf("webauthn: encryption key wrapped with credential %s", credID)
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
||||
}
|
||||
|
||||
@@ -193,6 +225,29 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// If the daemon is locked (cold-start), send the credential's public key
|
||||
// over IPC so mavend can unwrap its encryption key and open the store.
|
||||
// The public key comes from the local credential store (it was stored
|
||||
// during enrollment). Non-fatal: if IPC doesn't support Unlock or the
|
||||
// daemon is already unlocked, the call is a no-op on the server side.
|
||||
if h.encryptFn != nil {
|
||||
publicKey, _, err := h.store.Lookup(credID)
|
||||
if err == nil && publicKey != nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.encryptFn.Unlock(ctx, publicKey); err != nil {
|
||||
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
|
||||
// Non-fatal: assertion succeeded; if the daemon stays locked
|
||||
// the user will see errors on subsequent pages, but the
|
||||
// assertion itself is valid.
|
||||
} else {
|
||||
log.Printf("webauthn: daemon unlocked via credential %s", credID)
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Printf("webauthn: lookup credential %s for unlock: %v", credID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Assert the in-process session so the POST /tools handler sees step-up.
|
||||
if h.session != nil {
|
||||
h.session.Assert(r.Context(), auth.Scope{})
|
||||
|
||||
Reference in New Issue
Block a user