Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions pkg/appkit/appkit.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
198 changes: 198 additions & 0 deletions pkg/appkit/appkit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading