iclouddrive: add read only iCloud Photos support and SRP authentication

Add read-only iCloud Photos support to the existing iclouddrive
backend via `service = photos` config option.

Also includes auth improvements on top of #9209's SRP authentication.

**Photos features:**
- 3-level hierarchy: libraries (Personal + Shared Photo Library) →
  albums → photos/videos
- server-side smart albums (All Photos, Videos, Favorites,
  Screenshots, Live, Bursts, Panoramas, Slo-mo, Time-lapse, Portrait,
  Long Exposure, Animated, Hidden, Recently Deleted)
- User-created albums and nested album folders
- Live Photo `.MOV` companions as first-class entries
- Edited photo versions (`-edited` suffix) and RAW alternatives
- Duplicate filename dedup for camera counter wrap collisions
- Parallel cold listing for large albums
- Delta sync via CloudKit `changes/zone` - warm listings near-instant from disk cache
- Disk cache (libraries, albums, photos) with atomic writes for crash safety
- `ChangeNotify` support for FUSE mounts via `changes/zone` polling
- `ListR` support for `--fast-list` and recursive operations
- `--metadata` support - width, height, added-time, favorite, hidden
- Fresh download URLs per file - no stale URL failures on long copies
- FUSE mount documentation with recommended flags

**Auth improvements over #9209:**
- SMS 2FA fallback for users without trusted Apple devices
- Explicit push notification request - fixes iOS/macOS 26.4+ where 409
  no longer auto-pushes
- Thread safety for concurrent FUSE callers (mutexes on session and client state)
- Session endpoint caching - skips ~5s `/validate` round-trip on warm start
- `Disconnect` support - clears auth state + disk cache
- PCS cookie support for Advanced Data Protection accounts, including
  trusted-device approval for PCS cookies

