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)
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
}
+309 -44
View File
@@ -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
}
+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
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