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") + } +}