Files
msa-agent/internal/wire/wire.go
T

541 lines
18 KiB
Go

// Package wire implements the relay envelope: what is signed, what is encrypted, and in what order.
//
// The relay is not trusted, so signing and encryption are separate concerns on purpose. Ed25519
// signs a canonical byte encoding of the envelope's routing fields plus the ciphertext, which is
// what stops a compromised relay from retargeting a request at a different node, replaying it under
// a new id, or substituting ek. AES-256-GCM then carries the payload under keys from an
// ephemeral-static X25519 exchange.
//
// The signature is over a canonical encoding rather than over JSON text. Three languages have to
// agree on the exact bytes here, and "sign the JSON" is where cross-language signature bugs live:
// key order, number formatting, and Unicode escaping all differ between Swift, Node and Go.
package wire
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/ed25519"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strings"
)
// Version is envelope.v. A change here is a new constant, never a widened tolerance, so an older
// agent cannot be talked into accepting a format it does not fully understand.
const Version uint64 = 1
// Suite is envelope.alg. Exactly one suite exists. An unknown value is a hard error rather than a
// negotiation, because negotiation is where downgrade bugs come from.
const Suite = "X25519-ED25519-AES256GCM-HKDFSHA256"
// Domain separation. A request signature must never be replayable as a response signature, and a
// signature from this protocol must never be replayable in another protocol that reuses the key.
const (
DomainRequest = "msa-relay-v1-req"
DomainResponse = "msa-relay-v1-resp"
infoKeyRequest = "msa-relay-v1-req"
infoKeyResponse = "msa-relay-v1-resp"
infoIVRequest = "iv-req"
infoIVResponse = "iv-resp"
hkdfSaltLabel = "msa-relay-v1"
)
const (
KeyBytes = 32
PublicBytes = 32
IVBytes = 12
TagBytes = 16
)
var (
ErrSignature = errors.New("wire: signature does not verify")
ErrVersion = errors.New("wire: unsupported envelope version")
ErrSuite = errors.New("wire: unsupported cipher suite")
ErrBadKey = errors.New("wire: key must be 32 bytes")
ErrBadIV = errors.New("wire: iv must be 12 bytes")
ErrBadPublicKey = errors.New("wire: public key must be 32 bytes")
ErrOpen = errors.New("wire: ciphertext failed to authenticate")
ErrIdentityField = errors.New("wire: envelope is missing node, request id or suite")
ErrPeerKey = errors.New("wire: peer public key is unusable")
ErrDomain = errors.New("wire: unknown signing domain")
ErrIntent = errors.New("wire: confirmation intent does not match the request")
)
// Envelope is the outer, relay-visible structure. The relay routes on Node and correlates on Req,
// and can read nothing else: Ek is a public key, P is ciphertext, S covers everything.
type Envelope struct {
V uint64 `json:"v"`
Node string `json:"node"`
Req string `json:"req"`
App string `json:"app"`
TS uint64 `json:"ts"`
EK []byte `json:"ek"`
Alg string `json:"alg"`
P []byte `json:"p"`
S []byte `json:"s,omitempty"`
}
// MarshalJSON encodes the byte fields as base64, matching the schema the relay validates against.
func (e Envelope) MarshalJSON() ([]byte, error) {
type alias struct {
V uint64 `json:"v"`
Node string `json:"node"`
Req string `json:"req"`
App string `json:"app,omitempty"`
TS uint64 `json:"ts"`
EK string `json:"ek"`
Alg string `json:"alg"`
P string `json:"p"`
S string `json:"s,omitempty"`
}
raw := alias{
V: e.V, Node: e.Node, Req: e.Req, App: e.App, TS: e.TS, Alg: e.Alg,
EK: base64.StdEncoding.EncodeToString(e.EK),
P: base64.StdEncoding.EncodeToString(e.P),
}
if len(e.S) > 0 {
raw.S = base64.StdEncoding.EncodeToString(e.S)
}
return marshal(raw)
}
// UnmarshalJSON decodes base64 with strict padding, because Go's default decoder silently accepts
// some non-canonical forms and a signature must be computed over exactly the bytes the sender used.
func (e *Envelope) UnmarshalJSON(raw []byte) error {
type alias struct {
V uint64 `json:"v"`
Node string `json:"node"`
Req string `json:"req"`
App string `json:"app"`
TS uint64 `json:"ts"`
EK string `json:"ek"`
Alg string `json:"alg"`
P string `json:"p"`
S string `json:"s"`
}
var decoded alias
if err := unmarshal(raw, &decoded); err != nil {
return err
}
var err error
if e.EK, err = decodeBase64(decoded.EK); err != nil {
return fmt.Errorf("wire: ek: %w", err)
}
if e.P, err = decodeBase64(decoded.P); err != nil {
return fmt.Errorf("wire: p: %w", err)
}
if e.S, err = decodeBase64(decoded.S); err != nil {
return fmt.Errorf("wire: s: %w", err)
}
e.V, e.Node, e.Req, e.App, e.TS, e.Alg = decoded.V, decoded.Node, decoded.Req, decoded.App, decoded.TS, decoded.Alg
return nil
}
func decodeBase64(value string) ([]byte, error) {
if value == "" {
return nil, nil
}
raw, err := base64.StdEncoding.Strict().DecodeString(value)
if err != nil {
return nil, err
}
return raw, nil
}
func encodeBase64(value []byte) string {
if len(value) == 0 {
return ""
}
return base64.StdEncoding.EncodeToString(value)
}
// InnerRequest is the decrypted request payload. There is no separate nonce: Req is a uuid4, it is
// covered by the signature, and it is recorded in the replay cache, so reusing it requires forging
// a signature.
type InnerRequest struct {
Method string `json:"m"`
Path string `json:"p"`
// Intent is sha256 over the method, path and canonical body. It is produced by the phone as the
// result of a user gesture. The agent recomputes it and refuses a dangerous write that does not
// match, so a relay that fabricates a request cannot manufacture the user's consent.
Intent []byte `json:"intent,omitempty"`
Body []byte `json:"body,omitempty"`
}
// MarshalJSON encodes the byte fields as base64, the same convention as the outer envelope.
//
// Go would base64 a []byte on its own, but the decode side still has to be written, and keeping both
// directions together is what stops one of them from being forgotten when a field is added.
func (r InnerRequest) MarshalJSON() ([]byte, error) {
type alias struct {
Method string `json:"m"`
Path string `json:"p"`
Intent string `json:"intent,omitempty"`
Body string `json:"body,omitempty"`
}
return marshal(alias{
Method: r.Method, Path: r.Path,
Intent: encodeBase64(r.Intent), Body: encodeBase64(r.Body),
})
}
func (r *InnerRequest) UnmarshalJSON(raw []byte) error {
type alias struct {
Method string `json:"m"`
Path string `json:"p"`
Intent string `json:"intent,omitempty"`
Body string `json:"body,omitempty"`
}
var decoded alias
if err := unmarshal(raw, &decoded); err != nil {
return err
}
var err error
if r.Intent, err = decodeBase64(decoded.Intent); err != nil {
return fmt.Errorf("wire: intent: %w", err)
}
if r.Body, err = decodeBase64(decoded.Body); err != nil {
return fmt.Errorf("wire: body: %w", err)
}
r.Method, r.Path = decoded.Method, decoded.Path
return nil
}
// InnerResponse is the decrypted response payload. Status lives inside the ciphertext so the relay
// cannot rewrite a failure into a success.
type InnerResponse struct {
Status int `json:"status"`
// Outcome is the agent's own classification of what happened. It is inside the ciphertext for
// the same reason as Status: the relay is not entitled to know why a command failed, and the
// phone needs to tell the user what to do about it.
Outcome string `json:"outcome,omitempty"`
Body []byte `json:"body,omitempty"`
}
// MarshalJSON encodes the body as base64, matching InnerRequest.
func (r InnerResponse) MarshalJSON() ([]byte, error) {
type alias struct {
Status int `json:"status"`
Outcome string `json:"outcome,omitempty"`
Body string `json:"body,omitempty"`
}
return marshal(alias{Status: r.Status, Outcome: r.Outcome, Body: encodeBase64(r.Body)})
}
func (r *InnerResponse) UnmarshalJSON(raw []byte) error {
type alias struct {
Status int `json:"status"`
Outcome string `json:"outcome,omitempty"`
Body string `json:"body,omitempty"`
}
var decoded alias
if err := unmarshal(raw, &decoded); err != nil {
return err
}
body, err := decodeBase64(decoded.Body)
if err != nil {
return fmt.Errorf("wire: response body: %w", err)
}
r.Status, r.Outcome, r.Body = decoded.Status, decoded.Outcome, body
return nil
}
// MarshalInner renders an InnerResponse. It exists as a named function rather than a bare
// json.Marshal call so every caller gets the same encoder, which here means the same treatment of
// HTML characters as the rest of the protocol.
func MarshalInner(inner InnerResponse) ([]byte, error) { return marshal(inner) }
// UnmarshalInner decodes an InnerRequest, rejecting trailing data.
func UnmarshalInner(raw []byte) (InnerRequest, error) {
var inner InnerRequest
if err := unmarshal(raw, &inner); err != nil {
return InnerRequest{}, err
}
return inner, nil
}
// CanonicalRequest is the exact byte string K_app_sign signs and the agent verifies.
//
// Layout: domain bytes, one NUL, then for each field in fixed order a uint32be length followed by
// that many bytes. v and ts contribute their uint64be encoding, so their length is always 8 and
// there is no decimal formatting to disagree about. ek and p contribute raw decoded bytes, never
// their base64 text, so a relay cannot re-encode them differently.
func CanonicalRequest(e Envelope) ([]byte, error) {
return canonical(e, DomainRequest)
}
// CanonicalResponse is the counterpart for agent-to-app replies. It covers the same routing fields
// so the relay cannot swap responses between in-flight requests for the same node.
func CanonicalResponse(e Envelope) ([]byte, error) {
return canonical(e, DomainResponse)
}
func canonical(e Envelope, domain string) ([]byte, error) {
if domain != DomainRequest && domain != DomainResponse {
return nil, fmt.Errorf("%w: %q", ErrDomain, domain)
}
if strings.TrimSpace(e.Node) == "" || strings.TrimSpace(e.Req) == "" || strings.TrimSpace(e.Alg) == "" {
return nil, ErrIdentityField
}
if e.Alg != Suite {
return nil, fmt.Errorf("%w: %q", ErrSuite, e.Alg)
}
if len(e.EK) != PublicBytes {
return nil, fmt.Errorf("%w: got %d bytes", ErrBadPublicKey, len(e.EK))
}
buf := bytes.NewBuffer(make([]byte, 0, 256))
buf.WriteString(domain)
buf.WriteByte(0)
writeUint64(buf, e.V)
writeBytes(buf, []byte(e.Node))
writeBytes(buf, []byte(e.Req))
// The app id is only meaningful on requests: it lets a node operator attribute traffic to a
// paired device, and it is signed so a relay cannot relabel one phone's request as another's.
if domain == DomainRequest {
writeBytes(buf, []byte(e.App))
}
writeUint64(buf, e.TS)
writeBytes(buf, e.EK)
writeBytes(buf, e.P)
return buf.Bytes(), nil
}
func writeUint64(buf *bytes.Buffer, value uint64) {
var raw [8]byte
binary.BigEndian.PutUint64(raw[:], value)
writeBytes(buf, raw[:])
}
func writeBytes(buf *bytes.Buffer, value []byte) {
var length [4]byte
binary.BigEndian.PutUint32(length[:], uint32(len(value)))
buf.Write(length[:])
buf.Write(value)
}
// SignRequest attaches K_app_sign's signature over the canonical encoding.
func SignRequest(envelope *Envelope, private ed25519.PrivateKey) error {
if len(private) != ed25519.PrivateKeySize {
return errors.New("wire: private signing key has the wrong size")
}
canonical, err := CanonicalRequest(*envelope)
if err != nil {
return err
}
envelope.S = ed25519.Sign(private, canonical)
return nil
}
// SignResponse is SignRequest for the agent's own signing key.
func SignResponse(envelope *Envelope, private ed25519.PrivateKey) error {
if len(private) != ed25519.PrivateKeySize {
return errors.New("wire: private signing key has the wrong size")
}
canonical, err := CanonicalResponse(*envelope)
if err != nil {
return err
}
envelope.S = ed25519.Sign(private, canonical)
return nil
}
// VerifyRequest is what the agent calls before doing anything else with a request. Version and
// suite are checked here rather than at the caller so no code path can verify a signature and then
// act on a format it did not intend to accept.
func VerifyRequest(envelope Envelope, peer ed25519.PublicKey) error {
if envelope.V != Version {
return fmt.Errorf("%w: %d", ErrVersion, envelope.V)
}
if envelope.Alg != Suite {
return fmt.Errorf("%w: %q", ErrSuite, envelope.Alg)
}
if len(peer) != ed25519.PublicKeySize {
return fmt.Errorf("%w: got %d bytes", ErrBadPublicKey, len(peer))
}
canonical, err := CanonicalRequest(envelope)
if err != nil {
return err
}
if len(envelope.S) != ed25519.SignatureSize || !ed25519.Verify(peer, canonical, envelope.S) {
return ErrSignature
}
return nil
}
// VerifyResponse is the phone's side of the same check.
func VerifyResponse(envelope Envelope, peer ed25519.PublicKey) error {
if envelope.V != Version {
return fmt.Errorf("%w: %d", ErrVersion, envelope.V)
}
if envelope.Alg != Suite {
return fmt.Errorf("%w: %q", ErrSuite, envelope.Alg)
}
if len(peer) != ed25519.PublicKeySize {
return fmt.Errorf("%w: got %d bytes", ErrBadPublicKey, len(peer))
}
canonical, err := CanonicalResponse(envelope)
if err != nil {
return err
}
if len(envelope.S) != ed25519.SignatureSize || !ed25519.Verify(peer, canonical, envelope.S) {
return ErrSignature
}
return nil
}
// HKDF-SHA256, extract then expand. Implemented here rather than pulled in as a dependency: the
// agent must build with nothing but the standard library so an operator can cross-compile it for an
// ARM node from any machine, and the surface is thirty lines.
func HKDF(secret, salt, info []byte, length int) ([]byte, error) {
if length <= 0 || length > 255*sha256.Size {
return nil, fmt.Errorf("wire: hkdf length %d out of range", length)
}
extractor := hmac.New(sha256.New, salt)
extractor.Write(secret)
prk := extractor.Sum(nil)
out := make([]byte, 0, length)
var block []byte
for counter := byte(1); len(out) < length; counter++ {
mac := hmac.New(sha256.New, prk)
mac.Write(block)
mac.Write(info)
mac.Write([]byte{counter})
block = mac.Sum(nil)
out = append(out, block...)
}
return out[:length], nil
}
// SharedSecret performs X25519. crypto/ecdh rejects the all-zero output, which is what catches a
// peer key chosen to force a known shared secret.
func SharedSecret(privateKey, peerPublic []byte) ([]byte, error) {
if len(privateKey) != KeyBytes {
return nil, fmt.Errorf("%w: got %d bytes", ErrBadKey, len(privateKey))
}
if len(peerPublic) != PublicBytes {
return nil, fmt.Errorf("%w: got %d bytes", ErrBadPublicKey, len(peerPublic))
}
curve := ecdh.X25519()
private, err := curve.NewPrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("wire: private key rejected: %w", err)
}
peer, err := curve.NewPublicKey(peerPublic)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrPeerKey, err)
}
shared, err := private.ECDH(peer)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrPeerKey, err)
}
return shared, nil
}
// DeriveKey returns the AEAD key and IV for one direction of one request.
//
// The salt is the node id behind a fixed label, so a shared secret that somehow repeats across
// nodes still yields different keys. The IV is derived from the request id and the direction rather
// than being a fixed zero block: a fixed IV is only safe while every message uses a fresh key, and
// that invariant is exactly what a later "reuse the session key for a batch" optimisation would
// quietly break. Separating the IV by direction as well means a request and a response that somehow
// share a secret still cannot share a nonce.
func DeriveKey(shared []byte, nodeID, requestID string, response bool) (key, iv []byte, err error) {
if len(shared) != KeyBytes {
return nil, nil, fmt.Errorf("%w: got %d bytes", ErrBadKey, len(shared))
}
keyInfo := infoKeyRequest
ivInfo := infoIVRequest
if response {
keyInfo = infoKeyResponse
ivInfo = infoIVResponse
}
// The request id participates in the key as well as the IV. A shared secret is nominally used
// once, but binding the key to the request keeps that true even if a caller ever reuses one.
salt := []byte(hkdfSaltLabel + ":" + nodeID + ":" + requestID)
key, err = HKDF(shared, salt, []byte(keyInfo), KeyBytes)
if err != nil {
return nil, nil, err
}
iv, err = HKDF(shared, []byte(requestID), []byte(ivInfo), IVBytes)
if err != nil {
return nil, nil, err
}
return key, iv, nil
}
// Seal encrypts with AES-256-GCM. The tag is appended to the ciphertext, matching Node's
// cipher.getAuthTag() concatenation and CryptoKit's combined sealed box.
func Seal(key, iv, plaintext []byte) ([]byte, error) {
gcm, err := aead(key, iv)
if err != nil {
return nil, err
}
return gcm.Seal(nil, iv, plaintext, nil), nil
}
// Open decrypts and authenticates. A failure is terminal for the request, not a retry: a wrong key,
// a torn payload and a forged payload are indistinguishable here, and all three mean do not act.
func Open(key, iv, ciphertext []byte) ([]byte, error) {
gcm, err := aead(key, iv)
if err != nil {
return nil, err
}
if len(ciphertext) < TagBytes {
return nil, ErrOpen
}
plain, err := gcm.Open(nil, iv, ciphertext, nil)
if err != nil {
return nil, ErrOpen
}
return plain, nil
}
func aead(key, iv []byte) (cipher.AEAD, error) {
if len(key) != KeyBytes {
return nil, fmt.Errorf("%w: got %d bytes", ErrBadKey, len(key))
}
if len(iv) != IVBytes {
return nil, fmt.Errorf("%w: got %d bytes", ErrBadIV, len(iv))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// IntentDigest is sha256(method | path | canonical body), the value a phone sends to prove that a
// human confirmed this exact write.
//
// The body is canonicalised the same way the console does it in server/proxy/confirmations.js:
// object keys sorted, no insignificant whitespace. A dangerous write whose intent does not match
// the bytes the agent is about to forward is refused, so a relay cannot edit the recipient of an
// SMS in flight and keep the confirmation valid.
func IntentDigest(method, path string, body []byte) ([]byte, error) {
canonicalBody, err := CanonicalJSON(body)
if err != nil {
return nil, err
}
hash := sha256.New()
hash.Write([]byte(strings.ToUpper(strings.TrimSpace(method))))
hash.Write([]byte{'|'})
hash.Write([]byte(path))
hash.Write([]byte{'|'})
hash.Write(canonicalBody)
return hash.Sum(nil), nil
}
// VerifyIntent compares in constant time. A byte-by-byte comparison would leak how much of a
// guessed digest is correct, which turns a 256-bit search into a byte-at-a-time one.
func VerifyIntent(expected, actual []byte) bool {
return hmac.Equal(expected, actual)
}