diff --git a/internal/appleconnect/auth.go b/internal/appleconnect/auth.go index 5b389799..9168e03d 100644 --- a/internal/appleconnect/auth.go +++ b/internal/appleconnect/auth.go @@ -316,13 +316,61 @@ func phoneMatches(number, masked string) bool { } func (c *Client) authServiceKey(ctx context.Context) (string, error) { + key, redirectErr := c.authServiceKeyFromSignout(ctx) + if redirectErr == nil { + return key, nil + } + if ctx.Err() != nil { + return "", fmt.Errorf("get Apple auth service key: %w", ctx.Err()) + } + key, olympusErr := c.authServiceKeyFromOlympus(ctx) + if olympusErr != nil { + return "", fmt.Errorf("get Apple auth service key: %w", errors.Join(redirectErr, olympusErr)) + } + return key, nil +} + +func (c *Client) authServiceKeyFromSignout(ctx context.Context) (string, error) { + // The redirect performs signout. Read only its Location, without sending + // or changing the session cookies, even when the caller supplied a client. + client := *c.httpClient + client.Jar = nil + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.ascBaseURL+"/logout", nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("read Apple signout redirect: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + return "", fmt.Errorf("read Apple signout redirect: status %d", resp.StatusCode) + } + location, err := resp.Location() + if err != nil { + return "", fmt.Errorf("read Apple signout redirect location: %w", err) + } + query, err := url.ParseQuery(location.RawQuery) + if err != nil { + return "", fmt.Errorf("read Apple signout redirect query: %w", err) + } + key := strings.TrimSpace(query.Get("widgetKey")) + if key == "" { + return "", errors.New("the Apple signout redirect has no widgetKey") + } + return key, nil +} + +func (c *Client) authServiceKeyFromOlympus(ctx context.Context) (string, error) { var payload struct { AuthServiceKey string `json:"authServiceKey"` ServiceKey string `json:"serviceKey"` } endpoint := c.ascBaseURL + "/olympus/v1/app/config?hostname=itunesconnect.apple.com" if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &payload, nil); err != nil { - return "", fmt.Errorf("get Apple auth service key: %w", err) + return "", fmt.Errorf("read Apple Olympus configuration: %w", err) } key := strings.TrimSpace(payload.AuthServiceKey) if key == "" { @@ -421,6 +469,7 @@ func (c *Client) performSRPLogin(ctx context.Context, email, password, serviceKe return err } c.setAuthHeaders(req, serviceKey, "", "") + req.Header.Set("Accept", "application/json, text/javascript") if hashcash != "" { req.Header.Set("X-Apple-HC", hashcash) } @@ -432,6 +481,9 @@ func (c *Client) performSRPLogin(ctx context.Context, email, password, serviceKe responseBody, _ := io.ReadAll(resp.Body) switch resp.StatusCode { case http.StatusOK: + if message := appleAuthErrorMessage(responseBody); message != "" { + return fmt.Errorf("the Apple sign-in failed: %s", message) + } return nil case http.StatusConflict: return &twoFactorStateError{sessionID: resp.Header.Get("X-Apple-ID-Session-Id"), scnt: resp.Header.Get("scnt")} @@ -462,6 +514,9 @@ func (c *Client) authJSON(ctx context.Context, method, path, serviceKey, session return err } c.setAuthHeaders(req, serviceKey, sessionID, scnt) + if path == "/signin/init" { + req.Header.Set("Accept", "application/json, text/javascript") + } resp, err := c.httpClient.Do(req) if err != nil { return err @@ -471,6 +526,10 @@ func (c *Client) authJSON(ctx context.Context, method, path, serviceKey, session if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("the Apple authentication request failed with status %d: %s", resp.StatusCode, appleErrorMessage(responseBody)) } + // Apple can report verification and SMS delivery errors in a 200 response. + if message := appleAuthErrorMessage(responseBody); message != "" { + return fmt.Errorf("the Apple authentication request failed: %s", message) + } if out == nil || len(responseBody) == 0 { return nil } @@ -551,15 +610,14 @@ func (c *Client) doJSON(ctx context.Context, method, endpoint string, body, out } func appleErrorMessage(body []byte) string { + if message := appleAuthErrorMessage(body); message != "" { + return message + } var payload struct { Errors []struct { Detail string `json:"detail"` Title string `json:"title"` } `json:"errors"` - ServiceErrors []struct { - Message string `json:"message"` - Title string `json:"title"` - } `json:"serviceErrors"` } if json.Unmarshal(body, &payload) == nil { if len(payload.Errors) > 0 { @@ -568,16 +626,48 @@ func appleErrorMessage(body []byte) string { } return payload.Errors[0].Title } - if len(payload.ServiceErrors) > 0 { - if payload.ServiceErrors[0].Message != "" { - return payload.ServiceErrors[0].Message - } - return payload.ServiceErrors[0].Title - } } return "request rejected" } +func appleAuthErrorMessage(body []byte) string { + type serviceError struct { + Code json.RawMessage `json:"code"` + Message string `json:"message"` + Title string `json:"title"` + } + var payload struct { + ServiceErrors []serviceError `json:"serviceErrors"` + LegacyErrors []serviceError `json:"service_errors"` + ValidationErrors []serviceError `json:"validationErrors"` + NoTrustedDevices bool `json:"noTrustedDevices"` + } + if json.Unmarshal(body, &payload) != nil { + return "" + } + var messages []string + for _, item := range append(append(payload.ServiceErrors, payload.LegacyErrors...), payload.ValidationErrors...) { + message := item.Message + if message == "" { + message = item.Title + } + if code := strings.Trim(string(item.Code), `"`); code != "" && code != "null" { + message += " (" + code + ")" + } + if message == "" { + message = "request rejected" + } + messages = append(messages, message) + } + if len(messages) == 0 { + return "" + } + if payload.NoTrustedDevices { + messages = append(messages, "this Apple Account has no trusted devices; sign in on an Apple device to add one") + } + return strings.Join(messages, "; ") +} + func (c *Client) hashcash(ctx context.Context, serviceKey string) (string, error) { endpoint := c.authBaseURL + "/signin?widgetKey=" + url.QueryEscape(serviceKey) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) @@ -638,6 +728,10 @@ func preparePassword(password, protocol string) ([]byte, error) { } func calculateProof(username string, a, A, n, g *big.Int, serverB, password, salt []byte) (string, string, error) { + B := new(big.Int).SetBytes(serverB) + if new(big.Int).Mod(B, n).Sign() == 0 { + return "", "", errors.New("invalid Apple SRP server public value") + } bHex, saltHex, aHex := hex.EncodeToString(serverB), hex.EncodeToString(salt), numberHex(A) xInner, err := shaHex("3a" + hex.EncodeToString(password)) if err != nil { @@ -656,7 +750,6 @@ func calculateProof(username string, a, A, n, g *big.Int, serverB, password, sal if err != nil || u.Sign() == 0 { return "", "", errors.New("invalid Apple SRP scrambling parameter") } - B := new(big.Int).SetBytes(serverB) base := new(big.Int).Sub(B, new(big.Int).Mod(new(big.Int).Mul(k, new(big.Int).Exp(g, x, n)), n)) base.Mod(base, n) exponent := new(big.Int).Add(a, new(big.Int).Mul(u, x)) diff --git a/internal/appleconnect/auth_test.go b/internal/appleconnect/auth_test.go index 88fb4c2c..9e2b6e27 100644 --- a/internal/appleconnect/auth_test.go +++ b/internal/appleconnect/auth_test.go @@ -2,16 +2,393 @@ package appleconnect import ( "context" + "crypto/pbkdf2" "crypto/sha1" + "crypto/sha256" "encoding/base64" + "encoding/json" + "errors" "io" + "math/big" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" ) +func TestCalculateProofMatchesFastlaneSIRP(t *testing.T) { + n, _ := new(big.Int).SetString(appleSRPModulusHex, 16) + g := big.NewInt(2) + a := big.NewInt(123456) + A := new(big.Int).Exp(g, a, n) + prepared, err := preparePassword("secret", "s2k") + if err != nil { + t.Fatal(err) + } + salt := []byte("salt") + derived, err := pbkdf2.Key(sha256.New, string(prepared), salt, 1, derivedPasswordLen) + if err != nil { + t.Fatal(err) + } + m1, m2, err := calculateProof("dev@example.com", a, A, n, g, []byte{2}, derived, salt) + if err != nil { + t.Fatal(err) + } + // Generated independently with pinned fastlane-sirp code to catch padding + // and proof differences. Source and reproduction: testdata/srp-proof-fixture.md. + if m1 != "OL3tMhvYaZcmgV8KO40CG1UZ6Rgw4dDF1bSGsEN6c4s=" || m2 != "548X9ZQ5iGyqHhSBA3s2lDdzcZThics3GqSP20346T0=" { + t.Fatalf("proofs differ from fastlane-sirp: m1=%s m2=%s", m1, m2) + } +} + +func TestCalculateProofRejectsZeroServerPublicValue(t *testing.T) { + n, _ := new(big.Int).SetString(appleSRPModulusHex, 16) + for _, serverB := range [][]byte{nil, {0}, n.Bytes(), new(big.Int).Mul(n, big.NewInt(2)).Bytes()} { + _, _, err := calculateProof("dev@example.com", big.NewInt(1), big.NewInt(2), n, big.NewInt(2), serverB, []byte("password"), []byte("salt")) + if err == nil || !strings.Contains(err.Error(), "invalid Apple SRP server public value") { + t.Fatalf("expected invalid server public value, got %v", err) + } + } +} + +func TestLoginSRPRequestContract(t *testing.T) { + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/logout": + w.Header().Set("Location", "/signout?widgetKey=widget") + w.WriteHeader(http.StatusFound) + case "/auth/signin/init": + if r.Header.Get("X-Apple-Widget-Key") != "widget" || r.Header.Get("Accept") != "application/json, text/javascript" || r.Header.Get("X-Requested-With") != "XMLHttpRequest" { + t.Errorf("unexpected SRP init headers: %v", r.Header) + } + var payload struct { + A string `json:"a"` + AccountName string `json:"accountName"` + Protocols []string `json:"protocols"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + } + if payload.A == "" || payload.AccountName != "dev@example.com" || strings.Join(payload.Protocols, ",") != "s2k,s2k_fo" { + t.Errorf("unexpected SRP init payload: %+v", payload) + } + http.SetCookie(w, &http.Cookie{Name: "DES-session", Value: "value/with/slashes", Path: "/"}) + _, _ = io.WriteString(w, `{"iteration":1,"salt":"c2FsdA==","protocol":"s2k","b":"Ag==","c":"challenge"}`) + case "/auth/signin": + if r.Method != http.MethodGet || r.URL.Query().Get("widgetKey") != "widget" { + t.Errorf("unexpected hashcash request: %s %s", r.Method, r.URL) + } + w.Header().Set("X-Apple-HC-Bits", "1") + w.Header().Set("X-Apple-HC-Challenge", "challenge") + case "/auth/signin/complete": + if r.URL.Query().Get("isRememberMeEnabled") != "false" || r.Header.Get("X-Apple-Widget-Key") != "widget" || r.Header.Get("Accept") != "application/json, text/javascript" { + t.Errorf("unexpected SRP completion request: %s %v", r.URL, r.Header) + } + if !strings.Contains(r.Header.Get("Cookie"), `DES-session="value/with/slashes"`) { + t.Errorf("missing quoted DES cookie: %q", r.Header.Get("Cookie")) + } + hashcash := r.Header.Get("X-Apple-HC") + digest := sha1.Sum([]byte(hashcash)) + if !strings.HasPrefix(hashcash, "1:1:") || !leadingZeroBits(digest[:], 1) { + t.Errorf("invalid hashcash: %q", hashcash) + } + var payload struct { + AccountName string `json:"accountName"` + Challenge string `json:"c"` + M1 string `json:"m1"` + M2 string `json:"m2"` + RememberMe bool `json:"rememberMe"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + } + if payload.AccountName != "dev@example.com" || payload.Challenge != "challenge" || payload.RememberMe { + t.Errorf("unexpected SRP completion payload: %+v", payload) + } + for _, proof := range []string{payload.M1, payload.M2} { + decoded, err := base64.StdEncoding.DecodeString(proof) + if err != nil || len(decoded) != 32 { + t.Errorf("invalid proof encoding: %q", proof) + } + } + _, _ = io.WriteString(w, `{}`) + case "/olympus/v1/session": + _, _ = io.WriteString(w, `{"provider":{"providerId":42,"publicProviderId":"issuer","name":"Example"}}`) + default: + t.Errorf("unexpected request: %s", r.URL) + } + })) + t.Cleanup(server.Close) + client, err := New(Options{HTTPClient: server.Client(), ASCBaseURL: server.URL, AuthBaseURL: server.URL + "/auth"}) + if err != nil { + t.Fatal(err) + } + session, err := client.Login(context.Background(), " dev@example.com ", "secret") + if err != nil || session == nil || session.Provider.ID != 42 { + t.Fatalf("session = %+v, error = %v", session, err) + } + want := "HEAD /logout\nPOST /auth/signin/init\nGET /auth/signin\nPOST /auth/signin/complete\nGET /olympus/v1/session" + if got := strings.Join(requests, "\n"); got != want { + t.Fatalf("requests:\n%s\nwant:\n%s", got, want) + } +} + +func TestLoginHandlesSignInResponses(t *testing.T) { + cases := []struct { + name string + status int + body string + wantError error + wantMessage string + twoFactor bool + }{ + {name: "signed in", status: http.StatusOK, body: `{}`}, + {name: "two factor", status: http.StatusConflict, body: `{}`, twoFactor: true}, + {name: "invalid credentials", status: http.StatusUnauthorized, body: `{}`, wantError: ErrInvalidCredentials}, + {name: "forbidden", status: http.StatusForbidden, body: `{}`, wantError: ErrInvalidCredentials}, + {name: "account action", status: http.StatusPreconditionFailed, body: `{}`, wantError: ErrAccountAction}, + {name: "service error", status: http.StatusOK, body: `{"serviceErrors":[{"code":"-22421","message":"Try again later."}]}`, wantMessage: "Try again later. (-22421)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var sessionRequests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/logout": + w.Header().Set("Location", "/signout?widgetKey=widget") + w.WriteHeader(http.StatusFound) + case "/auth/signin/init": + _, _ = io.WriteString(w, `{"iteration":1,"salt":"c2FsdA==","protocol":"s2k","b":"Ag==","c":"challenge"}`) + case "/auth/signin": + w.WriteHeader(http.StatusOK) + case "/auth/signin/complete": + w.Header().Set("X-Apple-ID-Session-Id", "session") + w.Header().Set("scnt", "continuation") + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + case "/olympus/v1/session": + sessionRequests++ + _, _ = io.WriteString(w, `{"provider":{"providerId":42,"publicProviderId":"issuer","name":"Example"}}`) + default: + t.Errorf("unexpected request: %s", r.URL) + } + })) + t.Cleanup(server.Close) + client, err := New(Options{HTTPClient: server.Client(), ASCBaseURL: server.URL, AuthBaseURL: server.URL + "/auth"}) + if err != nil { + t.Fatal(err) + } + session, err := client.Login(context.Background(), "dev@example.com", "secret") + switch { + case tc.twoFactor: + var twoFactor *TwoFactorRequiredError + if !errors.As(err, &twoFactor) || session == nil || session.AppleIDSessionID != "session" || session.SCNT != "continuation" { + t.Fatalf("missing two-factor continuation: session = %+v, error = %v", session, err) + } + case tc.wantMessage != "": + if err == nil || !strings.Contains(err.Error(), tc.wantMessage) { + t.Fatalf("error = %v, want message %q", err, tc.wantMessage) + } + case tc.wantError != nil: + if !errors.Is(err, tc.wantError) { + t.Fatalf("error = %v, want %v", err, tc.wantError) + } + default: + if err != nil || session == nil || session.Provider.ID != 42 { + t.Fatalf("session = %+v, error = %v", session, err) + } + } + wantSessionRequests := 0 + if !tc.twoFactor && tc.wantError == nil && tc.wantMessage == "" { + wantSessionRequests = 1 + } + if sessionRequests != wantSessionRequests { + t.Fatalf("session requests = %d, want %d", sessionRequests, wantSessionRequests) + } + }) + } +} + +func TestAuthServiceKeyUsesIsolatedSignoutRequest(t *testing.T) { + var logoutRequests, signoutRequests, olympusRequests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/logout": + logoutRequests++ + if logoutRequests <= 2 && (r.Method != http.MethodHead || r.Header.Get("Cookie") != "") { + t.Errorf("logout request = %s, cookies = %q", r.Method, r.Header.Get("Cookie")) + } + http.SetCookie(w, &http.Cookie{Name: "session", Value: "destroyed", Path: "/"}) + w.Header().Set("Location", "/appleauth/signout?asop=destroy-session&widgetKey=fresh%2Bkey%3D") + w.WriteHeader(http.StatusFound) + case "/appleauth/signout": + signoutRequests++ + case "/olympus/v1/app/config": + olympusRequests++ + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + httpClient := server.Client() + var redirects int + httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { + redirects++ + return nil + } + client, err := New(Options{HTTPClient: httpClient, ASCBaseURL: server.URL}) + if err != nil { + t.Fatal(err) + } + baseURL, _ := url.Parse(server.URL) + client.httpClient.Jar.SetCookies(baseURL, []*http.Cookie{{Name: "session", Value: "active", Path: "/"}}) + for range 2 { + key, err := client.authServiceKey(context.Background()) + if err != nil || key != "fresh+key=" { + t.Fatalf("service key = %q, error = %v", key, err) + } + } + if logoutRequests != 2 || signoutRequests != 0 || olympusRequests != 0 || redirects != 0 { + t.Fatalf("requests: logout=%d signout=%d olympus=%d redirects=%d", logoutRequests, signoutRequests, olympusRequests, redirects) + } + if cookies := client.httpClient.Jar.Cookies(baseURL); len(cookies) != 1 || cookies[0].Value != "active" { + t.Fatalf("session cookies changed: %v", cookies) + } + resp, err := client.httpClient.Get(server.URL + "/logout") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if redirects != 1 || signoutRequests != 1 { + t.Fatal("the original client's redirect policy was changed") + } +} + +func TestAuthServiceKeyFallsBackToOlympus(t *testing.T) { + cases := []struct { + name string + status int + location string + payload string + }{ + {"missing key", http.StatusFound, "/signout?other=value", `{"authServiceKey":"fallback"}`}, + {"empty key", http.StatusFound, "/signout?widgetKey=%20", `{"serviceKey":"fallback"}`}, + {"missing location", http.StatusFound, "", `{"authServiceKey":"fallback"}`}, + {"invalid location", http.StatusFound, "://invalid", `{"authServiceKey":"fallback"}`}, + {"invalid query", http.StatusFound, "/signout?widgetKey=%ZZ", `{"authServiceKey":"fallback"}`}, + {"server error", http.StatusServiceUnavailable, "/signout?widgetKey=wrong", `{"authServiceKey":"fallback"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/logout": + w.Header().Set("Location", tc.location) + w.WriteHeader(tc.status) + case "/olympus/v1/app/config": + if r.Method != http.MethodGet || r.URL.Query().Get("hostname") != "itunesconnect.apple.com" { + t.Errorf("unexpected fallback request: %s %s", r.Method, r.URL) + } + _, _ = io.WriteString(w, tc.payload) + default: + t.Errorf("unexpected request: %s", r.URL) + } + })) + t.Cleanup(server.Close) + client, err := New(Options{HTTPClient: server.Client(), ASCBaseURL: server.URL}) + if err != nil { + t.Fatal(err) + } + key, err := client.authServiceKey(context.Background()) + if err != nil || key != "fallback" { + t.Fatalf("service key = %q, error = %v", key, err) + } + }) + } +} + +func TestAuthServiceKeyPreservesBothFailures(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/logout" { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + client, err := New(Options{HTTPClient: server.Client(), ASCBaseURL: server.URL}) + if err != nil { + t.Fatal(err) + } + _, err = client.authServiceKey(context.Background()) + if err == nil || !strings.Contains(err.Error(), "signout redirect: status 503") || !strings.Contains(err.Error(), "status 404") { + t.Fatalf("expected both service-key failures, got %v", err) + } +} + +func TestAuthServiceKeyDoesNotFallBackAfterCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var requests int + client, err := New(Options{HTTPClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + requests++ + cancel() + return nil, context.Canceled + })}}) + if err != nil { + t.Fatal(err) + } + _, err = client.authServiceKey(ctx) + if !errors.Is(err, context.Canceled) || requests != 1 { + t.Fatalf("error = %v, requests = %d", err, requests) + } +} + +func TestPrepareTwoFactorReportsAppleServiceErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"noTrustedDevices":true,"serviceErrors":[{"code":"-28248","message":"Verification codes can't be sent to this phone number at this time."}]}`) + })) + t.Cleanup(server.Close) + client, err := New(Options{HTTPClient: server.Client(), AuthBaseURL: server.URL}) + if err != nil { + t.Fatal(err) + } + session := &Session{client: client, ServiceKey: "widget", AppleIDSessionID: "session", SCNT: "scnt"} + _, err = client.PrepareTwoFactor(context.Background(), session, false, "") + if err == nil || !strings.Contains(err.Error(), "Verification codes can't be sent") || !strings.Contains(err.Error(), "-28248") || !strings.Contains(err.Error(), "no trusted devices") { + t.Fatalf("expected Apple's SMS delivery error, got %v", err) + } +} + +func TestCompleteTwoFactorStopsOnAppleVerificationErrors(t *testing.T) { + for _, field := range []string{"serviceErrors", "service_errors", "validationErrors"} { + t.Run(field, func(t *testing.T) { + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + _, _ = io.WriteString(w, `{"`+field+`":[{"code":-21669,"title":"Incorrect Verification Code"}]}`) + })) + t.Cleanup(server.Close) + client, err := New(Options{HTTPClient: server.Client(), AuthBaseURL: server.URL}) + if err != nil { + t.Fatal(err) + } + session := &Session{client: client, ServiceKey: "widget", AppleIDSessionID: "session", SCNT: "scnt", factorMethod: "trusteddevice"} + err = client.CompleteTwoFactor(context.Background(), session, "123456") + if err == nil || !strings.Contains(err.Error(), "Incorrect Verification Code (-21669)") { + t.Fatalf("expected verification error, got %v", err) + } + if len(requests) != 1 || requests[0] != "/verify/trusteddevice/securitycode" { + t.Fatalf("continued after rejected verification: %v", requests) + } + }) + } +} + func TestPreparePasswordMatchesAppleProtocols(t *testing.T) { s2k, err := preparePassword("secret", "s2k") if err != nil { diff --git a/internal/appleconnect/testdata/srp-proof-fixture.md b/internal/appleconnect/testdata/srp-proof-fixture.md new file mode 100644 index 00000000..7945793d --- /dev/null +++ b/internal/appleconnect/testdata/srp-proof-fixture.md @@ -0,0 +1,47 @@ +# SRP proof fixture + +`TestCalculateProofMatchesFastlaneSIRP` uses proofs generated by +[fastlane-sirp at revision 93d14c4e921b0b0408198756b32e1757f8f7e6a8](https://github.com/fastlane/fastlane-sirp/tree/93d14c4e921b0b0408198756b32e1757f8f7e6a8). +The upstream [client](https://github.com/fastlane/fastlane-sirp/blob/93d14c4e921b0b0408198756b32e1757f8f7e6a8/lib/fastlane-sirp/client.rb) +and [proof calculations](https://github.com/fastlane/fastlane-sirp/blob/93d14c4e921b0b0408198756b32e1757f8f7e6a8/lib/fastlane-sirp/sirp.rb) +are independent of the Go implementation. + +Inputs: 2048-bit group, client secret `a = 123456`, server public value +`B = 2`, username `dev@example.com`, password `secret`, salt `salt`, +protocol `s2k`, one PBKDF2 iteration, and a 32-byte derived password. +The fixed client secret is injected only to make this test fixture repeatable. + +Run the following with Ruby and its OpenSSL standard library. Verified with +Ruby 3.2.0. It downloads the pinned source into a temporary directory; no gems +or project dependencies are installed. + +```sh +fixture_dir=$(mktemp -d) +git clone --quiet https://github.com/fastlane/fastlane-sirp.git "$fixture_dir" +git -C "$fixture_dir" checkout --quiet 93d14c4e921b0b0408198756b32e1757f8f7e6a8 +ruby -I "$fixture_dir/lib" <<'RUBY' +require 'fastlane-sirp' +require 'base64' + +client = SIRP::Client.new(2048) +client.instance_variable_set(:@a, 123456) +client.start_authentication +password = OpenSSL::PKCS5.pbkdf2_hmac( + Digest::SHA256.digest('secret'), 'salt', 1, 32, OpenSSL::Digest::SHA256.new +) +m1 = client.process_challenge( + 'dev@example.com', password.unpack1('H*'), 'salt'.unpack1('H*'), '02', + is_password_encrypted: true +) +puts Base64.strict_encode64([m1].pack('H*')) +puts Base64.strict_encode64([client.H_AMK].pack('H*')) +RUBY +rm -rf "$fixture_dir" +``` + +Expected output, in `m1`, `m2` order: + +```text +OL3tMhvYaZcmgV8KO40CG1UZ6Rgw4dDF1bSGsEN6c4s= +548X9ZQ5iGyqHhSBA3s2lDdzcZThics3GqSP20346T0= +```