baseline: P1 契约、agent 核心与中继投递(既有工作首次入库)

This commit is contained in:
chick
2026-09-16 08:41:13 +08:00
commit 54d9696a4d
24 changed files with 4858 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
package wire
import (
"crypto/ecdh"
"crypto/rand"
"errors"
"fmt"
)
// Ephemeral is a one-use X25519 key pair.
//
// One use, enforced by not exposing the private half after the exchange: the caller gets the derived
// key material, never the key, so there is nothing to reuse by accident later.
type Ephemeral struct {
private []byte
public []byte
}
// ErrEphemeralExhausted is returned when the system RNG cannot supply bytes. It is fatal for the
// request and never retried: a PRNG failure means the process cannot be trusted to produce secrets.
var ErrEphemeralExhausted = errors.New("wire: cannot read random bytes for an ephemeral key")
// NewEphemeral generates a key pair for exactly one exchange.
func NewEphemeral() (Ephemeral, error) {
private := make([]byte, KeyBytes)
if _, err := rand.Read(private); err != nil {
return Ephemeral{}, ErrEphemeralExhausted
}
public, err := PublicFrom(private)
if err != nil {
return Ephemeral{}, err
}
return Ephemeral{private: private, public: public}, nil
}
// PublicFrom derives the X25519 public key for a private key.
//
// Exported because both sides of a test need it, and because a caller that already holds a private
// key should not have to build a throwaway Ephemeral just to learn its own public half.
func PublicFrom(private []byte) ([]byte, error) {
key, err := ecdh.X25519().NewPrivateKey(private)
if err != nil {
return nil, fmt.Errorf("%w: private key rejected: %v", ErrBadKey, err)
}
return key.PublicKey().Bytes(), nil
}
// Public is what goes in the envelope's ek field, signed along with everything else.
func (e Ephemeral) Public() []byte { return e.public }
// Exchange performs X25519 against a peer public key and discards the private half.
//
// Zeroing is not a substitute for a proper memory barrier and does not defend against a process
// dump, but it costs one line and removes the private key from the heap at the earliest point the
// protocol no longer needs it.
func (e Ephemeral) Exchange(peerPublic []byte) ([]byte, error) {
shared, err := SharedSecret(e.private, peerPublic)
if err != nil {
return nil, err
}
for i := range e.private {
e.private[i] = 0
}
return shared, nil
}
+145
View File
@@ -0,0 +1,145 @@
package wire
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"sort"
)
// marshal is encoding/json with HTML escaping off. The default escapes <, > and &, which would make
// a body containing those characters hash differently here than it does in Node or Swift.
func marshal(value any) ([]byte, error) {
buf := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
func unmarshal(raw []byte, value any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(value); err != nil {
return err
}
// Trailing content would mean the caller is looking at a different document than the one that
// was decoded, which for a signed field is a correctness bug rather than a style choice.
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return errors.New("wire: trailing data after JSON value")
}
return nil
}
// CanonicalJSON renders a JSON value in the one form this protocol hashes.
//
// The rules are deliberately small, and every implementation (Go agent, Node relay, Swift app)
// must follow exactly these:
//
// 1. Object keys are sorted by UTF-8 byte order and emitted without whitespace.
// 2. Arrays keep their order.
// 3. Strings use the same escapes as Go's encoder with HTML escaping disabled: \" \\ \n \r \t,
// \u00XX for other control characters, and literal UTF-8 for everything else.
// 4. Numbers must be integers in canonical form: an optional minus, no leading zero, no fraction,
// no exponent, no plus. This is stricter than JSON allows and on purpose. Any wider grammar has
// values that are the same number but different bytes (1 and 1.0, 1e2 and 100), and "which
// spelling did the phone hash" is not a question this protocol is willing to leave open. The
// APIs behind it take integers, strings, booleans, arrays and objects, so nothing is lost.
// 5. null, true and false are literal.
//
// An empty or absent body canonicalises to the empty byte string, not to the four characters
// "null", so "no body" and "empty body" cannot be confused.
func CanonicalJSON(raw []byte) ([]byte, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
return nil, nil
}
decoder := json.NewDecoder(bytes.NewReader(trimmed))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, fmt.Errorf("wire: body is not valid JSON: %w", err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return nil, errors.New("wire: body has trailing data after the JSON value")
}
buf := bytes.NewBuffer(make([]byte, 0, len(trimmed)))
if err := writeCanonical(buf, value); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// canonicalNumber is the only number spelling this protocol will hash. See rule 4 above.
//
// Negative zero is excluded even though it is an integer: no implementation can be relied on to
// round-trip "-0" as text rather than as the number 0, and nothing in these payloads needs it.
var canonicalNumber = regexp.MustCompile(`^-?[1-9][0-9]*$|^-?[1-9][0-9]*$|^0$`)
func writeCanonical(buf *bytes.Buffer, value any) error {
switch typed := value.(type) {
case nil:
buf.WriteString("null")
case bool:
if typed {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
case json.Number:
literal := typed.String()
if !canonicalNumber.MatchString(literal) || literal == "-0" {
return fmt.Errorf("wire: number %q is not a canonical integer", literal)
}
buf.WriteString(literal)
case string:
encoded, err := marshal(typed)
if err != nil {
return err
}
buf.Write(encoded)
case []any:
buf.WriteByte('[')
for index, item := range typed {
if index > 0 {
buf.WriteByte(',')
}
if err := writeCanonical(buf, item); err != nil {
return err
}
}
buf.WriteByte(']')
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
buf.WriteByte('{')
for index, key := range keys {
if index > 0 {
buf.WriteByte(',')
}
encodedKey, err := marshal(key)
if err != nil {
return err
}
buf.Write(encodedKey)
buf.WriteByte(':')
if err := writeCanonical(buf, typed[key]); err != nil {
return err
}
}
buf.WriteByte('}')
default:
return fmt.Errorf("wire: cannot canonicalise %T", value)
}
return nil
}
+540
View File
@@ -0,0 +1,540 @@
// 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)
}
+474
View File
@@ -0,0 +1,474 @@
package wire
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"testing"
)
func fixedEnvelope(t *testing.T) Envelope {
t.Helper()
ek, err := hex.DecodeString("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a")
if err != nil {
t.Fatal(err)
}
return Envelope{
V: Version,
Node: "nd_7f3a9c2e",
Req: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
App: "ap_5b1d",
TS: 1727289600,
EK: ek,
Alg: Suite,
P: []byte{0x00, 0x01, 0x02, 0xfe, 0xff},
}
}
// TestCanonicalRequestVector pins the exact bytes. This is the file every implementation must agree
// with: if the Node relay or the Swift app produces anything else here, signatures will fail in
// production with an error that says nothing about why. Keeping the vector in the repo turns that
// into a failing test instead.
func TestCanonicalRequestVector(t *testing.T) {
canonical, err := CanonicalRequest(fixedEnvelope(t))
if err != nil {
t.Fatal(err)
}
got := hex.EncodeToString(canonical)
const want = "" +
"6d73612d72656c61792d76312d72657100" + // "msa-relay-v1-req" NUL
"000000080000000000000001" + // v = 1 as uint64be, length-prefixed
"0000000b6e645f3766336139633265" + // node
"0000002433663235303465302d346638392d343164332d396130632d303330356538326333333031" + // req
"0000000661705f35623164" + // app
"000000080000000000000000" + // ts placeholder, replaced below
"000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a" + // ek
"00000005000102feff" // payload
_ = want
// The literal above is easier to get wrong than to derive, so assert the structural properties
// that actually matter, then pin the whole thing against a recomputed expectation.
if !bytes.HasPrefix(canonical, []byte("msa-relay-v1-req\x00")) {
t.Fatalf("canonical does not start with the domain: %x", canonical[:20])
}
rawEK := fixedEnvelope(t).EK
if !bytes.Contains(canonical, rawEK) {
t.Fatal("canonical is missing the raw ek bytes")
}
if bytes.Contains(canonical, []byte(base64.StdEncoding.EncodeToString(rawEK))) {
t.Fatal("canonical contains base64 ek text; the signature must cover raw bytes")
}
// Field order is part of the contract, so check each marker's offset is increasing.
positions := []int{
bytes.Index(canonical, []byte("msa-relay-v1-req")),
bytes.Index(canonical, []byte("nd_7f3a9c2e")),
bytes.Index(canonical, []byte("3f2504e0-4f89-41d3-9a0c-0305e82c3301")),
bytes.Index(canonical, []byte("ap_5b1d")),
}
for index := 1; index < len(positions); index++ {
if positions[index] <= positions[index-1] {
t.Fatalf("field order is wrong: %v", positions)
}
}
if got != expectCanonicalRequest() {
t.Fatalf("canonical request changed:\n got %s\nwant %s", got, expectCanonicalRequest())
}
}
// expectCanonicalRequest is the frozen vector. It is spelled out rather than computed so that a
// change to the encoding shows up as a diff here instead of silently invalidating every paired
// device in the field.
func expectCanonicalRequest() string {
parts := []string{
hex.EncodeToString([]byte("msa-relay-v1-req")),
"00",
"00000008", "0000000000000001",
"0000000b", hex.EncodeToString([]byte("nd_7f3a9c2e")),
"00000024", hex.EncodeToString([]byte("3f2504e0-4f89-41d3-9a0c-0305e82c3301")),
"00000007", hex.EncodeToString([]byte("ap_5b1d")),
"00000008", "0000000066f45900",
"00000020", "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a",
"00000005", "000102feff",
}
out := ""
for _, part := range parts {
out += part
}
return out
}
func TestCanonicalResponseOmitsAppID(t *testing.T) {
envelope := fixedEnvelope(t)
request, err := CanonicalRequest(envelope)
if err != nil {
t.Fatal(err)
}
response, err := CanonicalResponse(envelope)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(request, response) {
t.Fatal("request and response canonical forms are identical; a signature could be replayed across directions")
}
if bytes.Contains(response, []byte("ap_5b1d")) {
t.Fatal("response canonical form includes the app id")
}
if !bytes.HasPrefix(response, []byte("msa-relay-v1-resp\x00")) {
t.Fatalf("response canonical form has the wrong domain: %x", response[:20])
}
}
func TestCanonicalRejectsMalformedEnvelopes(t *testing.T) {
cases := map[string]func(*Envelope){
"missing node": func(e *Envelope) { e.Node = "" },
"missing req": func(e *Envelope) { e.Req = " " },
"missing suite": func(e *Envelope) { e.Alg = "" },
"unknown suite": func(e *Envelope) { e.Alg = "AES128" },
"short ek": func(e *Envelope) { e.EK = e.EK[:16] },
"empty ek": func(e *Envelope) { e.EK = nil },
}
for name, mutate := range cases {
envelope := fixedEnvelope(t)
mutate(&envelope)
if _, err := CanonicalRequest(envelope); err == nil {
t.Errorf("%s: canonical form accepted it", name)
}
}
}
func TestSignAndVerifyRoundTrip(t *testing.T) {
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
envelope := fixedEnvelope(t)
if err := SignRequest(&envelope, private); err != nil {
t.Fatal(err)
}
if err := VerifyRequest(envelope, public); err != nil {
t.Fatalf("VerifyRequest: %v", err)
}
// Every routing field must be covered. If any of these mutations still verified, the relay
// could change it in flight.
mutations := map[string]func(*Envelope){
"node": func(e *Envelope) { e.Node = "nd_attacker" },
"req": func(e *Envelope) { e.Req = "00000000-0000-4000-8000-000000000000" },
"app": func(e *Envelope) { e.App = "ap_other" },
"ts": func(e *Envelope) { e.TS = e.TS + 1 },
"ek": func(e *Envelope) { e.EK = bytes.Repeat([]byte{7}, PublicBytes) },
"p": func(e *Envelope) { e.P = append(append([]byte{}, e.P...), 0) },
"alg": func(e *Envelope) { e.Alg = "other" },
"v": func(e *Envelope) { e.V = 2 },
}
for name, mutate := range mutations {
changed := envelope
changed.EK = append([]byte{}, envelope.EK...)
changed.P = append([]byte{}, envelope.P...)
changed.S = append([]byte{}, envelope.S...)
mutate(&changed)
if err := VerifyRequest(changed, public); err == nil {
t.Errorf("signature still verified after mutating %s", name)
}
}
// A response signature must not satisfy a request verification even over identical bytes.
response := fixedEnvelope(t)
if err := SignResponse(&response, private); err != nil {
t.Fatal(err)
}
if err := VerifyRequest(response, public); err == nil {
t.Fatal("a response signature verified as a request signature")
}
}
func TestVerifyRejectsWrongKey(t *testing.T) {
_, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
otherPublic, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
envelope := fixedEnvelope(t)
if err := SignRequest(&envelope, private); err != nil {
t.Fatal(err)
}
if err := VerifyRequest(envelope, otherPublic); !errors.Is(err, ErrSignature) {
t.Fatalf("VerifyRequest with the wrong key = %v, want ErrSignature", err)
}
if err := VerifyRequest(envelope, otherPublic[:16]); err == nil {
t.Fatal("accepted a truncated public key")
}
}
// TestSharedSecretMatchesRFC7748 is the X25519 known-answer test. Without it a silent change to the
// scalar clamping or the endianness would produce a working system that interoperates with nobody.
func TestSharedSecretMatchesRFC7748(t *testing.T) {
// RFC 7748 section 6.1: alice private, bob public, expected shared secret.
alicePrivate := mustHex(t, "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a")
bobPublic := mustHex(t, "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f")
want := mustHex(t, "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742")
got, err := SharedSecret(alicePrivate, bobPublic)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("shared secret = %x, want %x", got, want)
}
}
func TestSharedSecretRejectsBadKeys(t *testing.T) {
valid := bytes.Repeat([]byte{7}, PublicBytes)
if _, err := SharedSecret(nil, valid); !errors.Is(err, ErrBadKey) {
t.Errorf("nil private key err = %v, want ErrBadKey", err)
}
if _, err := SharedSecret(bytes.Repeat([]byte{1}, KeyBytes), nil); !errors.Is(err, ErrBadPublicKey) {
t.Errorf("nil peer key err = %v, want ErrBadPublicKey", err)
}
// The all-zero public key is the classic low-order point; it must not yield a shared secret.
if _, err := SharedSecret(bytes.Repeat([]byte{9}, KeyBytes), make([]byte, PublicBytes)); err == nil {
t.Error("accepted the all-zero peer public key")
}
}
func TestSealOpenRoundTrip(t *testing.T) {
key := bytes.Repeat([]byte{0x2a}, KeyBytes)
iv := bytes.Repeat([]byte{0x0b}, IVBytes)
plaintext := []byte(`{"m":"GET","p":"/api/health"}`)
sealed, err := Seal(key, iv, plaintext)
if err != nil {
t.Fatal(err)
}
if len(sealed) != len(plaintext)+TagBytes {
t.Fatalf("sealed length = %d, want %d", len(sealed), len(plaintext)+TagBytes)
}
opened, err := Open(key, iv, sealed)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(opened, plaintext) {
t.Fatalf("opened = %q, want %q", opened, plaintext)
}
// Tampering anywhere, including in the tag, must fail closed.
for index := range sealed {
damaged := append([]byte{}, sealed...)
damaged[index] ^= 0x01
if _, err := Open(key, iv, damaged); !errors.Is(err, ErrOpen) {
t.Fatalf("flipping byte %d still authenticated", index)
}
}
if _, err := Open(bytes.Repeat([]byte{0x2b}, KeyBytes), iv, sealed); !errors.Is(err, ErrOpen) {
t.Fatal("a different key decrypted the payload")
}
if _, err := Open(key, bytes.Repeat([]byte{0x0c}, IVBytes), sealed); !errors.Is(err, ErrOpen) {
t.Fatal("a different iv decrypted the payload")
}
if _, err := Open(key, iv, sealed[:TagBytes-1]); !errors.Is(err, ErrOpen) {
t.Fatal("a truncated payload was accepted")
}
}
// TestDerivedKeysAreDirectionalAndRequestBound checks the properties that make key reuse safe to
// reason about: the two directions differ, and two requests never share an IV.
func TestDerivedKeysAreDirectionalAndRequestBound(t *testing.T) {
shared := bytes.Repeat([]byte{0x5c}, KeyBytes)
requestKey, requestIV, err := DeriveKey(shared, "nd_a", "req-1", false)
if err != nil {
t.Fatal(err)
}
responseKey, responseIV, err := DeriveKey(shared, "nd_a", "req-1", true)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(requestKey, responseKey) {
t.Fatal("both directions derived the same key")
}
if bytes.Equal(requestIV, responseIV) {
t.Fatal("both directions derived the same iv")
}
if len(requestKey) != KeyBytes || len(requestIV) != IVBytes {
t.Fatalf("derived sizes are wrong: key %d iv %d", len(requestKey), len(requestIV))
}
otherKey, otherIV, err := DeriveKey(shared, "nd_a", "req-2", false)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(requestIV, otherIV) {
t.Fatal("two request ids derived the same iv")
}
if bytes.Equal(requestKey, otherKey) {
t.Fatal("two request ids derived the same key")
}
_ = otherIV
otherNodeKey, _, err := DeriveKey(shared, "nd_b", "req-1", false)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(requestKey, otherNodeKey) {
t.Fatal("two nodes derived the same key from the same secret")
}
}
// TestHKDFMatchesRFC5869 pins the KDF itself against the published test case, so an implementation
// slip cannot hide behind the round-trip tests above.
func TestHKDFMatchesRFC5869(t *testing.T) {
ikm := mustHex(t, "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
salt := mustHex(t, "000102030405060708090a0b0c")
info := mustHex(t, "f0f1f2f3f4f5f6f7f8f9")
want := mustHex(t, "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865")
got, err := HKDF(ikm, salt, info, 42)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("hkdf = %x, want %x", got, want)
}
if _, err := HKDF(ikm, salt, info, 0); err == nil {
t.Fatal("accepted a zero-length output")
}
if _, err := HKDF(ikm, salt, info, 255*32+1); err == nil {
t.Fatal("accepted an output longer than HKDF allows")
}
}
func TestEnvelopeJSONRoundTrip(t *testing.T) {
original := fixedEnvelope(t)
original.S = bytes.Repeat([]byte{0x11}, ed25519.SignatureSize)
encoded, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
var decoded Envelope
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Node != original.Node || decoded.Req != original.Req || decoded.TS != original.TS {
t.Fatalf("string fields round-tripped wrong: %+v", decoded)
}
if !bytes.Equal(decoded.EK, original.EK) || !bytes.Equal(decoded.P, original.P) || !bytes.Equal(decoded.S, original.S) {
t.Fatal("byte fields did not round-trip")
}
// The wire form must be base64 text, not an array of numbers.
if !bytes.Contains(encoded, []byte(`"ek":"`)) {
t.Fatalf("ek is not encoded as base64: %s", encoded)
}
if bytes.Contains(encoded, []byte(`"ek":[`)) {
t.Fatalf("ek leaked as a byte array: %s", encoded)
}
}
func TestEnvelopeJSONRejectsMalformedBase64(t *testing.T) {
raw := []byte(`{"v":1,"node":"nd_x","req":"r","ts":1,"ek":"!!!!","alg":"` + Suite + `","p":"AA==","s":""}`)
var envelope Envelope
if err := json.Unmarshal(raw, &envelope); err == nil {
t.Fatal("accepted invalid base64 in ek")
}
}
// TestIntentDigestIsKeyOrderInsensitive is the property that lets the phone and the agent agree on
// a digest without agreeing on JSON key order.
func TestIntentDigestIsKeyOrderInsensitive(t *testing.T) {
first, err := IntentDigest("post", "/api/sms/send", []byte(`{"b":2,"a":1,"nested":{"z":true,"y":[1,2,3]}}`))
if err != nil {
t.Fatal(err)
}
second, err := IntentDigest("POST", "/api/sms/send", []byte("{\n \"nested\": {\"y\": [1, 2, 3], \"z\": true},\n \"a\": 1,\n \"b\": 2\n}"))
if err != nil {
t.Fatal(err)
}
if !VerifyIntent(first, second) {
t.Fatalf("intent digest depends on key order or whitespace: %x vs %x", first, second)
}
// Changing any part of the binding must change the digest.
variants := [][]byte{}
for _, mutate := range []func() ([]byte, error){
func() ([]byte, error) {
return IntentDigest("POST", "/api/sms/send", []byte(`{"a":1,"b":3,"nested":{"z":true,"y":[1,2,3]}}`))
},
func() ([]byte, error) { return IntentDigest("POST", "/api/sms/batch-delete", []byte(`{"a":1,"b":2}`)) },
func() ([]byte, error) { return IntentDigest("GET", "/api/sms/send", []byte(`{"a":1,"b":2}`)) },
func() ([]byte, error) { return IntentDigest("POST", "/api/sms/send", nil) },
} {
digest, err := mutate()
if err != nil {
t.Fatal(err)
}
variants = append(variants, digest)
}
for index, variant := range variants {
if VerifyIntent(first, variant) {
t.Errorf("variant %d produced the same intent digest", index)
}
}
// No body and an explicitly empty body are the same absence of a body.
empty, err := IntentDigest("POST", "/api/sms/send", []byte(" "))
if err != nil {
t.Fatal(err)
}
if !VerifyIntent(empty, variants[3]) {
t.Fatal("blank and absent bodies canonicalised differently")
}
}
func TestCanonicalJSONRules(t *testing.T) {
cases := []struct{ in, want string }{
{`{"b":1,"a":2}`, `{"a":2,"b":1}`},
{`[3,1,2]`, `[3,1,2]`},
{` null `, `null`},
{`true`, `true`},
{`"<&>"`, `"<&>"`},
{`{"x":100}`, `{"x":100}`},
{``, ``},
{` `, ``},
{`{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`, `{"A":9,"a":{"b":0,"c":[1,{"a":1,"b":2}]}}`},
}
for _, c := range cases {
got, err := CanonicalJSON([]byte(c.in))
if err != nil {
t.Errorf("CanonicalJSON(%q): %v", c.in, err)
continue
}
if string(got) != c.want {
t.Errorf("CanonicalJSON(%q) = %q, want %q", c.in, got, c.want)
}
}
// HTML escaping must be off, otherwise a body containing & or < hashes differently in Go than
// in Node or Swift.
got, err := CanonicalJSON([]byte(`{"text":"a<b&c>d"}`))
if err != nil {
t.Fatal(err)
}
if string(got) != `{"text":"a<b&c>d"}` {
t.Fatalf("HTML characters were escaped: %s", got)
}
}
func TestCanonicalJSONRejectsGarbage(t *testing.T) {
for _, raw := range []string{`{`, `{"a":1} trailing`, `{"a":+1}`, `{"a":.5}`, `{"a":NaN}`, `{"a":1.0}`, `{"a":1e3}`, `{"a":01}`, `{"a":-01}`, `{"a":-0}`} {
if _, err := CanonicalJSON([]byte(raw)); err == nil {
t.Errorf("CanonicalJSON(%q) accepted it", raw)
}
}
}
func mustHex(t *testing.T, value string) []byte {
t.Helper()
raw, err := hex.DecodeString(value)
if err != nil {
t.Fatalf("bad hex in test: %v", err)
}
return raw
}