baseline: P1 契约、agent 核心与中继投递(既有工作首次入库)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user