diff --git a/backend/internal/provider/adobe/arp_shape_test.go b/backend/internal/provider/adobe/arp_shape_test.go index b7a3a8f..5667b74 100644 --- a/backend/internal/provider/adobe/arp_shape_test.go +++ b/backend/internal/provider/adobe/arp_shape_test.go @@ -4,19 +4,20 @@ import ( "encoding/base64" "encoding/json" "regexp" + "strings" "testing" ) // captured from live firefly.adobe.com traffic: // // ark: 91818c89a54748463.1048135404|r=ap-southeast-1|…|rid=84|ag=101|… -// ftr: dbd9d77a491b4437bc5c4d649a04a794_1785846934401_6890_UDF43-m4_31ck_YRQXWT0P1AE=-7389-v2_tt +// ftr: 40d7ba7c70ff478e9c212073ec8254c4_1791795834777_27064_UDF43-mnf-a4_31ck_s/Clo68HDL4=-3629-v2_tt // // The Arkose slot is deliberately emitted empty rather than synthesized — see // buildARPSessionID — so the expected ftr ends in "_31ck__tt". var ( arkPat = regexp.MustCompile(`^[0-9a-f]{17}\.[1-9][0-9]{9}\|r=ap-southeast-1\|.*\|rid=[0-9]{1,2}\|ag=101\|`) - ftrPat = regexp.MustCompile(`^[0-9a-f]{32}_[0-9]{13}_[0-9]{4,5}_UDF43-m4_31ck__tt$`) + ftrPat = regexp.MustCompile(`^[0-9a-f]{32}_[0-9]{13}_[0-9]{4,5}_UDF43-mnf-a4_31ck__tt$`) ) func TestARPSessionIDShape(t *testing.T) { @@ -24,10 +25,13 @@ func TestARPSessionIDShape(t *testing.T) { if err != nil { t.Fatalf("not base64: %v", err) } - var got struct{ Sid, Ark, Ftr string } + var got struct{ Sid, Ark, Ftr, Bfp string } if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("not json: %v", err) } + if len(got.Bfp) != 36 { + t.Errorf("bfp not a uuid: %q", got.Bfp) + } if !arkPat.MatchString(got.Ark) { t.Errorf("ark shape mismatch:\n%s", got.Ark) } @@ -57,3 +61,102 @@ func TestPIDStablePerToken(t *testing.T) { t.Error("two tokens share a pid") } } + +// A real browser keeps sid / devHex / bfp for the whole session; only the ftr +// timestamp advances. Re-randomizing them per call is a bot signal. +func TestIdentityStablePerToken(t *testing.T) { + decode := func(token string) map[string]string { + raw, err := base64.StdEncoding.DecodeString(buildARPSessionID(token)) + if err != nil { + t.Fatalf("not base64: %v", err) + } + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("not json: %v", err) + } + return m + } + a, b := decode("tok-s"), decode("tok-s") + if a["sid"] != b["sid"] || a["bfp"] != b["bfp"] { + t.Error("sid/bfp changed across calls for the same token") + } + if a["ftr"][:39] != b["ftr"][:39] { + t.Error("ftr device hex changed across calls for the same token") + } +} + +// CacheARP stores a cookie-derived arp which getARPSessionID prefers over +// synthesis. +func TestCacheARPPreferred(t *testing.T) { + c := NewClient("", "") + CacheARP("tok-c", "eyJjb29raWUiOiJhYXJkZWYa") + if got := c.getARPSessionID("tok-c"); got != "eyJjb29raWUiOiJhYXJkZWYa" { + t.Errorf("cached arp not preferred: %q", got) + } +} + +// refreshARPTimestamp bumps only the ftr timestamp segment; devHex, pid, the +// Arkose slot, and every other payload field pass through untouched. +func TestRefreshARPTimestamp(t *testing.T) { + mk := func(ftr string) string { + b, _ := json.Marshal(map[string]string{ + "sid": "4e54df9a-79cf-4569-b40d-fa793e38b727", + "ftr": ftr, + "ark": "ark_blob", + "bfp": "bf", + }) + return base64.StdEncoding.EncodeToString(b) + } + old := "40d7ba7c70ff478e9c212073ec8254c4_1791795834777_27064_UDF43-mnf-a4_31ck__tt" + raw, err := base64.StdEncoding.DecodeString(refreshARPTimestamp(mk(old))) + if err != nil { + t.Fatalf("refreshed arp not base64: %v", err) + } + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("not json: %v", err) + } + parts := strings.Split(m["ftr"], "_") + if len(parts) != 7 { + t.Fatalf("ftr segment count changed: %q", m["ftr"]) + } + if parts[0] != "40d7ba7c70ff478e9c212073ec8254c4" || parts[2] != "27064" || parts[4] != "31ck" || parts[6] != "tt" { + t.Errorf("ftr fields mangled: %q", m["ftr"]) + } + if parts[1] == "1791795834777" { + t.Error("ftr timestamp not bumped") + } + if m["sid"] != "4e54df9a-79cf-4569-b40d-fa793e38b727" || m["ark"] != "ark_blob" || m["bfp"] != "bf" { + t.Errorf("non-ftr fields mangled: %v", m) + } + // malformed inputs pass through unchanged + if got := refreshARPTimestamp("!!!not-base64"); got != "!!!not-base64" { + t.Errorf("garbage input changed: %q", got) + } + if got := refreshARPTimestamp(mk("too_short")); got != mk("too_short") { + t.Error("short ftr changed") + } +} + +// ARPFromCookie returns the sherlockToken verbatim when present, and rebuilds +// the JSON from component cookies otherwise. +func TestARPFromCookie(t *testing.T) { + if got := ARPFromCookie("a=1; sherlockToken=eyJhIjoxfQ==; b=2"); got != "eyJhIjoxfQ==" { + t.Errorf("sherlockToken not returned verbatim: %q", got) + } + if got := ARPFromCookie("a=1"); got != "" { + t.Errorf("expected empty for cookie without arp pieces, got %q", got) + } + raw, err := base64.StdEncoding.DecodeString(ARPFromCookie( + "ff_session_guid=4e54df9a-79cf-4569-b40d-fa793e38b727; forterToken=ft_x%3D-v2; arkose=ark_blob; bfp=bf")) + if err != nil { + t.Fatalf("reassembled arp not base64: %v", err) + } + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("not json: %v", err) + } + if m["sid"] != "4e54df9a-79cf-4569-b40d-fa793e38b727" || m["ftr"] != "ft_x=-v2" || m["ark"] != "ark_blob" || m["bfp"] != "bf" { + t.Errorf("reassembled arp mismatch: %v", m) + } +} diff --git a/backend/internal/provider/adobe/client.go b/backend/internal/provider/adobe/client.go index 1315f38..2242129 100644 --- a/backend/internal/provider/adobe/client.go +++ b/backend/internal/provider/adobe/client.go @@ -105,10 +105,14 @@ func NewClient(apiKey, proxy string) *Client { } } -// getARPSessionID returns a fresh ARP session id for this token. The value is -// regenerated per call, but the embedded pid stays stable per token; see -// buildARPSessionID for the wire format. +// getARPSessionID returns the x-arp-session-id for this token. When a real, +// cookie-derived value was captured during ExchangeCookie (CacheARP) it is +// reused verbatim — it carries a genuine Arkose payload which the 3p endpoints +// require. Otherwise a shaped value is synthesized (see buildARPSessionID). func (c *Client) getARPSessionID(token string) string { + if id := arpIdentity(token); strings.TrimSpace(id.arp) != "" { + return refreshARPTimestamp(id.arp) + } return buildARPSessionID(token) } @@ -1103,10 +1107,20 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s // client_id=clio-playground-web with the creative_production scope, keyed by // user_id, which IS entitled. Mint that here too (best-effort: if it fails we // keep the Express token so the other models still work). - if uid := ExtractAccountID(token); uid != "" { + // A cookie without the IMS session (ims_sid) mints an anonymous guest + // token (no user_id) — generation rejects it with 401, so fail the + // exchange here instead of landing a dead account in the pool. + uid := ExtractAccountID(token) + if uid == "" { + return nil, errors.New("adobe cookie has no IMS session (ims_sid); minted token is anonymous") + } + arp := ARPFromCookie(cookie) + CacheARP(token, arp) + if uid != "" { clioBody := "client_id=" + clioClientID + "&scope=" + strings.ReplaceAll(clioScopeValue, ",", "%2C") + "&user_id=" + url.QueryEscape(uid) if clioPayload, err := doCookieExchange(ctx, sess, cookie, clioBody, "https://firefly.adobe.com"); err == nil { if clioToken := strings.TrimSpace(stringValue(clioPayload["access_token"])); clioToken != "" { + CacheARP(clioToken, arp) return &CookieExchangeResult{ AccessToken: clioToken, ExpiresIn: intValue(clioPayload["expires_in"]), diff --git a/backend/internal/provider/adobe/util.go b/backend/internal/provider/adobe/util.go index 3a36448..db01867 100644 --- a/backend/internal/provider/adobe/util.go +++ b/backend/internal/provider/adobe/util.go @@ -117,40 +117,164 @@ func buildARKBlob() string { arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail } +// Per-token stable session identity: a real browser keeps sid / the ftr device +// hex / bfp constant for the whole session, only the ftr timestamp advances. +// Re-randomizing them every call is a bot signal, so they're cached per token +// alongside the pid. +var ( + arpIdentMu sync.Mutex + arpIdent = map[string]arpSessionIdent{} +) + +type arpSessionIdent struct { + sid string + devHex string + bfp string + arp string // cookie-derived value (sherlockToken), preferred over synthesis +} + +// cookieValue extracts one cookie value from a raw cookie blob. +func cookieValue(cookie, name string) string { + decoded, err := url.QueryUnescape(cookie) + if err != nil { + decoded = cookie + } + for _, part := range strings.Split(decoded, ";") { + part = strings.TrimSpace(part) + if k, v, ok := strings.Cut(part, "="); ok && k == name { + return v + } + } + return "" +} + +// ARPFromCookie rebuilds x-arp-session-id from a browser cookie blob. The +// sherlockToken cookie IS the arp value verbatim; when it's absent the same +// {sid,ark,ftr,bfp} JSON is reassembled from the component cookies — +// forterToken is the complete ftr (with the real Arkose payload), arkose the +// ark blob, ff_session_guid the sid, bfp the fingerprint id. Returns "" when +// no usable pieces are found. +func ARPFromCookie(cookie string) string { + if st := cookieValue(cookie, "sherlockToken"); st != "" { + return st + } + ftr, ark := cookieValue(cookie, "forterToken"), cookieValue(cookie, "arkose") + if ftr == "" { + ftr = cookieValue(cookie, "forter") + } + if ftr == "" || ark == "" { + return "" + } + payload := map[string]any{"ftr": ftr, "ark": ark} + if v := cookieValue(cookie, "ff_session_guid"); v != "" { + payload["sid"] = v + } + if v := cookieValue(cookie, "bfp"); v != "" { + payload["bfp"] = v + } + b, _ := json.Marshal(payload) + return base64.StdEncoding.EncodeToString(b) +} + +// refreshARPTimestamp decodes a cookie-derived arp payload and bumps the ftr +// timestamp field to now before re-encoding. ftr layout is +// ____31ck__tt — a stale timestamp reads as +// a dead session to the gateway; every other field stays verbatim so the +// Forter/Arkose linkage is preserved. Returns arp unchanged on any shape +// mismatch. +func refreshARPTimestamp(arp string) string { + raw, err := base64.StdEncoding.DecodeString(arp) + if err != nil { + return arp + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return arp + } + ftr, _ := m["ftr"].(string) + parts := strings.Split(ftr, "_") + if len(parts) < 3 { + return arp + } + parts[1] = strconv.FormatInt(time.Now().UnixMilli(), 10) + m["ftr"] = strings.Join(parts, "_") + b, err := json.Marshal(m) + if err != nil { + return arp + } + return base64.StdEncoding.EncodeToString(b) +} + +// CacheARP stores a cookie-derived arp value for token so getARPSessionID +// prefers the real (Arkose-bearing) value over the synthesized fallback. +func CacheARP(token, arp string) { + token, arp = strings.TrimSpace(token), strings.TrimSpace(arp) + if token == "" || arp == "" { + return + } + arpIdentMu.Lock() + defer arpIdentMu.Unlock() + id := arpIdent[token] + id.arp = arp + arpIdent[token] = id +} + // buildARPSessionID returns the x-arp-session-id value: base64 of -// {"sid","ark","ftr"}, shaped to match live firefly.adobe.com traffic: +// {"sid","ark","ftr","bfp"}, shaped to match live firefly.adobe.com traffic: // // ark: <17hex>.<10digits>|…|rid=|… -// ftr: <32hex>___UDF43-m4_31ck__tt +// ftr: <32hex>___UDF43-mnf-a4_31ck__tt // -// The pid is allocated per token (allocPID) so it stays stable across an -// account's requests, the way a real browser session would. +// pid, sid, the ftr device hex and bfp stay stable per token — a real browser +// keeps them for the whole session and re-randomizing is a bot signal. // // The Arkose segment is left empty on purpose. In a real browser that slot // carries a "=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager // script; a synthesized one has the right shape but fails Adobe's server-side // validation, and 3p-images/generate-async rejects it with a misleading // {"error_code":"timeout_error","message":"system under load"}. Sending the -// slot empty reads as "no Arkose payload yet" and is accepted. +// slot empty reads as "no Arkose payload yet" and is accepted by the native +// endpoints; 3p models may require the cookie-derived value (see CacheARP). func buildARPSessionID(token string) string { ftr := strings.Join([]string{ - randomHex(16), // 32 hex chars, matching the capture + arpIdentity(token).devHex, strconv.FormatInt(time.Now().UnixMilli(), 10), strconv.Itoa(allocPID(token)), - "UDF43-m4", + "UDF43-mnf-a4", "31ck", "", "tt", }, "_") + id := arpIdentity(token) b, _ := json.Marshal(map[string]any{ - "sid": uuid.NewString(), + "sid": id.sid, "ark": buildARKBlob(), "ftr": ftr, + "bfp": id.bfp, }) return base64.StdEncoding.EncodeToString(b) } +// arpIdentity returns (creating on first use) the stable per-token identity +// pieces, preferring a cached cookie-derived arp value when one exists. +func arpIdentity(token string) arpSessionIdent { + arpIdentMu.Lock() + defer arpIdentMu.Unlock() + id, ok := arpIdent[token] + if !ok || id.sid == "" { + id.sid = uuid.NewString() + } + if !ok || id.devHex == "" { + id.devHex = randomHex(16) + } + if !ok || id.bfp == "" { + id.bfp = uuid.NewString() + } + arpIdent[token] = id + return id +} + // randomDigits returns n decimal digits with a non-zero leading digit, so the // rendered value keeps a constant width. func randomDigits(n int) string {