121 lines
5.6 KiB
Go
121 lines
5.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
// Gate — wraps Enrollment + optional Session state and exposes a CheckFunc
|
|
// the daemon wires into ipc.Server.Check. The ONE place a wire call gets
|
|
// authorized. Adding the auth layer does not change CoreAPI, dispatch, or
|
|
// module code; the daemon constructs a Gate and sets Server.Check = gate.Check.
|
|
//
|
|
// floors:
|
|
// - FloorEnrollment ⇒ no change from pre-auth behavior (same-uid trusted,
|
|
// full source scope).
|
|
// - nil Session ⇒ step-up never asserted; any AuthStepUp call refused
|
|
// (surface caps + session caps agree to fail closed). Today's CoreAPI has
|
|
// no AuthStepUp methods, so nil Session is the daemon floor.
|
|
type Gate struct {
|
|
Enrollment Enrollment
|
|
Session Session // nil ⇒ step-up not asserted
|
|
}
|
|
|
|
// Session — the per-session step-up state. The impure seam the passkey
|
|
// verifier implements: Assert records a successful user-verification gesture,
|
|
// CurrentLayer returns how high the session is asserted right now (cold boot ⇒
|
|
// not-asserted; passkey challenge ⇒ L3 for the session lifetime). Without a
|
|
// Session wired, step-up-requiring calls (EnableTool, cold-start unlock) fail
|
|
// closed — the gate can't grant what wasn't demonstrated.
|
|
//
|
|
// Today the daemon runs no interface that asserts session step-up (no pc client
|
|
// yet); the floor is nil Session ⇒ AuthStepUp always refused. The shape is
|
|
// here so the passkey verifier is a single new impl, not a dispatch change.
|
|
type Session interface {
|
|
// CurrentLayer returns the authority the session currently carries.
|
|
// Outside a step-up window, returns the layer the surface can carry on its
|
|
// own minus the step-up contribution (e.g. a pc_client without asserted
|
|
// step-up returns Layer2; the passkey gesture bumps it to Layer3 for the
|
|
// session lifetime).
|
|
CurrentLayer(ctx context.Context, scope Scope) Layer
|
|
|
|
// Assert — record a successful step-up gesture for scope. The passkey
|
|
// verifier returns nil and the daemon-queried Session treats this session
|
|
// as L3 until it expires. Floor impls may return ErrStepUpUnsupported.
|
|
Assert(ctx context.Context, scope Scope) error
|
|
}
|
|
|
|
// ErrStepUpUnsupported — returned by floor Session.Assert when no passkey
|
|
// verifier is wired. Distinct from ErrForbidden: a missing impl is a wiring
|
|
// bug, not a denial.
|
|
var ErrStepUpUnsupported = errors.New("auth: step-up not supported by this session")
|
|
|
|
// FloorSession — the step-up floor, mirroring FloorEnrollment: any caller that
|
|
// cleared the 0600 socket is trusted as fully step-asserted (L3). It exists so
|
|
// the floor is CONSISTENT — FloorEnrollment already grants same-uid callers L3
|
|
// for writes; without a matching Session floor, AuthStepUp methods (EnableTool)
|
|
// would be refused for the same callers, an accidental asymmetry. The real
|
|
// passkey verifier replaces this (a single new Session impl, no dispatch change)
|
|
// so step-up becomes a real gesture instead of a floor grant.
|
|
type FloorSession struct{}
|
|
|
|
// CurrentLayer — the floor trusts the local caller fully.
|
|
func (FloorSession) CurrentLayer(_ context.Context, _ Scope) Layer { return Layer3 }
|
|
|
|
// Assert — a no-op success at the floor (the caller is already trusted).
|
|
func (FloorSession) Assert(_ context.Context, _ Scope) error { return nil }
|
|
|
|
// Check — the authorization hook. Wired into ipc.Server.Check (single
|
|
// insertion point). Shape: nil ipc.Caller ⇒ in-process path (Lookup with
|
|
// hasCaller=false). Otherwise resolve via Enrollment; refuse on any error;
|
|
// run pure Can on the resolved scope + raw params.
|
|
//
|
|
// Returns ErrForbidden (mirrored to codeForbidden on the wire) for any
|
|
// authority failure; bubbles other errors (Enrollment I/O, ErrUnenrolled)
|
|
// up to dispatch where they're mapped to codeInternal or codeForbidden
|
|
// depending on identity-ness. We map ErrUnenrolled → forbidden: an unknown
|
|
// caller is not surfaced as "internal error" to a module.
|
|
func (g *Gate) Check(ctx context.Context, m ipc.Method, params json.RawMessage) error {
|
|
caller, hasCaller := ipc.CallerFrom(ctx)
|
|
scope, err := g.Enrollment.Lookup(ctx, caller, hasCaller)
|
|
if err != nil {
|
|
// Unenrolled ⇒ forbidden on the wire (codeForbidden). I/O failures of
|
|
// the enrollment table are not "the caller is unauthorized"; they
|
|
// bubble as internal via codeOf's default. Wrap with both sentinels
|
|
// (Go 1.20+ multi-%w) so:
|
|
// - ipc.codeOf resolves to codeForbidden via errors.Is(ipc.ErrForbidden)
|
|
// - auth-package tests resolve via errors.Is(auth.ErrForbidden)
|
|
// - the daemon log carries both names.
|
|
if errors.Is(err, ErrUnenrolled) {
|
|
return fmt.Errorf("%w: %w", ipc.ErrForbidden, err)
|
|
}
|
|
return err
|
|
}
|
|
if err := Can(m, scope, params); err != nil {
|
|
// Can already uses auth.ErrForbidden / ErrUnenrolled inside; we wrap
|
|
// with ipc.ErrForbidden so codeOf resolves to codeForbidden at the
|
|
// wire. The auth sentinel stays in the chain via %w (not %v) so
|
|
// errors.Is(auth.ErrForbidden) works in auth-package tests.
|
|
if errors.Is(err, ErrUnenrolled) || errors.Is(err, ErrForbidden) {
|
|
return fmt.Errorf("%w: %w", ipc.ErrForbidden, err)
|
|
}
|
|
return err
|
|
}
|
|
// AuthStepUp verdict's surface-cap half is in Can. The session-level
|
|
// check (was step-up actually asserted THIS session?) lives here so the
|
|
// Session owns its own state; Can stays pure-data.
|
|
if Requirement(m) == AuthStepUp {
|
|
if g.Session == nil {
|
|
return fmt.Errorf("%w: %w: AuthStepUp but no Session wired", ipc.ErrForbidden, ErrForbidden)
|
|
}
|
|
if g.Session.CurrentLayer(ctx, scope) < Layer3 {
|
|
return fmt.Errorf("%w: %w: step-up not asserted this session", ipc.ErrForbidden, ErrForbidden)
|
|
}
|
|
}
|
|
return nil
|
|
}
|