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
This commit is contained in:
Mike GIllan
2026-04-02 17:52:56 +01:00
committed by Nick Craig-Wood
parent 839a3b92e0
commit 35e4f60548
5 changed files with 945 additions and 51 deletions
+12 -5
View File
@@ -118,12 +118,19 @@ func (c *Client) Authenticate(ctx context.Context) error {
fs.Debugf("icloud", "Authenticating as %s\n", c.appleID) fs.Debugf("icloud", "Authenticating as %s\n", c.appleID)
err := c.Session.SignIn(ctx, c.appleID, c.password) err := c.Session.SignIn(ctx, c.appleID, c.password)
if err != nil {
return err
}
if err == nil { // If 2FA is required, don't try AuthWithToken yet — the caller
err = c.Session.AuthWithToken(ctx) // must complete 2FA first, then call AuthWithToken.
if err == nil && c.sessionSaveCallback != nil { if c.Session.Requires2FA() {
c.sessionSaveCallback(c.Session) return nil
} }
err = c.Session.AuthWithToken(ctx)
if err == nil && c.sessionSaveCallback != nil {
c.sessionSaveCallback(c.Session)
} }
return err return err
} }
+309 -44
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"maps" "maps"
"net/http" "net/http"
@@ -9,11 +10,15 @@ import (
"slices" "slices"
"strings" "strings"
"github.com/google/uuid"
"github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/common"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/fshttp" "github.com/rclone/rclone/fs/fshttp"
"github.com/rclone/rclone/lib/rest" "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 // Session represents an iCloud session
type Session struct { type Session struct {
SessionToken string `json:"session_token"` SessionToken string `json:"session_token"`
@@ -22,10 +27,22 @@ type Session struct {
AccountCountry string `json:"account_country"` AccountCountry string `json:"account_country"`
TrustToken string `json:"trust_token"` TrustToken string `json:"trust_token"`
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
AuthAttributes string `json:"auth_attributes"`
FrameID string `json:"frame_id"`
Cookies []*http.Cookie `json:"cookies"` Cookies []*http.Cookie `json:"cookies"`
AccountInfo AccountInfo `json:"account_info"` 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 // 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 != "" { if val := resp.Header.Get("scnt"); val != "" {
s.Scnt = val s.Scnt = val
} }
if val := resp.Header.Get("X-Apple-Auth-Attributes"); val != "" {
s.AuthAttributes = val
}
return resp, nil return resp, nil
} }
// Requires2FA returns true if the session requires 2FA // Requires2FA returns true if the session requires 2FA
func (s *Session) Requires2FA() bool { 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 { 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{} trustTokens := []string{}
if s.TrustToken != "" { if s.TrustToken != "" {
trustTokens = []string{s.TrustToken} trustTokens = []string{s.TrustToken}
} }
values := map[string]any{ values := map[string]any{
"accountName": appleID, "accountName": accountName,
"password": password, "m1": m1Base64,
"m2": m2Base64,
"c": c,
"rememberMe": true, "rememberMe": true,
"trustTokens": trustTokens, "trustTokens": trustTokens,
} }
@@ -82,21 +292,93 @@ func (s *Session) SignIn(ctx context.Context, appleID, password string) error {
if err != nil { if err != nil {
return err return err
} }
opts := rest.Opts{ opts := rest.Opts{
Method: "POST", Method: "POST",
Path: "/signin", Path: "/signin/complete",
Parameters: url.Values{}, Parameters: url.Values{"isRememberMeEnabled": {"true"}},
ExtraHeaders: s.GetAuthHeaders(map[string]string{}), ExtraHeaders: s.getSRPAuthHeaders(),
RootURL: authEndpoint, RootURL: authEndpoint,
IgnoreStatus: true, // need to handle 409 for hsa2 IgnoreStatus: true,
NoResponse: true, NoResponse: true,
Body: body, 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 // AuthWithToken authenticates the session
@@ -135,14 +417,10 @@ func (s *Session) Validate2FACode(ctx context.Context, code string) error {
return err return err
} }
headers := s.GetAuthHeaders(map[string]string{})
headers["scnt"] = s.Scnt
headers["X-Apple-ID-Session-Id"] = s.SessionID
opts := rest.Opts{ opts := rest.Opts{
Method: "POST", Method: "POST",
Path: "/verify/trusteddevice/securitycode", Path: "/verify/trusteddevice/securitycode",
ExtraHeaders: headers, ExtraHeaders: s.GetAuthHeaders(map[string]string{}),
RootURL: authEndpoint, RootURL: authEndpoint,
Body: body, Body: body,
NoResponse: true, NoResponse: true,
@@ -162,14 +440,10 @@ func (s *Session) Validate2FACode(ctx context.Context, code string) error {
// TrustSession trusts the session // TrustSession trusts the session
func (s *Session) TrustSession(ctx context.Context) error { 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{ opts := rest.Opts{
Method: "GET", Method: "GET",
Path: "/2sv/trust", Path: "/2sv/trust",
ExtraHeaders: headers, ExtraHeaders: s.GetAuthHeaders(map[string]string{}),
RootURL: authEndpoint, RootURL: authEndpoint,
NoResponse: true, NoResponse: true,
ContentLength: common.Int64(0), ContentLength: common.Int64(0),
@@ -201,25 +475,9 @@ func (s *Session) ValidateSession(ctx context.Context) error {
} }
// GetAuthHeaders returns the authentication headers for the session. // GetAuthHeaders returns the authentication headers for the session.
// // Used for 2FA validation and trust requests to idmsa.apple.com.
// It takes an `overwrite` map[string]string parameter which allows
// overwriting the default headers. It returns a map[string]string.
func (s *Session) GetAuthHeaders(overwrite map[string]string) map[string]string { func (s *Session) GetAuthHeaders(overwrite map[string]string) map[string]string {
headers := map[string]string{ headers := s.getSRPAuthHeaders()
"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",
}
maps.Copy(headers, overwrite) maps.Copy(headers, overwrite)
return headers return headers
} }
@@ -248,7 +506,7 @@ func GetCommonHeaders(overwrite map[string]string) map[string]string {
"Content-Type": "application/json", "Content-Type": "application/json",
"Origin": baseEndpoint, "Origin": baseEndpoint,
"Referer": fmt.Sprintf("%s/", 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) maps.Copy(headers, overwrite)
return headers 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. // NewSession creates a new Session instance with default values.
func NewSession() *Session { func NewSession() *Session {
session := &Session{} session := &Session{
session.srv = rest.NewClient(fshttp.NewClient(context.Background())).SetRoot(baseEndpoint) FrameID: strings.ToLower(uuid.New().String()),
//session.ClientID = "auth-" + 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 return session
} }
+229
View File
@@ -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)
}
+379
View File
@@ -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)
}
+16 -2
View File
@@ -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 This can be done by simply using the regular iCloud password, and accepting the code
prompt on another iCloud connected device. prompt on another iCloud connected device.
**IMPORTANT**: At the moment an app specific password won't be accepted. Only **IMPORTANT**: App-specific passwords are not accepted. Only use your
use your regular password and 2FA. regular Apple ID password and 2FA.
`rclone config` walks you through the token creation. The trust token is valid `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` for 30 days. After which you will have to reauthenticate with `rclone reconnect`
or `rclone config`. 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: Here is an example of how to make a remote called `iclouddrive`. First run:
```console ```console