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:
committed by
Nick Craig-Wood
parent
839a3b92e0
commit
35e4f60548
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user