Files
kami 5afff001c3 mavweb: add in-process auth gate for POST /tools
Add a local PasskeySession that handleTools checks before processing
any POST action (enable/disable). If the session hasn't been asserted
within the 5-minute TTL, return 403 Forbidden.

Changes:
- webauthn/session.go: add IsStepUp() convenience method (nil-safe)
- webauthn.go: PasskeyHandle holds a *PasskeySession; AssertFinish
  calls session.Assert() after IPC step-up
- main.go: create stepUpSession, pass to handleTools and
  newPasskeyHandle; handleTools returns 403 if !session.IsStepUp()
- handlers_test.go: update TestEnableTool_NoInProcessAuthGate to
  expect 403; add TestEnableTool_WithAuthGate_RequiresStepUp for
  the happy path with asserted session; update all 10 call sites
2026-07-05 11:55:44 +04:00

71 lines
2.2 KiB
Go

package webauthn
import (
"context"
"fmt"
"sync"
"time"
"github.com/kami/maven/internal/auth"
)
// PasskeySession implements auth.Session backed by WebAuthn passkey assertion.
// The session starts at Layer2 (passkey is enrolled, this session exists) and
// bumps to Layer3 on successful Assert(), which lasts for assertionTTL before
// decaying back to Layer2.
//
// A nil *PasskeySession is a valid zero: it acts like a session with no
// credentials enrolled (always L2, Assert returns ErrStepUpUnsupported).
// This mirrors the FloorSession behavior when passkey is not configured.
type PasskeySession struct {
mu sync.Mutex
assertedAt time.Time // zero = not asserted this session
assertionTTL time.Duration
}
// NewPasskeySession creates a session. The caller chooses the assertion TTL
// (how long a step-up gesture remains valid). 5 minutes is a sensible default.
func NewPasskeySession(assertionTTL time.Duration) *PasskeySession {
if assertionTTL <= 0 {
assertionTTL = 5 * time.Minute
}
return &PasskeySession{assertionTTL: assertionTTL}
}
// CurrentLayer returns L3 if step-up has been asserted within the TTL,
// otherwise L2 (passkey enrolled, this session proven). A nil receiver
// returns L2 (no way to reach L3 without a session).
func (s *PasskeySession) CurrentLayer(_ context.Context, _ auth.Scope) auth.Layer {
if s == nil {
return auth.Layer2
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.assertedAt.IsZero() && time.Since(s.assertedAt) < s.assertionTTL {
return auth.Layer3
}
return auth.Layer2
}
// IsStepUp returns true if the session was recently asserted (within TTL).
func (s *PasskeySession) IsStepUp() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return !s.assertedAt.IsZero() && time.Since(s.assertedAt) < s.assertionTTL
}
// Assert records a successful step-up gesture. The session bumps to L3 for
// the assertion TTL. A nil receiver returns ErrStepUpUnsupported.
func (s *PasskeySession) Assert(_ context.Context, _ auth.Scope) error {
if s == nil {
return fmt.Errorf("%w: passkey session not configured", auth.ErrStepUpUnsupported)
}
s.mu.Lock()
defer s.mu.Unlock()
s.assertedAt = time.Now()
return nil
}