From eacb2a20fac65d5e6085d9af27841b61520b1222 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 3 Jul 2026 15:18:40 +0300 Subject: [PATCH] Add daemon-attested origin and appkit per-node rate limiting - ipc.Envelope gains an Origin block set only via CallWithOrigin; the broker strips caller-supplied origins on cross-app calls so apps cannot forge sender identity through the broker - pkg/appkit: RequireOrigin, LimitPerNode, RequireEnvelope, and a sliding-window PerNodeLimiter keyed on verified node identity - new identity.verify manifest capability (declarative, like the rest of the grant model) --- pkg/appkit/appkit.go | 155 +++++++++++++++++++++++ pkg/appkit/appkit_test.go | 198 ++++++++++++++++++++++++++++++ pkg/appkit/limiter.go | 78 ++++++++++++ pkg/appkit/limiter_test.go | 129 +++++++++++++++++++ pkg/ipc/client.go | 10 ++ pkg/ipc/envelope.go | 20 +++ pkg/ipc/origin_test.go | 76 ++++++++++++ pkg/manifest/validate.go | 4 + pkg/manifest/zz2_validate_test.go | 12 ++ plugin/appstore/origin_test.go | 161 ++++++++++++++++++++++++ plugin/appstore/service.go | 21 ++++ plugin/appstore/supervisor.go | 26 +++- 12 files changed, 886 insertions(+), 4 deletions(-) create mode 100644 pkg/appkit/appkit.go create mode 100644 pkg/appkit/appkit_test.go create mode 100644 pkg/appkit/limiter.go create mode 100644 pkg/appkit/limiter_test.go create mode 100644 pkg/ipc/origin_test.go create mode 100644 plugin/appstore/origin_test.go diff --git a/pkg/appkit/appkit.go b/pkg/appkit/appkit.go new file mode 100644 index 0000000..97afbcc --- /dev/null +++ b/pkg/appkit/appkit.go @@ -0,0 +1,155 @@ +// Package appkit gives apps small, composable helpers for acting on +// verified sender identity. There are two distinct paths by which an app +// learns who a request came from: +// +// 1. Daemon-attested origin. When the daemon bridges a request from a +// remote Pilot node into an app, it stamps ipc.Envelope.Origin with the +// peer identity it proved during the authenticated key exchange +// (Service.CallWithOrigin). RequireOrigin and LimitPerNode consume that +// block directly — no crypto in the app. +// +// 2. Out-of-band envelope verification. Traffic that did NOT come through +// the daemon bridge (e.g. a signed blob relayed through another app or +// fetched from storage) carries no attested origin. RequireEnvelope +// instead expects the request payload to contain "pilot_envelope" and +// "pilot_signature" fields and asks the daemon to verify them via a +// VerifyFunc the app supplies. +// +// appkit is stdlib-only: it never imports the daemon driver. Apps wire the +// daemon's CmdVerifyEnvelope IPC in with a closure, e.g.: +// +// drv := driver.Dial(...) +// verify := func(envelope, signature string) (appkit.VerifyResult, error) { +// res, err := drv.VerifyEnvelope(envelope, signature) +// if err != nil { +// return appkit.VerifyResult{}, err +// } +// return appkit.VerifyResult{ +// Valid: res.Valid, +// Node: res.Node, +// NodeID: res.NodeID, +// Authenticated: res.Authenticated, +// Trusted: res.Trusted, +// Online: res.Online, +// }, nil +// } +// d.Register("pay", appkit.RequireEnvelope(verify, payHandler)) +// +// Trust boundary: origin (attested or synthesized) is trustworthy exactly +// as far as the daemon socket is. A same-UID local process dialing app.sock +// directly can forge anything — same-UID is the platform trust boundary +// today (no OS sandbox). What appkit does guarantee is that no *app* can +// forge an origin through the broker: cross-app calls always arrive with +// Origin == nil. +package appkit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// VerifyResult is what a VerifyFunc reports about a signed Pilot envelope. +// Mirrors the daemon's CmdVerifyEnvelope reply shape. +type VerifyResult struct { + Valid bool // signature checks out against the claimed identity + Node string // text address, e.g. "1:0001.ABCD.1234" + NodeID uint32 + Authenticated bool // identity proven via key exchange with the daemon + Trusted bool // in the daemon's handshake trust store + Online bool // node currently reachable, per the daemon +} + +// VerifyFunc asks the daemon to verify a signed envelope. Apps implement +// it as a closure over common/driver's VerifyEnvelope — see the package +// doc for an example. appkit never dials the daemon itself. +type VerifyFunc func(envelope, signature string) (VerifyResult, error) + +// Sentinel errors returned by the middleware handlers. Compare with +// errors.Is; on the wire they surface as EnvErr messages. +var ( + // ErrOriginRequired rejects requests with no authenticated origin. + ErrOriginRequired = errors.New("appkit: authenticated origin required") + // ErrRateLimited rejects requests over the per-node budget. + ErrRateLimited = errors.New("appkit: rate limit exceeded") + // ErrEnvelopeRequired rejects payloads missing pilot_envelope / + // pilot_signature (or with a payload that isn't a JSON object). + ErrEnvelopeRequired = errors.New("appkit: pilot_envelope and pilot_signature required") + // ErrEnvelopeInvalid rejects envelopes the daemon verified as not valid. + ErrEnvelopeInvalid = errors.New("appkit: envelope verification failed") +) + +// RequireOrigin wraps next so it only runs for requests carrying a +// daemon-attested, key-exchange-authenticated origin. Everything else — +// no origin at all (direct connection, cross-app call) or an origin the +// daemon could not authenticate — is rejected with ErrOriginRequired. +func RequireOrigin(next ipc.Handler) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + if req.Origin == nil || !req.Origin.Authenticated { + return nil, ErrOriginRequired + } + return next(ctx, req) + } +} + +// LimitPerNode wraps next with a per-node-identity rate limit, keyed on +// Origin.Node. Compose it after RequireOrigin — but it is defensive on +// its own: a request with no origin (nothing to key on) is rejected with +// ErrOriginRequired rather than sharing one anonymous bucket. +func LimitPerNode(l *NodeLimiter, next ipc.Handler) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + if req.Origin == nil || req.Origin.Node == "" { + return nil, ErrOriginRequired + } + if !l.Allow(req.Origin.Node) { + return nil, fmt.Errorf("%w: %s", ErrRateLimited, req.Origin.Node) + } + return next(ctx, req) + } +} + +// RequireEnvelope wraps next for out-of-band traffic: requests whose +// payload carries a signed Pilot envelope instead of arriving through the +// daemon bridge. The payload must be a JSON object with non-empty +// "pilot_envelope" and "pilot_signature" string fields; verify is called +// on the pair, and on a Valid result the handler runs with a synthesized +// env.Origin built from the verification. Missing fields, unparseable +// payloads, verify errors, and invalid envelopes are all rejected. +// +// next receives a shallow copy of the request so the synthesized origin +// never leaks into the caller's envelope. +func RequireEnvelope(verify VerifyFunc, next ipc.Handler) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + var wrap struct { + Envelope string `json:"pilot_envelope"` + Signature string `json:"pilot_signature"` + } + if len(req.Payload) == 0 { + return nil, ErrEnvelopeRequired + } + if err := json.Unmarshal(req.Payload, &wrap); err != nil { + return nil, fmt.Errorf("%w: %v", ErrEnvelopeRequired, err) + } + if wrap.Envelope == "" || wrap.Signature == "" { + return nil, ErrEnvelopeRequired + } + res, err := verify(wrap.Envelope, wrap.Signature) + if err != nil { + return nil, fmt.Errorf("appkit: verify envelope: %w", err) + } + if !res.Valid { + return nil, ErrEnvelopeInvalid + } + attested := *req + attested.Origin = &ipc.Origin{ + Node: res.Node, + NodeID: res.NodeID, + Authenticated: res.Authenticated, + Trusted: res.Trusted, + } + return next(ctx, &attested) + } +} diff --git a/pkg/appkit/appkit_test.go b/pkg/appkit/appkit_test.go new file mode 100644 index 0000000..0ca67ba --- /dev/null +++ b/pkg/appkit/appkit_test.go @@ -0,0 +1,198 @@ +package appkit + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// okHandler returns "ok" and records the envelope it was invoked with. +func okHandler(got **ipc.Envelope) ipc.Handler { + return func(_ context.Context, req *ipc.Envelope) (json.RawMessage, error) { + if got != nil { + *got = req + } + return json.RawMessage(`"ok"`), nil + } +} + +func reqWithOrigin(o *ipc.Origin) *ipc.Envelope { + return &ipc.Envelope{Type: ipc.EnvReq, ReqID: "r", Method: "m", Origin: o} +} + +func TestRequireOrigin(t *testing.T) { + t.Parallel() + ctx := context.Background() + + cases := []struct { + name string + origin *ipc.Origin + wantOK bool + }{ + {"nil origin rejected", nil, false}, + {"unauthenticated origin rejected", &ipc.Origin{Node: "1:0001.0000.0001", NodeID: 1}, false}, + {"authenticated origin passes", &ipc.Origin{Node: "1:0001.0000.0001", NodeID: 1, Authenticated: true}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := RequireOrigin(okHandler(nil)) + _, err := h(ctx, reqWithOrigin(tc.origin)) + if tc.wantOK && err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !tc.wantOK && !errors.Is(err, ErrOriginRequired) { + t.Fatalf("err = %v, want ErrOriginRequired", err) + } + }) + } +} + +func TestLimitPerNode(t *testing.T) { + t.Parallel() + ctx := context.Background() + l := PerNodeLimiter(2, time.Minute) + h := LimitPerNode(l, okHandler(nil)) + + a := &ipc.Origin{Node: "1:0001.0000.000A", NodeID: 10, Authenticated: true} + b := &ipc.Origin{Node: "1:0001.0000.000B", NodeID: 11, Authenticated: true} + + for i := 0; i < 2; i++ { + if _, err := h(ctx, reqWithOrigin(a)); err != nil { + t.Fatalf("request %d from a: %v", i+1, err) + } + } + if _, err := h(ctx, reqWithOrigin(a)); !errors.Is(err, ErrRateLimited) { + t.Fatalf("err = %v, want ErrRateLimited", err) + } + // Budget is per node identity — b is unaffected by a's exhaustion. + if _, err := h(ctx, reqWithOrigin(b)); err != nil { + t.Fatalf("request from b: %v", err) + } + // Defensive: no origin → nothing to key on → rejected, not pooled. + if _, err := h(ctx, reqWithOrigin(nil)); !errors.Is(err, ErrOriginRequired) { + t.Fatalf("err = %v, want ErrOriginRequired", err) + } +} + +func TestRequireEnvelope_ValidSynthesizesOrigin(t *testing.T) { + t.Parallel() + verify := func(envelope, signature string) (VerifyResult, error) { + if envelope != "env-blob" || signature != "sig-blob" { + t.Errorf("verify called with (%q, %q)", envelope, signature) + } + return VerifyResult{ + Valid: true, + Node: "1:0001.ABCD.1234", + NodeID: 77, + Authenticated: true, + Trusted: true, + Online: true, + }, nil + } + var got *ipc.Envelope + h := RequireEnvelope(verify, okHandler(&got)) + + req := &ipc.Envelope{ + Type: ipc.EnvReq, + ReqID: "r", + Method: "m", + Payload: json.RawMessage(`{"pilot_envelope":"env-blob","pilot_signature":"sig-blob","extra":1}`), + } + if _, err := h(context.Background(), req); err != nil { + t.Fatalf("handler: %v", err) + } + if got == nil || got.Origin == nil { + t.Fatal("handler did not receive a synthesized origin") + } + want := ipc.Origin{Node: "1:0001.ABCD.1234", NodeID: 77, Authenticated: true, Trusted: true} + if *got.Origin != want { + t.Errorf("origin = %+v, want %+v", *got.Origin, want) + } + // The caller's envelope must not be mutated. + if req.Origin != nil { + t.Error("RequireEnvelope mutated the caller's envelope") + } +} + +func TestRequireEnvelope_Rejections(t *testing.T) { + t.Parallel() + neverVerify := func(string, string) (VerifyResult, error) { + t.Error("verify must not run for malformed payloads") + return VerifyResult{}, nil + } + + cases := []struct { + name string + payload string + }{ + {"empty payload", ""}, + {"not json", `{{{`}, + {"json but wrong shape", `[1,2,3]`}, + {"neither field", `{"other":"x"}`}, + {"missing signature", `{"pilot_envelope":"e"}`}, + {"missing envelope", `{"pilot_signature":"s"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := RequireEnvelope(neverVerify, okHandler(nil)) + req := &ipc.Envelope{Type: ipc.EnvReq, ReqID: "r", Method: "m"} + if tc.payload != "" { + req.Payload = json.RawMessage(tc.payload) + } + _, err := h(context.Background(), req) + if !errors.Is(err, ErrEnvelopeRequired) { + t.Fatalf("err = %v, want ErrEnvelopeRequired", err) + } + }) + } +} + +func TestRequireEnvelope_InvalidAndErrorResults(t *testing.T) { + t.Parallel() + payload := json.RawMessage(`{"pilot_envelope":"e","pilot_signature":"s"}`) + + t.Run("verify says not valid", func(t *testing.T) { + h := RequireEnvelope(func(string, string) (VerifyResult, error) { + return VerifyResult{Valid: false}, nil + }, okHandler(nil)) + _, err := h(context.Background(), &ipc.Envelope{Type: ipc.EnvReq, Payload: payload}) + if !errors.Is(err, ErrEnvelopeInvalid) { + t.Fatalf("err = %v, want ErrEnvelopeInvalid", err) + } + }) + + t.Run("verify transport error propagates", func(t *testing.T) { + boom := errors.New("daemon unreachable") + h := RequireEnvelope(func(string, string) (VerifyResult, error) { + return VerifyResult{}, boom + }, okHandler(nil)) + _, err := h(context.Background(), &ipc.Envelope{Type: ipc.EnvReq, Payload: payload}) + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want wrapped %v", err, boom) + } + }) +} + +// TestComposedChain wires the intended stack — RequireOrigin → +// LimitPerNode → handler — and drives it as one unit. +func TestComposedChain(t *testing.T) { + t.Parallel() + l := PerNodeLimiter(1, time.Minute) + h := RequireOrigin(LimitPerNode(l, okHandler(nil))) + ctx := context.Background() + + if _, err := h(ctx, reqWithOrigin(nil)); !errors.Is(err, ErrOriginRequired) { + t.Fatalf("err = %v, want ErrOriginRequired", err) + } + o := &ipc.Origin{Node: "1:0001.0000.0001", NodeID: 1, Authenticated: true} + if _, err := h(ctx, reqWithOrigin(o)); err != nil { + t.Fatalf("first request: %v", err) + } + if _, err := h(ctx, reqWithOrigin(o)); !errors.Is(err, ErrRateLimited) { + t.Fatalf("err = %v, want ErrRateLimited", err) + } +} diff --git a/pkg/appkit/limiter.go b/pkg/appkit/limiter.go new file mode 100644 index 0000000..020e8d5 --- /dev/null +++ b/pkg/appkit/limiter.go @@ -0,0 +1,78 @@ +package appkit + +import ( + "sync" + "time" +) + +// NodeLimiter is a sliding-window rate limiter keyed by node identity +// (the Origin.Node text address). Each node gets an independent budget of +// maxReqs requests per window; a hostile or chatty remote node exhausts +// only its own bucket, never the app's whole surface. +// +// Concurrency-safe. Stale buckets are swept opportunistically on Allow +// (at most one full sweep per window) — no background goroutine, so a +// forgotten limiter never leaks. +type NodeLimiter struct { + mu sync.Mutex + max int + window time.Duration + now func() time.Time // injectable for tests + hits map[string][]time.Time + lastSweep time.Time +} + +// PerNodeLimiter builds a limiter allowing maxReqs requests per node +// within any sliding window of the given duration. +func PerNodeLimiter(maxReqs int, window time.Duration) *NodeLimiter { + return &NodeLimiter{ + max: maxReqs, + window: window, + now: time.Now, + hits: map[string][]time.Time{}, + } +} + +// Allow reports whether one more request from node may proceed now, +// recording it if so. A request is counted against the window ending at +// the moment of the call. +func (l *NodeLimiter) Allow(node string) bool { + l.mu.Lock() + defer l.mu.Unlock() + t := l.now() + l.sweepLocked(t) + cutoff := t.Add(-l.window) + kept := pruneBefore(l.hits[node], cutoff) + if len(kept) >= l.max { + l.hits[node] = kept + return false + } + l.hits[node] = append(kept, t) + return true +} + +// sweepLocked drops buckets whose every hit has aged out of the window. +// Runs at most once per window so a hot Allow path stays O(own bucket). +func (l *NodeLimiter) sweepLocked(t time.Time) { + if t.Sub(l.lastSweep) < l.window { + return + } + l.lastSweep = t + cutoff := t.Add(-l.window) + for node, hits := range l.hits { + if len(hits) == 0 || hits[len(hits)-1].Before(cutoff) { + delete(l.hits, node) + } + } +} + +// pruneBefore drops leading timestamps older than cutoff. Hits are +// appended in call order, so the slice is ascending and one scan from +// the front suffices. +func pruneBefore(hits []time.Time, cutoff time.Time) []time.Time { + i := 0 + for i < len(hits) && hits[i].Before(cutoff) { + i++ + } + return hits[i:] +} diff --git a/pkg/appkit/limiter_test.go b/pkg/appkit/limiter_test.go new file mode 100644 index 0000000..fd3387b --- /dev/null +++ b/pkg/appkit/limiter_test.go @@ -0,0 +1,129 @@ +package appkit + +import ( + "fmt" + "sync" + "testing" + "time" +) + +// fakeClock lets limiter tests step wall-clock time deterministically. +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func newFakeClock() *fakeClock { + return &fakeClock{t: time.Unix(1_000_000, 0)} +} + +func (c *fakeClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +// TestNodeLimiter_AllowsThenBlocks exhausts a node's budget and asserts +// the next request is refused, while an unrelated node stays unaffected. +func TestNodeLimiter_AllowsThenBlocks(t *testing.T) { + t.Parallel() + clk := newFakeClock() + l := PerNodeLimiter(3, time.Minute) + l.now = clk.now + + for i := 0; i < 3; i++ { + if !l.Allow("node-a") { + t.Fatalf("request %d should be allowed", i+1) + } + } + if l.Allow("node-a") { + t.Error("request 4 should be blocked") + } + if !l.Allow("node-b") { + t.Error("independent node must have its own budget") + } +} + +// TestNodeLimiter_WindowSlides asserts the window is sliding, not fixed: +// capacity comes back exactly as old hits age out. +func TestNodeLimiter_WindowSlides(t *testing.T) { + t.Parallel() + clk := newFakeClock() + l := PerNodeLimiter(2, time.Minute) + l.now = clk.now + + if !l.Allow("n") { // t=0 + t.Fatal("first request should pass") + } + clk.advance(30 * time.Second) + if !l.Allow("n") { // t=30s + t.Fatal("second request should pass") + } + if l.Allow("n") { // still 2 hits in window + t.Error("third request inside window should be blocked") + } + clk.advance(31 * time.Second) // t=61s: hit at t=0 aged out, t=30s still in + if !l.Allow("n") { + t.Error("capacity should return once the oldest hit slides out") + } + if l.Allow("n") { // hits at 30s and 61s both within [1s, 61s] + t.Error("window must slide, not reset wholesale") + } +} + +// TestNodeLimiter_SweepDropsStaleBuckets asserts the opportunistic sweep +// releases memory for nodes that went quiet. +func TestNodeLimiter_SweepDropsStaleBuckets(t *testing.T) { + t.Parallel() + clk := newFakeClock() + l := PerNodeLimiter(5, time.Second) + l.now = clk.now + + for i := 0; i < 100; i++ { + l.Allow(fmt.Sprintf("node-%d", i)) + } + clk.advance(5 * time.Second) + l.Allow("node-fresh") // triggers the sweep + + l.mu.Lock() + n := len(l.hits) + l.mu.Unlock() + if n != 1 { + t.Errorf("stale buckets not swept: %d remain, want 1", n) + } +} + +// TestNodeLimiter_Concurrent hammers one limiter from many goroutines — +// run with -race this is the concurrency-safety check, and the total +// admitted must never exceed the budget. +func TestNodeLimiter_Concurrent(t *testing.T) { + t.Parallel() + l := PerNodeLimiter(50, time.Minute) + + var wg sync.WaitGroup + var mu sync.Mutex + allowed := 0 + for g := 0; g < 8; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 25; i++ { + if l.Allow("shared") { + mu.Lock() + allowed++ + mu.Unlock() + } + } + }() + } + wg.Wait() + if allowed != 50 { + t.Errorf("allowed = %d, want exactly 50", allowed) + } +} diff --git a/pkg/ipc/client.go b/pkg/ipc/client.go index 3b1412f..ce22af8 100644 --- a/pkg/ipc/client.go +++ b/pkg/ipc/client.go @@ -26,6 +26,15 @@ func (e *ErrServerError) Error() string { return "ipc: server error: " + e.Msg } // Returns *ErrServerError on EnvErr replies, a wrapped framing error on // transport failures, or nil on success. func Call(conn io.ReadWriter, method string, args, result any) error { + return CallWithOrigin(conn, method, args, result, nil) +} + +// CallWithOrigin is Call with a daemon-attested Origin stamped on the +// request envelope. Only the trusted bridge path (the daemon / broker +// acting on behalf of a remote peer) should pass a non-nil origin — +// see the Origin doc for the trust model. A nil origin behaves exactly +// like Call. +func CallWithOrigin(conn io.ReadWriter, method string, args, result any, origin *Origin) error { reqID, err := randReqID() if err != nil { return fmt.Errorf("ipc call: req_id: %w", err) @@ -42,6 +51,7 @@ func Call(conn io.ReadWriter, method string, args, result any) error { Type: EnvReq, ReqID: reqID, Method: method, + Origin: origin, Payload: payload, } if err := WriteFrame(conn, req); err != nil { diff --git a/pkg/ipc/envelope.go b/pkg/ipc/envelope.go index d8efc89..82e3449 100644 --- a/pkg/ipc/envelope.go +++ b/pkg/ipc/envelope.go @@ -23,6 +23,20 @@ const ( EnvErr EnvelopeType = "err" ) +// Origin identifies the remote Pilot node a request originated from. +// It is DAEMON-ATTESTED: only the daemon bridge sets it, from peer identity +// proven during the authenticated key exchange. The broker strips any +// caller-supplied origin on cross-app calls so apps cannot forge it. +// Trust boundary: a same-UID local process dialing app.sock directly can +// forge anything — same-UID is the platform trust boundary today (no OS +// sandbox); origin is trustworthy exactly as far as the daemon socket is. +type Origin struct { + Node string `json:"node"` // text address, e.g. "1:0001.ABCD.1234" + NodeID uint32 `json:"node_id"` + Authenticated bool `json:"authenticated"` // proven via key exchange + Trusted bool `json:"trusted"` // in the handshake trust store +} + // Envelope is the single message shape on the wire. ReqID is set by the // caller and echoed in the reply so a multiplexing client can match. // @@ -30,12 +44,18 @@ const ( // from one app to another — handlers can use them for the equivalent of // `ipc.context()` (knowing who is calling, under which manifest_version). // On a direct connection (no daemon in between) both fields are zero. +// +// Origin, when present, is the daemon-attested remote-node identity of +// the request — see the Origin doc for the trust model. It is only ever +// set by the trusted bridge path (CallWithOrigin); the broker never +// propagates it on cross-app calls. type Envelope struct { Type EnvelopeType `json:"type"` ReqID string `json:"req_id"` Method string `json:"method,omitempty"` AppID string `json:"app_id,omitempty"` ManifestVersion int `json:"manifest_version,omitempty"` + Origin *Origin `json:"origin,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` Error string `json:"error,omitempty"` } diff --git a/pkg/ipc/origin_test.go b/pkg/ipc/origin_test.go new file mode 100644 index 0000000..5cc1e69 --- /dev/null +++ b/pkg/ipc/origin_test.go @@ -0,0 +1,76 @@ +package ipc + +import ( + "bytes" + "encoding/json" + "testing" +) + +// TestOrigin_JSONRoundTrip serializes an envelope carrying a full Origin +// block and reads it back — every field must survive the wire. +func TestOrigin_JSONRoundTrip(t *testing.T) { + t.Parallel() + in := &Envelope{ + Type: EnvReq, + ReqID: "r1", + Method: "pay", + Origin: &Origin{ + Node: "1:0001.ABCD.1234", + NodeID: 0xABCD1234, + Authenticated: true, + Trusted: true, + }, + Payload: json.RawMessage(`{"amount":5}`), + } + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out Envelope + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Origin == nil { + t.Fatal("origin lost in round trip") + } + if *out.Origin != *in.Origin { + t.Errorf("origin = %+v, want %+v", *out.Origin, *in.Origin) + } +} + +// TestOrigin_OmittedWhenNil asserts the omitempty contract: an envelope +// without an origin must not emit an "origin" key at all, so pre-origin +// peers see byte-identical wire shapes. +func TestOrigin_OmittedWhenNil(t *testing.T) { + t.Parallel() + raw, err := json.Marshal(&Envelope{Type: EnvReq, ReqID: "r2", Method: "ping"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if bytes.Contains(raw, []byte("origin")) { + t.Errorf("nil origin must be omitted, got %s", raw) + } +} + +// TestOrigin_FrameRoundTrip pushes an origin-bearing envelope through the +// actual length-prefixed framing layer. +func TestOrigin_FrameRoundTrip(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + in := &Envelope{ + Type: EnvReq, + ReqID: "r3", + Method: "ping", + Origin: &Origin{Node: "1:0001.0000.0007", NodeID: 7, Authenticated: true}, + } + if err := WriteFrame(&buf, in); err != nil { + t.Fatalf("write frame: %v", err) + } + out, err := ReadFrame(&buf) + if err != nil { + t.Fatalf("read frame: %v", err) + } + if out.Origin == nil || *out.Origin != *in.Origin { + t.Errorf("origin = %+v, want %+v", out.Origin, in.Origin) + } +} diff --git a/pkg/manifest/validate.go b/pkg/manifest/validate.go index 9156bc3..8538923 100644 --- a/pkg/manifest/validate.go +++ b/pkg/manifest/validate.go @@ -25,6 +25,10 @@ var KnownCaps = map[string]bool{ // declared, install-consented capability the app enforces itself (it execs // the child directly), not a per-call brokered one. See procExecTargetPattern. "proc.exec": true, + // identity.verify: declarative cap for apps that call the daemon's + // envelope-verification IPC (CmdVerifyEnvelope) to check a remote + // peer's signed envelope out-of-band. See pkg/appkit. + "identity.verify": true, } // procExecTargetPattern constrains a proc.exec target to a single executable: diff --git a/pkg/manifest/zz2_validate_test.go b/pkg/manifest/zz2_validate_test.go index 44aee2a..d501ff8 100644 --- a/pkg/manifest/zz2_validate_test.go +++ b/pkg/manifest/zz2_validate_test.go @@ -207,6 +207,18 @@ func TestValidate_GrantEmptyTarget(t *testing.T) { } } +// TestValidate_IdentityVerifyCapKnown confirms identity.verify is in the +// capability vocabulary — apps calling the daemon's envelope-verification +// IPC declare it, and manifests carrying it must validate. +func TestValidate_IdentityVerifyCapKnown(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants = append(m.Grants, Grant{Cap: "identity.verify", Target: "*"}) + if errs := m.Validate(); len(errs) != 0 { + t.Errorf("identity.verify grant should validate, got: %v", errs) + } +} + // TestMarshal_PreservesStructuralFields confirms a roundtrip produces // valid JSON containing the input fields. Doubles as smoke for // Marshal(). diff --git a/plugin/appstore/origin_test.go b/plugin/appstore/origin_test.go new file mode 100644 index 0000000..b5518f3 --- /dev/null +++ b/plugin/appstore/origin_test.go @@ -0,0 +1,161 @@ +package appstore + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// originCapture is a handler that records the Origin block of every +// request it serves. Safe for the concurrent Serve goroutines that +// startAppSocket spins up. +type originCapture struct { + mu sync.Mutex + seen []*ipc.Origin +} + +func (c *originCapture) handler(_ context.Context, req *ipc.Envelope) (json.RawMessage, error) { + c.mu.Lock() + c.seen = append(c.seen, req.Origin) + c.mu.Unlock() + return json.RawMessage(`"ok"`), nil +} + +func (c *originCapture) last(t *testing.T) *ipc.Origin { + t.Helper() + c.mu.Lock() + defer c.mu.Unlock() + if len(c.seen) == 0 { + t.Fatal("handler never ran") + } + return c.seen[len(c.seen)-1] +} + +// installLiveApp registers id as installed+ready against a real listening +// app.sock whose method dispatches into capture. +func installLiveApp(t *testing.T, sup *supervisor, id, method string, capture *originCapture) { + t.Helper() + dir := t.TempDir() + appDir := filepath.Join(dir, id) + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + socketPath := shortSocketPath(t, "app.sock") + cleanup := startAppSocket(t, socketPath, method, capture.handler) + t.Cleanup(cleanup) + + m := parseDummyManifest(t, id) + m.Exposes = []string{method} + sup.mu.Lock() + sup.installed[id] = &installedApp{Dir: appDir, SocketPath: socketPath, Manifest: m} + sup.ready[id] = true + sup.mu.Unlock() +} + +// TestCallWithOrigin_DeliversOrigin drives the trusted bridge path end to +// end: supervisor.CallWithOrigin must stamp the daemon-attested origin on +// the request envelope the app's handler sees. +func TestCallWithOrigin_DeliversOrigin(t *testing.T) { + t.Parallel() + sup := newSupervisor(Config{}, Deps{}, newQuietLogger(t)) + capture := &originCapture{} + installLiveApp(t, sup, "io.origin.target", "whoami", capture) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + origin := &ipc.Origin{Node: "1:0001.ABCD.1234", NodeID: 42, Authenticated: true, Trusted: true} + var out string + if err := sup.CallWithOrigin(ctx, "io.origin.target", "whoami", nil, &out, origin); err != nil { + t.Fatalf("CallWithOrigin: %v", err) + } + got := capture.last(t) + if got == nil { + t.Fatal("handler saw nil origin, want attested block") + } + if *got != *origin { + t.Errorf("origin = %+v, want %+v", *got, *origin) + } +} + +// TestCallWithOrigin_GatesStillApply confirms the trusted-origin path is +// not a gate bypass: the broker-surface (exposes) check runs before any +// socket is dialed. +func TestCallWithOrigin_GatesStillApply(t *testing.T) { + t.Parallel() + sup := newSupervisor(Config{}, Deps{}, newQuietLogger(t)) + capture := &originCapture{} + installLiveApp(t, sup, "io.origin.gated", "whoami", capture) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + origin := &ipc.Origin{Node: "1:0001.0000.0001", NodeID: 1, Authenticated: true} + err := sup.CallWithOrigin(ctx, "io.origin.gated", "secret", nil, nil, origin) + if !errors.Is(err, ErrMethodNotExposed) { + t.Fatalf("err = %v, want ErrMethodNotExposed", err) + } + err = sup.CallWithOrigin(ctx, "io.missing", "whoami", nil, nil, origin) + if !errors.Is(err, ErrAppNotInstalled) { + t.Fatalf("err = %v, want ErrAppNotInstalled", err) + } +} + +// TestCallFrom_StripsOrigin is the anti-forgery seam: even if an origin +// reaches callFrom alongside a non-empty callerID (a cross-app call), the +// target app must see Origin == nil. Apps cannot launder a forged origin +// through the broker. +func TestCallFrom_StripsOrigin(t *testing.T) { + t.Parallel() + sup := newSupervisor(Config{}, Deps{}, newQuietLogger(t)) + capture := &originCapture{} + installLiveApp(t, sup, "io.origin.victim", "whoami", capture) + installCaller(sup, "io.origin.attacker", "io.origin.victim.whoami") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + forged := &ipc.Origin{Node: "1:0001.DEAD.BEEF", NodeID: 99, Authenticated: true, Trusted: true} + var out string + if err := sup.callFrom(ctx, "io.origin.attacker", "io.origin.victim", "whoami", nil, &out, forged); err != nil { + t.Fatalf("callFrom: %v", err) + } + if got := capture.last(t); got != nil { + t.Errorf("cross-app call delivered origin %+v, want nil", *got) + } +} + +// TestCallAndCallFrom_NoOriginByDefault confirms the plain Call path +// keeps delivering origin-less envelopes. +func TestCallAndCallFrom_NoOriginByDefault(t *testing.T) { + t.Parallel() + sup := newSupervisor(Config{}, Deps{}, newQuietLogger(t)) + capture := &originCapture{} + installLiveApp(t, sup, "io.origin.plain", "whoami", capture) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var out string + if err := sup.Call(ctx, "io.origin.plain", "whoami", nil, &out); err != nil { + t.Fatalf("Call: %v", err) + } + if got := capture.last(t); got != nil { + t.Errorf("Call delivered origin %+v, want nil", *got) + } +} + +// TestService_CallWithOrigin_NotStarted mirrors the existing +// not-started guard tests for the new public entry point. +func TestService_CallWithOrigin_NotStarted(t *testing.T) { + t.Parallel() + s := &Service{} + err := s.CallWithOrigin(context.Background(), "io.test", "method", nil, nil, + &ipc.Origin{Node: "1:0001.0000.0001", Authenticated: true}) + if err == nil { + t.Error("expected 'service not started' error") + } +} diff --git a/plugin/appstore/service.go b/plugin/appstore/service.go index 9195ef3..62cf3bf 100644 --- a/plugin/appstore/service.go +++ b/plugin/appstore/service.go @@ -19,6 +19,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" ) // Config carries the integration-time settings. Passed by the daemon's @@ -279,3 +281,22 @@ func (s *Service) CallFrom(ctx context.Context, callerID, appID, method string, } return sup.CallFrom(ctx, callerID, appID, method, args, out) } + +// CallWithOrigin is the trusted daemon-bridge entry point: Call with a +// daemon-attested ipc.Origin stamped on the request envelope, so the app's +// handler can see which remote Pilot node the request originated from and +// whether that identity was proven during the key exchange. Empty-callerID +// semantics apply (no cross-app grant gate). +// +// Only the daemon should call this — origin is exactly as trustworthy as +// the caller. Cross-app calls (CallFrom) always deliver a nil origin, so +// apps cannot forge one through the broker. +func (s *Service) CallWithOrigin(ctx context.Context, appID, method string, args, out any, origin *ipc.Origin) error { + s.startMu.Lock() + sup := s.sup + s.startMu.Unlock() + if sup == nil { + return errors.New("appstore: service not started") + } + return sup.CallWithOrigin(ctx, appID, method, args, out, origin) +} diff --git a/plugin/appstore/supervisor.go b/plugin/appstore/supervisor.go index 9fcc9a4..0d0c562 100644 --- a/plugin/appstore/supervisor.go +++ b/plugin/appstore/supervisor.go @@ -1031,7 +1031,17 @@ func (s *supervisor) Get(appID string) *installedApp { // CallFrom with an empty callerID — the broker-surface (exposes) gate // still applies, but no cross-app ipc.call grant is required. func (s *supervisor) Call(ctx context.Context, appID, method string, args, out any) error { - return s.callFrom(ctx, "", appID, method, args, out) + return s.callFrom(ctx, "", appID, method, args, out, nil) +} + +// CallWithOrigin is Call with a daemon-attested Origin stamped on the +// request envelope delivered to the app. It is the trusted bridge path: +// the daemon proved the remote peer's identity during the authenticated +// key exchange and vouches for it here. Empty-callerID semantics apply +// (no cross-app grant gate). Apps never reach this path — cross-app +// calls go through CallFrom, which always strips origin. +func (s *supervisor) CallWithOrigin(ctx context.Context, appID, method string, args, out any, origin *ipc.Origin) error { + return s.callFrom(ctx, "", appID, method, args, out, origin) } // CallFrom dispatches method+args into the named installed app on behalf @@ -1046,7 +1056,7 @@ func (s *supervisor) Call(ctx context.Context, appID, method string, args, out a // 3. cross-app only: caller must be installed and hold an `ipc.call` // grant matching "." → ErrGrantMissing func (s *supervisor) CallFrom(ctx context.Context, callerID, appID, method string, args, out any) error { - return s.callFrom(ctx, callerID, appID, method, args, out) + return s.callFrom(ctx, callerID, appID, method, args, out, nil) } // callFrom is the shared implementation behind Call and CallFrom. The @@ -1056,7 +1066,15 @@ func (s *supervisor) CallFrom(ctx context.Context, callerID, appID, method strin // // After every call (success or failure) a telemetry usage event is // emitted — best-effort, never blocks the caller. -func (s *supervisor) callFrom(ctx context.Context, callerID, appID, method string, args, out any) error { +// +// origin is the daemon-attested remote-node identity to stamp on the +// request envelope — non-nil only on the CallWithOrigin path. Anti-forgery: +// a cross-app call (non-empty callerID) NEVER carries an origin, so an app +// cannot launder a forged origin through the broker to another app. +func (s *supervisor) callFrom(ctx context.Context, callerID, appID, method string, args, out any, origin *ipc.Origin) error { + if callerID != "" { + origin = nil + } s.mu.RLock() app, ok := s.installed[appID] caller, callerOK := s.installed[callerID] @@ -1115,7 +1133,7 @@ func (s *supervisor) callFrom(ctx context.Context, callerID, appID, method strin } start := time.Now() - err = ipc.Call(conn, method, args, out) + err = ipc.CallWithOrigin(conn, method, args, out, origin) dur := time.Since(start).Milliseconds() if err != nil { s.emitUsage(callerID, appID, method, false, dur, err.Error())