Built on @coughlanio's Photos PoC (Closes #8734) and @mikegillan's SRP auth (#9209).

Fixes #7982
Co-authored-by: Chris Coughlan <chris@coughlan.io>
This commit is contained in:
Yakov Till
2026-04-27 16:55:31 +01:00
committed by GitHub
parent 6b67be9d48
commit d0c469c3c0
12 changed files with 6293 additions and 478 deletions
+83 -49
View File
@@ -1,4 +1,4 @@
// Package api provides functionality for interacting with the iCloud API.
// Package api provides functionality for interacting with the iCloud API
package api
import (
@@ -8,16 +8,19 @@ import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/fshttp"
"github.com/rclone/rclone/lib/rest"
)
const (
baseEndpoint = "https://www.icloud.com"
homeEndpoint = "https://www.icloud.com"
setupEndpoint = "https://setup.icloud.com/setup/ws/1"
authEndpoint = "https://idmsa.apple.com/appleauth/auth"
)
@@ -28,26 +31,21 @@ type sessionSave func(*Session)
type Client struct {
appleID string
password string
remoteName string // rclone remote name, used for cache namespacing
srv *rest.Client
Session *Session
sessionSaveCallback sessionSave
drive *DriveService
mu sync.Mutex // protects drive and Authenticate
}
// New creates a new Client instance with the provided Apple ID, password, trust token, cookies, and session save callback.
//
// Parameters:
// - appleID: the Apple ID of the user.
// - password: the password of the user.
// - trustToken: the trust token for the session.
// - clientID: the client id for the session.
// - cookies: the cookies for the session.
// - sessionSaveCallback: the callback function to save the session.
func New(appleID, password, trustToken string, clientID string, cookies []*http.Cookie, sessionSaveCallback sessionSave) (*Client, error) {
// New creates a new iCloud API client and initializes its HTTP session
func New(appleID, password, trustToken string, clientID string, cookies []*http.Cookie, sessionSaveCallback sessionSave, remoteName string) (*Client, error) {
icloud := &Client{
appleID: appleID,
appleID: strings.ToLower(appleID), // Apple SRP requires lowercase in client-side proof
password: password,
remoteName: filepath.Base(remoteName),
srv: rest.NewClient(fshttp.NewClient(context.Background())),
Session: NewSession(),
sessionSaveCallback: sessionSaveCallback,
@@ -59,10 +57,12 @@ func New(appleID, password, trustToken string, clientID string, cookies []*http.
return icloud, nil
}
// DriveService returns the DriveService instance associated with the Client.
// DriveService returns the DriveService instance, creating it on first call
func (c *Client) DriveService() (*DriveService, error) {
var err error
c.mu.Lock()
defer c.mu.Unlock()
if c.drive == nil {
var err error
c.drive, err = NewDriveService(c)
if err != nil {
return nil, err
@@ -71,11 +71,7 @@ func (c *Client) DriveService() (*DriveService, error) {
return c.drive, nil
}
// Request makes a request and retries it if the session is invalid.
//
// This function is the main entry point for making requests to the iCloud
// API. If the initial request returns a 401 (Unauthorized), it will try to
// reauthenticate and retry the request.
// Request makes a request to the iCloud API, re-authenticating on 401/421
func (c *Client) Request(ctx context.Context, opts rest.Opts, request any, response any) (resp *http.Response, err error) {
resp, err = c.Session.Request(ctx, opts, request, response)
if err != nil && resp != nil {
@@ -89,55 +85,93 @@ func (c *Client) Request(ctx context.Context, opts rest.Opts, request any, respo
if c.Session.Requires2FA() {
return nil, errors.New("trust token expired, please reauth")
}
return c.RequestNoReAuth(ctx, opts, request, response)
return c.Session.Request(ctx, opts, request, response)
}
}
return resp, err
}
// RequestNoReAuth makes a request without re-authenticating.
//
// This function is useful when you have a session that is already
// authenticated, but you need to make a request without triggering
// a re-authentication.
func (c *Client) RequestNoReAuth(ctx context.Context, opts rest.Opts, request any, response any) (resp *http.Response, err error) {
// Make the request without re-authenticating
resp, err = c.Session.Request(ctx, opts, request, response)
return resp, err
}
// Authenticate authenticates the client with the iCloud API.
// Authenticate authenticates the client, reusing existing session if valid
func (c *Client) Authenticate(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
// Skip /validate round-trip when saved session has cookies + service endpoints
// Native client behavior: use cached session, reauth lazily on 401/421
if c.Session.Cookies != nil && len(c.Session.AccountInfo.Webservices) > 0 {
fs.Debugf(nil, "iclouddrive: reusing saved session")
return nil
}
// Try loading cached service endpoints to avoid /validate round-trip (~5s)
if c.Session.Cookies != nil && c.loadCachedWebservices() {
fs.Debugf(nil, "iclouddrive: reusing session with cached endpoints")
return nil
}
if c.Session.Cookies != nil {
if err := c.Session.ValidateSession(ctx); err == nil {
fs.Debugf("icloud", "Valid session, no need to reauth")
fs.Debugf(nil, "iclouddrive: valid session, no need to reauth")
c.saveCachedWebservices()
return nil
}
c.Session.Cookies = nil
}
fs.Debugf("icloud", "Authenticating as %s\n", c.appleID)
fs.Debugf(nil, "iclouddrive: authenticating")
err := c.Session.SignIn(ctx, c.appleID, c.password)
if err != nil {
return err
}
// If 2FA is required, don't try AuthWithToken yet — the caller
// must complete 2FA first, then call AuthWithToken.
// If 2FA is required, skip AuthWithToken - caller must complete 2FA first
if c.Session.Requires2FA() {
return nil
}
err = c.Session.AuthWithToken(ctx)
if err == nil && c.sessionSaveCallback != nil {
c.sessionSaveCallback(c.Session)
if err == nil {
c.saveCachedWebservices()
if c.sessionSaveCallback != nil {
c.sessionSaveCallback(c.Session)
}
}
return err
}
// SignIn signs in the client using the provided context and credentials.
func (c *Client) SignIn(ctx context.Context) error {
return c.Session.SignIn(ctx, c.appleID, c.password)
// loadCachedWebservices loads service endpoints from disk cache
func (c *Client) loadCachedWebservices() bool {
data, err := os.ReadFile(filepath.Join(config.GetCacheDir(), cacheSubdir, c.remoteName, "webservices.json"))
if err != nil {
return false
}
var ws map[string]*webService
if err := json.Unmarshal(data, &ws); err != nil {
return false
}
if len(ws) == 0 {
return false
}
c.Session.AccountInfo.Webservices = ws
return true
}
// saveCachedWebservices persists service endpoints to disk
func (c *Client) saveCachedWebservices() {
if len(c.Session.AccountInfo.Webservices) == 0 {
return
}
saveJSONCache(filepath.Join(config.GetCacheDir(), cacheSubdir, c.remoteName), "webservices.json", c.Session.AccountInfo.Webservices)
}
// CacheDir returns the disk cache directory for this remote
func (c *Client) CacheDir() string {
return filepath.Join(config.GetCacheDir(), cacheSubdir, c.remoteName)
}
// ClearCacheDir removes all disk cache files for a remote
func ClearCacheDir(remoteName string) {
dir := filepath.Join(config.GetCacheDir(), cacheSubdir, filepath.Base(remoteName))
if err := os.RemoveAll(dir); err != nil {
fs.Debugf(nil, "iclouddrive: failed to clear cache: %v", err)
}
}
// IntoReader marshals the provided values into a JSON encoded reader
@@ -155,19 +189,19 @@ type RequestError struct {
Text string
}
// Error satisfy the error interface.
// Error satisfies the error interface
func (e *RequestError) Error() string {
return fmt.Sprintf("%s: %s", e.Text, e.Status)
}
func newRequestError(Status string, Text string) *RequestError {
func newRequestError(status string, text string) *RequestError {
return &RequestError{
Status: strings.ToLower(Status),
Text: Text,
Status: strings.ToLower(status),
Text: text,
}
}
// newErr orf makes a new error from sprintf parameters.
func newRequestErrorf(Status string, Text string, Parameters ...any) *RequestError {
return newRequestError(strings.ToLower(Status), fmt.Sprintf(Text, Parameters...))
// newRequestErrorf makes a new error from sprintf parameters
func newRequestErrorf(status string, text string, params ...any) *RequestError {
return newRequestError(status, fmt.Sprintf(text, params...))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+441 -227
View File
@@ -2,16 +2,21 @@ package api
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"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"
@@ -32,6 +37,7 @@ type Session struct {
Cookies []*http.Cookie `json:"cookies"`
AccountInfo AccountInfo `json:"account_info"`
mu sync.Mutex `json:"-"` // protects session fields during concurrent Request calls
srv *rest.Client `json:"-"`
needs2FA bool `json:"-"` // set when SRP signin returns 409
}
@@ -45,20 +51,117 @@ type srpInitResponse struct {
C string `json:"c"`
}
// String returns the session as a string
// func (s *Session) String() string {
// jsession, _ := json.Marshal(s)
// return string(jsession)
// }
// Request makes a request
func (s *Session) Request(ctx context.Context, opts rest.Opts, request any, response any) (*http.Response, error) {
resp, err := s.srv.CallJSON(ctx, &opts, &request, &response)
if err != nil {
return resp, err
func cookieValueFingerprint(value string) string {
if value == "" {
return ""
}
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:6])
}
func authStateBodySummary(body []byte) string {
summary := []string{fmt.Sprintf("bytes=%d", len(body)), "hash=" + cookieValueFingerprint(string(body))}
var top map[string]json.RawMessage
if err := json.Unmarshal(body, &top); err != nil {
return strings.Join(summary, " ")
}
keys := make([]string, 0, len(top))
for key := range top {
keys = append(keys, key)
}
slices.Sort(keys)
summary = append(summary, fmt.Sprintf("keys=%v", keys))
if _, ok := top["phoneNumberVerification"]; ok {
summary = append(summary, "wrapped=true")
}
return strings.Join(summary, " ")
}
func cookieDebugSummary(c *http.Cookie) string {
parts := []string{c.Name}
if c.Value == "" {
parts = append(parts, "empty", "len=0")
} else {
parts = append(parts, "set", fmt.Sprintf("len=%d", len(c.Value)), "hash="+cookieValueFingerprint(c.Value))
}
if c.Path != "" {
parts = append(parts, "path="+c.Path)
}
if c.Domain != "" {
parts = append(parts, "domain="+c.Domain)
}
if c.MaxAge != 0 {
parts = append(parts, fmt.Sprintf("maxAge=%d", c.MaxAge))
}
if !c.Expires.IsZero() {
parts = append(parts, "expires")
}
return strings.Join(parts, ",")
}
func cookieDebugSummaries(cookies []*http.Cookie) []string {
if len(cookies) == 0 {
return nil
}
out := make([]string, 0, len(cookies))
for _, c := range cookies {
out = append(out, cookieDebugSummary(c))
}
return out
}
func cookieJarDebugSummaries(cookies []*http.Cookie) []string {
if len(cookies) == 0 {
return nil
}
out := make([]string, 0, len(cookies))
for _, c := range cookies {
summary := c.Name
if c.Value == "" {
summary += ",empty,len=0"
} else {
summary += fmt.Sprintf(",len=%d,hash=%s", len(c.Value), cookieValueFingerprint(c.Value))
}
out = append(out, summary)
}
return out
}
func (s *Session) mergeCookies(cookies []*http.Cookie) {
if len(cookies) == 0 {
return
}
existing := make(map[string]int, len(s.Cookies))
for i, c := range s.Cookies {
existing[c.Name] = i
}
for _, c := range cookies {
if c.Value == "" {
if idx, ok := existing[c.Name]; ok {
fs.Debugf(nil, "iclouddrive: deleting empty auth cookie: %s", cookieDebugSummary(c))
s.Cookies = append(s.Cookies[:idx], s.Cookies[idx+1:]...)
delete(existing, c.Name)
for j := idx; j < len(s.Cookies); j++ {
existing[s.Cookies[j].Name] = j
}
} else {
fs.Debugf(nil, "iclouddrive: ignoring empty auth cookie tombstone for missing cookie: %s", cookieDebugSummary(c))
}
continue
}
if idx, ok := existing[c.Name]; ok {
s.Cookies[idx] = c
} else {
s.Cookies = append(s.Cookies, c)
existing[c.Name] = len(s.Cookies) - 1
}
}
}
// extractHeaders reads Apple session headers from an HTTP response into the session
// Does NOT acquire s.mu - caller is responsible for locking if needed
func (s *Session) extractHeaders(resp *http.Response) {
s.mergeCookies(resp.Cookies())
if val := resp.Header.Get("X-Apple-ID-Account-Country"); val != "" {
s.AccountCountry = val
}
@@ -77,7 +180,17 @@ func (s *Session) Request(ctx context.Context, opts rest.Opts, request any, resp
if val := resp.Header.Get("X-Apple-Auth-Attributes"); val != "" {
s.AuthAttributes = val
}
}
// Request makes a JSON API request and extracts session headers
func (s *Session) Request(ctx context.Context, opts rest.Opts, request any, response any) (*http.Response, error) {
resp, err := s.srv.CallJSON(ctx, &opts, &request, &response)
if err != nil {
return resp, err
}
s.mu.Lock()
s.extractHeaders(resp)
s.mu.Unlock()
return resp, nil
}
@@ -89,13 +202,8 @@ func (s *Session) Requires2FA() bool {
return s.AccountInfo.DsInfo != nil && s.AccountInfo.DsInfo.HsaVersion == 2 && s.AccountInfo.HsaChallengeRequired
}
// SignIn performs SRP-based authentication against Apple's idmsa endpoint.
// SignIn performs SRP-based authentication against Apple's idmsa endpoint
func (s *Session) SignIn(ctx context.Context, appleID, password string) error {
// Apple's SRP implementation expects a lowercase account name.
// The old plaintext flow didn't need this because the server normalized
// it, but SRP uses the username in client-side proof computation (M1).
appleID = strings.ToLower(appleID)
// Step 1: Initialize the auth session
if err := s.authStart(ctx); err != nil {
return fmt.Errorf("authStart: %w", err)
@@ -106,8 +214,11 @@ func (s *Session) SignIn(ctx context.Context, appleID, password string) error {
return fmt.Errorf("authFederate: %w", err)
}
// Step 3: SRP init send client public value A, get salt + B
client := newSRPClient()
// Step 3: SRP init - send client public value A, get salt + B
client, err := newSRPClient()
if err != nil {
return fmt.Errorf("newSRPClient: %w", err)
}
aBase64 := base64.StdEncoding.EncodeToString(client.getABytes())
initResp, err := s.authSRPInit(ctx, aBase64, appleID)
@@ -130,9 +241,11 @@ func (s *Session) SignIn(ctx context.Context, appleID, password string) error {
if err != nil {
return fmt.Errorf("derivePassword: %w", err)
}
client.processChallenge([]byte(appleID), derivedKey, salt, serverB)
if err := client.processChallenge([]byte(appleID), derivedKey, salt, serverB); err != nil {
return fmt.Errorf("processChallenge: %w", err)
}
// Step 5: Complete send M1, M2 proofs
// Step 5: Complete - send M1, M2 proofs
m1Base64 := base64.StdEncoding.EncodeToString(client.M1)
m2Base64 := base64.StdEncoding.EncodeToString(client.M2)
@@ -143,7 +256,7 @@ func (s *Session) SignIn(ctx context.Context, appleID, password string) error {
return nil
}
// authStart initializes the SRP auth session by hitting the authorize/signin endpoint.
// 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())
@@ -184,20 +297,11 @@ func (s *Session) authStart(ctx context.Context) error {
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
}
s.extractHeaders(resp)
return nil
}
// authFederate submits the account name to Apple's federate endpoint.
// 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,
@@ -224,16 +328,7 @@ func (s *Session) authFederate(ctx context.Context, accountName string) error {
}
_ = 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
}
s.extractHeaders(resp)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("authFederate: unexpected status %s", resp.Status)
}
@@ -241,7 +336,7 @@ func (s *Session) authFederate(ctx context.Context, accountName string) error {
}
// authSRPInit sends the client's public value A to the server and retrieves
// the salt, server public value B, iteration count, protocol, and challenge.
// 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,
@@ -267,18 +362,12 @@ func (s *Session) authSRPInit(ctx context.Context, aBase64, accountName string)
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
}
s.extractHeaders(resp)
return &initResp, nil
}
// authSRPComplete sends the SRP proofs M1 and M2 to complete authentication.
// Returns nil on success (200 or 409/2FA needed).
// 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 != "" {
@@ -305,7 +394,6 @@ func (s *Session) authSRPComplete(ctx context.Context, accountName, m1Base64, m2
ExtraHeaders: s.getSRPAuthHeaders(),
RootURL: authEndpoint,
IgnoreStatus: true,
NoResponse: true,
Body: body,
}
@@ -313,51 +401,65 @@ func (s *Session) authSRPComplete(ctx context.Context, accountName, m1Base64, m2
if err != nil {
return err
}
respBody, _ := io.ReadAll(resp.Body)
_ = 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
}
s.extractHeaders(resp)
switch resp.StatusCode {
case http.StatusOK:
fs.Debugf("icloud", "SRP sign in successful")
fs.Debugf(nil, "iclouddrive: SRP sign in successful")
return nil
case http.StatusConflict:
// 409 = 2FA required, this is expected
fs.Debugf("icloud", "SRP sign in requires 2FA")
// 409 = 2FA required
fs.Debugf(nil, "iclouddrive: SRP sign in requires 2FA, response: %s", respBody)
s.needs2FA = true
return nil
case http.StatusPreconditionFailed:
// 412 = non-2FA account needs repair/complete step
fs.Debugf(nil, "iclouddrive: SRP sign in returned 412, attempting repair/complete")
return s.authRepairComplete(ctx)
case http.StatusForbidden:
return fmt.Errorf("sign in failed: incorrect username or password")
default:
return fmt.Errorf("sign in failed: %s", resp.Status)
return fmt.Errorf("sign in failed: %s: %s", resp.Status, respBody)
}
}
// getAuthOrigin returns the origin URL for auth requests.
// Supports both global (idmsa.apple.com) and China (idmsa.apple.com.cn) endpoints.
// authRepairComplete handles the repair flow for non-2FA accounts that return 412
func (s *Session) authRepairComplete(ctx context.Context) error {
body, err := IntoReader(map[string]any{})
if err != nil {
return err
}
opts := rest.Opts{
Method: "POST",
Path: "/repair/complete",
ExtraHeaders: s.getSRPAuthHeaders(),
RootURL: authEndpoint,
IgnoreStatus: true,
NoResponse: true,
Body: body,
}
resp, err := s.srv.Call(ctx, &opts)
if err != nil {
return fmt.Errorf("repair/complete failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("repair/complete returned %s", resp.Status)
}
s.extractHeaders(resp)
fs.Debugf(nil, "iclouddrive: repair/complete successful")
return nil
}
// getAuthOrigin returns the origin URL for auth requests
// Supports both global (idmsa.apple.com) and China (idmsa.apple.com.cn) endpoints
func getAuthOrigin() string {
return strings.TrimSuffix(authEndpoint, "/appleauth/auth")
}
// getSRPAuthHeaders returns headers needed for SRP auth requests.
// getSRPAuthHeaders returns headers needed for SRP auth requests
func (s *Session) getSRPAuthHeaders() map[string]string {
frameTag := "auth-" + s.FrameID
authOrigin := getAuthOrigin()
@@ -414,14 +516,110 @@ func (s *Session) AuthWithToken(ctx context.Context) error {
}
resp, err := s.Request(ctx, opts, nil, &s.AccountInfo)
if err == nil {
s.Cookies = resp.Cookies()
if err != nil {
return err
}
fs.Debugf(nil, "iclouddrive: accountLogin response cookies: %v", cookieDebugSummaries(resp.Cookies()))
fs.Debugf(nil, "iclouddrive: session cookie jar after accountLogin: %v", cookieJarDebugSummaries(s.Cookies))
// Acquire PCS cookies if Advanced Data Protection is enabled
if ws := s.AccountInfo.Webservices["ckdatabasews"]; ws != nil && ws.PcsRequired {
fs.Debugf(nil, "iclouddrive: ADP detected (pcsRequired=true)")
if s.hasPCSCookies() {
fs.Debugf(nil, "iclouddrive: PCS cookies already present, skipping acquisition")
} else {
if err := s.acquirePCSCookies(ctx); err != nil {
return err
}
}
} else {
fs.Debugf(nil, "iclouddrive: no ADP (pcsRequired=false)")
}
return nil
}
// hasPCSCookies checks if the required PCS cookies for Photos are already present
func (s *Session) hasPCSCookies() bool {
var hasPhotos, hasSharing bool
for _, c := range s.Cookies {
switch c.Name {
case "X-APPLE-WEBAUTH-PCS-Photos":
hasPhotos = true
case "X-APPLE-WEBAUTH-PCS-Sharing":
hasSharing = true
}
}
return hasPhotos && hasSharing
}
// acquirePCSCookies requests PCS cookies for ADP-enabled accounts
// May require user approval on a trusted device (polls every 10s, max 5 min)
func (s *Session) acquirePCSCookies(ctx context.Context) error {
fs.Logf(nil, "iclouddrive: Advanced Data Protection enabled, requesting PCS cookies")
const maxAttempts = 30 // 30 * 10s = 5 minutes max
for attempt := 0; attempt < maxAttempts; attempt++ {
fs.Debugf(nil, "iclouddrive: requestPCS outgoing cookies: %v", cookieJarDebugSummaries(s.Cookies))
values := map[string]any{
"appName": "photos",
"derivedFromUserAction": true,
}
body, err := IntoReader(values)
if err != nil {
return fmt.Errorf("requestPCS: %w", err)
}
opts := rest.Opts{
Method: "POST",
Path: "/requestPCS",
ExtraHeaders: s.GetHeaders(map[string]string{}),
RootURL: setupEndpoint,
}
opts.Body = body
var pcsResp struct {
Status string `json:"status"`
Message string `json:"message"`
}
resp, err := s.Request(ctx, opts, nil, &pcsResp)
if err != nil {
return fmt.Errorf("requestPCS: %w", err)
}
fs.Debugf(nil, "iclouddrive: requestPCS response cookies: %v", cookieDebugSummaries(resp.Cookies()))
fs.Debugf(nil, "iclouddrive: requestPCS response: status=%q message=%q cookies=%d",
pcsResp.Status, pcsResp.Message, len(resp.Cookies()))
if pcsResp.Status == "success" {
if !s.hasPCSCookies() {
return fmt.Errorf("requestPCS: server returned success but PCS cookies missing")
}
fs.Logf(nil, "iclouddrive: PCS cookies acquired")
return nil
}
// Device consent required - poll until approved
fs.Logf(nil, "iclouddrive: waiting for device approval for PCS (%s)", pcsResp.Message)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Second):
}
}
return fmt.Errorf("requestPCS: timed out waiting for device approval after 5 minutes")
}
// RequestPushNotification explicitly requests a push notification to trusted devices
// Required for iOS 26.4+ where the SRP 409 response no longer auto-pushes
func (s *Session) RequestPushNotification(ctx context.Context) error {
opts := rest.Opts{
Method: "PUT",
Path: "/verify/trusteddevice/securitycode",
ExtraHeaders: s.GetAuthHeaders(map[string]string{}),
RootURL: authEndpoint,
NoResponse: true,
}
_, err := s.Request(ctx, opts, nil, nil)
return err
}
// Validate2FACode validates the 2FA code
// Validate2FACode validates the 2FA code from a trusted device push notification
func (s *Session) Validate2FACode(ctx context.Context, code string) error {
values := map[string]any{"securityCode": map[string]string{"code": code}}
body, err := IntoReader(values)
@@ -450,6 +648,132 @@ func (s *Session) Validate2FACode(ctx context.Context, code string) error {
return fmt.Errorf("validate2FACode failed: %w", err)
}
// TrustedPhoneNumber represents a phone number that can receive SMS verification codes
type TrustedPhoneNumber struct {
ID int `json:"id"`
NumberWithDialCode string `json:"numberWithDialCode"`
ObfuscatedNumber string `json:"obfuscatedNumber"`
PushMode string `json:"pushMode"`
NonFTEU bool `json:"nonFTEU"`
}
// AuthStateResponse is the response from GET /appleauth/auth after sign-in
// Some accounts return fields at top level, others nest them under phoneNumberVerification
type AuthStateResponse struct {
TrustedPhoneNumbers []TrustedPhoneNumber `json:"trustedPhoneNumbers"`
TrustedPhoneNumber *TrustedPhoneNumber `json:"trustedPhoneNumber"`
NoTrustedDevices bool `json:"noTrustedDevices"`
AuthenticationType string `json:"authenticationType"`
Hsa2Account bool `json:"hsa2Account"`
PhoneNumberVerification *AuthStateResponse `json:"phoneNumberVerification"`
}
// GetAuthState retrieves the current auth state including trusted phone numbers for SMS 2FA
func (s *Session) GetAuthState(ctx context.Context) (*AuthStateResponse, error) {
opts := rest.Opts{
Method: "GET",
Path: "",
ExtraHeaders: s.GetAuthHeaders(map[string]string{}),
RootURL: authEndpoint,
ContentLength: int64Ptr(0),
}
// Use srv.Call directly to capture the raw response body for debugging
resp, err := s.srv.Call(ctx, &opts)
if err != nil {
return nil, fmt.Errorf("getAuthState: %w", err)
}
s.mu.Lock()
s.extractHeaders(resp)
s.mu.Unlock()
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("getAuthState read: %w", readErr)
}
fs.Debugf(nil, "iclouddrive: auth state response summary: %s", authStateBodySummary(body))
var state AuthStateResponse
if err := json.Unmarshal(body, &state); err != nil {
fs.Debugf(nil, "iclouddrive: auth state parse failed: %s", authStateBodySummary(body))
return nil, fmt.Errorf("getAuthState unmarshal: %w", err)
}
// Some accounts nest auth data under phoneNumberVerification
if state.PhoneNumberVerification != nil && state.AuthenticationType == "" {
fs.Debugf(nil, "iclouddrive: unwrapping phoneNumberVerification envelope")
n := state.PhoneNumberVerification
state.TrustedPhoneNumbers = n.TrustedPhoneNumbers
state.TrustedPhoneNumber = n.TrustedPhoneNumber
state.NoTrustedDevices = n.NoTrustedDevices
state.AuthenticationType = n.AuthenticationType
state.Hsa2Account = n.Hsa2Account
state.PhoneNumberVerification = nil
}
fs.Debugf(nil, "iclouddrive: auth state: type=%s hsa2=%v noTrustedDevices=%v phones=%d phoneSingular=%v",
state.AuthenticationType, state.Hsa2Account, state.NoTrustedDevices,
len(state.TrustedPhoneNumbers), state.TrustedPhoneNumber != nil)
// Fall back to singular trustedPhoneNumber when plural array is empty
// Some accounts return only the singular form (SMS-only, no trusted devices)
if len(state.TrustedPhoneNumbers) == 0 && state.TrustedPhoneNumber != nil {
fs.Debugf(nil, "iclouddrive: using singular trustedPhoneNumber (id=%d) as fallback",
state.TrustedPhoneNumber.ID)
state.TrustedPhoneNumbers = []TrustedPhoneNumber{*state.TrustedPhoneNumber}
}
return &state, nil
}
// RequestSMSCode triggers SMS code delivery to a trusted phone number
func (s *Session) RequestSMSCode(ctx context.Context, phoneID int, mode string) error {
values := map[string]any{
"phoneNumber": map[string]any{"id": phoneID},
"mode": mode,
}
body, err := IntoReader(values)
if err != nil {
return err
}
opts := rest.Opts{
Method: "PUT",
Path: "/verify/phone",
ExtraHeaders: s.GetAuthHeaders(map[string]string{}),
RootURL: authEndpoint,
Body: body,
NoResponse: true,
}
_, err = s.Request(ctx, opts, nil, nil)
if err != nil {
return fmt.Errorf("requestSMSCode: %w", err)
}
return nil
}
// ValidateSMSCode validates a 2FA code received via SMS
func (s *Session) ValidateSMSCode(ctx context.Context, code string, phoneID int, mode string) error {
values := map[string]any{
"securityCode": map[string]string{"code": code},
"phoneNumber": map[string]any{"id": phoneID},
"mode": mode,
}
body, err := IntoReader(values)
if err != nil {
return err
}
opts := rest.Opts{
Method: "POST",
Path: "/verify/phone/securitycode",
ExtraHeaders: s.GetAuthHeaders(map[string]string{}),
RootURL: authEndpoint,
Body: body,
NoResponse: true,
}
_, err = s.Request(ctx, opts, nil, nil)
if err == nil {
if err := s.TrustSession(ctx); err != nil {
return err
}
return nil
}
return fmt.Errorf("validateSMSCode: %w", err)
}
// TrustSession trusts the session
func (s *Session) TrustSession(ctx context.Context) error {
opts := rest.Opts{
@@ -458,7 +782,7 @@ func (s *Session) TrustSession(ctx context.Context) error {
ExtraHeaders: s.GetAuthHeaders(map[string]string{}),
RootURL: authEndpoint,
NoResponse: true,
ContentLength: common.Int64(0),
ContentLength: int64Ptr(0),
}
_, err := s.Request(ctx, opts, nil, nil)
@@ -476,25 +800,26 @@ func (s *Session) ValidateSession(ctx context.Context) error {
Path: "/validate",
ExtraHeaders: s.GetHeaders(map[string]string{}),
RootURL: setupEndpoint,
ContentLength: common.Int64(0),
ContentLength: int64Ptr(0),
}
_, err := s.Request(ctx, opts, nil, &s.AccountInfo)
if err != nil {
return fmt.Errorf("validateSession failed: %w", err)
}
s.needs2FA = false
return nil
}
// GetAuthHeaders returns the authentication headers for the session.
// Used for 2FA validation and trust requests to idmsa.apple.com.
// GetAuthHeaders returns the authentication headers for the session
// Used for 2FA validation and trust requests to idmsa.apple.com
func (s *Session) GetAuthHeaders(overwrite map[string]string) map[string]string {
headers := s.getSRPAuthHeaders()
maps.Copy(headers, overwrite)
return headers
}
// GetHeaders Gets the authentication headers required for a request
// GetHeaders returns the authentication headers required for a request
func (s *Session) GetHeaders(overwrite map[string]string) map[string]string {
headers := GetCommonHeaders(map[string]string{})
headers["Cookie"] = s.GetCookieString()
@@ -502,17 +827,26 @@ func (s *Session) GetHeaders(overwrite map[string]string) map[string]string {
return headers
}
// GetCookieString returns the cookie header string for the session.
// GetCookieString returns the cookie header string for the session
func (s *Session) GetCookieString() string {
cookieHeader := ""
// we only care about name and value.
var b strings.Builder
first := true
for _, cookie := range s.Cookies {
cookieHeader = cookieHeader + cookie.Name + "=" + cookie.Value + ";"
if cookie.Value == "" {
continue
}
if !first {
b.WriteString("; ")
}
first = false
b.WriteString(cookie.Name)
b.WriteByte('=')
b.WriteString(cookie.Value)
}
return cookieHeader
return b.String()
}
// GetCommonHeaders generates common HTTP headers with optional overwrite.
// GetCommonHeaders generates common HTTP headers with optional overwrite
func GetCommonHeaders(overwrite map[string]string) map[string]string {
headers := map[string]string{
"Content-Type": "application/json",
@@ -524,32 +858,9 @@ func GetCommonHeaders(overwrite map[string]string) map[string]string {
return headers
}
// MergeCookies merges two slices of http.Cookies, ensuring no duplicates are added.
func MergeCookies(left []*http.Cookie, right []*http.Cookie) ([]*http.Cookie, error) {
var hashes []string
for _, cookie := range right {
hashes = append(hashes, cookie.Raw)
}
for _, cookie := range left {
if !slices.Contains(hashes, cookie.Raw) {
right = append(right, cookie)
}
}
return right, nil
}
func int64Ptr(v int64) *int64 { return &v }
// GetCookiesForDomain filters the provided cookies based on the domain of the given URL.
func GetCookiesForDomain(url *url.URL, cookies []*http.Cookie) ([]*http.Cookie, error) {
var domainCookies []*http.Cookie
for _, cookie := range cookies {
if strings.HasSuffix(url.Host, cookie.Domain) {
domainCookies = append(domainCookies, cookie)
}
}
return domainCookies, nil
}
// NewSession creates a new Session instance with default values.
// NewSession creates a new Session instance with default values
func NewSession() *Session {
session := &Session{
FrameID: strings.ToLower(uuid.New().String()),
@@ -564,117 +875,20 @@ func NewSession() *Session {
return session
}
// AccountInfo represents an account info
// AccountInfo represents the subset of Apple's account response we actually use
// json.Unmarshal silently ignores extra fields in the response
type AccountInfo struct {
DsInfo *ValidateDataDsInfo `json:"dsInfo"`
HasMinimumDeviceForPhotosWeb bool `json:"hasMinimumDeviceForPhotosWeb"`
ICDPEnabled bool `json:"iCDPEnabled"`
Webservices map[string]*webService `json:"webservices"`
PcsEnabled bool `json:"pcsEnabled"`
TermsUpdateNeeded bool `json:"termsUpdateNeeded"`
ConfigBag struct {
Urls struct {
AccountCreateUI string `json:"accountCreateUI"`
AccountLoginUI string `json:"accountLoginUI"`
AccountLogin string `json:"accountLogin"`
AccountRepairUI string `json:"accountRepairUI"`
DownloadICloudTerms string `json:"downloadICloudTerms"`
RepairDone string `json:"repairDone"`
AccountAuthorizeUI string `json:"accountAuthorizeUI"`
VettingURLForEmail string `json:"vettingUrlForEmail"`
AccountCreate string `json:"accountCreate"`
GetICloudTerms string `json:"getICloudTerms"`
VettingURLForPhone string `json:"vettingUrlForPhone"`
} `json:"urls"`
AccountCreateEnabled bool `json:"accountCreateEnabled"`
} `json:"configBag"`
HsaTrustedBrowser bool `json:"hsaTrustedBrowser"`
AppsOrder []string `json:"appsOrder"`
Version int `json:"version"`
IsExtendedLogin bool `json:"isExtendedLogin"`
PcsServiceIdentitiesIncluded bool `json:"pcsServiceIdentitiesIncluded"`
IsRepairNeeded bool `json:"isRepairNeeded"`
HsaChallengeRequired bool `json:"hsaChallengeRequired"`
RequestInfo struct {
Country string `json:"country"`
TimeZone string `json:"timeZone"`
Region string `json:"region"`
} `json:"requestInfo"`
PcsDeleted bool `json:"pcsDeleted"`
ICloudInfo struct {
SafariBookmarksHasMigratedToCloudKit bool `json:"SafariBookmarksHasMigratedToCloudKit"`
} `json:"iCloudInfo"`
Apps map[string]*ValidateDataApp `json:"apps"`
DsInfo *dsInfo `json:"dsInfo"`
Webservices map[string]*webService `json:"webservices"`
HsaChallengeRequired bool `json:"hsaChallengeRequired"`
}
// ValidateDataDsInfo represents an validation info
type ValidateDataDsInfo struct {
HsaVersion int `json:"hsaVersion"`
LastName string `json:"lastName"`
ICDPEnabled bool `json:"iCDPEnabled"`
TantorMigrated bool `json:"tantorMigrated"`
Dsid string `json:"dsid"`
HsaEnabled bool `json:"hsaEnabled"`
IsHideMyEmailSubscriptionActive bool `json:"isHideMyEmailSubscriptionActive"`
IroncadeMigrated bool `json:"ironcadeMigrated"`
Locale string `json:"locale"`
BrZoneConsolidated bool `json:"brZoneConsolidated"`
ICDRSCapableDeviceList string `json:"ICDRSCapableDeviceList"`
IsManagedAppleID bool `json:"isManagedAppleID"`
IsCustomDomainsFeatureAvailable bool `json:"isCustomDomainsFeatureAvailable"`
IsHideMyEmailFeatureAvailable bool `json:"isHideMyEmailFeatureAvailable"`
ContinueOnDeviceEligibleDeviceInfo []string `json:"ContinueOnDeviceEligibleDeviceInfo"`
Gilligvited bool `json:"gilligvited"`
AppleIDAliases []any `json:"appleIdAliases"`
UbiquityEOLEnabled bool `json:"ubiquityEOLEnabled"`
IsPaidDeveloper bool `json:"isPaidDeveloper"`
CountryCode string `json:"countryCode"`
NotificationID string `json:"notificationId"`
PrimaryEmailVerified bool `json:"primaryEmailVerified"`
ADsID string `json:"aDsID"`
Locked bool `json:"locked"`
ICDRSCapableDeviceCount int `json:"ICDRSCapableDeviceCount"`
HasICloudQualifyingDevice bool `json:"hasICloudQualifyingDevice"`
PrimaryEmail string `json:"primaryEmail"`
AppleIDEntries []struct {
IsPrimary bool `json:"isPrimary"`
Type string `json:"type"`
Value string `json:"value"`
} `json:"appleIdEntries"`
GilliganEnabled bool `json:"gilligan-enabled"`
IsWebAccessAllowed bool `json:"isWebAccessAllowed"`
FullName string `json:"fullName"`
MailFlags struct {
IsThreadingAvailable bool `json:"isThreadingAvailable"`
IsSearchV2Provisioned bool `json:"isSearchV2Provisioned"`
SCKMail bool `json:"sCKMail"`
IsMppSupportedInCurrentCountry bool `json:"isMppSupportedInCurrentCountry"`
} `json:"mailFlags"`
LanguageCode string `json:"languageCode"`
AppleID string `json:"appleId"`
HasUnreleasedOS bool `json:"hasUnreleasedOS"`
AnalyticsOptInStatus bool `json:"analyticsOptInStatus"`
FirstName string `json:"firstName"`
ICloudAppleIDAlias string `json:"iCloudAppleIdAlias"`
NotesMigrated bool `json:"notesMigrated"`
BeneficiaryInfo struct {
IsBeneficiary bool `json:"isBeneficiary"`
} `json:"beneficiaryInfo"`
HasPaymentInfo bool `json:"hasPaymentInfo"`
PcsDelet bool `json:"pcsDelet"`
AppleIDAlias string `json:"appleIdAlias"`
BrMigrated bool `json:"brMigrated"`
StatusCode int `json:"statusCode"`
FamilyEligible bool `json:"familyEligible"`
// dsInfo holds the account metadata fields we read
type dsInfo struct {
HsaVersion int `json:"hsaVersion"`
}
// ValidateDataApp represents an app
type ValidateDataApp struct {
CanLaunchWithOneFactor bool `json:"canLaunchWithOneFactor"`
IsQualifiedForBeta bool `json:"isQualifiedForBeta"`
}
// WebService represents a web service
// webService represents a web service
type webService struct {
PcsRequired bool `json:"pcsRequired"`
URL string `json:"url"`
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractHeadersMergesCookies(t *testing.T) {
s := NewSession()
s.Cookies = []*http.Cookie{{Name: "existing", Value: "old"}}
resp := &http.Response{Header: make(http.Header)}
resp.Header.Add("Set-Cookie", (&http.Cookie{Name: "existing", Value: "new"}).String())
resp.Header.Add("Set-Cookie", (&http.Cookie{Name: "fresh", Value: "value"}).String())
resp.Header.Set("X-Apple-Session-Token", "session-token")
s.extractHeaders(resp)
require.Len(t, s.Cookies, 2)
assert.Equal(t, "new", s.Cookies[0].Value)
assert.Equal(t, "fresh", s.Cookies[1].Name)
assert.Equal(t, "session-token", s.SessionToken)
}
func TestExtractHeadersDeletesEmptyCookies(t *testing.T) {
s := NewSession()
s.Cookies = []*http.Cookie{{Name: "X-APPLE-WEBAUTH-HSA-LOGIN", Value: "stale"}, {Name: "keep", Value: "value"}}
resp := &http.Response{Header: make(http.Header)}
resp.Header.Add("Set-Cookie", (&http.Cookie{Name: "X-APPLE-WEBAUTH-HSA-LOGIN", Value: ""}).String())
s.extractHeaders(resp)
require.Len(t, s.Cookies, 1)
assert.Equal(t, "keep", s.Cookies[0].Name)
assert.Equal(t, "keep=value", s.GetCookieString())
}
+17 -9
View File
@@ -46,10 +46,10 @@ type srpClient struct {
}
// newSRPClient generates a random 32-byte secret and computes the public value A.
func newSRPClient() *srpClient {
func newSRPClient() (*srpClient, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
panic(fmt.Sprintf("srp: rand.Read failed: %v", err))
return nil, fmt.Errorf("srp: rand.Read failed: %w", err)
}
a := new(big.Int).SetBytes(secret)
@@ -60,7 +60,7 @@ func newSRPClient() *srpClient {
a: a,
A: A,
k: k,
}
}, nil
}
// getABytes returns the padded public value A.
@@ -71,16 +71,21 @@ func (c *srpClient) getABytes() []byte {
// 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) {
func (c *srpClient) processChallenge(username, derivedKey, salt, serverB []byte) error {
B := new(big.Int).SetBytes(serverB)
// Validate B
// Validate B (RFC 5054)
if B.Cmp(big.NewInt(0)) <= 0 || B.Cmp(srpN) >= 0 {
panic("srp: invalid server-supplied B, must be 1..N-1")
return fmt.Errorf("srp: invalid server-supplied B, must be 1..N-1")
}
x := calculateX(salt, derivedKey)
u := calculateU(c.A, B)
// Validate u (RFC 5054)
if u.Sign() == 0 {
return fmt.Errorf("srp: calculated u is zero, aborting")
}
S := calculateS(c.k, x, c.a, B, u)
c.K = calculateK(S)
@@ -88,6 +93,7 @@ func (c *srpClient) processChallenge(username, derivedKey, salt, serverB []byte)
bBytes := padToN(B)
c.M1 = calculateM1(username, salt, aBytes, bBytes, c.K)
c.M2 = calculateM2(aBytes, c.M1, c.K)
return nil
}
// derivePassword performs Apple's password key derivation.
@@ -98,10 +104,12 @@ func derivePassword(password string, salt []byte, iterations int, protocol strin
var passInput string
switch protocol {
case "s2k":
passInput = string(passHash[:])
case "s2k_fo":
passInput = hex.EncodeToString(passHash[:])
default: // "s2k"
passInput = string(passHash[:])
default:
return nil, fmt.Errorf("unsupported SRP protocol: %q", protocol)
}
return pbkdf2.Key(sha256.New, passInput, salt, iterations, 32)
}
@@ -142,7 +150,7 @@ func getMultiplier() *big.Int {
}
// calculateX computes x = H(salt | H(":" | password))
// Apple's variant: NoUserNameInX username is omitted from the inner hash.
// 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()
+39 -7
View File
@@ -27,7 +27,7 @@ func TestPadToN(t *testing.T) {
})
t.Run("preserves value that fills N", func(t *testing.T) {
// Use N itself already 256 bytes
// Use N itself - already 256 bytes
result := padToN(srpN)
assert.Equal(t, srpNLenBytes, len(result))
assert.Equal(t, srpN.Bytes(), result)
@@ -52,7 +52,7 @@ func TestGetMultiplier(t *testing.T) {
k2 := getMultiplier()
assert.Equal(t, 0, k.Cmp(k2), "multiplier must be deterministic")
// k = H(N | pad(g)) verify by manual computation
// k = H(N | pad(g)) - verify by manual computation
h := srpHashFunc()
nBytes := srpN.Bytes()
gBytes := srpG.Bytes()
@@ -123,6 +123,12 @@ func TestDerivePassword(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 32, len(key))
})
t.Run("unknown protocol returns error", func(t *testing.T) {
_, err := derivePassword(password, salt, 1000, "unknown")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported SRP protocol")
})
}
func TestCalculateX(t *testing.T) {
@@ -256,7 +262,8 @@ func TestCalculateM2(t *testing.T) {
}
func TestNewSRPClient(t *testing.T) {
client := newSRPClient()
client, err := newSRPClient()
require.NoError(t, err)
// A must be non-zero and in range [1, N-1]
assert.True(t, client.A.Sign() > 0, "A must be positive")
@@ -273,7 +280,8 @@ func TestNewSRPClient(t *testing.T) {
assert.Equal(t, 0, client.A.Cmp(recoveredA))
// Two clients should have different secrets (probabilistic, but 2^256 collision chance)
client2 := newSRPClient()
client2, err := newSRPClient()
require.NoError(t, err)
assert.NotEqual(t, 0, client.a.Cmp(client2.a), "two clients must have different secrets")
}
@@ -315,7 +323,8 @@ func TestProcessChallenge(t *testing.T) {
serverB := padToN(B)
client.processChallenge(username, derivedKey, salt, serverB)
err := client.processChallenge(username, derivedKey, salt, serverB)
require.NoError(t, err)
// M1, M2, K must be populated
assert.NotNil(t, client.M1)
@@ -328,13 +337,14 @@ func TestProcessChallenge(t *testing.T) {
// M1 and M2 must be different
assert.NotEqual(t, client.M1, client.M2)
// Results must be deterministic run again with same inputs
// 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)
err = client2.processChallenge(username, derivedKey, salt, serverB)
require.NoError(t, err)
assert.Equal(t, client.M1, client2.M1, "M1 must be deterministic")
assert.Equal(t, client.M2, client2.M2, "M2 must be deterministic")
@@ -352,6 +362,28 @@ func TestProcessChallenge(t *testing.T) {
assert.Equal(t, client.K, kServer, "client and server must derive the same session key")
}
func TestProcessChallenge_InvalidB(t *testing.T) {
k := getMultiplier()
client := &srpClient{
a: big.NewInt(42),
A: new(big.Int).Exp(srpG, big.NewInt(42), srpN),
k: k,
}
username := []byte("test@apple.com")
derivedKey := make([]byte, 32)
salt := []byte("salt")
t.Run("B=0 rejected", func(t *testing.T) {
err := client.processChallenge(username, derivedKey, salt, padToN(big.NewInt(0)))
assert.Error(t, err)
})
t.Run("B=N rejected", func(t *testing.T) {
err := client.processChallenge(username, derivedKey, salt, padToN(srpN))
assert.Error(t, err)
})
}
func TestSRPGroupParameters(t *testing.T) {
// Verify the 2048-bit prime starts with known hex prefix
nHex := hex.EncodeToString(srpN.Bytes())