initial commit

This commit is contained in:
kami
2026-07-03 00:32:48 +02:00
commit 612583d59a
92 changed files with 14521 additions and 0 deletions
+393
View File
@@ -0,0 +1,393 @@
package auth
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
func TestMaxLayer_SurfaceCapsAuthority(t *testing.T) {
// The invariant: surface caps maximum authority. A surface structurally
// unable to carry passkey-user-verification caps below L3 — voice never
// reaches step-up, telegram never reaches step-up, pc_client + authed_page
// + the daemon's own process DO.
cases := []struct {
surface Surface
max Layer
}{
{SurfaceVoice, Layer0},
{SurfaceTelegram, Layer2},
{SurfacePCClient, Layer3},
{SurfaceAuthedPage, Layer3},
{SurfaceCoreProcess, Layer3},
{SurfaceUnknown, -1},
}
for _, c := range cases {
got := MaxLayer(c.surface)
if got != c.max {
t.Errorf("MaxLayer(%s) = %d; want %d (surface caps authority)", c.surface, got, c.max)
}
}
}
func TestSourceAllowed(t *testing.T) {
cases := []struct {
scope []string
src string
want bool
}{
{[]string{"*"}, "anything", true},
{[]string{"poll:healthcheck"}, "poll:healthcheck", true},
{[]string{"poll:healthcheck"}, "poll:uptime", false}, // compromised poller can't forge a trigger
{[]string{"poll:healthcheck", "tap:water"}, "tap:water", true},
{[]string{"poll:healthcheck", "tap:water"}, "ambient", false},
{[]string{}, "anything", false}, // fail closed
{nil, "anything", false},
}
for _, c := range cases {
if got := SourceAllowed(c.scope, c.src); got != c.want {
t.Errorf("SourceAllowed(%v, %q) = %v; want %v", c.scope, c.src, got, c.want)
}
}
}
func TestRequirement_Table(t *testing.T) {
// WriteFact is AuthWrite (carries source-scope); EnableTool is AuthStepUp
// (registration-enable moves the boundary); all others are AuthRead.
if got := Requirement(ipc.MethodWriteFact); got != AuthWrite {
t.Errorf("WriteFact authority = %v; want AuthWrite", got)
}
if got := Requirement(ipc.MethodEnableTool); got != AuthStepUp {
t.Errorf("EnableTool authority = %v; want AuthStepUp", got)
}
reads := []ipc.Method{
ipc.MethodLatestFact, ipc.MethodLatestFactBySource, ipc.MethodSince,
ipc.MethodPresence, ipc.MethodRecentOutcomes,
ipc.MethodCreateReminder, ipc.MethodMarkReminder,
ipc.MethodRecordNudge, ipc.MethodResolveNudge,
}
for _, m := range reads {
if got := Requirement(m); got != AuthRead {
t.Errorf("%s authority = %v; want AuthRead", m, got)
}
}
}
func TestGate_EnableTool_StepUp(t *testing.T) {
// EnableTool is AuthStepUp. At the floor (FloorEnrollment L3 + FloorSession
// asserting L3), the local caller may enable — the authed mavweb /tools page
// hits this path. With a nil Session (no step-up asserted), it's refused —
// step-up can't be granted from what wasn't demonstrated.
ctx := context.Background()
withSession := &Gate{Enrollment: NewFloorEnrollment(), Session: FloorSession{}}
if err := withSession.Check(ctx, ipc.MethodEnableTool, nil); err != nil {
t.Errorf("EnableTool with FloorSession = %v; want nil (floor permits)", err)
}
noSession := &Gate{Enrollment: NewFloorEnrollment()}
if err := noSession.Check(ctx, ipc.MethodEnableTool, nil); !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("EnableTool with nil Session = %v; want ErrForbidden (fail closed)", err)
}
}
func TestCan_Unenrolled_FailClosed(t *testing.T) {
// No Module / unknown surface ⇒ refused, NOT "0-level authed". This is
// the surface-caps property applied before the layer caps.
if err := Can(ipc.MethodPresence, Scope{}, nil); !errors.Is(err, ErrUnenrolled) {
t.Errorf("Can with empty Scope = %v; want ErrUnenrolled", err)
}
if err := Can(ipc.MethodPresence, Scope{Module: "x", Surface: SurfaceUnknown}, nil); !errors.Is(err, ErrUnenrolled) {
t.Errorf("Can with unknown surface = %v; want ErrUnenrolled", err)
}
}
func TestCan_WriteFact_SourceScope(t *testing.T) {
// The spec's compromised-poller case: a poller enrolled to write
// poll:healthcheck can't forge poll:uptime (or anything else).
poller := Scope{
Surface: SurfaceCoreProcess,
Module: "poller:healthcheck",
SourceScope: []string{"poll:healthcheck"},
}
core := Scope{
Surface: SurfaceCoreProcess,
Module: "core",
SourceScope: []string{"*"},
}
// In-scope write succeeds.
if err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:healthcheck")); err != nil {
t.Errorf("poller writing poll:healthcheck = %v; want nil", err)
}
// Out-of-scope write is forbidden — auth.ErrForbidden in the chain.
err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:uptime"))
if err == nil || !errors.Is(err, ErrForbidden) {
t.Errorf("poller writing poll:uptime = %v; want ErrForbidden in chain", err)
}
if err != nil && !strings.Contains(err.Error(), "poll:uptime") {
t.Errorf("forbidden err should name the offending source: got %q", err)
}
// Core (wildcard) writes anything.
if err := Can(ipc.MethodWriteFact, core, mustWriteFactParams("poll:uptime")); err != nil {
t.Errorf("core writing poll:uptime = %v; want nil (wildcard scope)", err)
}
// Empty SourceScope ⇒ fail closed even for an enrolled module.
empty := Scope{Surface: SurfaceCoreProcess, Module: "x", SourceScope: nil}
if err := Can(ipc.MethodWriteFact, empty, mustWriteFactParams("poll:healthcheck")); !errors.Is(err, ErrForbidden) {
t.Errorf("empty SourceScope WriteFact = %v; want ErrForbidden", err)
}
}
func TestCan_Reads_AnyEnrolledModule(t *testing.T) {
// Reads permit any enrolled module (the enrollment already gated caller
// identity). Reads on SurfaceVoice are OK too — enrollment may have
// enrolled a voice module for read-only purposes (e.g. ambient parse).
for _, surf := range []Surface{SurfaceVoice, SurfaceTelegram, SurfacePCClient, SurfaceCoreProcess} {
scope := Scope{Surface: surf, Module: "x", SourceScope: []string{"*"}}
if err := Can(ipc.MethodPresence, scope, nil); err != nil {
t.Errorf("read on %s = %v; want nil (any enrolled module may read)", surf, err)
}
}
}
// --- Gate ---
func TestGate_FloorEnrollment_PreservesPreAuth(t *testing.T) {
// The floor must be a no-op vs pre-auth: same-uid trusted, full source
// scope, every read and in-scope write passes. Tied end-to-end through
// Gate.Check on the ipc.Method enumeration, so AuthRead / AuthWrite both
// flow through the composition.
g := &Gate{Enrollment: NewFloorEnrollment()}
ctx := context.Background()
for _, m := range []ipc.Method{
ipc.MethodPresence, ipc.MethodSince, ipc.MethodRecentOutcomes,
ipc.MethodCreateReminder, ipc.MethodRecordNudge,
} {
if err := g.Check(ctx, m, nil); err != nil {
t.Errorf("floor gate Check(%s) = %v; want nil (pre-auth preserved)", m, err)
}
}
if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")); err != nil {
t.Errorf("floor gate Check(WriteFact, *) = %v; want nil (full source scope)", err)
}
}
func TestGate_StaticEnrollment_SourceScopeEnforced(t *testing.T) {
// A poller enrolled to write only poll:healthcheck is denied a write to
// poll:uptime. Gate.Authoritative on real source-scope. Wire code should
// be forbidden once we wire through the ipc layer (next test exercises
// that path through a real socket).
pollerUid := int32(70001)
enrollment := &StaticEnrollment{
ByUid: map[int32]Scope{
pollerUid: {
Surface: SurfaceCoreProcess,
Module: "poll:healthcheck",
SourceScope: []string{"poll:healthcheck"},
},
},
Default: &Scope{ // any other uid gets full trust — for tests, doesn't matter
Surface: SurfaceCoreProcess,
Module: "core",
SourceScope: []string{"*"},
},
}
g := &Gate{Enrollment: enrollment}
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1234})
if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil {
t.Errorf("poller in-scope write = %v; want nil", err)
}
err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime"))
if err == nil {
t.Fatalf("poller out-of-scope write returned nil; want forbidden")
}
if !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("poller out-of-scope err chain = %v; want ipc.ErrForbidden in chain (wire code wraps ipc.ErrForbidden)", err)
}
if !errors.Is(err, ErrForbidden) {
t.Errorf("poller out-of-scope err chain = %v; want auth.ErrForbidden in chain (auth tests can satisfy Is)", err)
}
}
func TestGate_Unenrolled_FailsClosed(t *testing.T) {
enrollment := &StaticEnrollment{} // no Default ⇒ ErrUnenrolled for any caller
g := &Gate{Enrollment: enrollment}
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: 4242, Pid: 99})
err := g.Check(ctx, ipc.MethodPresence, nil)
if err == nil {
t.Fatalf("un-enrolled caller returned nil; want forbidden")
}
if !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("unenrolled err = %v; want ipc.ErrForbidden in chain (mapped at wire edge)", err)
}
}
// --- end-to-end through ipc.Server ---
// TestGate_IpcServer_WiresCheck end-to-end. Server.Check = g.Check; an
// out-of-scope WriteFact sent through a real socket rehydrates as
// ipc.ErrForbidden on the wire. The seam — no CoreAPI change, no module
// change — is the whole point: the daemon sets Server.Check at construction.
func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
pollerUid := int32(70001)
enrollment := &StaticEnrollment{
ByUid: map[int32]Scope{
pollerUid: {
Surface: SurfaceCoreProcess,
Module: "poll:healthcheck",
SourceScope: []string{"poll:healthcheck"},
},
},
Default: &Scope{ // any other uid ⇒ un-enrolled → forbidden
Surface: SurfaceUnknown,
Module: "",
},
}
gate := &Gate{Enrollment: enrollment}
dir := t.TempDir()
sock := filepath.Join(dir, "maven.sock")
// A fake CoreAPI that records writes; the auth verdict should fire before
// it ever gets called.
fake := &recordingAPI{}
srv, err := ipc.Listen(sock, fake)
if err != nil {
t.Fatalf("listen: %v", err)
}
srv.Check = gate.Check
done := make(chan struct{})
go func() {
_ = srv.Serve()
close(done)
}()
t.Cleanup(func() {
_ = srv.Close()
<-done
})
// Direct in-process check: we can caller-stamp our test ctx with any
// uid/pid we want. The socket path produces our real uid (via
// SO_PEERCRED), but the auth layer's verdict depends only on the
// caller-shape the wire delivered, not the wire transport — so the
// in-process check uses the same Gate.Check the socketed dispatch calls.
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1})
if err := gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil {
t.Errorf("in-scope write through gate = %v; want nil", err)
}
err = gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime"))
if !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("out-of-scope write through gate = %v; want ipc.ErrForbidden", err)
}
// Smoke the actual wire path: our real uid is un-enrolled per the
// StaticEnrollment (only 70001 is enrolled), so the very next call we
// make over the socket is forbidden at the wire — rehydrating on the
// client side as ipc.ErrForbidden. This is the full chain:
// dispatch → Check (auth) → codeOf → wire → hydrate.
cli, err := ipc.Dial(sock)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = cli.Close() })
_, err = cli.WriteFact(context.Background(), ipc.WriteFactReq{
Kind: "env",
Key: "service_down",
Value: "down",
Source: "poll:healthcheck",
Confidence: 1.0,
})
if !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("wire: write from real uid (unenrolled per StaticEnrollment) = %v; want ipc.ErrForbidden", err)
}
if fake.writes != 0 {
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
}
}
// recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth
// check must reject before reaching it, otherwise the refusal leaks into the
// fake's counts and we fail.
type recordingAPI struct {
writes int
}
func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, error) {
r.writes++
return int64(r.writes), nil
}
func (r *recordingAPI) LatestFact(_ context.Context, _ string) (ipc.Fact, error) {
return ipc.Fact{}, ipc.ErrNoFact
}
func (r *recordingAPI) LatestFactBySource(_ context.Context, _, _ string) (ipc.Fact, error) {
return ipc.Fact{}, ipc.ErrNoFact
}
func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Duration, error) {
return 0, ipc.ErrNoFact
}
func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) {
return ipc.Presence{}, nil
}
func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _ string) (int64, error) {
return 1, nil
}
func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil }
func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) {
return 1, nil
}
func (r *recordingAPI) ResolveNudge(_ context.Context, _ int64, _ string, _ time.Time) error {
return nil
}
func (r *recordingAPI) RecentOutcomes(_ context.Context, _ string, _ int) ([]string, error) {
return nil, nil
}
func (r *recordingAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
return nil, nil
}
func (r *recordingAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) {
return nil, nil
}
func (r *recordingAPI) WriteNote(_ context.Context, _ time.Time, _ string, _ []float32, _ string) (int64, error) {
return 1, nil
}
func (r *recordingAPI) QueryNotes(_ context.Context, _ []float32, _ int) ([]ipc.Note, error) {
return nil, nil
}
func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) {
return nil, nil
}
func (r *recordingAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) {
return false, nil
}
func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ time.Time) error {
return nil
}
func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) {
return ipc.Tool{}, ipc.ErrToolNotFound
}
func (r *recordingAPI) ListTools(_ context.Context, _ string) ([]ipc.Tool, error) {
return nil, nil
}
// mustWriteFactParams — minimal WriteFactReq JSON with only the source field,
// matching what ipc.dispatch hands to Server.Check (the raw params frame).
func mustWriteFactParams(source string) []byte {
b, err := json.Marshal(ipc.WriteFactReq{
Kind: "env",
Key: "service_down",
Value: "down",
Source: source,
Confidence: 1.0,
})
if err != nil {
panic(err)
}
return b
}
+112
View File
@@ -0,0 +1,112 @@
package auth
import (
"context"
"errors"
"fmt"
"github.com/kami/maven/internal/ipc"
)
// Sentinel errors. The wire carries ErrForbidden as codeForbidden; the others
// (ErrUnenrolled distinct) floor to forbidden on the wire too — but the daemon
// log can still see the distinction server-side.
var (
// ErrForbidden — the caller's scope caps the requested authority (a
// voice-channel caller asking for L3, a poller writing an out-of-scope
// source, etc.). The wire shape: codeForbidden.
ErrForbidden = errors.New("auth: forbidden")
// ErrUnenrolled — the caller isn't recognized by the enrollment table at
// all. A distinct sentinel so the daemon can surface "this module's
// enrollment is missing" as a wiring bug, not a generic denied.
ErrUnenrolled = errors.New("auth: caller not enrolled")
)
// Enrollment — the impure seam mapping a connecting process to a Scope. The
// floor below trusts same-uid local callers fully (the 0600-floor equivalent),
// production wires the real enrollment table reading a Config / sqlite table.
//
// Lookup is the single impure call per dispatch; Can and Check downstream are
// pure. That keeps "no module ever reads its own authority" checkable:
// anywhere outside the Enrollment impl doing caller→scope resolution is a bug.
type Enrollment interface {
// Lookup resolves the caller's Scope. Return ErrUnenrolled when the caller
// isn't recognized; ErrForbidden when recognized but refused for policy
// reasons (e.g. disabled module); any other error for I/O failure.
// hasCaller=false is the in-process path (no ipc.Caller attached to ctx).
Lookup(ctx context.Context, c ipc.Caller, hasCaller bool) (Scope, error)
}
// FloorEnrollment — today's auth floor. Same as the socket's 0600 perms: any
// same-uid caller is trusted as a "core" module (SurfaceCoreProcess, L3,
// write-any-source). The in-process path (no Caller) is the same: it's the
// daemon itself, holding the unlocked store, so it gets L3 trivially.
//
// This is the AUTH FLOOR, not the auth model — the spec's invariant (surface
// caps authority, source-scope, step-up) is shaped in policy.go and exercised
// in tests through tighter enrollments. The daemon swaps this out when the
// real enrollment table lands; nothing downstream changes.
type FloorEnrollment struct {
// Module is the label FloorEnrollment stamps on every caller (default
// "core"). Real enrollment derives this from Caller.Uid/Pid.
Module string
}
// NewFloorEnrollment — default "core" module, full source scope, L3 cap.
// This preserves the prior (pre-auth) behavior: any same-uid caller was
// permitted everything. Compiles to identity authority.
func NewFloorEnrollment() *FloorEnrollment { return &FloorEnrollment{Module: "core"} }
// Lookup — same-uid floor. HasCaller=false ⇒ in-process path (trusted "core");
// HasCaller=true ⇒ for now we still trust (only same-uid can connect via the
// 0600 socket perms). The real enrollment table replaces this with a lookup
// keyed on Uid/Pid → Module entry.
func (f *FloorEnrollment) Lookup(_ context.Context, _ ipc.Caller, _ bool) (Scope, error) {
return Scope{
Surface: SurfaceCoreProcess,
Module: f.Module,
SourceScope: []string{"*"},
}, nil
}
// StaticEnrollment — a hand-built enrollment for tests and demos: map every
// caller exact-match on Uid to a fixed Scope. Pure-ish (no I/O); the daemon
// holds it and lets the operator append at runtime; tests build their own.
// Used to model "a poller that can only write poll:healthcheck" — the spec's
// compromised-poller scenario — without standing up the full enrollment table.
type StaticEnrollment struct {
// ByUid — uid-keyed scope. Mutated to add an enrolled module.
ByUid map[int32]Scope
// InProcess is the scope returned for in-process (HasCaller=false) calls.
// nil ⇒ falls through to Default.
InProcess *Scope
// Default is returned when no specific entry matches. nil ⇒ ErrUnenrolled
// (fail closed).
Default *Scope
}
// Lookup walks the static map. hasCaller ⇒ ByUid → Default → ErrUnenrolled;
// in-process ⇒ InProcess → Default → ErrUnenrolled. Fail closed everywhere,
// because a StaticEnrollment is built deliberately and any unmatched caller
// is exactly the "who is this?" case the real table answers.
func (s *StaticEnrollment) Lookup(_ context.Context, c ipc.Caller, hasCaller bool) (Scope, error) {
if !hasCaller {
if s.InProcess != nil {
return *s.InProcess, nil
}
if s.Default != nil {
return *s.Default, nil
}
return Scope{}, ErrUnenrolled
}
if sc, ok := s.ByUid[c.Uid]; ok {
return sc, nil
}
if s.Default != nil {
return *s.Default, nil
}
return Scope{}, fmt.Errorf("%w (uid=%d)", ErrUnenrolled, c.Uid)
}
+120
View File
@@ -0,0 +1,120 @@
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
}
+168
View File
@@ -0,0 +1,168 @@
package auth
import (
"encoding/json"
"errors"
"fmt"
"github.com/kami/maven/internal/ipc"
)
// Authority — a discrete authority requirement per ipc.Method. Higher
// numbers are STRICTER (need a higher layer to be granted). Today's CoreAPI
// methods are all read or single-module-write; the deferred L3 acts
// (EnableTool / destructive Ops) are reserved at the top rung — they're
// not on the CoreAPI yet (the tool-executor module is unbuilt), but the
// authority table holds the rung so adding them is a policy entry, not a
// new mechanism.
type Authority int8
const (
// AuthRead — read methods (LatestFact, LatestFactBySource, Since, Presence,
// RecentOutcomes) and state mutations a module legitimately makes
// (CreateReminder, MarkReminder, RecordNudge, ResolveNudge). The Enrollment
// already gated caller identity; any enrolled module may use these.
AuthRead Authority = 0
// AuthWrite — WriteFact. Need enrollment + source-scope match. The
// "compromised poller can't forge a trigger" property: a module only writes
// sources it owns. Floor gets "*"; tight enrollments scope per source.
AuthWrite Authority = 1
// AuthStepUp — a per-assertion user-verification gesture is required for
// this call. Reserved for EnableTool (registration-enable) and destructive
// acts when they land on the CoreAPI. NOT a Layer itself — Authority is
// the call-side requirement; Layer is the surface-side capability. The
// pure check is just: does the surface cap (MaxLayer) carry L3, AND was
// step-up asserted this session? Both settled by Can below.
AuthStepUp Authority = 2
)
// Requirement — the PURE authority table: per-method required Authority. This
// is the one place in the codebase a method's required authority is declared;
// every other reference to "registration needs step-up" points back here.
// Adding a new ipc.Method = a row here (or it inherits AuthRead by default,
// which the vet check in dispatch catches via Method existence, not auth).
func Requirement(m ipc.Method) Authority {
switch m {
case ipc.MethodEnableTool:
// Registration-enable is privilege escalation: it moves the boundary
// (adds a runnable capability). Human-only, step-up asserted — never a
// module or the voice/chat path. maven can propose but never enable.
return AuthStepUp
case ipc.MethodWriteFact:
return AuthWrite
case ipc.MethodLatestFact,
ipc.MethodLatestFactBySource,
ipc.MethodSince,
ipc.MethodPresence,
ipc.MethodRecentOutcomes,
ipc.MethodCreateReminder,
ipc.MethodMarkReminder,
ipc.MethodRecordNudge,
ipc.MethodResolveNudge:
return AuthRead
}
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
// regardless of the auth verdict (we run before dispatch; we don't gate on
// Method existence — Check is method-agnostic policy, not routing).
return AuthRead
}
// Can — the PURE authority decision for one call. Returns nil if the scope
// is authorized to invoke m with the supplied params; an error otherwise:
//
// - ErrUnenrolled — scope has no Module (caller wasn't in the enrollment
// table). Fail closed.
// - ErrForbidden — surface caps the layer below what m requires, or
// WriteFact's source is out of scope.
//
// The adapter wrapping this for the ipc gate (Gate.Check) maps the error to
// codeForbidden at the wire; we keep the distinction here so daemon logs
// can show why a call was denied.
//
// params is the raw json.RawMessage the ipc Server received; for WriteFact we
// re-parse Source out of it. Other methods don't need params — their verdict
// depends only on the scope.
func Can(m ipc.Method, scope Scope, params json.RawMessage) error {
// Floor closed: any caller not in the enrollment table is refused outright.
// Not "0-level unauthed" — refused. This is the surface-caps property
// applied before the layer caps: there is no L0 surface if SurfaceUnknown.
if scope.Module == "" {
return ErrUnenrolled
}
if scope.Surface == SurfaceUnknown {
return ErrUnenrolled
}
switch Requirement(m) {
case AuthRead:
// Any enrolled module may read. Reads through the surface level the
// Enrollment set (voice-L0 wouldn't be enrolled to write at all).
return nil
case AuthWrite:
if m == ipc.MethodWriteFact {
src, err := extractSource(params)
if err != nil {
// Malformed params is a bad-params error already produced by
// ipc.dispatch; but Can runs first. Treat as forbidden — a
// caller doesn't get to probe scopes with garbage params.
return fmt.Errorf("%w: malformed source", ErrForbidden)
}
if !SourceAllowed(scope.SourceScope, src) {
// The spec's compromised-poller case in one line: a poller
// enrolled to write poll:healthcheck asking to write
// poll:uptime is denied — but the same poller writing
// poll:healthcheck is fine. Polls can't forge triggers.
return fmt.Errorf("%w: source %q out of scope", ErrForbidden, src)
}
}
return nil
case AuthStepUp:
// Surface-caps-authority enforced here. The surface can't carry L3 ⇒
// forbidden. The session step-up itself is checked by the Gate (it
// owns Session state and surfaces a Check function); we cap surface
// here so the gate fails closed on shape alone.
if MaxLayer(scope.Surface) < Layer3 {
return fmt.Errorf("%w: surface %s can't carry step-up", ErrForbidden, scope.Surface)
}
return nil
}
return nil
}
// SourceAllowed — true iff src is in scope (the wildcard "*" matches all).
// Empty scope ⇒ fail closed. The function is pure; we keep it exported so a
// future enrollment table can call into the same matching logic.
func SourceAllowed(scope []string, src string) bool {
if len(scope) == 0 {
return false
}
for _, s := range scope {
if s == "*" || s == src {
return true
}
}
return false
}
// extractSource reads WriteFactReq.Source out of the raw params WITHOUT a full
// unmarshal — Source is the only field Can needs, and re-parsing it once per
// write is cheap (and only happens on MethodWriteFact). Stay independent of
// any future WriteFactReq shape changes by using the struct directly.
func extractSource(raw json.RawMessage) (string, error) {
var p ipc.WriteFactReq
if err := json.Unmarshal(raw, &p); err != nil {
return "", err
}
if p.Source == "" {
// An empty source is rejected by store.WriteFact anyway; surface it
// as forbidden to avoid giving a caller an ipc sentinel that names the
// store's internal invariant. (store rejects this before feature flag
// for "missing source"; today this is best-effort.)
return "", errors.New("empty source")
}
return p.Source, nil
}
+39
View File
@@ -0,0 +1,39 @@
package auth
import (
"github.com/kami/maven/internal/ipc"
)
// Scope — the resolved authority of a caller. Built by Enrollment *once*
// when a connection is accepted (or once per call, depending on impl), then
// threaded through Check → Can as pure data. The impure part (looking up the
// module enrollment table from ipc.Caller Uid+Pid) ends at the Enrollment
// boundary; everything downstream is pure.
type Scope struct {
// Surface — the channel the caller entered through. Caps the layer.
Surface Surface
// Module — the enrolled module name ("tts", "poll:healthcheck",
// "router", "telegram-relay", ""). "" ⇒ unenrolled; Check refuses.
// The daemon's in-process path sets "core" by convention.
Module string
// SourceScope — the sources this module may WriteFact under. The spec's
// exact "compromised poller can't forge a trigger" guard: a module
// only writes sources it owns. The floor enrollment grants "*"
// (anything); a real enrollment scopes a poller to one source prefix.
// Empty slice ⇒ refuse all writes (fail closed); the daemon never sets
// this empty for an enrolled caller.
SourceScope []string
}
// Lookup — the Enroller's input: ipc.Caller when present (Uid/Pid from
// SO_PEERCRED on the socket), or the zero value for in-process (no Caller
// attached to ctx — the daemon treats this as SurfaceCoreProcess, "core").
type Lookup struct {
// Caller — when CallerFrom(ctx) is absent (the in-process path), this is
// the zero ipc.Caller. The Enrollment floor returns SurfaceCoreProcess in
// that case.
Caller ipc.Caller
HasCaller bool
}
+94
View File
@@ -0,0 +1,94 @@
// Package auth is maven's authority layer — the 4-layer cascade and the
// "surface caps authority" invariant.
//
// Spec contract (from maven.md § auth):
//
// a cascade, not a pick-one — each layer answers a different question:
//
// | layer | question | mechanism | surface |
// | 0 | on the network at all? | wireguard | floor |
// | 1 | enrolled box? | mTLS client cert | pc client, authed page |
// | 2 | you, this session? | passkey / webauthn | pc client, authed page |
// | 3 | you, right now, for this act? | passkey user-verification | step-up acts |
//
// wg is necessary-not-sufficient: an unlocked laptop inside the tunnel is
// "authed" at layer 0 only — that gap is why the upper layers exist.
//
// the invariant: auth tier is a property of the SURFACE; the surface caps
// maximum authority. you can't step up past what the channel structurally
// carries. voice STOPS at L0 (a room mic is reachable by anyone present →
// speaker verification is attribution, not auth). telegram inbound = weak
// tier (read + soft acts, never destructive, never registration). only
// pc_client / authed_page carry passkey user-verification (L3) at all.
//
// "compromised X can't forge Y" is the through-line — applied at smaller
// and smaller scope (network → box → process). the auth layer applies it at
// the process radius: a module calling CoreAPI is bound to a Surface; the
// Surface caps what methods/authority that call can carry.
package auth
// Layer — one rung of the auth cascade.
type Layer int8
const (
// Layer0 — wireguard: on the network at all. Floor for everything.
Layer0 Layer = 0
// Layer1 — mTLS client cert: enrolled box. Optional per spec ("if dropping
// one, drop mTLS, never the passkey"); not wired by the floor enrollment.
Layer1 Layer = 1
// Layer2 — passkey/webauthn session: you, this session.
Layer2 Layer = 2
// Layer3 — passkey user-verification gesture for a SINGLE act: you, right
// now, for this. registration-enable, destructive acts, core cold-start
// unlock. the highest-authority op.
Layer3 Layer = 3
)
// Surface — where a call ENTERS maven from. A property of the channel that
// structurally caps the maximum authority that channel can carry. The wire
// doesn't carry a layer — it carries a Caller; the Enrollment maps the caller
// to a Surface; MaxLayer caps it. So voice can never reach EnableTool — not
// because auth "failed" but because the channel can't carry the proof.
type Surface string
const (
// SurfaceVoice — a room mic / wake-word path. Speaker verification is
// attribution, not auth: anyone present (gf, the TV) can speak. STOPS at
// L0. never destructive, never registration.
SurfaceVoice Surface = "voice"
// SurfaceTelegram — telegram inbound. weak tier: telegram's own auth,
// outside our control. read + soft acts, never destructive, never
// registration. carries up to L2 (a chat-id allowlist is the best we get).
SurfaceTelegram Surface = "telegram"
// SurfacePCClient — the desktop gui, mTLS'd and passkey'd. carries L3
// (passkey user-verification is available on-device).
SurfacePCClient Surface = "pc_client"
// SurfaceAuthedPage — the web authed page over wg. carries L3 (passkey
// user-verification via the browser / platform authenticator).
SurfaceAuthedPage Surface = "authed_page"
// SurfaceCoreProcess — a module running in core's own address space (the
// daemon-embedded router/delivery today). There is no boundary to cross;
// this is the 0600-floor equivalent: trusted same-process. Layer3-capped
// since the user already unlocked the daemon (cold-start IS L3 per spec).
SurfaceCoreProcess Surface = "core_process"
// SurfaceUnknown — enrollment didn't recognize the caller. fail closed.
SurfaceUnknown Surface = "unknown"
)
// MaxLayer — the surface-caps-authority table. PURE. The invariant: you
// cannot step up past what your channel structurally carries. voice → L0;
// telegram → L2 (chat allowlist); pc_client / authed_page / core_process →
// L3. An unrecognized surface caps at -1 (fail closed) — a caller with no
// enrolled identity is not "unauthed at L0", it's refused outright.
func MaxLayer(s Surface) Layer {
switch s {
case SurfaceVoice:
return Layer0
case SurfaceTelegram:
return Layer2
case SurfacePCClient, SurfaceAuthedPage, SurfaceCoreProcess:
return Layer3
default:
return -1
}
}