From 43e166e8d1810bb156356c66d74b8e7db9d4f51f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 26 Oct 2026 14:20:51 -0500 Subject: [PATCH] Answer a stale EAP-AKA SQN with a synchronization failure The pod took any SQN a controller sent: the session keys don't depend on it and AUTN wasn't checked. So a controller whose SQN had fallen behind, because another controller had sessions with the pod since its last, connected as if nothing had happened, and OmnipodKit's resynchronization path never ran in the Simulator. The pod now reads the controller's SQN from AUTN, verifies MAC-A, and answers an SQN that isn't past the last one it accepted with a synchronization failure: AUTS carries its own SQN, as a real pod's does. It then waits for the retry on the same connection. An AUTN that doesn't verify hangs up. The pod stores the controller's SQN, starting from 0 at pairing, since the controller's first session uses 1. --- pkg/eap/eap.go | 91 ++++++++++++++++++++++++---- pkg/eap/eap_test.go | 12 ++++ pkg/pod/pod.go | 41 +++++++++---- pkg/pod/pod_test.go | 142 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 23 deletions(-) diff --git a/pkg/eap/eap.go b/pkg/eap/eap.go index 0e2bde7..5ba7a0a 100644 --- a/pkg/eap/eap.go +++ b/pkg/eap/eap.go @@ -22,11 +22,13 @@ const ( CodeSuccess CodeFailure - SubTypeAkaChallenge = 1 + SubTypeAkaChallenge = 1 + SubTypeAkaSynchronizationFailure = 4 AT_RAND AttributeType = 1 AT_AUTN AttributeType = 2 AT_RES AttributeType = 3 + AT_AUTS AttributeType = 4 AT_CUSTOM_IV AttributeType = 126 ) @@ -54,9 +56,10 @@ type EapAkaChallenge struct { amf uint16 op []byte - podIV []byte - pdmIV []byte - Sqn uint64 + podIV []byte + pdmIV []byte + Sqn uint64 // the controller's, read from AUTN + lastSqn uint64 // the last one the pod accepted identifier byte } @@ -107,6 +110,10 @@ func Unmarshal(data []byte) (*EapAka, error) { } data = data[2:10] // 8 bytes + case AT_AUTS: + if len != 16 { + return nil, fmt.Errorf("invalid len received for attribute: %d -- %d", aType, len) + } case AT_CUSTOM_IV: if len != 8 { return nil, fmt.Errorf("invalid len received for attribute: %d -- %d", len, aType) @@ -162,6 +169,10 @@ func (e *EapAka) Marshal() ([]byte, error) { buf.WriteByte(0) buf.WriteByte(64) // RES len in bits dataLen = 8 + case AT_AUTS: + len = 4 // 4 * 4 = 16 bytes, no reserved bytes + buf.WriteByte(len) + dataLen = 14 case AT_CUSTOM_IV: len = 2 buf.WriteByte(len) @@ -180,17 +191,75 @@ func (e *EapAka) Marshal() ([]byte, error) { return ret, nil } -func NewEapAkaChallenge(k []byte, sqn uint64) *EapAkaChallenge { +func NewEapAkaChallenge(k []byte, lastSqn uint64) *EapAkaChallenge { op, _ := hex.DecodeString("cdc202d5123e20f62b6d676ac72cb318") // amf, _ := hex.DecodeString("b9b9") - log.Debugf("Starting EAP-AKA session with SQN(after incrementing SQN): %d", sqn+1) + log.Debugf("Starting EAP-AKA session; last accepted SQN: %d", lastSqn) return &EapAkaChallenge{ - k: k, - op: op, - Sqn: sqn + 1, - amf: 47545, // b9b9 - podIV: []byte{0xa, 0xa, 0xa, 0xa}, // constant for now, easier to debug. TODO + k: k, + op: op, + lastSqn: lastSqn, + amf: 47545, // b9b9 + podIV: []byte{0xa, 0xa, 0xa, 0xa}, // constant for now, easier to debug. TODO + } +} + +// CheckSqn reads the controller's SQN from AUTN (SQN xor AK || AMF || MAC-A) and +// verifies MAC-A. It returns false when the SQN is not past the last one accepted, +// which a pod answers with a synchronization failure. +func (e *EapAkaChallenge) CheckSqn() (bool, error) { + if len(e.autn) != 16 || len(e.rand) != 16 { + return false, fmt.Errorf("missing RAND or AUTN") + } + _, _, _, ak, err := milenage.New(e.k, e.op, e.rand, 0, 0).F2345() + if err != nil { + return false, err } + var sqn uint64 + for i := 0; i < 6; i++ { + sqn = sqn<<8 | uint64(e.autn[i]^ak[i]) + } + amf := uint16(e.autn[6])<<8 | uint16(e.autn[7]) + mac, err := milenage.New(e.k, e.op, e.rand, sqn, amf).F1() + if err != nil { + return false, err + } + if !bytes.Equal(mac, e.autn[8:16]) { + return false, fmt.Errorf("AUTN MAC-A mismatch for SQN %d", sqn) + } + e.Sqn = sqn + return sqn > e.lastSqn, nil +} + +// GenerateSynchronizationFailure answers a stale SQN with AUTS = SQN_ms xor AK* || MAC-S, +// SQN_ms being the last SQN the pod accepted. +func (e *EapAkaChallenge) GenerateSynchronizationFailure() (*message.Message, error) { + mil := milenage.New(e.k, e.op, e.rand, e.lastSqn, 0) + aks, err := mil.F5Star() + if err != nil { + return nil, err + } + macS, err := mil.F1Star(mil.SQN, []byte{0, 0}) + if err != nil { + return nil, err + } + auts := make([]byte, 0, 14) + for i := 0; i < 6; i++ { + auts = append(auts, mil.SQN[i]^aks[i]) + } + auts = append(auts, macS...) + + eap := &EapAka{Code: CodeResponse, + Attributes: map[AttributeType]*Attribute{AT_AUTS: {Data: auts}}, + SubType: SubTypeAkaSynchronizationFailure, + Identifier: e.identifier, + } + ret := message.NewMessage(message.MessageTypeSessionEstablishment, e.podID, e.pdmID) + ret.Payload, err = eap.Marshal() + if err != nil { + return nil, err + } + return ret, nil } func (e *EapAkaChallenge) ParseChallenge(msg *message.Message) error { diff --git a/pkg/eap/eap_test.go b/pkg/eap/eap_test.go index 15f4742..79c199e 100644 --- a/pkg/eap/eap_test.go +++ b/pkg/eap/eap_test.go @@ -54,6 +54,18 @@ func TestEapAka_MarshalUnmarshal(t *testing.T) { }, }, }, + { + name: "synchronization failure", + eap: &EapAka{ + Code: CodeResponse, + SubType: SubTypeAkaSynchronizationFailure, + Attributes: map[AttributeType]*Attribute{ + AT_AUTS: { + Data: make([]byte, 14), + }, + }, + }, + }, } for _, tt := range tests { diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index a50d1c8..69659ca 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -183,7 +183,7 @@ func (p *Pod) StartActivation() { log.Fatalf("pkg pod; could not get LTK %s", err) } log.Infof("pkg pod; LTK %x", p.state.LTK) - p.state.EapAkaSeq = 1 + p.state.EapAkaSeq = 0 // the controller's first session uses SQN 1 p.state.Save() p.EapAka() @@ -191,19 +191,36 @@ func (p *Pod) StartActivation() { func (p *Pod) EapAka() { - session := eap.NewEapAkaChallenge(p.state.LTK, p.state.EapAkaSeq) - - msg, ok := p.readSetup() - if !ok { - return - } - err := session.ParseChallenge(msg) - if err != nil { - p.startOver("error parsing the EAP-AKA challenge: %s", err) - return + var session *eap.EapAkaChallenge + var msg *message.Message + var ok bool + for { + session = eap.NewEapAkaChallenge(p.state.LTK, p.state.EapAkaSeq) + if msg, ok = p.readSetup(); !ok { + return + } + if err := session.ParseChallenge(msg); err != nil { + p.startOver("error parsing the EAP-AKA challenge: %s", err) + return + } + fresh, err := session.CheckSqn() + if err != nil { + p.startOver("error checking the EAP-AKA challenge: %s", err) + return + } + if fresh { + break + } + // Another controller has had a session since this one's last: tell it our SQN. + // It retries on the same connection with the next one. + log.Infof("pkg pod; EAP-AKA SQN %d is not past %d; sending a synchronization failure", session.Sqn, p.state.EapAkaSeq) + if msg, err = session.GenerateSynchronizationFailure(); err != nil { + log.Fatalf("pkg pod; error generating the eap-aka synchronization failure: %s", err) + } + p.ble.WriteMessage(msg) } - msg, err = session.GenerateChallengeResponse() + msg, err := session.GenerateChallengeResponse() if err != nil { log.Fatalf("pkg pod; error generating the eap-aka challenge response") } diff --git a/pkg/pod/pod_test.go b/pkg/pod/pod_test.go index 274033a..6ed83ee 100644 --- a/pkg/pod/pod_test.go +++ b/pkg/pod/pod_test.go @@ -1,13 +1,17 @@ package pod import ( + "bytes" + "encoding/hex" "path/filepath" "sync" "testing" "time" "github.com/avereha/pod/pkg/bluetooth" + "github.com/avereha/pod/pkg/eap" "github.com/avereha/pod/pkg/message" + "github.com/wmnsk/milenage" ) // radio carries one side's notifications to the other side, and records @@ -83,3 +87,141 @@ func TestAGarbledSessionMessageHangsUp(t *testing.T) { t.Fatal("the pod did not hang up") } } + +var milenageOP, _ = hex.DecodeString("cdc202d5123e20f62b6d676ac72cb318") + +// challenge is the controller's EAP-AKA challenge: AUTN = SQN xor AK || AMF || MAC-A. +func challenge(t *testing.T, ltk, rand []byte, sqn uint64) *message.Message { + m := milenage.New(ltk, milenageOP, rand, sqn, 0xb9b9) + _, _, _, ak, err := m.F2345() + if err != nil { + t.Fatal(err) + } + mac, err := m.F1() + if err != nil { + t.Fatal(err) + } + autn := make([]byte, 0, 16) + for i := 0; i < 6; i++ { + autn = append(autn, m.SQN[i]^ak[i]) + } + autn = append(append(autn, 0xb9, 0xb9), mac...) + msg := message.NewMessage(message.MessageTypeSessionEstablishment, []byte{1, 2, 3, 4}, []byte{5, 6, 7, 8}) + msg.Payload, err = (&eap.EapAka{Code: eap.CodeRequest, SubType: eap.SubTypeAkaChallenge, + Attributes: map[eap.AttributeType]*eap.Attribute{ + eap.AT_RAND: {Data: rand}, + eap.AT_AUTN: {Data: autn}, + eap.AT_CUSTOM_IV: {Data: []byte{1, 2, 3, 4}}, + }}).Marshal() + if err != nil { + t.Fatal(err) + } + return msg +} + +func readEap(t *testing.T, phone *bluetooth.Ble) *eap.EapAka { + msg, didTimeout, _ := phone.ReadMessageWithTimeout(2 * time.Second) + if didTimeout || msg == nil { + t.Fatal("no answer from the pod") + } + e, err := eap.Unmarshal(msg.Payload) + if err != nil { + t.Fatal(err) + } + return e +} + +// A controller whose SQN is behind gets the pod's in AUTS, as OmnipodKit reads it, and its +// retry with the next SQN on the same connection establishes the session. +func TestAStaleSqnGetsASynchronizationFailure(t *testing.T) { + toPhone, toPod := &radio{hungUp: make(chan bool, 1)}, &radio{} + podBle, phone := newBle(t, toPhone), newBle(t, toPod) + toPod.peer = podBle + toPhone.peer = phone + + ltk := bytes.Repeat([]byte{0x5a}, 16) + state := NewFreshState(filepath.Join(t.TempDir(), "state.toml")) + state.LTK = ltk + state.EapAkaSeq = 40 + p := New(podBle, state) + + podBle.Connected() + go p.StartAcceptingCommands() + podBle.CmdWritten(bluetooth.Packet{6, 1, 4}) + phone.Connected() + phone.StartMessageLoop() + + rand := bytes.Repeat([]byte{0x11}, 16) + phone.WriteMessage(challenge(t, ltk, rand, 37)) + answer := readEap(t, phone) + if answer.SubType != eap.SubTypeAkaSynchronizationFailure || answer.Attributes[eap.AT_AUTS] == nil { + t.Fatalf("expected a synchronization failure, got %+v", answer) + } + auts := answer.Attributes[eap.AT_AUTS].Data + m := milenage.New(ltk, milenageOP, rand, 0, 0) + aks, err := m.F5Star() + if err != nil { + t.Fatal(err) + } + sqnMs := make([]byte, 6) + for i := range sqnMs { + sqnMs[i] = auts[i] ^ aks[i] + } + if !bytes.Equal(sqnMs, []byte{0, 0, 0, 0, 0, 40}) { + t.Fatalf("AUTS carries SQN %x, want 40", sqnMs) + } + macS, err := m.F1Star(sqnMs, []byte{0, 0}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(macS, auts[6:]) { + t.Fatalf("MAC-S %x, want %x", auts[6:], macS) + } + + phone.WriteMessage(challenge(t, ltk, bytes.Repeat([]byte{0x22}, 16), 41)) + if answer = readEap(t, phone); answer.SubType != eap.SubTypeAkaChallenge || answer.Attributes[eap.AT_RES] == nil { + t.Fatalf("expected a challenge response, got %+v", answer) + } + success := message.NewMessage(message.MessageTypeSessionEstablishment, []byte{1, 2, 3, 4}, []byte{5, 6, 7, 8}) + success.Payload, _ = (&eap.EapAka{Code: eap.CodeSuccess}).Marshal() + phone.WriteMessage(success) + + deadline := time.Now().Add(2 * time.Second) + for { + p.mtx.Lock() + sqn := p.state.EapAkaSeq + p.mtx.Unlock() + if sqn == 41 { + break + } + if time.Now().After(deadline) { + t.Fatalf("pod SQN %d after the session, want 41", sqn) + } + time.Sleep(10 * time.Millisecond) + } +} + +// An AUTN whose MAC-A doesn't verify ends the connection. +func TestABadAutnHangsUp(t *testing.T) { + toPhone, toPod := &radio{hungUp: make(chan bool, 1)}, &radio{} + podBle, phone := newBle(t, toPhone), newBle(t, toPod) + toPod.peer = podBle + toPhone.peer = phone + + state := NewFreshState(filepath.Join(t.TempDir(), "state.toml")) + state.LTK = bytes.Repeat([]byte{0x5a}, 16) + p := New(podBle, state) + + podBle.Connected() + go p.StartAcceptingCommands() + podBle.CmdWritten(bluetooth.Packet{6, 1, 4}) + phone.Connected() + phone.StartMessageLoop() + + phone.WriteMessage(challenge(t, bytes.Repeat([]byte{0x77}, 16), bytes.Repeat([]byte{0x11}, 16), 1)) + select { + case <-toPhone.hungUp: + case <-time.After(2 * time.Second): + t.Fatal("the pod did not hang up") + } +}