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 }