217 lines
8.0 KiB
Go
217 lines
8.0 KiB
Go
// Package agent is the control loop: pull an envelope, prove it came from a paired phone, decrypt
|
|
// it, decide, execute, and answer.
|
|
//
|
|
// The ordering in Handle is the security property and is asserted by tests rather than described in
|
|
// comments alone: nothing that depends on the payload happens before the signature verifies, and
|
|
// nothing that touches SimAdmin happens before the policy says yes.
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"fun.chick/msa-agent/internal/policy"
|
|
"fun.chick/msa-agent/internal/runtime"
|
|
"fun.chick/msa-agent/internal/wire"
|
|
)
|
|
|
|
// Keys is the agent's own key material, loaded once at startup.
|
|
type Keys struct {
|
|
// Sign signs responses and the handshake challenge. The phone verifies responses against its
|
|
// copy of the public half, so a relay cannot forge an answer.
|
|
Sign ed25519.PrivateKey
|
|
// Kx is the raw 32-byte X25519 private key the phone encrypts requests to. It is raw on
|
|
// purpose: clamping is the curve library's job, and a stored key that has already been clamped
|
|
// would be a second, non-standard encoding to keep in sync across three languages.
|
|
Kx []byte
|
|
}
|
|
|
|
// PublicKx derives the X25519 public key to publish at pairing time.
|
|
//
|
|
// Called rather than stored, so the published value cannot disagree with the private key it was
|
|
// derived from. A mismatched pair would fail as an unreadable request with no useful cause.
|
|
func (k Keys) PublicKx() ([]byte, error) { return wire.PublicFrom(k.Kx) }
|
|
|
|
// Devices answers "is this phone allowed to command this node".
|
|
//
|
|
// It is an interface because the first implementation is a static list from the config file and the
|
|
// intended one is a snapshot pulled from the relay, and the loop must not change when that arrives.
|
|
type Devices interface {
|
|
// SigningKey returns the Ed25519 public key for app, or nil if it is not paired.
|
|
SigningKey(app string) ed25519.PublicKey
|
|
// Len reports how many devices are trusted. Logged at startup and on reload.
|
|
Len() int
|
|
}
|
|
|
|
// StaticDevices is a paired-device list fixed at startup.
|
|
type StaticDevices map[string]ed25519.PublicKey
|
|
|
|
func (d StaticDevices) SigningKey(app string) ed25519.PublicKey { return d[app] }
|
|
func (d StaticDevices) Len() int { return len(d) }
|
|
|
|
// Error classes the agent reports to the phone, inside the encrypted response so the relay cannot
|
|
// read or rewrite them.
|
|
const (
|
|
OutcomeBadEnvelope = "bad_envelope"
|
|
OutcomeBadSig = "bad_signature"
|
|
OutcomeNoDevice = "unknown_device"
|
|
OutcomeExpired = "expired"
|
|
OutcomeReplay = "replay"
|
|
OutcomePolicy = "policy_denied"
|
|
OutcomeNoConfirm = "confirmation_missing"
|
|
OutcomeBadConfirm = "confirmation_mismatch"
|
|
OutcomeRateLimited = "rate_limited"
|
|
OutcomeTooLarge = "payload_too_large"
|
|
OutcomeUpstream = "upstream_error"
|
|
)
|
|
|
|
// Agent owns the loop over one node.
|
|
type Agent struct {
|
|
nodeID string
|
|
keys Keys
|
|
devices Devices
|
|
pol *policy.Policy
|
|
exec *runtime.Executor
|
|
now func() time.Time
|
|
}
|
|
|
|
// New wires the pieces. It returns an error rather than starting degraded: an agent that runs with
|
|
// no policy or no devices would accept nothing and look healthy while doing so.
|
|
func New(nodeID string, keys Keys, devices Devices, pol *policy.Policy, exec *runtime.Executor) (*Agent, error) {
|
|
if nodeID == "" {
|
|
return nil, errors.New("agent: node id is empty")
|
|
}
|
|
if len(keys.Sign) != ed25519.PrivateKeySize {
|
|
return nil, errors.New("agent: signing key is the wrong size")
|
|
}
|
|
if len(keys.Kx) != wire.KeyBytes {
|
|
return nil, fmt.Errorf("agent: x25519 key must be %d bytes", wire.KeyBytes)
|
|
}
|
|
if devices == nil || devices.Len() == 0 {
|
|
return nil, errors.New("agent: no paired devices, so nothing could ever be authorised")
|
|
}
|
|
if pol == nil || exec == nil {
|
|
return nil, errors.New("agent: policy and executor are required")
|
|
}
|
|
return &Agent{nodeID: nodeID, keys: keys, devices: devices, pol: pol, exec: exec, now: time.Now}, nil
|
|
}
|
|
|
|
// Handle processes one request envelope and returns the response envelope ready to post back.
|
|
//
|
|
// It never returns an error for a bad request. A request that fails any check is answered with a
|
|
// refusal inside the encrypted response, for two reasons: the relay must not be able to read why a
|
|
// command failed, and the phone must be able to tell the user what to do about it. A transport-level
|
|
// error would put that explanation in the relay's hands instead.
|
|
func (a *Agent) Handle(ctx context.Context, raw []byte) (json.RawMessage, error) {
|
|
var envelope wire.Envelope
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
return nil, fmt.Errorf("agent: envelope: %w", err)
|
|
}
|
|
// The node is checked before the signature, and this is the one exception to "verify first": an
|
|
// envelope addressed to another node is not for us at all, and answering it would leak that this
|
|
// agent is alive and knows the difference.
|
|
if envelope.Node != a.nodeID {
|
|
return nil, fmt.Errorf("agent: envelope is addressed to %q, not %q", envelope.Node, a.nodeID)
|
|
}
|
|
|
|
device := a.devices.SigningKey(envelope.App)
|
|
if device == nil {
|
|
return a.answer(envelope, OutcomeNoDevice, 403, nil)
|
|
}
|
|
if err := wire.VerifyRequest(envelope, device); err != nil {
|
|
return a.answer(envelope, OutcomeBadSig, 401, nil)
|
|
}
|
|
// Only now is the payload touched. Decrypting before verifying would let an unauthenticated
|
|
// caller choose the bytes the AEAD processes, which is exactly what an unauthenticated AEAD
|
|
// oracle is.
|
|
shared, err := wire.SharedSecret(a.keys.Kx, envelope.EK)
|
|
if err != nil {
|
|
return a.answer(envelope, OutcomeBadEnvelope, 400, nil)
|
|
}
|
|
key, iv, err := wire.DeriveKey(shared, envelope.Node, envelope.Req, false)
|
|
if err != nil {
|
|
return a.answer(envelope, OutcomeBadEnvelope, 400, nil)
|
|
}
|
|
opened, err := wire.Open(key, iv, envelope.P)
|
|
if err != nil {
|
|
return a.answer(envelope, OutcomeBadEnvelope, 400, nil)
|
|
}
|
|
var inner wire.InnerRequest
|
|
if err := json.Unmarshal(opened, &inner); err != nil {
|
|
return a.answer(envelope, OutcomeBadEnvelope, 400, nil)
|
|
}
|
|
|
|
result, refusal := a.exec.Execute(ctx, envelope, inner)
|
|
if refusal != nil {
|
|
return a.answer(envelope, refusal.Class, statusFor(refusal.Class), nil)
|
|
}
|
|
return a.answer(envelope, "ok", result.Status, result.Body)
|
|
}
|
|
|
|
// answer encrypts, signs and serialises the response.
|
|
//
|
|
// The ephemeral key pair is generated per response. The request direction is ephemeral-static, so
|
|
// generating a fresh one here is what keeps the two directions from sharing a key.
|
|
func (a *Agent) answer(request wire.Envelope, outcome string, status int, body []byte) (json.RawMessage, error) {
|
|
ephemeral, err := wire.NewEphemeral()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The response is encrypted to the phone's per-request ephemeral public key, the one that
|
|
// arrived in ek and is covered by the signature just verified. That keeps this direction
|
|
// ephemeral-ephemeral rather than falling back to a key the phone published once at pairing
|
|
// time, and it needs no extra round trip: ek is right there in the request.
|
|
shared, err := ephemeral.Exchange(request.EK)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, iv, err := wire.DeriveKey(shared, request.Node, request.Req, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inner, err := wire.MarshalInner(wire.InnerResponse{Status: status, Body: body, Outcome: outcome})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sealed, err := wire.Seal(key, iv, inner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
response := wire.Envelope{
|
|
V: wire.Version,
|
|
Node: request.Node,
|
|
Req: request.Req,
|
|
TS: uint64(a.now().Unix()),
|
|
EK: ephemeral.Public(),
|
|
Alg: wire.Suite,
|
|
P: sealed,
|
|
}
|
|
if err := wire.SignResponse(&response, a.keys.Sign); err != nil {
|
|
return nil, err
|
|
}
|
|
encoded, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.RawMessage(encoded), nil
|
|
}
|
|
|
|
func statusFor(class string) int {
|
|
switch class {
|
|
case runtime.ClassExpired, runtime.ClassReplay:
|
|
return 408
|
|
case runtime.ClassRateLimited:
|
|
return 429
|
|
case runtime.ClassTooLarge:
|
|
return 413
|
|
case runtime.ClassUpstream:
|
|
return 502
|
|
default:
|
|
return 403
|
|
}
|
|
}
|