From 35e4f605485e0d1eff044c99c6f5515a2605f1f2 Mon Sep 17 00:00:00 2001 From: Mike GIllan Date: Mon, 23 Feb 2026 17:24:23 -0500 Subject: [PATCH] iclouddrive: replace plaintext signin with SRP authentication Apple has deprecated the legacy /appleauth/auth/signin endpoint and now blocks it, causing "Invalid Session Token" errors for all users when their trust token expires. The browser login flow now requires SRP (Secure Remote Password), a cryptographic handshake that never transmits the password. Replace Session.SignIn() with a multi-step SRP-6a flow: 1. authStart - initialize session at /authorize/signin 2. authFederate - submit account name to /federate 3. authSRPInit - exchange client public value for salt/B at /signin/init 4. authSRPComplete - send M1/M2 proofs to /signin/complete The SRP implementation uses the RFC 5054 2048-bit group with SHA-256 and Apple's NoUserNameInX variant. Password derivation supports both s2k and s2k_fo protocols via SHA-256 + PBKDF2. The 2FA and trust token flow is unchanged. Auth headers for all idmsa.apple.com requests now include X-Apple-Auth-Attributes, X-Apple-Frame-Id, and use Origin/Referer of https://idmsa.apple.com. Fixes #8587 --- backend/iclouddrive/api/client.go | 17 +- backend/iclouddrive/api/session.go | 353 ++++++++++++++++++++++---- backend/iclouddrive/api/srp.go | 229 +++++++++++++++++ backend/iclouddrive/api/srp_test.go | 379 ++++++++++++++++++++++++++++ docs/content/iclouddrive.md | 18 +- 5 files changed, 945 insertions(+), 51 deletions(-) create mode 100644 backend/iclouddrive/api/srp.go create mode 100644 backend/iclouddrive/api/srp_test.go diff --git a/backend/iclouddrive/api/client.go b/backend/iclouddrive/api/client.go index a0c8af228..350a4fc33 100644 --- a/backend/iclouddrive/api/client.go +++ b/backend/iclouddrive/api/client.go @@ -118,12 +118,19 @@ func (c *Client) Authenticate(ctx context.Context) error { fs.Debugf("icloud", "Authenticating as %s\n", c.appleID) err := c.Session.SignIn(ctx, c.appleID, c.password) + if err != nil { + return err + } - if err == nil { - err = c.Session.AuthWithToken(ctx) - if err == nil && c.sessionSaveCallback != nil { - c.sessionSaveCallback(c.Session) - } + // If 2FA is required, don't try AuthWithToken yet — the caller + // must complete 2FA first, then call AuthWithToken. + if c.Session.Requires2FA() { + return nil + } + + err = c.Session.AuthWithToken(ctx) + if err == nil && c.sessionSaveCallback != nil { + c.sessionSaveCallback(c.Session) } return err } diff --git a/backend/iclouddrive/api/session.go b/backend/iclouddrive/api/session.go index 0fd9072de..8c352c961 100644 --- a/backend/iclouddrive/api/session.go +++ b/backend/iclouddrive/api/session.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/base64" "fmt" "maps" "net/http" @@ -9,11 +10,15 @@ import ( "slices" "strings" + "github.com/google/uuid" "github.com/oracle/oci-go-sdk/v65/common" + "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/fshttp" "github.com/rclone/rclone/lib/rest" ) +const iCloudUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15" + // Session represents an iCloud session type Session struct { SessionToken string `json:"session_token"` @@ -22,10 +27,22 @@ type Session struct { AccountCountry string `json:"account_country"` TrustToken string `json:"trust_token"` ClientID string `json:"client_id"` + AuthAttributes string `json:"auth_attributes"` + FrameID string `json:"frame_id"` Cookies []*http.Cookie `json:"cookies"` AccountInfo AccountInfo `json:"account_info"` - srv *rest.Client `json:"-"` + srv *rest.Client `json:"-"` + needs2FA bool `json:"-"` // set when SRP signin returns 409 +} + +// srpInitResponse is the server response from /auth/signin/init +type srpInitResponse struct { + Iteration int `json:"iteration"` + Salt string `json:"salt"` + Protocol string `json:"protocol"` + B string `json:"b"` + C string `json:"c"` } // String returns the session as a string @@ -57,24 +74,217 @@ func (s *Session) Request(ctx context.Context, opts rest.Opts, request any, resp if val := resp.Header.Get("scnt"); val != "" { s.Scnt = val } + if val := resp.Header.Get("X-Apple-Auth-Attributes"); val != "" { + s.AuthAttributes = val + } return resp, nil } // Requires2FA returns true if the session requires 2FA func (s *Session) Requires2FA() bool { - return s.AccountInfo.DsInfo.HsaVersion == 2 && s.AccountInfo.HsaChallengeRequired + if s.needs2FA { + return true + } + return s.AccountInfo.DsInfo != nil && s.AccountInfo.DsInfo.HsaVersion == 2 && s.AccountInfo.HsaChallengeRequired } -// SignIn signs in the session +// SignIn performs SRP-based authentication against Apple's idmsa endpoint. func (s *Session) SignIn(ctx context.Context, appleID, password string) error { + // Step 1: Initialize the auth session + if err := s.authStart(ctx); err != nil { + return fmt.Errorf("authStart: %w", err) + } + + // Step 2: Federate (submit account name) + if err := s.authFederate(ctx, appleID); err != nil { + return fmt.Errorf("authFederate: %w", err) + } + + // Step 3: SRP init — send client public value A, get salt + B + client := newSRPClient() + aBase64 := base64.StdEncoding.EncodeToString(client.getABytes()) + + initResp, err := s.authSRPInit(ctx, aBase64, appleID) + if err != nil { + return fmt.Errorf("authSRPInit: %w", err) + } + + // Decode server values + serverB, err := base64.StdEncoding.DecodeString(initResp.B) + if err != nil { + return fmt.Errorf("decode B: %w", err) + } + salt, err := base64.StdEncoding.DecodeString(initResp.Salt) + if err != nil { + return fmt.Errorf("decode salt: %w", err) + } + + // Step 4: Derive password key and process the SRP challenge + derivedKey, err := derivePassword(password, salt, initResp.Iteration, initResp.Protocol) + if err != nil { + return fmt.Errorf("derivePassword: %w", err) + } + client.processChallenge([]byte(appleID), derivedKey, salt, serverB) + + // Step 5: Complete — send M1, M2 proofs + m1Base64 := base64.StdEncoding.EncodeToString(client.M1) + m2Base64 := base64.StdEncoding.EncodeToString(client.M2) + + if err := s.authSRPComplete(ctx, appleID, m1Base64, m2Base64, initResp.C); err != nil { + return fmt.Errorf("authSRPComplete: %w", err) + } + + return nil +} + +// authStart initializes the SRP auth session by hitting the authorize/signin endpoint. +func (s *Session) authStart(ctx context.Context) error { + if s.FrameID == "" { + s.FrameID = strings.ToLower(uuid.New().String()) + } + frameTag := "auth-" + s.FrameID + + params := url.Values{} + params.Set("frame_id", frameTag) + params.Set("language", "en_US") + params.Set("skVersion", "7") + params.Set("iframeId", frameTag) + params.Set("client_id", s.ClientID) + params.Set("redirect_uri", "https://www.icloud.com") + params.Set("response_type", "code") + params.Set("response_mode", "web_message") + params.Set("state", frameTag) + params.Set("authVersion", "latest") + + opts := rest.Opts{ + Method: "GET", + Path: "/authorize/signin", + Parameters: params, + ExtraHeaders: map[string]string{ + "Accept": "*/*", + "User-Agent": iCloudUserAgent, + }, + RootURL: authEndpoint, + NoResponse: true, + } + + resp, err := s.srv.Call(ctx, &opts) + if err != nil { + return err + } + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("authStart: unexpected status %s", resp.Status) + } + + if val := resp.Header.Get("X-Apple-Auth-Attributes"); val != "" { + s.AuthAttributes = val + } + if val := resp.Header.Get("scnt"); val != "" { + s.Scnt = val + } + if val := resp.Header.Get("X-Apple-ID-Session-Id"); val != "" { + s.SessionID = val + } + + return nil +} + +// authFederate submits the account name to Apple's federate endpoint. +func (s *Session) authFederate(ctx context.Context, accountName string) error { + values := map[string]any{ + "accountName": accountName, + "rememberMe": true, + } + body, err := IntoReader(values) + if err != nil { + return err + } + + opts := rest.Opts{ + Method: "POST", + Path: "/federate", + Parameters: url.Values{"isRememberMeEnabled": {"true"}}, + ExtraHeaders: s.getSRPAuthHeaders(), + RootURL: authEndpoint, + Body: body, + NoResponse: true, + } + + resp, err := s.srv.Call(ctx, &opts) + if err != nil { + return err + } + _ = resp.Body.Close() + + if val := resp.Header.Get("X-Apple-Auth-Attributes"); val != "" { + s.AuthAttributes = val + } + if val := resp.Header.Get("scnt"); val != "" { + s.Scnt = val + } + if val := resp.Header.Get("X-Apple-ID-Session-Id"); val != "" { + s.SessionID = val + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("authFederate: unexpected status %s", resp.Status) + } + return nil +} + +// authSRPInit sends the client's public value A to the server and retrieves +// the salt, server public value B, iteration count, protocol, and challenge. +func (s *Session) authSRPInit(ctx context.Context, aBase64, accountName string) (*srpInitResponse, error) { + values := map[string]any{ + "a": aBase64, + "accountName": accountName, + "protocols": []string{"s2k", "s2k_fo"}, + } + body, err := IntoReader(values) + if err != nil { + return nil, err + } + + opts := rest.Opts{ + Method: "POST", + Path: "/signin/init", + ExtraHeaders: s.getSRPAuthHeaders(), + RootURL: authEndpoint, + Body: body, + } + + var initResp srpInitResponse + resp, err := s.srv.CallJSON(ctx, &opts, nil, &initResp) + if err != nil { + return nil, err + } + + if val := resp.Header.Get("scnt"); val != "" { + s.Scnt = val + } + if val := resp.Header.Get("X-Apple-ID-Session-Id"); val != "" { + s.SessionID = val + } + + return &initResp, nil +} + +// authSRPComplete sends the SRP proofs M1 and M2 to complete authentication. +// Returns nil on success (200 or 409/2FA needed). +func (s *Session) authSRPComplete(ctx context.Context, accountName, m1Base64, m2Base64, c string) error { trustTokens := []string{} if s.TrustToken != "" { trustTokens = []string{s.TrustToken} } + values := map[string]any{ - "accountName": appleID, - "password": password, + "accountName": accountName, + "m1": m1Base64, + "m2": m2Base64, + "c": c, "rememberMe": true, "trustTokens": trustTokens, } @@ -82,21 +292,93 @@ func (s *Session) SignIn(ctx context.Context, appleID, password string) error { if err != nil { return err } + opts := rest.Opts{ Method: "POST", - Path: "/signin", - Parameters: url.Values{}, - ExtraHeaders: s.GetAuthHeaders(map[string]string{}), + Path: "/signin/complete", + Parameters: url.Values{"isRememberMeEnabled": {"true"}}, + ExtraHeaders: s.getSRPAuthHeaders(), RootURL: authEndpoint, - IgnoreStatus: true, // need to handle 409 for hsa2 + IgnoreStatus: true, NoResponse: true, Body: body, } - opts.Parameters.Set("isRememberMeEnabled", "true") - _, err = s.Request(ctx, opts, nil, nil) - return err + resp, err := s.srv.Call(ctx, &opts) + if err != nil { + return err + } + _ = resp.Body.Close() + // Extract updated headers + if val := resp.Header.Get("X-Apple-Auth-Attributes"); val != "" { + s.AuthAttributes = val + } + if val := resp.Header.Get("X-Apple-Session-Token"); val != "" { + s.SessionToken = val + } + if val := resp.Header.Get("scnt"); val != "" { + s.Scnt = val + } + if val := resp.Header.Get("X-Apple-ID-Session-Id"); val != "" { + s.SessionID = val + } + if val := resp.Header.Get("X-Apple-ID-Account-Country"); val != "" { + s.AccountCountry = val + } + if val := resp.Header.Get("X-Apple-TwoSV-Trust-Token"); val != "" { + s.TrustToken = val + } + + switch resp.StatusCode { + case http.StatusOK: + fs.Debugf("icloud", "SRP sign in successful") + return nil + case http.StatusConflict: + // 409 = 2FA required, this is expected + fs.Debugf("icloud", "SRP sign in requires 2FA") + s.needs2FA = true + return nil + case http.StatusForbidden: + return fmt.Errorf("sign in failed: incorrect username or password") + default: + return fmt.Errorf("sign in failed: %s", resp.Status) + } +} + +// getSRPAuthHeaders returns headers needed for SRP auth requests to idmsa.apple.com. +func (s *Session) getSRPAuthHeaders() map[string]string { + frameTag := "auth-" + s.FrameID + headers := map[string]string{ + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": iCloudUserAgent, + "Origin": "https://idmsa.apple.com", + "Referer": "https://idmsa.apple.com/", + "X-Apple-Widget-Key": s.ClientID, + "X-Apple-OAuth-Client-Id": s.ClientID, + "X-Apple-OAuth-Client-Type": "firstPartyAuth", + "X-Apple-OAuth-Redirect-URI": "https://www.icloud.com", + "X-Apple-OAuth-Require-Grant-Code": "true", + "X-Apple-OAuth-Response-Mode": "web_message", + "X-Apple-OAuth-Response-Type": "code", + "X-Apple-OAuth-State": frameTag, + "X-Apple-Frame-Id": frameTag, + "X-Requested-With": "XMLHttpRequest", + "X-Apple-Mandate-Security-Upgrade": "0", + "X-Apple-I-Require-UE": "true", + "X-Apple-I-FD-Client-Info": `{"U":"` + iCloudUserAgent + `","L":"en-US","Z":"GMT-05:00","V":"1.1","F":""}`, + } + if s.AuthAttributes != "" { + headers["X-Apple-Auth-Attributes"] = s.AuthAttributes + } + if s.Scnt != "" { + headers["scnt"] = s.Scnt + } + if s.SessionID != "" { + headers["X-Apple-ID-Session-Id"] = s.SessionID + } + return headers } // AuthWithToken authenticates the session @@ -135,14 +417,10 @@ func (s *Session) Validate2FACode(ctx context.Context, code string) error { return err } - headers := s.GetAuthHeaders(map[string]string{}) - headers["scnt"] = s.Scnt - headers["X-Apple-ID-Session-Id"] = s.SessionID - opts := rest.Opts{ Method: "POST", Path: "/verify/trusteddevice/securitycode", - ExtraHeaders: headers, + ExtraHeaders: s.GetAuthHeaders(map[string]string{}), RootURL: authEndpoint, Body: body, NoResponse: true, @@ -162,14 +440,10 @@ func (s *Session) Validate2FACode(ctx context.Context, code string) error { // TrustSession trusts the session func (s *Session) TrustSession(ctx context.Context) error { - headers := s.GetAuthHeaders(map[string]string{}) - headers["scnt"] = s.Scnt - headers["X-Apple-ID-Session-Id"] = s.SessionID - opts := rest.Opts{ Method: "GET", Path: "/2sv/trust", - ExtraHeaders: headers, + ExtraHeaders: s.GetAuthHeaders(map[string]string{}), RootURL: authEndpoint, NoResponse: true, ContentLength: common.Int64(0), @@ -201,25 +475,9 @@ func (s *Session) ValidateSession(ctx context.Context) error { } // GetAuthHeaders returns the authentication headers for the session. -// -// It takes an `overwrite` map[string]string parameter which allows -// overwriting the default headers. It returns a map[string]string. +// Used for 2FA validation and trust requests to idmsa.apple.com. func (s *Session) GetAuthHeaders(overwrite map[string]string) map[string]string { - headers := map[string]string{ - "Accept": "application/json", - "Content-Type": "application/json", - "X-Apple-OAuth-Client-Id": s.ClientID, - "X-Apple-OAuth-Client-Type": "firstPartyAuth", - "X-Apple-OAuth-Redirect-URI": "https://www.icloud.com", - "X-Apple-OAuth-Require-Grant-Code": "true", - "X-Apple-OAuth-Response-Mode": "web_message", - "X-Apple-OAuth-Response-Type": "code", - "X-Apple-OAuth-State": s.ClientID, - "X-Apple-Widget-Key": s.ClientID, - "Origin": homeEndpoint, - "Referer": fmt.Sprintf("%s/", homeEndpoint), - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:103.0) Gecko/20100101 Firefox/103.0", - } + headers := s.getSRPAuthHeaders() maps.Copy(headers, overwrite) return headers } @@ -248,7 +506,7 @@ func GetCommonHeaders(overwrite map[string]string) map[string]string { "Content-Type": "application/json", "Origin": baseEndpoint, "Referer": fmt.Sprintf("%s/", baseEndpoint), - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:103.0) Gecko/20100101 Firefox/103.0", + "User-Agent": iCloudUserAgent, } maps.Copy(headers, overwrite) return headers @@ -281,9 +539,16 @@ func GetCookiesForDomain(url *url.URL, cookies []*http.Cookie) ([]*http.Cookie, // NewSession creates a new Session instance with default values. func NewSession() *Session { - session := &Session{} - session.srv = rest.NewClient(fshttp.NewClient(context.Background())).SetRoot(baseEndpoint) - //session.ClientID = "auth-" + uuid.New().String() + session := &Session{ + FrameID: strings.ToLower(uuid.New().String()), + } + httpClient := fshttp.NewClient(context.Background()) + if tr, ok := httpClient.Transport.(*fshttp.Transport); ok { + tr.SetRequestFilter(func(req *http.Request) { + req.Header.Set("User-Agent", iCloudUserAgent) + }) + } + session.srv = rest.NewClient(httpClient).SetRoot(baseEndpoint) return session } diff --git a/backend/iclouddrive/api/srp.go b/backend/iclouddrive/api/srp.go new file mode 100644 index 000000000..e2f7c7f66 --- /dev/null +++ b/backend/iclouddrive/api/srp.go @@ -0,0 +1,229 @@ +package api + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "math/big" +) + +// RFC 5054 2048-bit group parameters for SRP-6a (g=2, SHA-256). +var ( + srpN = mustParseBigHex( + "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050" + + "A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50" + + "E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8" + + "55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B" + + "CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748" + + "544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB37861602790" + + "04E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8" + + "E9DBFBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F" + + "9E4AFF73") + srpG = big.NewInt(2) + srpNLenBytes = 2048 / 8 + srpHashFunc = sha256.New +) + +func mustParseBigHex(s string) *big.Int { + b, ok := new(big.Int).SetString(s, 16) + if !ok { + panic("srp: bad hex constant for N") + } + return b +} + +// srpClient implements the client side of SRP-6a for Apple's iCloud auth. +type srpClient struct { + a *big.Int // client secret + A *big.Int // client public value + k *big.Int // multiplier + M1 []byte + M2 []byte + K []byte +} + +// newSRPClient generates a random 32-byte secret and computes the public value A. +func newSRPClient() *srpClient { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + panic(fmt.Sprintf("srp: rand.Read failed: %v", err)) + } + + a := new(big.Int).SetBytes(secret) + A := new(big.Int).Exp(srpG, a, srpN) + k := getMultiplier() + + return &srpClient{ + a: a, + A: A, + k: k, + } +} + +// getABytes returns the padded public value A. +func (c *srpClient) getABytes() []byte { + return padToN(c.A) +} + +// processChallenge computes the session key and proof values from the server challenge. +// username is the Apple ID, derivedKey is the output of derivePassword, salt and B come +// from the server's init response (raw bytes, already base64-decoded). +func (c *srpClient) processChallenge(username, derivedKey, salt, serverB []byte) { + B := new(big.Int).SetBytes(serverB) + + // Validate B + if B.Cmp(big.NewInt(0)) <= 0 || B.Cmp(srpN) >= 0 { + panic("srp: invalid server-supplied B, must be 1..N-1") + } + + x := calculateX(salt, derivedKey) + u := calculateU(c.A, B) + S := calculateS(c.k, x, c.a, B, u) + c.K = calculateK(S) + + aBytes := padToN(c.A) + bBytes := padToN(B) + c.M1 = calculateM1(username, salt, aBytes, bBytes, c.K) + c.M2 = calculateM2(aBytes, c.M1, c.K) +} + +// derivePassword performs Apple's password key derivation. +// For "s2k": PBKDF2(SHA256(password), salt, iterations, 32, SHA256) +// For "s2k_fo": PBKDF2(hex(SHA256(password)), salt, iterations, 32, SHA256) +func derivePassword(password string, salt []byte, iterations int, protocol string) ([]byte, error) { + passHash := sha256.Sum256([]byte(password)) + + var passInput string + switch protocol { + case "s2k_fo": + passInput = hex.EncodeToString(passHash[:]) + default: // "s2k" + passInput = string(passHash[:]) + } + return pbkdf2.Key(sha256.New, passInput, salt, iterations, 32) +} + +// padToN pads a big.Int to the SRP group size (256 bytes for 2048-bit). +func padToN(n *big.Int) []byte { + b := n.Bytes() + if len(b) >= srpNLenBytes { + return b + } + padded := make([]byte, srpNLenBytes) + copy(padded[srpNLenBytes-len(b):], b) + return padded +} + +func srpHash(data []byte) []byte { + h := srpHashFunc() + h.Write(data) + return h.Sum(nil) +} + +func hashToInt(h hash.Hash) *big.Int { + return new(big.Int).SetBytes(h.Sum(nil)) +} + +// getMultiplier computes k = H(N | pad(g)) +func getMultiplier() *big.Int { + h := srpHashFunc() + nBytes := srpN.Bytes() + gBytes := srpG.Bytes() + // pad g to same length as N + for len(gBytes) < len(nBytes) { + gBytes = append([]byte{0}, gBytes...) + } + h.Write(nBytes) + h.Write(gBytes) + return hashToInt(h) +} + +// calculateX computes x = H(salt | H(":" | password)) +// Apple's variant: NoUserNameInX — username is omitted from the inner hash. +// The "password" here is actually the derived key from derivePassword. +func calculateX(salt, derivedKey []byte) *big.Int { + h := srpHashFunc() + // Apple omits username (NoUserNameInX), only writes ":" + derivedKey + h.Write([]byte(":")) + h.Write(derivedKey) + digest := h.Sum(nil) + + h2 := srpHashFunc() + h2.Write(salt) + h2.Write(digest) + return new(big.Int).SetBytes(h2.Sum(nil)) +} + +// calculateU computes u = H(pad(A) | pad(B)) +func calculateU(A, B *big.Int) *big.Int { + h := srpHashFunc() + h.Write(padToN(A)) + h.Write(padToN(B)) + return hashToInt(h) +} + +// calculateS computes the client-side shared secret: +// S = (B - k * g^x) ^ (a + u*x) mod N +func calculateS(k, x, a, B, u *big.Int) []byte { + // g^x mod N + gx := new(big.Int).Exp(srpG, x, srpN) + + // k * g^x + kgx := new(big.Int).Mul(k, gx) + + // B - k*g^x + diff := new(big.Int).Sub(B, kgx) + + // u*x + ux := new(big.Int).Mul(u, x) + + // a + u*x + exp := new(big.Int).Add(a, ux) + + // (B - k*g^x) ^ (a + u*x) mod N + S := new(big.Int).Exp(diff, exp, srpN) + + // Ensure positive + S.Mod(S, srpN) + + return padToN(S) +} + +// calculateK computes K = H(S) +func calculateK(S []byte) []byte { + return srpHash(S) +} + +// calculateM1 computes the client proof (Apple/iCloud variant): +// M1 = H(H(g) XOR H(N) | H(username) | salt | A | B | K) +func calculateM1(username, salt, A, B, K []byte) []byte { + hg := srpHash(padToN(srpG)) + hn := srpHash(srpN.Bytes()) + hi := srpHash(username) + + hxor := make([]byte, len(hg)) + for i := range hg { + hxor[i] = hg[i] ^ hn[i] + } + + h := srpHashFunc() + h.Write(hxor) + h.Write(hi) + h.Write(salt) + h.Write(A) + h.Write(B) + h.Write(K) + return h.Sum(nil) +} + +// calculateM2 computes the server proof: M2 = H(A | M1 | K) +func calculateM2(A, M1, K []byte) []byte { + h := srpHashFunc() + h.Write(A) + h.Write(M1) + h.Write(K) + return h.Sum(nil) +} diff --git a/backend/iclouddrive/api/srp_test.go b/backend/iclouddrive/api/srp_test.go new file mode 100644 index 000000000..3136eda55 --- /dev/null +++ b/backend/iclouddrive/api/srp_test.go @@ -0,0 +1,379 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPadToN(t *testing.T) { + t.Run("pads small value to 256 bytes", func(t *testing.T) { + result := padToN(big.NewInt(1)) + assert.Equal(t, srpNLenBytes, len(result)) + assert.Equal(t, byte(0), result[0]) + assert.Equal(t, byte(1), result[srpNLenBytes-1]) + }) + + t.Run("pads zero to 256 bytes", func(t *testing.T) { + result := padToN(big.NewInt(0)) + assert.Equal(t, srpNLenBytes, len(result)) + for _, b := range result { + assert.Equal(t, byte(0), b) + } + }) + + t.Run("preserves value that fills N", func(t *testing.T) { + // Use N itself — already 256 bytes + result := padToN(srpN) + assert.Equal(t, srpNLenBytes, len(result)) + assert.Equal(t, srpN.Bytes(), result) + }) + + t.Run("round-trips through big.Int", func(t *testing.T) { + original := big.NewInt(123456789) + padded := padToN(original) + recovered := new(big.Int).SetBytes(padded) + assert.Equal(t, 0, original.Cmp(recovered)) + }) +} + +func TestGetMultiplier(t *testing.T) { + k := getMultiplier() + + // k must be non-zero and less than N + assert.True(t, k.Sign() > 0, "multiplier must be positive") + assert.True(t, k.Cmp(srpN) < 0, "multiplier must be less than N") + + // k is deterministic + k2 := getMultiplier() + assert.Equal(t, 0, k.Cmp(k2), "multiplier must be deterministic") + + // k = H(N | pad(g)) — verify by manual computation + h := srpHashFunc() + nBytes := srpN.Bytes() + gBytes := srpG.Bytes() + for len(gBytes) < len(nBytes) { + gBytes = append([]byte{0}, gBytes...) + } + h.Write(nBytes) + h.Write(gBytes) + expected := new(big.Int).SetBytes(h.Sum(nil)) + assert.Equal(t, 0, expected.Cmp(k), "multiplier must equal H(N|pad(g))") +} + +func TestDerivePassword(t *testing.T) { + salt := []byte("test_salt_value!") + password := "test_password" + + t.Run("s2k produces 32-byte key", func(t *testing.T) { + key, err := derivePassword(password, salt, 1000, "s2k") + require.NoError(t, err) + assert.Equal(t, 32, len(key)) + }) + + t.Run("s2k_fo produces 32-byte key", func(t *testing.T) { + key, err := derivePassword(password, salt, 1000, "s2k_fo") + require.NoError(t, err) + assert.Equal(t, 32, len(key)) + }) + + t.Run("s2k and s2k_fo produce different keys", func(t *testing.T) { + key1, err := derivePassword(password, salt, 1000, "s2k") + require.NoError(t, err) + key2, err := derivePassword(password, salt, 1000, "s2k_fo") + require.NoError(t, err) + assert.NotEqual(t, key1, key2, "s2k and s2k_fo must produce different keys") + }) + + t.Run("deterministic output", func(t *testing.T) { + key1, err := derivePassword(password, salt, 1000, "s2k") + require.NoError(t, err) + key2, err := derivePassword(password, salt, 1000, "s2k") + require.NoError(t, err) + assert.Equal(t, key1, key2) + }) + + t.Run("different passwords produce different keys", func(t *testing.T) { + key1, err := derivePassword("password1", salt, 1000, "s2k") + require.NoError(t, err) + key2, err := derivePassword("password2", salt, 1000, "s2k") + require.NoError(t, err) + assert.NotEqual(t, key1, key2) + }) + + t.Run("s2k uses raw SHA256 bytes", func(t *testing.T) { + // Verify that s2k uses SHA256(password) as the PBKDF2 input. + // Two passwords with the same SHA256 hash would produce the same key, + // while two passwords with different SHA256 hashes must produce different keys. + key1, err := derivePassword("hello", salt, 10, "s2k") + require.NoError(t, err) + key2, err := derivePassword("world", salt, 10, "s2k") + require.NoError(t, err) + assert.NotEqual(t, key1, key2) + }) + + t.Run("s2k_fo uses hex-encoded SHA256", func(t *testing.T) { + // For s2k_fo, the PBKDF2 input is hex(SHA256(password)), which is + // a 64-character ASCII string, not raw 32-byte hash. + key, err := derivePassword("hello", salt, 10, "s2k_fo") + require.NoError(t, err) + assert.Equal(t, 32, len(key)) + }) +} + +func TestCalculateX(t *testing.T) { + salt := []byte("some_salt") + derivedKey := []byte("some_derived_key_value_32_bytes!") + + x := calculateX(salt, derivedKey) + + // x must be positive and less than the hash output space + assert.True(t, x.Sign() > 0, "x must be positive") + + // x is deterministic + x2 := calculateX(salt, derivedKey) + assert.Equal(t, 0, x.Cmp(x2), "x must be deterministic") + + // Verify manually: x = H(salt | H(":" | derivedKey)) + h1 := srpHashFunc() + h1.Write([]byte(":")) + h1.Write(derivedKey) + inner := h1.Sum(nil) + + h2 := srpHashFunc() + h2.Write(salt) + h2.Write(inner) + expected := new(big.Int).SetBytes(h2.Sum(nil)) + assert.Equal(t, 0, expected.Cmp(x), "x must equal H(salt | H(\":\" | derivedKey))") +} + +func TestCalculateU(t *testing.T) { + A := big.NewInt(12345) + B := big.NewInt(67890) + + u := calculateU(A, B) + assert.True(t, u.Sign() > 0, "u must be positive") + + // u is deterministic + u2 := calculateU(A, B) + assert.Equal(t, 0, u.Cmp(u2)) + + // u changes when inputs change + u3 := calculateU(A, big.NewInt(99999)) + assert.NotEqual(t, 0, u.Cmp(u3), "u must change when B changes") + + // Verify: u = H(pad(A) | pad(B)) + h := srpHashFunc() + h.Write(padToN(A)) + h.Write(padToN(B)) + expected := new(big.Int).SetBytes(h.Sum(nil)) + assert.Equal(t, 0, expected.Cmp(u)) +} + +func TestCalculateK(t *testing.T) { + S := []byte("some_shared_secret_value_32bytes") + + K := calculateK(S) + + // K is a SHA-256 hash, so 32 bytes + assert.Equal(t, sha256.Size, len(K)) + + // K is deterministic + K2 := calculateK(S) + assert.Equal(t, K, K2) + + // Verify: K = H(S) + expected := srpHash(S) + assert.Equal(t, expected, K) +} + +func TestCalculateM1(t *testing.T) { + username := []byte("test@example.com") + salt := []byte("test_salt") + A := make([]byte, srpNLenBytes) + B := make([]byte, srpNLenBytes) + K := make([]byte, 32) + + // Fill with non-zero values + A[srpNLenBytes-1] = 0x42 + B[srpNLenBytes-1] = 0x43 + K[0] = 0x44 + + m1 := calculateM1(username, salt, A, B, K) + + // M1 is a SHA-256 hash + assert.Equal(t, sha256.Size, len(m1)) + + // M1 is deterministic + m1b := calculateM1(username, salt, A, B, K) + assert.Equal(t, m1, m1b) + + // M1 changes when username changes + m1c := calculateM1([]byte("other@example.com"), salt, A, B, K) + assert.NotEqual(t, m1, m1c) + + // Verify: M1 = H(H(g) XOR H(N) | H(username) | salt | A | B | K) + hg := srpHash(padToN(srpG)) + hn := srpHash(srpN.Bytes()) + hi := srpHash(username) + hxor := make([]byte, len(hg)) + for i := range hg { + hxor[i] = hg[i] ^ hn[i] + } + h := srpHashFunc() + h.Write(hxor) + h.Write(hi) + h.Write(salt) + h.Write(A) + h.Write(B) + h.Write(K) + expected := h.Sum(nil) + assert.Equal(t, expected, m1) +} + +func TestCalculateM2(t *testing.T) { + A := make([]byte, srpNLenBytes) + M1 := make([]byte, 32) + K := make([]byte, 32) + + A[srpNLenBytes-1] = 0x01 + M1[0] = 0x02 + K[0] = 0x03 + + m2 := calculateM2(A, M1, K) + assert.Equal(t, sha256.Size, len(m2)) + + // Verify: M2 = H(A | M1 | K) + h := srpHashFunc() + h.Write(A) + h.Write(M1) + h.Write(K) + assert.Equal(t, h.Sum(nil), m2) +} + +func TestNewSRPClient(t *testing.T) { + client := newSRPClient() + + // A must be non-zero and in range [1, N-1] + assert.True(t, client.A.Sign() > 0, "A must be positive") + assert.True(t, client.A.Cmp(srpN) < 0, "A must be less than N") + + // A = g^a mod N + expectedA := new(big.Int).Exp(srpG, client.a, srpN) + assert.Equal(t, 0, expectedA.Cmp(client.A), "A must equal g^a mod N") + + // getABytes returns padded A + aBytes := client.getABytes() + assert.Equal(t, srpNLenBytes, len(aBytes)) + recoveredA := new(big.Int).SetBytes(aBytes) + assert.Equal(t, 0, client.A.Cmp(recoveredA)) + + // Two clients should have different secrets (probabilistic, but 2^256 collision chance) + client2 := newSRPClient() + assert.NotEqual(t, 0, client.a.Cmp(client2.a), "two clients must have different secrets") +} + +func TestProcessChallenge(t *testing.T) { + // Use a fixed secret for deterministic testing + fixedSecret := make([]byte, 32) + fixedSecret[31] = 0x42 // small non-zero value + + a := new(big.Int).SetBytes(fixedSecret) + A := new(big.Int).Exp(srpG, a, srpN) + k := getMultiplier() + + client := &srpClient{ + a: a, + A: A, + k: k, + } + + username := []byte("testuser@apple.com") + derivedKey := make([]byte, 32) + for i := range derivedKey { + derivedKey[i] = byte(i) + } + salt := []byte("fixed_salt_for_test!") + + // Compute a valid B: B = (k*v + g^b) mod N where v = g^x mod N + x := calculateX(salt, derivedKey) + v := new(big.Int).Exp(srpG, x, srpN) + + bSecret := make([]byte, 32) + bSecret[31] = 0x99 + b := new(big.Int).SetBytes(bSecret) + + // B = (k*v + g^b) mod N + kv := new(big.Int).Mul(k, v) + gb := new(big.Int).Exp(srpG, b, srpN) + B := new(big.Int).Add(kv, gb) + B.Mod(B, srpN) + + serverB := padToN(B) + + client.processChallenge(username, derivedKey, salt, serverB) + + // M1, M2, K must be populated + assert.NotNil(t, client.M1) + assert.NotNil(t, client.M2) + assert.NotNil(t, client.K) + assert.Equal(t, sha256.Size, len(client.M1)) + assert.Equal(t, sha256.Size, len(client.M2)) + assert.Equal(t, sha256.Size, len(client.K)) + + // M1 and M2 must be different + assert.NotEqual(t, client.M1, client.M2) + + // Results must be deterministic — run again with same inputs + client2 := &srpClient{ + a: new(big.Int).Set(a), + A: new(big.Int).Set(A), + k: new(big.Int).Set(k), + } + client2.processChallenge(username, derivedKey, salt, serverB) + + assert.Equal(t, client.M1, client2.M1, "M1 must be deterministic") + assert.Equal(t, client.M2, client2.M2, "M2 must be deterministic") + assert.Equal(t, client.K, client2.K, "K must be deterministic") + + // Verify the server could also compute S and get the same K. + // Server computes: S_server = (A * v^u)^b mod N + u := calculateU(A, B) + vu := new(big.Int).Exp(v, u, srpN) + avu := new(big.Int).Mul(A, vu) + avu.Mod(avu, srpN) + sServer := new(big.Int).Exp(avu, b, srpN) + sServer.Mod(sServer, srpN) + kServer := calculateK(padToN(sServer)) + assert.Equal(t, client.K, kServer, "client and server must derive the same session key") +} + +func TestSRPGroupParameters(t *testing.T) { + // Verify the 2048-bit prime starts with known hex prefix + nHex := hex.EncodeToString(srpN.Bytes()) + assert.True(t, len(nHex) > 0) + assert.Equal(t, "ac6bdb41", nHex[:8], "N must start with known prefix") + + // g must be 2 + assert.Equal(t, int64(2), srpG.Int64()) + + // N byte length must match expected + assert.Equal(t, 256, srpNLenBytes) + assert.Equal(t, 256, len(srpN.Bytes())) +} + +func TestSRPHash(t *testing.T) { + data := []byte("hello world") + hash := srpHash(data) + + // Must be SHA-256 output + assert.Equal(t, sha256.Size, len(hash)) + + // Must equal standard sha256 + expected := sha256.Sum256(data) + assert.Equal(t, expected[:], hash) +} diff --git a/docs/content/iclouddrive.md b/docs/content/iclouddrive.md index 6d29fe84c..af18796fd 100644 --- a/docs/content/iclouddrive.md +++ b/docs/content/iclouddrive.md @@ -12,13 +12,27 @@ The initial setup for an iCloud Drive backend involves getting a trust token/ses This can be done by simply using the regular iCloud password, and accepting the code prompt on another iCloud connected device. -**IMPORTANT**: At the moment an app specific password won't be accepted. Only -use your regular password and 2FA. +**IMPORTANT**: App-specific passwords are not accepted. Only use your +regular Apple ID password and 2FA. `rclone config` walks you through the token creation. The trust token is valid for 30 days. After which you will have to reauthenticate with `rclone reconnect` or `rclone config`. +## Authentication + +rclone authenticates with Apple using [SRP (Secure Remote Password)](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol), +the same protocol used by the iCloud web interface. Your password is never +sent to Apple's servers -- instead, a cryptographic proof is exchanged that +verifies you know the password without revealing it. + +The authentication flow is: + +1. rclone initiates a session with Apple's identity service +2. An SRP key exchange takes place (your password is used locally to derive a key) +3. Apple sends a 2FA prompt to your trusted devices +4. After you enter the 2FA code, rclone receives a trust token for future sessions + Here is an example of how to make a remote called `iclouddrive`. First run: ```console