Files
Maven/internal/auth/policy.go
T
kami 6239eca243 items 5-7: passkey step-up, tools enable/disable, note RAG — end to end
Completes the three in-flight open items and fixes the away-fallthrough bug.

Item 7 — passkey step-up (WebAuthn):
- internal/webauthn: ES256/P-256 register + assert with real ecdsa signature
  verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert,
  decays after TTL). Drop the RS256 offer we can't verify (register-ok/
  assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is
  the step-up gesture. Round-trip test with negative cases (tampered sig,
  missing UV, wrong origin).
- cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do
  a WebAuthn gesture) + the four begin/finish endpoints. Without this the
  daemon's PasskeySession swap leaves /tools enable permanently blocked.
- daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates
  MethodAssertStepUp at AuthRead.

Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a
disable action and a link to the passkey page. Lifecycle test.

Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k
notes, raw-notes fallback); IntentQuery routes through it. Stub returns a
deterministic summary.

Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes
through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop)
instead of silently dropping / mis-routing to the present-list remainder.
Covers DispatchNudge + DispatchReminder. 4 tests.

Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix
missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc,
gitignore /mavcaldav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:41:13 +04:00

171 lines
6.6 KiB
Go

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.MethodAssertStepUp:
return AuthRead
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
}