diff --git a/.env.example b/.env.example index ddaca07..475fdbd 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,8 @@ WA_APP_REDIS_URL= # Optional upstream proxies (leave empty for direct outbound) WA_COMMON_PROXY= + +# Optional registration proxy-runtime lease. Modes: optional, disabled, required. +WA_REGISTRATION_PROXY_LEASE_MODE=optional +PROXY_RUNTIME_API_BASE_URL= +PROXY_RUNTIME_SERVICE_AUTH_TOKEN= diff --git a/README.md b/README.md index 0f2cbf3..ae364c4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ docker compose up -d - `WA_APP_PG_DSN`:可选 PostgreSQL DSN;为空时使用内置 SQLite 持久化。 - `WA_APP_REDIS_URL`:可选 Redis URL;为空时使用内置 SQLite 运行态存储。 - `WA_COMMON_PROXY`:系统默认 WA 出站代理;账号未配置代理策略且阶段代理为空时使用,仍为空则直连。 +- `WA_REGISTRATION_PROXY_LEASE_MODE`:注册链路 proxy-runtime 租约模式;`optional` 会失败回退,`disabled` 关闭租约,`required` 强制租约成功。 +- `PROXY_RUNTIME_API_BASE_URL`:可选 proxy-runtime API 地址;留空时不请求动态租约。 +- `PROXY_RUNTIME_SERVICE_AUTH_TOKEN`:可选 proxy-runtime 服务令牌;示例文件只保留空占位。 PostgreSQL 和 Redis 都是可选组件。需要启用时,在 `docker-compose.yml` 中取消对应服务注释,并在 `.env` 中填写 `WA_APP_PG_DSN` / `WA_APP_REDIS_URL`。 diff --git a/cmd/wa-app-service/dashboard_http.go b/cmd/wa-app-service/dashboard_http.go index 808338c..c2aa7cd 100644 --- a/cmd/wa-app-service/dashboard_http.go +++ b/cmd/wa-app-service/dashboard_http.go @@ -267,7 +267,7 @@ func (s *dashboardHTTP) handleAccountOTPMessages(w http.ResponseWriter, r *http. WaAccountId: q.Get("wa_account_id"), Limit: int32(positiveInt(q.Get("limit"), 20)), Cursor: q.Get("cursor"), - IncludeSensitiveValues: true, + IncludeSensitiveValues: queryBool(q.Get("include_sensitive_values"), true), }) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "load WA OTP history failed"}) @@ -847,6 +847,17 @@ func positiveInt(value string, fallback int) int { return parsed } +func queryBool(value string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + default: + return fallback + } +} + func methodNotAllowed(w http.ResponseWriter, allowed string) { w.Header().Set("Allow", allowed) writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) diff --git a/cmd/wa-app-service/main.go b/cmd/wa-app-service/main.go index cd12976..fca1a15 100644 --- a/cmd/wa-app-service/main.go +++ b/cmd/wa-app-service/main.go @@ -57,6 +57,8 @@ func main() { } service := app.NewServer(store, runtime, engine, clock, ids) service.SetCommonProxyURL(cfg.CommonProxy) + service.SetRegistrationProxyLeaseMode(cfg.RegistrationProxyLeaseMode) + service.SetProxyRuntimeLeaseClient(cfg.ProxyRuntimeAPI, cfg.ProxyRuntimeToken) authConfig := newDashboardAuthConfig(cfg.DashboardAuthPass) grpcListenAddr := configValue(cfg.GRPCListenAddr, defaultGRPCListenAddr) dashboardHTTPAddr := configValue(cfg.DashboardHTTPAddr, defaultDashboardHTTPAddr) diff --git a/docker-compose.yml b/docker-compose.yml index 4e33543..fb47577 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,9 @@ services: WA_APP_PG_DSN: ${WA_APP_PG_DSN:-} WA_APP_REDIS_URL: ${WA_APP_REDIS_URL:-} WA_COMMON_PROXY: ${WA_COMMON_PROXY:-} + WA_REGISTRATION_PROXY_LEASE_MODE: ${WA_REGISTRATION_PROXY_LEASE_MODE:-optional} + PROXY_RUNTIME_API_BASE_URL: ${PROXY_RUNTIME_API_BASE_URL:-} + PROXY_RUNTIME_SERVICE_AUTH_TOKEN: ${PROXY_RUNTIME_SERVICE_AUTH_TOKEN:-} WA_APP_DATA_DIR: ${WA_APP_DATA_DIR:-/var/lib/wa-app} ports: - "127.0.0.1:50091:50091" diff --git a/internal/app/action_gateway.go b/internal/app/action_gateway.go index 2de2b2a..d8de16f 100644 --- a/internal/app/action_gateway.go +++ b/internal/app/action_gateway.go @@ -18,13 +18,15 @@ import ( ) const transientStateTTL = 30 * time.Minute +const registrationAttemptStateTTL = 26 * time.Hour const registrationOTPWaitDefaultTTL = 20 * time.Minute type registrationOTPWait struct { - WAAccountID string `json:"wa_account_id"` - VerificationRequestID string `json:"verification_request_id"` - ResumeURL string `json:"resume_url"` - CreatedAtUnix int64 `json:"created_at_unix"` + WAAccountID string `json:"wa_account_id"` + VerificationRequestID string `json:"verification_request_id"` + ResumeURL string `json:"resume_url"` + CreatedAtUnix int64 `json:"created_at_unix"` + ProxyLease registrationProxyLease `json:"proxy_lease,omitempty"` } type actionGateway struct{ server *Server } @@ -61,6 +63,10 @@ func (g *actionGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { result, err = g.resumeOTP(r.Context(), payload) case "registration/submit-otp": result, err = g.submitOTP(r.Context(), payload) + case "registration/account-transfer/refresh": + result, err = g.refreshAccountTransferChallenge(r.Context(), payload) + case "registration/account-transfer/poll": + result, err = g.pollAccountTransferRegistration(r.Context(), payload) case "registration/cleanup-failed-account": result, err = g.cleanupFailedRegistration(r.Context(), payload) case "registration/persist-login-state": @@ -168,7 +174,7 @@ func (g *actionGateway) requestSMSOTP(ctx context.Context, payload map[string]an if reason := directRegistrationMethodUnsupportedReason(method); reason != "" { return registrationMethodUnsupportedMap(method, reason), nil } - runner, route, managedRoute, err := g.registrationRequestRunner(ctx, payload) + runner, route, managedRoute, proxyLease, err := g.registrationRequestRunner(ctx, payload) if err != nil { return nil, err } @@ -182,20 +188,42 @@ func (g *actionGateway) requestSMSOTP(ctx context.Context, payload map[string]an }, runner) runner.CloseIdleConnections() if err != nil { + g.releaseRegistrationProxyLease(context.Background(), proxyLease) return nil, err } if resp.GetError() != nil { + g.releaseRegistrationProxyLease(context.Background(), proxyLease) return map[string]any{"success": false, "error": protoMap(resp.GetError()), "error_message": resp.GetError().GetMessage()}, nil } record := resp.GetVerificationRequest() + success := record.GetStatus() == waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_SENT || record.GetStatus() == waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_WAITING + if !success { + g.releaseRegistrationProxyLease(context.Background(), proxyLease) + } + if success && validRegistrationProxyLease(proxyLease) { + wait := registrationOTPWait{ + WAAccountID: record.GetWaAccountId(), + VerificationRequestID: record.GetVerificationRequestId(), + CreatedAtUnix: time.Now().UTC().Unix(), + ProxyLease: proxyLease, + } + if err := g.saveRegistrationOTPWait(ctx, wait, registrationOTPWaitDefaultTTL); err != nil { + g.releaseRegistrationProxyLease(context.Background(), proxyLease) + return nil, err + } + } response := map[string]any{ - "success": record.GetStatus() == waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_SENT || record.GetStatus() == waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_WAITING, + "success": success, "status": record.GetStatus().String(), "verification_request_id": record.GetVerificationRequestId(), "verification_request": protoMap(record), "method_statuses": protoMethodStatusMaps(record.GetMethodStatuses()), "proxy": registrationProxyRouteMap(route, managedRoute), } + if challenge := resp.GetAccountTransferChallenge(); challenge != nil { + response["account_transfer_challenge"] = protoMap(challenge) + response["registration_phase"] = "ACCOUNT_TRANSFER_WAITING" + } if seconds := durationSeconds(record.GetRetryAfter()); seconds > 0 { response["retry_after_seconds"] = seconds } @@ -207,6 +235,11 @@ func (g *actionGateway) awaitOTP(ctx context.Context, payload map[string]any) (m if err != nil { return nil, err } + if wait.ProxyLease.LeaseID == "" && g.server.registrationProxyLeaseEnabled() { + if existing, err := g.loadRegistrationOTPWait(ctx, wait.WAAccountID, wait.VerificationRequestID); err == nil { + wait.ProxyLease = existing.ProxyLease + } + } if err := g.saveRegistrationOTPWait(ctx, wait, ttl); err != nil { return nil, err } @@ -231,6 +264,7 @@ func (g *actionGateway) resumeOTP(ctx context.Context, payload map[string]any) ( if err := postRegistrationOTPResume(ctx, wait, code); err != nil { return nil, err } + g.releaseRegistrationProxyLease(context.Background(), wait.ProxyLease) _ = g.deleteRegistrationOTPWait(ctx, wait) return map[string]any{"success": true, "wa_account_id": wait.WAAccountID, "verification_request_id": wait.VerificationRequestID}, nil } @@ -357,7 +391,7 @@ func registrationOTPWaitAccountKey(waAccountIDValue string) string { } func (g *actionGateway) submitOTP(ctx context.Context, payload map[string]any) (map[string]any, error) { - runner, route, managedRoute, err := g.registrationSubmitRunner(ctx, payload) + runner, route, managedRoute, proxyLease, err := g.registrationSubmitRunner(ctx, payload) if err != nil { return nil, err } @@ -374,6 +408,13 @@ func (g *actionGateway) submitOTP(ctx context.Context, payload map[string]any) ( return map[string]any{"success": false, "error": protoMap(resp.GetError()), "error_message": resp.GetError().GetMessage(), "registration": protoMap(resp.GetRegistration())}, nil } success := resp.GetRegistration().GetStatus() == waappv1.RegistrationStatus_REGISTRATION_STATUS_REGISTERED && resp.GetLoginState().GetStatus() == waappv1.LoginStateStatus_LOGIN_STATE_STATUS_ACTIVE + if success { + g.releaseRegistrationProxyLease(context.Background(), proxyLease) + _ = g.deleteRegistrationOTPWait(ctx, registrationOTPWait{ + WAAccountID: resp.GetRegistration().GetWaAccountId(), + VerificationRequestID: resp.GetRegistration().GetVerificationRequestId(), + }) + } return map[string]any{ "success": success, "status": resp.GetRegistration().GetStatus().String(), @@ -383,15 +424,99 @@ func (g *actionGateway) submitOTP(ctx context.Context, payload map[string]any) ( }, nil } +func (g *actionGateway) refreshAccountTransferChallenge(ctx context.Context, payload map[string]any) (map[string]any, error) { + resp, err := g.server.RefreshAccountTransferChallenge(ctx, &waappv1.RefreshAccountTransferChallengeRequest{ + Context: actionContext(payload), + VerificationRequestId: textField(payload, "verification_request_id"), + }) + if err != nil { + return nil, err + } + if resp.GetError() != nil { + return map[string]any{"success": false, "error": protoMap(resp.GetError()), "error_message": resp.GetError().GetMessage()}, nil + } + return map[string]any{ + "success": true, + "registration_phase": "ACCOUNT_TRANSFER_WAITING", + "account_transfer_challenge": protoMap(resp.GetAccountTransferChallenge()), + }, nil +} + +func (g *actionGateway) pollAccountTransferRegistration(ctx context.Context, payload map[string]any) (map[string]any, error) { + attempts := int(numberField(payload, "max_attempts")) + if attempts <= 0 { + attempts = 1 + } + if attempts > 100 { + attempts = 100 + } + interval := time.Duration(numberField(payload, "interval_seconds")) * time.Second + var result map[string]any + for attempt := 0; attempt < attempts; attempt++ { + if attempt > 0 && interval > 0 { + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + } + submitPayload := cloneActionPayload(payload) + submitPayload["code"] = "" + resultValue, err := g.submitOTP(ctx, submitPayload) + if err != nil { + return nil, err + } + result = resultValue + if boolField(result, "success") { + _ = g.deleteRegistrationOTPWait(ctx, registrationOTPWait{WAAccountID: textField(payload, "wa_account_id"), VerificationRequestID: textField(payload, "verification_request_id")}) + result["attempts"] = attempt + 1 + return result, nil + } + if !accountTransferPollRetryable(result) { + result["attempts"] = attempt + 1 + return result, nil + } + } + if result == nil { + result = map[string]any{"success": false} + } + result["registration_phase"] = "ACCOUNT_TRANSFER_WAITING" + result["attempts"] = attempts + return result, nil +} + +func accountTransferPollRetryable(result map[string]any) bool { + if result == nil { + return true + } + if textField(result, "status") == waappv1.RegistrationStatus_REGISTRATION_STATUS_SUBMITTED.String() { + return true + } + errorMap := objectField(result, "error") + if boolField(errorMap, "retryable") { + return true + } + message := strings.ToLower(firstNonEmpty(textField(result, "error_message"), textField(errorMap, "message"))) + return strings.Contains(message, "pending") || strings.Contains(message, "temporarily") || strings.Contains(message, "too_recent") +} + func (g *actionGateway) cleanupFailedRegistration(ctx context.Context, payload map[string]any) (map[string]any, error) { reqCtx := actionContext(payload) accountID := cleanupWAAccountID(payload) verificationRequestID := cleanupVerificationRequestID(payload) if verificationRequestID != "" || accountID != "" { - _ = g.deleteRegistrationOTPWait(ctx, registrationOTPWait{ - WAAccountID: accountID, - VerificationRequestID: verificationRequestID, - }) + wait, err := g.loadRegistrationOTPWait(ctx, accountID, verificationRequestID) + if err == nil { + g.releaseRegistrationProxyLease(context.Background(), wait.ProxyLease) + _ = g.deleteRegistrationOTPWait(ctx, wait) + } else { + _ = g.deleteRegistrationOTPWait(ctx, registrationOTPWait{ + WAAccountID: accountID, + VerificationRequestID: verificationRequestID, + }) + } } if accountID == "" { return map[string]any{"success": true, "deleted": false, "reason": "missing_wa_account_id"}, nil @@ -554,7 +679,7 @@ func (s *Server) ensureDefaultProtocolProfile(ctx context.Context) (*waappv1.Pro waappv1.ProtocolCapability_PROTOCOL_CAPABILITY_MESSAGE_SESSION, waappv1.ProtocolCapability_PROTOCOL_CAPABILITY_ACCOUNT_SETTINGS, }, - RegistrationFlows: []waappv1.RegistrationFlowKind{waappv1.RegistrationFlowKind_REGISTRATION_FLOW_KIND_NEW_ACCOUNT}, + RegistrationFlows: []waappv1.RegistrationFlowKind{waappv1.RegistrationFlowKind_REGISTRATION_FLOW_KIND_NEW_ACCOUNT, waappv1.RegistrationFlowKind_REGISTRATION_FLOW_KIND_EXISTING_ACCOUNT}, MessageTransports: []waappv1.MessageTransportKind{waappv1.MessageTransportKind_MESSAGE_TRANSPORT_KIND_LONG_CONNECTION}, DiscoveredAt: timestamppb.New(now), Audit: &waappv1.AuditStamp{CreatedAt: timestamppb.New(now), UpdatedAt: timestamppb.New(now)}, @@ -577,10 +702,10 @@ func (g *actionGateway) nativeEngineForPayload(payload map[string]any) (*NativeE return engine.WithProxyURL(proxyURL) } -func (g *actionGateway) registrationRequestRunner(ctx context.Context, payload map[string]any) (*NativeEngine, WAProxyRoute, bool, error) { +func (g *actionGateway) registrationRequestRunner(ctx context.Context, payload map[string]any) (*NativeEngine, WAProxyRoute, bool, registrationProxyLease, error) { engine, err := g.nativeEngine() if err != nil { - return nil, WAProxyRoute{}, false, err + return nil, WAProxyRoute{}, false, registrationProxyLease{}, err } route, useProxy, err := g.server.resolveWAProxyRoute(ctx, waProxyResolveRequest{ Stage: waProxyStageRegistration, @@ -589,35 +714,51 @@ func (g *actionGateway) registrationRequestRunner(ctx context.Context, payload m CountryCode: proxyCountryCodeFromPayload(payload), }) if err != nil { - return nil, WAProxyRoute{}, false, err + return nil, WAProxyRoute{}, false, registrationProxyLease{}, err } if !useProxy { - return engine, route, false, nil + return engine, route, false, registrationProxyLease{}, nil + } + lease, leasedRoute, err := g.acquireRegistrationProxyLease(ctx, payload, route, registrationOTPWaitDefaultTTL) + if err != nil { + return nil, WAProxyRoute{}, false, registrationProxyLease{}, err + } + if validRegistrationProxyLease(lease) { + route = leasedRoute } proxied, err := engine.WithProxyURL(route.ProxyURL) if err != nil { - return nil, WAProxyRoute{}, false, err + g.releaseRegistrationProxyLease(context.Background(), lease) + return nil, WAProxyRoute{}, false, registrationProxyLease{}, err } - return proxied, route, true, nil + return proxied, route, true, lease, nil } -func (g *actionGateway) registrationSubmitRunner(ctx context.Context, payload map[string]any) (*NativeEngine, WAProxyRoute, bool, error) { +func (g *actionGateway) registrationSubmitRunner(ctx context.Context, payload map[string]any) (*NativeEngine, WAProxyRoute, bool, registrationProxyLease, error) { engine, err := g.nativeEngine() if err != nil { - return nil, WAProxyRoute{}, false, err + return nil, WAProxyRoute{}, false, registrationProxyLease{}, err + } + if wait, err := g.loadRegistrationOTPWait(ctx, textField(payload, "wa_account_id"), textField(payload, "verification_request_id")); err == nil && g.server.registrationProxyLeaseEnabled() && validRegistrationProxyLease(wait.ProxyLease) { + route := proxyRuntimeLeaseRoute(wait.ProxyLease, WAProxyRoute{Source: waProxySourceSystemCommon, PolicyMode: waProxyModeCommon}) + proxied, err := engine.WithProxyURL(route.ProxyURL) + if err != nil { + return nil, WAProxyRoute{}, false, registrationProxyLease{}, err + } + return proxied, route, true, wait.ProxyLease, nil } route, useProxy, err := g.registrationSubmitProxyRoute(ctx, payload) if err != nil { - return nil, WAProxyRoute{}, false, err + return nil, WAProxyRoute{}, false, registrationProxyLease{}, err } if !useProxy { - return engine, route, false, nil + return engine, route, false, registrationProxyLease{}, nil } proxied, err := engine.WithProxyURL(route.ProxyURL) if err != nil { - return nil, WAProxyRoute{}, false, err + return nil, WAProxyRoute{}, false, registrationProxyLease{}, err } - return proxied, route, true, nil + return proxied, route, true, registrationProxyLease{}, nil } func (g *actionGateway) registrationSubmitProxyRoute(ctx context.Context, payload map[string]any) (WAProxyRoute, bool, error) { diff --git a/internal/app/chatd_client.go b/internal/app/chatd_client.go index b867d65..998a291 100644 --- a/internal/app/chatd_client.go +++ b/internal/app/chatd_client.go @@ -56,6 +56,7 @@ type chatdSession struct { transport chatdTransport endpoint chatdEndpoint serverStaticPublic string + deviceID string } type chatdSessionUpdate struct { @@ -69,6 +70,7 @@ type chatdSessionUpdate struct { type chatdReceivedItem struct { message *waappv1.InboundMessage payload *chatdEncPayload + otp *waappv1.OtpMessage } func chatdPhase(phase string, err error) error { @@ -101,10 +103,10 @@ func (c *chatdClient) openTimeout() time.Duration { return c.cfg.Timeout } -func (c *chatdClient) receiveBatch(ctx context.Context, state nativeState, input EngineMessageInput, appVersion string, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, error) { +func (c *chatdClient) receiveBatch(ctx context.Context, state nativeState, input EngineMessageInput, appVersion string, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, error) { session, err := c.openSession(ctx, state, input.RegisteredIdentityID, defaultLoginPayload, appVersion) if err != nil { - return nil, nil, chatdSessionUpdate{}, err + return nil, nil, nil, chatdSessionUpdate{}, err } defer session.Close() return session.receiveBatch(input, now) @@ -178,6 +180,7 @@ func (c *chatdClient) openEndpointSession(ctx context.Context, endpoint chatdEnd transport: chatdTransport{rw: rw, keys: keys, codec: c.codec, maxFrameBytes: c.cfg.MaxFrameBytes}, endpoint: endpoint, serverStaticPublic: b64u(keys.serverStaticPublic), + deviceID: chatdDeviceIDFromState(state), }, nil } @@ -252,9 +255,9 @@ func (s *chatdSession) update() chatdSessionUpdate { return chatdSessionUpdate{Endpoint: s.endpoint, ServerStaticPublic: s.serverStaticPublic} } -func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, error) { +func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, error) { if s == nil { - return nil, nil, chatdSessionUpdate{}, fmt.Errorf("chatd session is not open") + return nil, nil, nil, chatdSessionUpdate{}, fmt.Errorf("chatd session is not open") } update := s.update() maxMessages := input.MaxMessages @@ -273,7 +276,7 @@ func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([] if len(items) > 0 { break } - return nil, nil, update, chatdPhase("chatd frame read", err) + return nil, nil, nil, update, chatdPhase("chatd frame read", err) } nextUpdate, nextItems, err := s.consumeIncomingNode(input, node, update, now) update = nextUpdate @@ -281,7 +284,7 @@ func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([] if len(items) > 0 { break } - return nil, nil, update, err + return nil, nil, nil, update, err } before := len(items) items = appendReceivedItems(items, nextItems, maxMessages) @@ -289,8 +292,8 @@ func (s *chatdSession) receiveBatch(input EngineMessageInput, now time.Time) ([] deadline = minTime(deadline, time.Now().Add(defaultChatdPostMessageDrainWindow)) } } - messages, payloads := splitReceivedItems(items) - return messages, payloads, update, nil + messages, payloads, otps := splitReceivedItems(items) + return messages, payloads, otps, update, nil } func (s *chatdSession) consumeIncomingNode(input EngineMessageInput, node chatdNode, update chatdSessionUpdate, now time.Time) (chatdSessionUpdate, []chatdReceivedItem, error) { @@ -300,7 +303,12 @@ func (s *chatdSession) consumeIncomingNode(input EngineMessageInput, node chatdN if isChatdTerminalNode(node) { return update, nil, newChatdError("server sent %s", controlNodeSummary(node)) } - if ack, ok := buildAckForNode(node); ok { + oldRegistrationOTP, isOldRegistrationNode, oldRegistrationValid := oldRegistrationOTPFromChatdNode(node, s.deviceID, now) + ackAttrs := map[string]string(nil) + if isOldRegistrationNode && s.deviceID != "" { + ackAttrs = map[string]string{"device_id": s.deviceID} + } + if ack, ok := buildAckForNodeWithAttrs(node, ackAttrs); ok { if err := s.transport.sendNode(ack); err != nil { return update, nil, chatdPhase("chatd ack write", err) } @@ -310,21 +318,26 @@ func (s *chatdSession) consumeIncomingNode(input EngineMessageInput, node chatdN } update.ContactHints = dedupeWAContactHints(append(update.ContactHints, contactHintsFromChatdNode(node)...)) update.PrivacyTokens = dedupePrivacyTokenUpdates(append(update.PrivacyTokens, privacyTokenUpdatesFromChatdNode(node)...)) + items := []chatdReceivedItem{} + if oldRegistrationValid { + if otp := oldRegistrationOTPMessage(input, node, oldRegistrationOTP, now); otp != nil { + items = append(items, chatdReceivedItem{otp: otp}) + } + } if input.MessageSessionID == "" { - return update, nil, nil + return update, items, nil } encs := iterEncPayloads(node) if len(encs) == 0 { if node.Tag != "message" { - return update, nil, nil + return update, items, nil } contact := firstNonEmpty(node.Attrs["from"], node.Attrs["participant"]) sender := firstNonEmpty(node.Attrs["participant"], node.Attrs["from"]) payloadSummary := nodePayloadSummary(node) message := &waappv1.InboundMessage{MessageId: inboundMessageID(input.WAAccountID, node.Attrs["id"], node.Tag, sender, payloadSummary), MessageSessionId: input.MessageSessionID, Kind: inboundKind(node.Tag), EncryptionState: waappv1.MessageEncryptionState_MESSAGE_ENCRYPTION_STATE_PLAINTEXT, AckStatus: ackStatusForNode(node), ContactRef: contact, SenderRef: sender, PayloadRef: "node:" + redacted(payloadSummary), ProviderMessageId: node.Attrs["id"], ProviderTimestamp: chatdProviderTimestamp(node.Attrs["t"]), DeleteStatus: waappv1.MessageDeleteStatus_MESSAGE_DELETE_STATUS_NOT_DELETED, ReceivedAt: timestamppb.New(now)} - return update, []chatdReceivedItem{{message: message}}, nil + return update, append(items, chatdReceivedItem{message: message}), nil } - items := make([]chatdReceivedItem, 0, len(encs)) for _, enc := range encs { payload := enc payloadRef := payloadRefForEnc(input.WAAccountID, payload.Payload) @@ -347,19 +360,22 @@ func appendReceivedItems(dst []chatdReceivedItem, src []chatdReceivedItem, limit return dst } -func splitReceivedItems(items []chatdReceivedItem) ([]*waappv1.InboundMessage, []chatdEncPayload) { +func splitReceivedItems(items []chatdReceivedItem) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage) { messages := make([]*waappv1.InboundMessage, 0, len(items)) payloads := []chatdEncPayload{} + otps := []*waappv1.OtpMessage{} for _, item := range items { - if item.message == nil { - continue + if item.otp != nil { + otps = append(otps, item.otp) } - messages = append(messages, item.message) - if item.payload != nil { - payloads = append(payloads, *item.payload) + if item.message != nil { + messages = append(messages, item.message) + if item.payload != nil { + payloads = append(payloads, *item.payload) + } } } - return messages, payloads + return messages, payloads, otps } func chatdProviderTimestamp(value string) *timestamppb.Timestamp { diff --git a/internal/app/chatd_node.go b/internal/app/chatd_node.go index a3fb3e8..a306cbf 100644 --- a/internal/app/chatd_node.go +++ b/internal/app/chatd_node.go @@ -777,6 +777,10 @@ func buildPingNode() chatdNode { } func buildAckForNode(node chatdNode) (chatdNode, bool) { + return buildAckForNodeWithAttrs(node, nil) +} + +func buildAckForNodeWithAttrs(node chatdNode, extra map[string]string) (chatdNode, bool) { nodeID := node.Attrs["id"] sender := node.Attrs["from"] if nodeID == "" || sender == "" { @@ -788,6 +792,7 @@ func buildAckForNode(node chatdNode) (chatdNode, bool) { if t := node.Attrs["type"]; t != "" { attrs["type"] = t } + addAckExtraAttrs(attrs, extra) return chatdNode{Tag: "ack", Attrs: attrs}, true case "message": attrs := map[string]string{"id": nodeID, "to": sender, "class": "message"} @@ -797,12 +802,21 @@ func buildAckForNode(node chatdNode) (chatdNode, bool) { if p := node.Attrs["participant"]; p != "" { attrs["participant"] = p } + addAckExtraAttrs(attrs, extra) return chatdNode{Tag: "ack", Attrs: attrs}, true default: return chatdNode{}, false } } +func addAckExtraAttrs(attrs map[string]string, extra map[string]string) { + for key, value := range extra { + if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" { + attrs[key] = value + } + } +} + func iterEncPayloads(node chatdNode) []chatdEncPayload { out := []chatdEncPayload{} var walk func(chatdNode, []string, chatdMessageRefs) diff --git a/internal/app/chatd_old_registration.go b/internal/app/chatd_old_registration.go new file mode 100644 index 0000000..e340855 --- /dev/null +++ b/internal/app/chatd_old_registration.go @@ -0,0 +1,81 @@ +package app + +import ( + "encoding/hex" + "strconv" + "strings" + "time" + + waappv1 "github.com/byte-v-forge/wa-app/gen/go/byte/v/forge/waapp/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type chatdOldRegistrationOTP struct { + code string + deviceID string + expiresAt time.Time +} + +func chatdDeviceIDFromState(state nativeState) string { + return chatdDeviceIDFromUUID(state.Profile.FDID) +} + +func chatdDeviceIDFromUUID(value string) string { + compact := strings.ReplaceAll(strings.TrimSpace(value), "-", "") + raw, err := hex.DecodeString(compact) + if err != nil || len(raw) != 16 { + return "" + } + return b64u(raw) +} + +func oldRegistrationOTPFromChatdNode(node chatdNode, currentDeviceID string, now time.Time) (chatdOldRegistrationOTP, bool, bool) { + if node.Tag != "notification" { + return chatdOldRegistrationOTP{}, false, false + } + child, ok := chatdChild(node, "wa_old_registration") + if !ok { + return chatdOldRegistrationOTP{}, false, false + } + otp := chatdOldRegistrationOTP{ + code: strings.TrimSpace(child.Attrs["code"]), + deviceID: strings.TrimSpace(child.Attrs["device_id"]), + expiresAt: oldRegistrationExpiresAt(child.Attrs["expiry_t"]), + } + if otp.code == "" || otp.expiresAt.IsZero() || !now.Before(otp.expiresAt) { + return otp, true, false + } + if currentDeviceID != "" && otp.deviceID == currentDeviceID { + return otp, true, false + } + return otp, true, true +} + +func oldRegistrationExpiresAt(value string) time.Time { + stamp, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || stamp <= 0 { + return time.Time{} + } + if stamp > 1_000_000_000_000 { + return time.UnixMilli(stamp).UTC() + } + return time.Unix(stamp, 0).UTC() +} + +func oldRegistrationOTPMessage(input EngineMessageInput, node chatdNode, otp chatdOldRegistrationOTP, now time.Time) *waappv1.OtpMessage { + if strings.TrimSpace(input.WAAccountID) == "" || strings.TrimSpace(otp.code) == "" || otp.expiresAt.IsZero() { + return nil + } + otpID := "waotp_old_" + stableID(strings.Join([]string{input.WAAccountID, node.Attrs["id"], otp.code, strconv.FormatInt(otp.expiresAt.Unix(), 10)}, ":")) + return &waappv1.OtpMessage{ + OtpMessageId: otpID, + WaAccountId: input.WAAccountID, + ClientProfileId: input.ClientProfileID, + RegisteredIdentityId: input.RegisteredIdentityID, + Source: waappv1.WaOtpSource_WA_OTP_SOURCE_LONG_CONNECTION, + SourceParty: firstNonEmpty(node.Attrs["from"], "wa_old_registration"), + Otp: &waappv1.SensitiveText{Value: otp.code, RedactedValue: redacted(otp.code), SecretRef: "wa-otp:" + stableID(otpID)}, + ReceivedAt: timestamppb.New(now.UTC()), + ExpiresAt: timestamppb.New(otp.expiresAt.UTC()), + } +} diff --git a/internal/app/migrations/001_init.sql b/internal/app/migrations/001_init.sql index accddbc..a08d1f4 100644 --- a/internal/app/migrations/001_init.sql +++ b/internal/app/migrations/001_init.sql @@ -213,6 +213,7 @@ CREATE TABLE IF NOT EXISTS wa_otp_messages ( otp_redacted TEXT NOT NULL DEFAULT '', otp_secret_ref TEXT NOT NULL DEFAULT '', received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -267,6 +268,7 @@ ALTER TABLE wa_inbound_messages ADD COLUMN IF NOT EXISTS provider_timestamp TIME ALTER TABLE wa_inbound_messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ; ALTER TABLE wa_inbound_messages ADD COLUMN IF NOT EXISTS delete_status TEXT NOT NULL DEFAULT 'MESSAGE_DELETE_STATUS_NOT_DELETED'; ALTER TABLE wa_inbound_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE wa_otp_messages ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; ALTER TABLE wa_contacts ADD COLUMN IF NOT EXISTS profile_picture_id TEXT NOT NULL DEFAULT ''; CREATE UNIQUE INDEX IF NOT EXISTS wa_accounts_e164_number_key ON wa_accounts (e164_number); diff --git a/internal/app/native_ab_config.go b/internal/app/native_ab_config.go new file mode 100644 index 0000000..ef9a48a --- /dev/null +++ b/internal/app/native_ab_config.go @@ -0,0 +1,189 @@ +package app + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/json" + "strconv" + "strings" +) + +const ( + nativeABCodeGPIA = 3753 + nativeABCodeSIMSignal = 4435 + nativeABCodeRecaptchaThreshold = 7343 + nativeABCodeRandomRequestID = 9463 +) + +type nativePreChatdABConfig struct { + Value string +} + +type nativePreChatdABSummary struct { + ConfigCount int + GPIAEnabled bool + SIMSignalEnabled bool + RecaptchaStage string + RequestIDRandom bool +} + +func nativePreChatdABConfigs(state nativeState) map[int]nativePreChatdABConfig { + return parseNativePreChatdABConfigs(state.PreChatdAB.ExpConfig) +} + +func parseNativePreChatdABConfigs(expConfig string) map[int]nativePreChatdABConfig { + expConfig = strings.TrimSpace(expConfig) + if expConfig == "" || expConfig == "wamsys initialization fails" { + return nil + } + if configs, ok := parseNativePreChatdABConfigArray(expConfig); ok { + return configs + } + var nested string + if err := json.Unmarshal([]byte(expConfig), &nested); err == nil { + if configs, ok := parseNativePreChatdABConfigArray(nested); ok { + return configs + } + } + return nil +} + +func parseNativePreChatdABConfigArray(expConfig string) (map[int]nativePreChatdABConfig, bool) { + var items []map[string]any + if err := json.Unmarshal([]byte(expConfig), &items); err != nil { + return nil, false + } + configs := make(map[int]nativePreChatdABConfig, len(items)) + for _, item := range items { + code, err := strconv.Atoi(strings.TrimSpace(nativeJSONScalarString(item["config_code"]))) + if err != nil { + continue + } + configs[code] = nativePreChatdABConfig{Value: strings.TrimSpace(nativeJSONScalarString(item["config_value"]))} + } + return configs, true +} + +func nativeJSONScalarString(value any) string { + switch v := value.(type) { + case string: + return v + case float64: + if v == float64(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + if v { + return "1" + } + return "0" + case json.Number: + return v.String() + default: + return "" + } +} + +func nativeABBoolFromConfigs(configs map[int]nativePreChatdABConfig, code int, fallback bool) bool { + config, ok := configs[code] + if !ok { + return fallback + } + value := strings.ToLower(strings.TrimSpace(config.Value)) + switch value { + case "1", "true", "yes", "on": + return true + case "", "0", "false", "no", "off": + return false + default: + n, err := strconv.ParseFloat(value, 64) + return err == nil && n != 0 + } +} + +func nativeABIntFromConfigs(configs map[int]nativePreChatdABConfig, code int) (int, bool) { + config, ok := configs[code] + if !ok { + return 0, false + } + n, err := strconv.Atoi(strings.TrimSpace(config.Value)) + if err != nil { + return 0, false + } + return n, true +} + +func nativePreChatdABLogSummary(state nativeState) nativePreChatdABSummary { + configs := nativePreChatdABConfigs(state) + return nativePreChatdABSummary{ + ConfigCount: len(configs), + GPIAEnabled: nativeABBoolFromConfigs(configs, nativeABCodeGPIA, true), + SIMSignalEnabled: nativeABBoolFromConfigs(configs, nativeABCodeSIMSignal, true), + RecaptchaStage: nativeRecaptchaStage(state, configs), + RequestIDRandom: nativeABBoolFromConfigs(configs, nativeABCodeRandomRequestID, false), + } +} + +func nativeShouldSendRegistrationGPIA(state nativeState) bool { + return nativeABBoolFromConfigs(nativePreChatdABConfigs(state), nativeABCodeGPIA, true) +} + +func nativeShouldRandomizeRegistrationRequestID(state nativeState) bool { + return nativeABBoolFromConfigs(nativePreChatdABConfigs(state), nativeABCodeRandomRequestID, false) +} + +func applyNativePreChatdABDeviceFields(fields map[string]string, state nativeState) { + if fields == nil { + return + } + configs := nativePreChatdABConfigs(state) + if nativeABBoolFromConfigs(configs, nativeABCodeSIMSignal, true) { + fields["sim_type"] = nativeABSIMType(fields) + fields["airplane_mode_type"] = "0" + fields["cellular_strength"] = "5" + fields["roaming_type"] = "0" + } + fields["recaptcha"] = nativeRecaptchaPayload(nativeRecaptchaStage(state, configs)) +} + +func nativeABSIMType(fields map[string]string) string { + if fields["simnum"] == "1" || nativeOperatorPresent(fields["sim_mcc"], fields["sim_mnc"]) { + return "1" + } + return "0" +} + +func nativeOperatorPresent(mcc string, mnc string) bool { + mcc = strings.TrimSpace(mcc) + mnc = strings.TrimSpace(mnc) + return mcc != "" && mcc != "000" && mnc != "" && mnc != "000" +} + +func nativeRecaptchaStage(state nativeState, configs map[int]nativePreChatdABConfig) string { + threshold, ok := nativeABIntFromConfigs(configs, nativeABCodeRecaptchaThreshold) + if !ok || threshold <= 0 { + return "ABPROP_DISABLED" + } + if nativeRecaptchaInstallRoll(state) < threshold { + return "ABPROP_ENABLED" + } + return "ABPROP_DISABLED" +} + +func nativeRecaptchaInstallRoll(state nativeState) int { + seed := firstNonEmpty(state.Profile.ExpID, state.Profile.FDID, state.Profile.PhoneSHA256, state.AuthKey) + sum := sha256.Sum256([]byte(seed)) + return 1 + int(binary.BigEndian.Uint16(sum[:2])%1000) +} + +func nativeRecaptchaPayload(stage string) string { + if strings.TrimSpace(stage) == "" { + stage = "ABPROP_DISABLED" + } + data, err := json.Marshal(map[string]string{"stage": stage}) + if err != nil { + return `{"stage":"ABPROP_DISABLED"}` + } + return string(data) +} diff --git a/internal/app/native_ab_props.go b/internal/app/native_ab_props.go new file mode 100644 index 0000000..461adf8 --- /dev/null +++ b/internal/app/native_ab_props.go @@ -0,0 +1,121 @@ +package app + +import ( + "context" + "log" + "strings" + "time" + + waappv1 "github.com/byte-v-forge/wa-app/gen/go/byte/v/forge/waapp/v1" +) + +type nativePreChatdABState struct { + Hash string `json:"hash,omitempty"` + Key string `json:"key,omitempty"` + ExpConfig string `json:"exp_config,omitempty"` + NextFetchAtUnixMilli int64 `json:"next_fetch_at_unix_ms,omitempty"` +} + +const nativeABPropSuccessRefreshDelay = time.Minute + +func (e *NativeEngine) refreshPreChatdABProps(ctx context.Context, phone *waappv1.PhoneTarget, state *nativeState, appVersion string) { + if e == nil || state == nil || !state.PreChatdAB.due(e.clock.Now()) { + return + } + result, err := e.fetchPreChatdABProps(ctx, phone, *state, appVersion) + now := e.clock.Now() + if err != nil { + state.PreChatdAB.scheduleRetry(now, 5*time.Minute) + log.Printf("wa_registration_abprop_status status=transport_error retry_after_seconds=%d", int64(5*time.Minute/time.Second)) + return + } + state.PreChatdAB.applyResponse(result, now) + summary := nativePreChatdABLogSummary(*state) + log.Printf( + "wa_registration_abprop_status status=%s reason=%s has_hash=%t has_exp_cfg=%t retry_after_seconds=%d exp_cfg_count=%d gpia_enabled=%t sim_signal_enabled=%t recaptcha_stage=%s request_id_random=%t", + probeLogValue(responseStatus(result)), + probeLogValue(responseReason(result)), + strings.TrimSpace(state.PreChatdAB.Hash) != "", + strings.TrimSpace(state.PreChatdAB.ExpConfig) != "", + nativeABPropRetryAfterSeconds(result), + summary.ConfigCount, + summary.GPIAEnabled, + summary.SIMSignalEnabled, + summary.RecaptchaStage, + summary.RequestIDRandom, + ) +} + +func (s nativePreChatdABState) due(now time.Time) bool { + if now.IsZero() { + now = time.Now() + } + return s.NextFetchAtUnixMilli <= 0 || now.UTC().UnixMilli() >= s.NextFetchAtUnixMilli +} + +func (s *nativePreChatdABState) scheduleRetry(now time.Time, delay time.Duration) { + if s == nil || delay <= 0 { + return + } + if now.IsZero() { + now = time.Now() + } + s.NextFetchAtUnixMilli = now.UTC().Add(delay).UnixMilli() +} + +func (s *nativePreChatdABState) applyResponse(data map[string]any, now time.Time) { + if s == nil || data == nil { + return + } + if hash := strings.TrimSpace(jsonString(data["ab_hash"])); hash != "" { + s.Hash = hash + } + if key := strings.TrimSpace(jsonString(data["ab_key"])); key != "" { + s.Key = key + } + if expConfig := strings.TrimSpace(jsonString(data["exp_cfg"])); expConfig != "" && expConfig != "wamsys initialization fails" { + s.ExpConfig = expConfig + } + if seconds := nativeABPropRetryAfterSeconds(data); seconds > 0 { + s.scheduleRetry(now, time.Duration(seconds)*time.Second) + return + } + if responseStatus(data) == "ok" { + s.scheduleRetry(now, nativeABPropSuccessRefreshDelay) + } +} + +func nativeABPropRetryAfterSeconds(data map[string]any) int64 { + if data == nil { + return 0 + } + return jsonInt64(data["retry_after"]) +} + +func (e *NativeEngine) fetchPreChatdABProps(ctx context.Context, phone *waappv1.PhoneTarget, state nativeState, appVersion string) (map[string]any, error) { + params := preChatdABPropParams(phone, state) + logNativeRegistrationOrderedShape("abprop", phone, waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_UNSPECIFIED, params) + client, err := e.httpForProxy() + if err != nil { + return nil, err + } + return client.postWASafeNoAuth(ctx, defaultWAABPropURL, params.render(), nativeUserAgentForState(state, appVersion)) +} + +func preChatdABPropParams(phone *waappv1.PhoneTarget, state nativeState) orderedParams { + params := orderedParams{} + params.set("cc", phoneCC(phone), false) + params.set("in", phoneNational(phone), false) + params.set("lg", "en", false) + params.set("lc", "US", false) + params.set("fdid", state.Profile.FDID, false) + params.set("expid", state.Profile.ExpID, false) + if state.Profile.AccessSessionID != "" { + params.set("access_session_id", state.Profile.AccessSessionID, false) + } + applyNativeE2EParams(¶ms, state) + if hash := strings.TrimSpace(state.PreChatdAB.Hash); hash != "" { + params.set("ab_hash", pctBytes([]byte(hash)), true) + } + return params +} diff --git a/internal/app/native_account_transfer.go b/internal/app/native_account_transfer.go new file mode 100644 index 0000000..f52efcc --- /dev/null +++ b/internal/app/native_account_transfer.go @@ -0,0 +1,165 @@ +package app + +import ( + "fmt" + "strings" + "time" + + waappv1 "github.com/byte-v-forge/wa-app/gen/go/byte/v/forge/waapp/v1" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + accountTransferMaxCodes = 6 + accountTransferRotationIntervalSec = 60 + accountTransferDeeplinkBase = "https://wa.me/fpm" + accountTransferDeeplinkPort = "8988" + accountTransferDeeplinkVersion = "3" + accountTransferDeeplinkPlatform = "android" + accountTransferAuthMethod = "cert" + accountTransferEncKeyVersion = "1" +) + +func newNativeAccountTransferState(phone *waappv1.PhoneTarget, codes []string, now time.Time) nativeAccountTransferState { + codes = normalizeAccountTransferCodes(codes) + ttlSeconds := int64(len(codes) * accountTransferRotationIntervalSec) + return nativeAccountTransferState{ + Codes: codes, + CurrentIndex: 1, + RequestedAtUnix: now.UTC().Unix(), + ExpiresAtUnix: now.UTC().Unix() + ttlSeconds, + RotationIntervalSec: accountTransferRotationIntervalSec, + SessionID: b64u(randomBytes(32)), + Certificate: b64u(randomBytes(64)), + AuthToken: b64u(randomBytes(32)), + PeerID: b64u(randomBytes(16)), + EncryptionKeyVersion: accountTransferEncKeyVersion, + EncryptionAccountHash: b64u(randomBytes(32)), + EncryptionKeySalt: b64u(randomBytes(32)), + DeeplinkBase: accountTransferDeeplinkBase, + AccountPhoneNumber: fullPhoneKey(phoneCC(phone), phoneNational(phone)), + LastChallengeIssuedSec: now.UTC().Unix(), + } +} + +func normalizeAccountTransferCodes(codes []string) []string { + out := make([]string, 0, min(len(codes), accountTransferMaxCodes)) + seen := map[string]struct{}{} + for _, code := range codes { + code = strings.TrimSpace(code) + if code == "" { + continue + } + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + out = append(out, code) + if len(out) >= accountTransferMaxCodes { + break + } + } + return out +} + +func accountTransferCodesFromResponse(data map[string]any) []string { + return normalizeAccountTransferCodes(stringList(data["code_list"])) +} + +func (s nativeAccountTransferState) empty() bool { + return len(s.Codes) == 0 +} + +func (s nativeAccountTransferState) interval() time.Duration { + if s.RotationIntervalSec <= 0 { + return accountTransferRotationIntervalSec * time.Second + } + return time.Duration(s.RotationIntervalSec) * time.Second +} + +func (s nativeAccountTransferState) expiresAt() time.Time { + if s.ExpiresAtUnix > 0 { + return time.Unix(s.ExpiresAtUnix, 0).UTC() + } + if s.RequestedAtUnix <= 0 || len(s.Codes) == 0 { + return time.Time{} + } + return time.Unix(s.RequestedAtUnix+int64(len(s.Codes))*int64(s.interval()/time.Second), 0).UTC() +} + +func (s *nativeAccountTransferState) currentCode(now time.Time) (string, int, error) { + if s == nil || len(s.Codes) == 0 { + return "", 0, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_EXPIRED, "account transfer challenge is not available", false) + } + requestedAt := s.RequestedAtUnix + if requestedAt <= 0 { + requestedAt = now.UTC().Unix() + s.RequestedAtUnix = requestedAt + } + intervalSeconds := int64(s.interval() / time.Second) + if intervalSeconds <= 0 { + intervalSeconds = accountTransferRotationIntervalSec + } + index := int((now.UTC().Unix()-requestedAt)/intervalSeconds) + 1 + if index < 1 { + index = 1 + } + if index > len(s.Codes) { + return "", index, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_EXPIRED, "account transfer challenge expired", false) + } + s.CurrentIndex = index + s.LastChallengeIssuedSec = now.UTC().Unix() + return s.Codes[index-1], index, nil +} + +func (s *nativeAccountTransferState) challenge(verificationRequestID string, now time.Time) (*waappv1.AccountTransferChallenge, error) { + code, index, err := s.currentCode(now) + if err != nil { + return nil, err + } + payload := s.deeplink(code) + return &waappv1.AccountTransferChallenge{ + VerificationRequestId: verificationRequestID, + CodeCount: int32(len(s.Codes)), + CurrentCodeIndex: int32(index), + CurrentCodeLength: int32(len(code)), + RotationInterval: durationpb.New(s.interval()), + RequestedAt: timestamppb.New(time.Unix(s.RequestedAtUnix, 0).UTC()), + ExpiresAt: timestamppb.New(s.expiresAt()), + QrDeeplink: &waappv1.SensitiveText{ + Value: payload, + RedactedValue: accountTransferDeeplinkBase + "?", + }, + }, nil +} + +func (s nativeAccountTransferState) deeplink(code string) string { + base := firstNonEmpty(s.DeeplinkBase, accountTransferDeeplinkBase) + values := []struct { + key string + value string + }{ + {"version", accountTransferDeeplinkVersion}, + {"platform", accountTransferDeeplinkPlatform}, + {"sessionID", s.SessionID}, + {"authMethod", accountTransferAuthMethod}, + {"cert", s.Certificate}, + {"authToken", s.AuthToken}, + {"peerID", s.PeerID}, + {"ip", ""}, + {"ssid", ""}, + {"ssidPw", ""}, + {"otpCode", code}, + {"port", accountTransferDeeplinkPort}, + {"encKeyVer", firstNonEmpty(s.EncryptionKeyVersion, accountTransferEncKeyVersion)}, + {"encKeyAccHash", s.EncryptionAccountHash}, + {"encKeySalt", s.EncryptionKeySalt}, + {"phoneNumber", s.AccountPhoneNumber}, + } + parts := make([]string, 0, len(values)) + for _, item := range values { + parts = append(parts, item.key+"="+item.value) + } + return fmt.Sprintf("%s?%s", strings.TrimRight(base, "?"), strings.Join(parts, "&")) +} diff --git a/internal/app/native_attestation.go b/internal/app/native_attestation.go index 432c667..6256af4 100644 --- a/internal/app/native_attestation.go +++ b/internal/app/native_attestation.go @@ -40,6 +40,10 @@ const ( nativeAttestationFirstIntermediateDER + nativeAttestationSecondIntermediateDER + nativeAttestationLeafDERLength + nativeAttestationFreshness = 5 * time.Minute + nativeAttestationFutureSkew = 2 * time.Minute + nativeAttestationSignatureRawURLLength = 96 + nativeAttestationSignatureMaxAttempts = 64 ) func buildWASafeEnvelope(plain []byte, serverPublicKeyHex string, attestation nativeSoftwareAttestation) (waSafeEnvelope, error) { @@ -58,18 +62,25 @@ func buildWASafeEnvelope(plain []byte, serverPublicKeyHex string, attestation na return waSafeEnvelope{Body: body + "&H=" + signature, Enc: enc, Authorization: authorization}, nil } -func ensureNativeSoftwareAttestation(state *nativeState) error { +func ensureNativeSoftwareAttestation(state *nativeState, now time.Time) error { if state == nil { return nil } - if state.Attestation.ready() && nativeAttestationChainShapeOK(state.Attestation.CertificateChainDER) { + if now.IsZero() { + now = time.Now().UTC() + } else { + now = now.UTC() + } + if state.Attestation.ready() && + nativeAttestationChainShapeOK(state.Attestation.CertificateChainDER) && + state.Attestation.freshAt(now) { return nil } challenge, err := nativeAttestationChallenge(*state) if err != nil { return err } - attestation, err := newNativeSoftwareAttestation(challenge, time.Now().UTC()) + attestation, err := newNativeSoftwareAttestation(challenge, now) if err != nil { return err } @@ -320,6 +331,49 @@ func nativeAttestationChainShapeOK(certificateChain string) bool { return strings.EqualFold(certificates[3].Subject.CommonName, "Android Keystore Key") } +func (a nativeSoftwareAttestation) freshAt(now time.Time) bool { + issuedAt, ok := nativeAttestationIssuedAt(a.CertificateChainDER) + if !ok { + return false + } + if now.IsZero() { + now = time.Now().UTC() + } else { + now = now.UTC() + } + if issuedAt.After(now.Add(nativeAttestationFutureSkew)) { + return false + } + return !now.After(issuedAt.Add(nativeAttestationFreshness)) +} + +func nativeAttestationIssuedAt(certificateChain string) (time.Time, bool) { + certificateDER, err := decodeB64Any(certificateChain) + if err != nil { + return time.Time{}, false + } + certificates, err := x509.ParseCertificates(certificateDER) + if err != nil || len(certificates) == 0 { + return time.Time{}, false + } + leaf := certificates[len(certificates)-1] + for _, extension := range leaf.Extensions { + if extension.Id.String() != androidKeyAttestationOID.String() { + continue + } + var description nativeSoftwareAndroidKeyDescription + if _, err := asn1.Unmarshal(extension.Value, &description); err != nil { + return time.Time{}, false + } + challenge := description.AttestationChallenge + if len(challenge) < 9 || challenge[8] != 0x1f { + return time.Time{}, false + } + return time.Unix(int64(binary.BigEndian.Uint64(challenge[:8])), 0).UTC(), true + } + return time.Time{}, false +} + func (a nativeSoftwareAttestation) sign(body []byte) (string, string, error) { privateKeyDER, err := decodeB64Any(a.PrivateKeyPKCS8) if err != nil { @@ -335,13 +389,13 @@ func (a nativeSoftwareAttestation) sign(body []byte) (string, string, error) { } digest := sha256.Sum256(body) var signature []byte - for attempt := 0; attempt < 8; attempt++ { + for attempt := 0; attempt < nativeAttestationSignatureMaxAttempts; attempt++ { candidate, err := ecdsa.SignASN1(rand.Reader, privateKey, digest[:]) if err != nil { return "", "", err } signature = candidate - if len(base64.RawURLEncoding.EncodeToString(candidate)) == 96 { + if len(base64.RawURLEncoding.EncodeToString(candidate)) == nativeAttestationSignatureRawURLLength { break } } diff --git a/internal/app/native_engine.go b/internal/app/native_engine.go index e8ba00b..d311e0f 100644 --- a/internal/app/native_engine.go +++ b/internal/app/native_engine.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "regexp" "strings" "time" @@ -15,9 +16,12 @@ import ( const ( defaultWAAppVersion = "2.26.23.71" + defaultWAABPropURL = "https://y9yrsygcg6.execute-api.us-east-1.amazonaws.com/s/s?_=/v2/reg_onboard_abprop&" defaultWAExistURL = "https://y9yrsygcg6.execute-api.us-east-1.amazonaws.com/s/s?_=/v2/exist&" defaultWACodeURL = "https://y9yrsygcg6.execute-api.us-east-1.amazonaws.com/s/s?_=/v2/code&" defaultWARegisterURL = "https://y9yrsygcg6.execute-api.us-east-1.amazonaws.com/s/s?_=/v2/register&" + + nativeDefaultSMSCodeLength int32 = 6 ) var nativeSensitiveDigitsPattern = regexp.MustCompile(`\b[0-9]{4,8}\b`) @@ -89,34 +93,35 @@ func (e *NativeEngine) ProbeAccount(ctx context.Context, input EngineRegistratio if err != nil { return EngineProbeResult{Status: waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REJECTED, Err: err} } - return e.probeAccountWithState(ctx, input, state) + result, _ := e.probeAccountWithState(ctx, input, state) + return result } -func (e *NativeEngine) probeAccountWithState(ctx context.Context, input EngineRegistrationInput, state nativeState) EngineProbeResult { - if err := ensureNativeSoftwareAttestation(&state); err != nil { - return EngineProbeResult{Status: waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REJECTED, Err: err} +func (e *NativeEngine) probeAccountWithState(ctx context.Context, input EngineRegistrationInput, state nativeState) (EngineProbeResult, nativeState) { + if err := ensureNativeSoftwareAttestation(&state, e.clock.Now()); err != nil { + return EngineProbeResult{Status: waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REJECTED, Err: err}, state } params, rawKeys := e.existParams(input.Phone, state) if err := e.applyRuntimeWamsys(ctx, waappv1.RegistrationRequestKind_REGISTRATION_REQUEST_KIND_EXIST, input.Phone, state, params, rawKeys); err != nil { - return EngineProbeResult{Status: waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REJECTED, Err: err} + return EngineProbeResult{Status: waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REJECTED, Err: err}, state } logNativeRegistrationMapShape("exist", input.Phone, input.DeliveryMethod, params, rawKeys) plain := renderNativePlain(params, rawKeys) client, err := e.httpForProxy() if err != nil { - return EngineProbeResult{Status: waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REJECTED, Err: err} + return EngineProbeResult{Status: waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REJECTED, Err: err}, state } data, _, err := client.postWASafe(ctx, defaultWAExistURL, plain, nativeUserAgentForState(state, input.AppVersion), state.Attestation) result := parseExistProbeResult(data) if err != nil { if result.Err != nil || parsedExistApplicationOutcome(result) { - return result + return result, state } result.Status = waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REJECTED result.AccountFlow = accountProbeFlowProbeFailed result.Err = classifyHTTPError(data, err) } - return result + return result, state } func parsedExistApplicationOutcome(result EngineProbeResult) bool { @@ -138,9 +143,10 @@ func (e *NativeEngine) RequestVerificationCode(ctx context.Context, input Engine } func (e *NativeEngine) requestVerificationCodeWithState(ctx context.Context, input EngineRegistrationInput, state nativeState) (EngineCodeResult, nativeState) { - if err := ensureNativeSoftwareAttestation(&state); err != nil { + if err := ensureNativeSoftwareAttestation(&state, e.clock.Now()); err != nil { return EngineCodeResult{Status: waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_REJECTED, Err: err}, state } + state.nextGenerateCodeAttempt() params, err := e.codeRequestOrderedParams(ctx, input.Phone, input.DeliveryMethod, state, input.AuthCodeContext) if err != nil { return EngineCodeResult{Status: waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_REJECTED, Err: err}, state @@ -157,6 +163,9 @@ func (e *NativeEngine) requestVerificationCodeWithState(ctx context.Context, inp if enc != "" { state.LastCodeResult["enc_sha256"] = encHash(enc) } + if input.DeliveryMethod != waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + state.AccountTransfer = nativeAccountTransferState{} + } retryAfter := verificationCodeRetryAfter(data, input.DeliveryMethod) now := e.clock.Now() if err != nil { @@ -188,28 +197,75 @@ func (e *NativeEngine) requestVerificationCodeWithState(ctx context.Context, inp Err: waProtocolError(data, "verification request was rejected"), }, state } - return verificationCodeResult(status, data, input.DeliveryMethod, now, retryAfter), state + result := verificationCodeResult(status, data, input.DeliveryMethod, now, retryAfter) + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + challenge, challengeErr := e.prepareAccountTransferChallenge(input.Phone, &state, data, now) + if challengeErr != nil { + result.Status = waappv1.VerificationRequestStatus_VERIFICATION_REQUEST_STATUS_REJECTED + result.Err = challengeErr + result.RawStatus = responseStatus(data) + result.RawReason = responseReason(data) + return result, state + } + result.AccountTransferChallenge = challenge + result.ExpectedCodeLength = challenge.GetCurrentCodeLength() + result.ExpiresAt = challenge.GetExpiresAt().AsTime() + result.MethodStatuses = upsertVerificationMethodStatus(result.MethodStatuses, "acc_tr", verificationWaitStatus{Present: true}) + } + return result, state } -func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineSubmitInput) EngineRegisterResult { - if strings.TrimSpace(input.Code) == "" { - return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: NewError(waappv1.WaErrorCode_WA_ERROR_CODE_VALIDATION_FAILED, "verification code is required", false)} +func (e *NativeEngine) prepareAccountTransferChallenge(phone *waappv1.PhoneTarget, state *nativeState, data map[string]any, now time.Time) (*waappv1.AccountTransferChallenge, error) { + codes := accountTransferCodesFromResponse(data) + if len(codes) == 0 { + return nil, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_REJECTED, "account transfer code list is missing", false) } + state.AccountTransfer = newNativeAccountTransferState(phone, codes, now) + return state.AccountTransfer.challenge("", now) +} + +func (e *NativeEngine) RefreshAccountTransferChallenge(ctx context.Context, input EngineAccountTransferChallengeInput) EngineAccountTransferChallengeResult { + state, err := e.loadState(ctx, input.ClientProfileID) + if err != nil { + return EngineAccountTransferChallengeResult{Err: err} + } + if state.AccountTransfer.empty() { + return EngineAccountTransferChallengeResult{Err: NewError(waappv1.WaErrorCode_WA_ERROR_CODE_EXPIRED, "account transfer challenge is not available", false)} + } + challenge, err := state.AccountTransfer.challenge(input.VerificationRequestID, e.clock.Now()) + if err != nil { + return EngineAccountTransferChallengeResult{Err: err} + } + _ = e.saveState(ctx, input.ClientProfileID, state) + return EngineAccountTransferChallengeResult{Challenge: challenge} +} + +func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineSubmitInput) EngineRegisterResult { state, err := e.loadState(ctx, input.ClientProfileID) if err != nil { return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: err} } - if err := ensureNativeSoftwareAttestation(&state); err != nil { + if err := ensureNativeSoftwareAttestation(&state, e.clock.Now()); err != nil { return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: err} } - params, rawKeys := e.registerParams(input.Phone, input.DeliveryMethod, input.Code, state, input.AuthCodeContext) + code := strings.TrimSpace(input.Code) + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + code, _, err = state.AccountTransfer.currentCode(e.clock.Now()) + if err != nil { + return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: err} + } + } + if code == "" { + return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: NewError(waappv1.WaErrorCode_WA_ERROR_CODE_VALIDATION_FAILED, "verification code is required", false)} + } + params, rawKeys := e.registerParams(input.Phone, input.DeliveryMethod, code, state, input.AuthCodeContext) logNativeRegistrationMapShape("register", input.Phone, input.DeliveryMethod, params, rawKeys) plain := renderNativePlain(params, rawKeys) client, err := e.httpForProxy() if err != nil { return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: err} } - data, enc, err := client.postWASafe(ctx, defaultWARegisterURL, plain, nativeUserAgentForState(state, input.AppVersion), state.Attestation) + data, enc, err := postRegisterWithRetry(ctx, client, plain, nativeUserAgentForState(state, input.AppVersion), state.Attestation) state.LastRegister = sanitizeResponse(data) if routingInfo := chatRoutingInfoFromValue(data["edge_routing_info"]); routingInfo != "" { state.ChatRoutingInfo = routingInfo @@ -222,6 +278,13 @@ func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineS return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: classifyHTTPError(data, err)} } if status := responseStatus(data); status != "ok" && status != "registered" { + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER && !accountTransferRegisterTerminalFailure(data) { + _ = e.saveState(ctx, input.ClientProfileID, state) + return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_SUBMITTED, Err: NewError(waappv1.WaErrorCode_WA_ERROR_CODE_CONFLICT, "account transfer confirmation is pending", true)} + } + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + state.AccountTransfer = nativeAccountTransferState{} + } _ = e.saveState(ctx, input.ClientProfileID, state) return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REJECTED, Err: waProtocolError(data, "registration was rejected")} } @@ -230,11 +293,71 @@ func (e *NativeEngine) SubmitVerificationCode(ctx context.Context, input EngineS if login != "" { state.RegistrationJID = normalizeJID(login) } + if input.DeliveryMethod == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + state.AccountTransfer = nativeAccountTransferState{} + } _ = e.saveState(ctx, input.ClientProfileID, state) completedAt := e.clock.Now() return EngineRegisterResult{Status: waappv1.RegistrationStatus_REGISTRATION_STATUS_REGISTERED, RegisteredID: "waid_" + stableID(login), ServiceAccountID: lid, ServiceLoginID: login, CompletedAt: completedAt} } +func postRegisterWithRetry(ctx context.Context, client *nativeHTTPClient, plain string, userAgent string, attestation nativeSoftwareAttestation) (map[string]any, string, error) { + const maxRegisterRetries = 2 + data, enc, err := client.postWASafe(ctx, defaultWARegisterURL, plain, userAgent, attestation) + for attempt := 1; err != nil && retryableRegisterHTTPFailure(data, err) && attempt <= maxRegisterRetries; attempt++ { + log.Printf( + "wa_registration_register_retry status=scheduled attempt=%d http_status=%d wa_status=%s wa_reason=%s", + attempt, + int(jsonNumber(data["status_code"])), + probeLogValue(responseStatus(data)), + probeLogValue(responseReason(data)), + ) + if ctxErr := ctx.Err(); ctxErr != nil { + return data, enc, ctxErr + } + data, enc, err = client.postWASafe(ctx, defaultWARegisterURL, plain, userAgent, attestation) + if err == nil { + log.Printf( + "wa_registration_register_retry status=accepted attempt=%d http_status=%d wa_status=%s wa_reason=%s", + attempt, + int(jsonNumber(data["status_code"])), + probeLogValue(responseStatus(data)), + probeLogValue(responseReason(data)), + ) + } else { + log.Printf( + "wa_registration_register_retry status=failed attempt=%d http_status=%d wa_status=%s wa_reason=%s retryable=%t", + attempt, + int(jsonNumber(data["status_code"])), + probeLogValue(responseStatus(data)), + probeLogValue(responseReason(data)), + retryableRegisterHTTPFailure(data, err), + ) + } + } + return data, enc, err +} + +func retryableRegisterHTTPFailure(data map[string]any, err error) bool { + if err == nil { + return false + } + statusCode := int(jsonNumber(data["status_code"])) + if statusCode == 429 || statusCode >= 500 { + return true + } + if statusCode > 0 { + return false + } + message := strings.ToLower(err.Error()) + for _, marker := range []string{"timeout", "deadline", "temporary", "connection reset", "connection refused", "eof", "proxy", "network"} { + if strings.Contains(message, marker) { + return true + } + } + return false +} + func (e *NativeEngine) CheckLoginState(ctx context.Context, input EngineLoginCheckInput) EngineLoginCheckResult { state, err := e.loadState(ctx, input.ClientProfileID) if err != nil { @@ -293,14 +416,14 @@ func (e *NativeEngine) ReceiveMessageBatch(ctx context.Context, input EngineMess } client := newChatdClient(chatdConfigForState(proxyURL, state, 0)) now := e.clock.Now() - messages, payloads, update, err := client.receiveBatch(ctx, state, input, input.AppVersion, now) + messages, payloads, otps, update, err := client.receiveBatch(ctx, state, input, input.AppVersion, now) if err != nil { return EngineMessageBatchResult{Err: chatdReceiveError(err)} } if applyChatdReceiveState(&state, input, payloads, update) { _ = e.saveState(ctx, input.ClientProfileID, state) } - return EngineMessageBatchResult{Messages: messages, Contacts: contactsFromContactHints(input.WAAccountID, nil, update.ContactHints, now)} + return EngineMessageBatchResult{Messages: messages, Contacts: contactsFromContactHints(input.WAAccountID, nil, update.ContactHints, now), OTPMessages: otps} } func applyChatdReceiveState(state *nativeState, input EngineMessageInput, payloads []chatdEncPayload, update chatdSessionUpdate) bool { @@ -628,7 +751,7 @@ func (e *NativeEngine) codeParams(phone *waappv1.PhoneTarget, method waappv1.Ver "fdid": state.Profile.FDID, "expid": state.Profile.ExpID, "access_session_id": state.Profile.AccessSessionID, - "id": state.Profile.ID, + "id": nativeRegistrationRequestID(state), "backup_token": state.Profile.BackupToken, "authkey": state.AuthKey, "e_ident": state.KeyBundle.IdentityPublic, @@ -638,14 +761,15 @@ func (e *NativeEngine) codeParams(phone *waappv1.PhoneTarget, method waappv1.Ver "e_skey_val": state.KeyBundle.SignedKeyValue, "e_skey_sig": state.KeyBundle.SignedKeySig, } - if token := e.registrationToken(phone, state); token != "" { - params["token"] = token - } - if contextValue := strings.TrimSpace(authCodeContext); contextValue != "" { - params["context"] = contextValue + if nativeRegistrationMethodUsesToken(methodName) { + if token := e.registrationToken(phone, state); token != "" { + params["token"] = token + } } - if advertisingID := nativeAdvertisingID(state); advertisingID != "" && shouldSendNativeAdvertisingID(phone) { - params["advertising_id"] = advertisingID + if nativeRegistrationMethodUsesAuthContext(methodName) { + if contextValue := strings.TrimSpace(authCodeContext); contextValue != "" { + params["context"] = contextValue + } } raw := map[string]struct{}{"id": {}, "backup_token": {}} applyNativeRawParamMap(params, raw, codeDeviceMap(methodName, state), true) @@ -675,7 +799,7 @@ func (e *NativeEngine) registerParams(phone *waappv1.PhoneTarget, method waappv1 "fdid": firstNonEmpty(state.LastCodeParams["fdid"], state.Profile.FDID), "expid": firstNonEmpty(state.LastCodeParams["expid"], state.Profile.ExpID), "access_session_id": firstNonEmpty(state.LastCodeParams["access_session_id"], state.Profile.AccessSessionID), - "id": firstNonEmpty(state.LastCodeParams["id"], state.Profile.ID), + "id": nativeRegistrationRequestID(state), "backup_token": firstNonEmpty(state.LastCodeParams["backup_token"], state.Profile.BackupToken), "code": code, "authkey": firstNonEmpty(state.LastCodeParams["authkey"], state.AuthKey), @@ -686,13 +810,19 @@ func (e *NativeEngine) registerParams(phone *waappv1.PhoneTarget, method waappv1 "e_skey_val": firstNonEmpty(state.LastCodeParams["e_skey_val"], state.KeyBundle.SignedKeyValue), "e_skey_sig": firstNonEmpty(state.LastCodeParams["e_skey_sig"], state.KeyBundle.SignedKeySig), } - if token := e.registrationToken(phone, state); token != "" { - params["token"] = token + if nativeRegistrationMethodUsesToken(methodName) { + if token := e.registrationToken(phone, state); token != "" { + params["token"] = token + } + } + if nativeRegistrationMethodUsesAuthContext(methodName) { + if contextValue := firstNonEmpty(authCodeContext, state.LastCodeParams["context"]); contextValue != "" { + params["context"] = contextValue + } } - if contextValue := firstNonEmpty(authCodeContext, state.LastCodeParams["context"]); contextValue != "" { - params["context"] = contextValue + if methodName != "acc_tr" { + applyRegisterCodeResultParams(params, state) } - applyRegisterCodeResultParams(params, state) raw := map[string]struct{}{"id": {}, "backup_token": {}} applyNativeRawParamMap(params, raw, registerDeviceMap(methodName, state), true) return params, raw diff --git a/internal/app/native_http.go b/internal/app/native_http.go index fc5597d..21f3f57 100644 --- a/internal/app/native_http.go +++ b/internal/app/native_http.go @@ -299,6 +299,15 @@ func nativeAndroidOkHTTPClientHelloSpec() *utls.ClientHelloSpec { } func (c *nativeHTTPClient) postWASafe(ctx context.Context, endpoint string, plain string, userAgent string, attestation nativeSoftwareAttestation) (map[string]any, string, error) { + return c.postWASafeEnvelope(ctx, endpoint, plain, userAgent, attestation) +} + +func (c *nativeHTTPClient) postWASafeNoAuth(ctx context.Context, endpoint string, plain string, userAgent string) (map[string]any, error) { + data, _, err := c.postWASafeEnvelope(ctx, endpoint, plain, userAgent, nativeSoftwareAttestation{}) + return data, err +} + +func (c *nativeHTTPClient) postWASafeEnvelope(ctx context.Context, endpoint string, plain string, userAgent string, attestation nativeSoftwareAttestation) (map[string]any, string, error) { if endpoint == "" { return nil, "", fmt.Errorf("endpoint is not configured") } @@ -416,6 +425,8 @@ func nativeRegistrationEndpointKind(endpoint *url.URL) string { } raw := endpoint.Query().Get("_") switch raw { + case "/v2/reg_onboard_abprop": + return "abprop" case "/v2/exist": return "exist" case "/v2/code": diff --git a/internal/app/native_long_connection.go b/internal/app/native_long_connection.go index 530be59..0d33bc9 100644 --- a/internal/app/native_long_connection.go +++ b/internal/app/native_long_connection.go @@ -87,10 +87,10 @@ func (e *longConnectionNativeEngine) ReceiveMessageBatch(ctx context.Context, in return EngineMessageBatchResult{Err: chatdReceiveError(err)} } now := e.clock.Now() - messages, payloads, update, drained := e.drainPendingLocked(input) + messages, payloads, otps, update, drained := e.drainPendingLocked(input) if !drained { var preempted bool - messages, payloads, update, err, preempted = e.receiveBatchWithActiveReadLocked(ctx, session, input, now) + messages, payloads, otps, update, err, preempted = e.receiveBatchWithActiveReadLocked(ctx, session, input, now) if err != nil { if preempted { return EngineMessageBatchResult{} @@ -101,7 +101,7 @@ func (e *longConnectionNativeEngine) ReceiveMessageBatch(ctx context.Context, in return EngineMessageBatchResult{Err: chatdReceiveError(retryErr)} } now = e.clock.Now() - messages, payloads, update, err, preempted = e.receiveBatchWithActiveReadLocked(ctx, session, input, now) + messages, payloads, otps, update, err, preempted = e.receiveBatchWithActiveReadLocked(ctx, session, input, now) if err != nil { if preempted { return EngineMessageBatchResult{} @@ -124,7 +124,7 @@ func (e *longConnectionNativeEngine) ReceiveMessageBatch(ctx context.Context, in } } } - return EngineMessageBatchResult{Messages: messages, Contacts: contactsFromContactHints(input.WAAccountID, nil, update.ContactHints, now)} + return EngineMessageBatchResult{Messages: messages, Contacts: contactsFromContactHints(input.WAAccountID, nil, update.ContactHints, now), OTPMessages: otps} } func (e *longConnectionNativeEngine) ResolveContactProfilePicture(ctx context.Context, input EngineContactProfilePictureInput) EngineContactProfilePictureResult { @@ -204,9 +204,9 @@ func (e *longConnectionNativeEngine) ensureSessionForIQLocked(ctx context.Contex return nil, err } -func (e *longConnectionNativeEngine) drainPendingLocked(input EngineMessageInput) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, bool) { +func (e *longConnectionNativeEngine) drainPendingLocked(input EngineMessageInput) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, bool) { if len(e.pending) == 0 && !hasChatdSessionUpdate(e.pendingUp) { - return nil, nil, chatdSessionUpdate{}, false + return nil, nil, nil, chatdSessionUpdate{}, false } limit := input.MaxMessages if limit <= 0 { @@ -220,8 +220,8 @@ func (e *longConnectionNativeEngine) drainPendingLocked(input EngineMessageInput e.pending = append([]chatdReceivedItem(nil), e.pending[count:]...) update := e.pendingUp e.pendingUp = chatdSessionUpdate{} - messages, payloads := splitReceivedItems(items) - return messages, payloads, update, true + messages, payloads, otps := splitReceivedItems(items) + return messages, payloads, otps, update, true } func (e *longConnectionNativeEngine) bufferPendingLocked(items []chatdReceivedItem, update chatdSessionUpdate) { @@ -232,13 +232,13 @@ func (e *longConnectionNativeEngine) bufferPendingLocked(items []chatdReceivedIt e.pendingUp = mergeChatdSessionUpdate(e.pendingUp, update) } -func (e *longConnectionNativeEngine) receiveBatchWithActiveReadLocked(ctx context.Context, session *chatdSession, input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, error, bool) { +func (e *longConnectionNativeEngine) receiveBatchWithActiveReadLocked(ctx context.Context, session *chatdSession, input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, error, bool) { read, readCtx := e.startActiveReadLocked(ctx) e.mu.Unlock() - messages, payloads, update, err := receiveChatdBatchWithContext(readCtx, session, input, now) + messages, payloads, otps, update, err := receiveChatdBatchWithContext(readCtx, session, input, now) e.mu.Lock() preempted := e.finishActiveReadLocked(read) - return messages, payloads, update, err, preempted + return messages, payloads, otps, update, err, preempted } func (e *longConnectionNativeEngine) startActiveReadLocked(ctx context.Context) (*longConnectionActiveRead, context.Context) { @@ -516,7 +516,7 @@ func (e *longConnectionNativeEngine) closeLocked() error { return err } -func receiveChatdBatchWithContext(ctx context.Context, session *chatdSession, input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, chatdSessionUpdate, error) { +func receiveChatdBatchWithContext(ctx context.Context, session *chatdSession, input EngineMessageInput, now time.Time) ([]*waappv1.InboundMessage, []chatdEncPayload, []*waappv1.OtpMessage, chatdSessionUpdate, error) { stopContextClose := closeChatdSessionOnContext(ctx, session) defer stopContextClose() return session.receiveBatch(input, now) diff --git a/internal/app/native_registration_params.go b/internal/app/native_registration_params.go index 89d7c88..856d679 100644 --- a/internal/app/native_registration_params.go +++ b/internal/app/native_registration_params.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "os" "strconv" "strings" "time" @@ -23,7 +24,7 @@ func (e *NativeEngine) existParams(phone *waappv1.PhoneTarget, state nativeState "fdid": state.Profile.FDID, "expid": state.Profile.ExpID, "access_session_id": state.Profile.AccessSessionID, - "id": state.Profile.ID, + "id": nativeRegistrationRequestID(state), "backup_token": state.Profile.BackupToken, "authkey": state.AuthKey, "e_ident": state.KeyBundle.IdentityPublic, @@ -55,6 +56,7 @@ func (e *NativeEngine) codeRequestOrderedParams(ctx context.Context, phone *waap func (e *NativeEngine) codeRequestOrderedParamsWithWamsys(ctx context.Context, phone *waappv1.PhoneTarget, method waappv1.VerificationDeliveryMethod, state nativeState, authCodeContext string, wamsysCapture *waappv1.WamsysCapture, includeWamsys bool) (orderedParams, error) { methodName := registrationMethodName(method, "sms") fields := nativeDeviceMapFields(state) + attempts := nativeCodeRequestAttempts(state) params := orderedParams{} params.set("cc", phoneCC(phone), false) params.set("in", phoneNational(phone), false) @@ -65,36 +67,49 @@ func (e *NativeEngine) codeRequestOrderedParamsWithWamsys(ctx context.Context, p if state.Profile.AccessSessionID != "" { params.set("access_session_id", state.Profile.AccessSessionID, false) } - params.set("id", state.Profile.ID, true) + params.set("id", nativeRegistrationRequestID(state), true) params.set("backup_token", state.Profile.BackupToken, true) if token := e.registrationToken(phone, state); token != "" { params.set("token", token, false) } params.set("method", methodName, false) - if contextValue := strings.TrimSpace(authCodeContext); contextValue != "" { - params.set("context", contextValue, false) + if nativeRegistrationMethodUsesAuthContext(methodName) { + if contextValue := strings.TrimSpace(authCodeContext); contextValue != "" { + params.set("context", contextValue, false) + } } - if advertisingID := nativeAdvertisingID(state); advertisingID != "" && shouldSendNativeAdvertisingID(phone) { + if advertisingID := nativeAdvertisingID(state); advertisingID != "" { params.set("advertising_id", advertisingID, false) } applyNativeE2EParams(¶ms, state) - applyNativeCodeRequestMapParams(¶ms, fields, methodName) + applyNativeCodeRequestMapParams(¶ms, fields, methodName, attempts, nativeCodeRequestReason(state)) var capture *waappv1.WamsysCapture if includeWamsys { var err error - capture, err = e.wamsysProvider().RegistrationMaterial(ctx, wamsysMaterialInput{Capture: wamsysCapture, Kind: waappv1.RegistrationRequestKind_REGISTRATION_REQUEST_KIND_CODE, Phone: phone, State: state}) + capture, err = e.wamsysProvider().RegistrationMaterial(ctx, wamsysMaterialInput{Capture: wamsysCapture, Kind: waappv1.RegistrationRequestKind_REGISTRATION_REQUEST_KIND_CODE, Phone: phone, State: state, Now: e.clock.Now()}) if err != nil { return nil, err } } - applyOrderedWamsysKey(¶ms, capture, "gpia") + if nativeShouldSendRegistrationGPIA(state) { + applyOrderedWamsysKey(¶ms, capture, "gpia") + } addOptionalRawParam(¶ms, "db", fields["db"]) addOptionalRawParam(¶ms, "recaptcha", fields["recaptcha"]) + applyNativeCodeRequestOptionalTailParams(¶ms, fields) applyOrderedWamsysExcept(¶ms, capture, map[string]struct{}{"gpia": {}}) addOptionalRawParam(¶ms, "feo2_query_status", fields["feo2_query_status"]) return params, nil } +func nativeRegistrationMethodUsesToken(methodName string) bool { + return true +} + +func nativeRegistrationMethodUsesAuthContext(methodName string) bool { + return methodName != "acc_tr" +} + func applyNativeE2EParams(params *orderedParams, state nativeState) { params.set("authkey", state.AuthKey, false) params.set("e_ident", state.KeyBundle.IdentityPublic, false) @@ -105,11 +120,28 @@ func applyNativeE2EParams(params *orderedParams, state nativeState) { params.set("e_skey_sig", state.KeyBundle.SignedKeySig, false) } -func applyNativeCodeRequestMapParams(params *orderedParams, fields map[string]string, method string) { +func applyNativeCodeRequestMapParams(params *orderedParams, fields map[string]string, method string, attempts int, reason string) { + if method == "sms" { + addOptionalRawParam(params, "mistyped", fields["mistyped"]) + addRawParam(params, "reason", reason) + addOptionalRawParam(params, "hasav", fields["hasav"]) + addRawParam(params, "client_metrics", nativeCodeClientMetrics(attempts)) + addOptionalRawParam(params, "mcc", fields["mcc"]) + addOptionalRawParam(params, "mnc", fields["mnc"]) + addOptionalRawParam(params, "sim_mcc", fields["sim_mcc"]) + addOptionalRawParam(params, "sim_mnc", fields["sim_mnc"]) + addRawParam(params, "education_screen_displayed", "false") + addRawParam(params, "prefer_sms_over_flash", nativePreferSMSOverFlash(method, fields)) + applyNativeCodeRequestRuntimeParams(params, fields, method) + applyNativeCodeRequestStoredParams(params, fields) + addOptionalRawParam(params, "old_phone_number", fields["old_phone_number"]) + addOptionalRawParam(params, "device_ram", fields["device_ram"]) + return + } addOptionalRawParam(params, "mistyped", fields["mistyped"]) - addRawParam(params, "reason", "") + addRawParam(params, "reason", reason) addOptionalRawParam(params, "hasav", fields["hasav"]) - addRawParam(params, "client_metrics", nativeCodeClientMetrics()) + addRawParam(params, "client_metrics", nativeCodeClientMetrics(attempts)) addOptionalRawParam(params, "mcc", fields["mcc"]) addOptionalRawParam(params, "mnc", fields["mnc"]) addOptionalRawParam(params, "sim_mcc", fields["sim_mcc"]) @@ -121,11 +153,43 @@ func applyNativeCodeRequestMapParams(params *orderedParams, fields map[string]st addOptionalRawParam(params, "hasinrc", fields["hasinrc"]) addOptionalRawParam(params, "pid", fields["pid"]) addOptionalRawParam(params, "rc", fields["rc"]) + applyNativeCodeRequestSIMSignalParams(params, fields) + applyNativeCodeRequestStoredParams(params, fields) + addOptionalRawParam(params, "old_phone_number", fields["old_phone_number"]) + addOptionalRawParam(params, "device_ram", fields["device_ram"]) +} + +func applyNativeCodeRequestRuntimeParams(params *orderedParams, fields map[string]string, method string) { + if method != "sms" { + return + } + addOptionalRawParam(params, "network_radio_type", fields["network_radio_type"]) + addOptionalRawParam(params, "simnum", fields["simnum"]) + addOptionalRawParam(params, "hasinrc", fields["hasinrc"]) + addOptionalRawParam(params, "pid", fields["pid"]) + addOptionalRawParam(params, "rc", fields["rc"]) + applyNativeCodeRequestSIMSignalParams(params, fields) +} + +func applyNativeCodeRequestSIMSignalParams(params *orderedParams, fields map[string]string) { addOptionalRawParam(params, "sim_type", fields["sim_type"]) addOptionalRawParam(params, "airplane_mode_type", fields["airplane_mode_type"]) addOptionalRawParam(params, "cellular_strength", fields["cellular_strength"]) addOptionalRawParam(params, "roaming_type", fields["roaming_type"]) - addOptionalRawParam(params, "device_ram", fields["device_ram"]) +} + +func applyNativeCodeRequestStoredParams(params *orderedParams, fields map[string]string) { + addOptionalRawParam(params, "push_code", fields["push_code"]) + addOptionalRawParam(params, "new_acc_uuid", fields["new_acc_uuid"]) +} + +func applyNativeCodeRequestOptionalTailParams(params *orderedParams, fields map[string]string) { + addOptionalRawParam(params, "fid", fields["fid"]) + addOptionalRawParam(params, "preloads_app_manager_id", fields["preloads_app_manager_id"]) + addOptionalRawParam(params, "preloads_attribution", fields["preloads_attribution"]) + addOptionalRawParam(params, "tos_version", fields["tos_version"]) + addOptionalRawParam(params, "entrypoint", fields["entrypoint"]) + addOptionalRawParam(params, "cred_token", fields["cred_token"]) } func addOptionalRawParam(params *orderedParams, key string, value string) { @@ -240,8 +304,8 @@ func applyNativeRawParamMap(params map[string]string, raw map[string]struct{}, v func codeDeviceMap(method string, state nativeState) map[string]string { fields := nativeDeviceMapFields(state) out := map[string]string{ - "reason": "", - "client_metrics": nativeCodeClientMetrics(), + "reason": nativeCodeRequestReason(state), + "client_metrics": nativeCodeClientMetrics(nativeCodeRequestAttempts(state)), "education_screen_displayed": "false", "prefer_sms_over_flash": nativePreferSMSOverFlash(method, fields), "network_radio_type": fields["network_radio_type"], @@ -252,7 +316,6 @@ func codeDeviceMap(method string, state nativeState) map[string]string { "device_ram": fields["device_ram"], "db": fields["db"], "recaptcha": fields["recaptcha"], - "feo2_query_status": fields["feo2_query_status"], "mcc": fields["mcc"], "mnc": fields["mnc"], "sim_mcc": fields["sim_mcc"], @@ -260,10 +323,13 @@ func codeDeviceMap(method string, state nativeState) map[string]string { } addNonEmptyNativeCodeField(out, fields, "mistyped") addNonEmptyNativeCodeField(out, fields, "hasav") - addNonEmptyNativeCodeField(out, fields, "sim_type") - addNonEmptyNativeCodeField(out, fields, "airplane_mode_type") - addNonEmptyNativeCodeField(out, fields, "cellular_strength") - addNonEmptyNativeCodeField(out, fields, "roaming_type") + for _, key := range []string{ + "sim_type", "airplane_mode_type", "cellular_strength", "roaming_type", + "push_code", "new_acc_uuid", "old_phone_number", "fid", "preloads_app_manager_id", + "preloads_attribution", "tos_version", "entrypoint", "cred_token", + } { + addNonEmptyNativeCodeField(out, fields, key) + } return out } @@ -304,21 +370,45 @@ func nativeDeviceMapFields(state nativeState) map[string]string { if isOpaqueWamsysMapKey(key) { continue } + if isRuntimeNativeDeviceMapKey(key) { + continue + } fields[key] = value } for key, value := range nativeDefaultDeviceMapFields() { fields[key] = firstNonEmpty(fields[key], value) } - if fields["feo2_query_status"] == legacyNativeFeo2QueryStatus { - fields["feo2_query_status"] = nativeDefaultFeo2QueryStatus + applyNativePreChatdABDeviceFields(fields, state) + for key, value := range nativeRuntimeDeviceMapFields(state) { + fields[key] = value } return fields } +func nativeRuntimeDeviceMapFields(state nativeState) map[string]string { + return map[string]string{ + "pid": nativeRuntimeProcessID(state), + "feo2_query_status": nativeDefaultFeo2QueryStatus, + } +} + +func isRuntimeNativeDeviceMapKey(key string) bool { + switch key { + case "pid", "feo2_query_status": + return true + default: + return false + } +} + +func nativeRuntimeProcessID(state nativeState) string { + _ = state + return strconv.Itoa(os.Getpid()) +} + const ( nativeDefaultFeo2QueryStatus = "did_not_query" - legacyNativeFeo2QueryStatus = "error_security_exception" - nativeDefaultDebugBridgeStatus = "0" + nativeDefaultDebugBridgeStatus = "1" ) func nativeDefaultDeviceMapFields() map[string]string { @@ -326,11 +416,10 @@ func nativeDefaultDeviceMapFields() map[string]string { "network_radio_type": "1", "mistyped": "7", "hasav": "2", - "pid": "29418", "simnum": "0", "hasinrc": "1", "rc": "0", - "device_ram": "3.53", + "device_ram": nativeDefaultDeviceRAMGiB, "db": nativeDefaultDebugBridgeStatus, "recaptcha": `{"stage":"ABPROP_DISABLED"}`, "feo2_query_status": nativeDefaultFeo2QueryStatus, @@ -343,8 +432,70 @@ func nativeDefaultDeviceMapFields() map[string]string { } } -func nativeCodeClientMetrics() string { - return `{"attempts":1,"app_campaign_download_source":"google-play|unknown"}` +func nativeCodeRequestAttempts(state nativeState) int { + if state.GenerateCodeAttempts > 0 { + return state.GenerateCodeAttempts + } + return nativeCodeClientMetricAttempts(nativeCodeRequestAttemptsFromLastParams(state.LastCodeParams)) +} + +func (s *nativeState) nextGenerateCodeAttempt() int { + previous := s.GenerateCodeAttempts + if previous < 1 { + previous = nativeCodeRequestAttemptsFromLastParams(s.LastCodeParams) + } + if previous < 0 { + previous = 0 + } + s.GenerateCodeAttempts = previous + 1 + return s.GenerateCodeAttempts +} + +func nativeCodeRequestAttemptsFromLastParams(params map[string]string) int { + metrics := strings.TrimSpace(params["client_metrics"]) + if metrics == "" { + return 0 + } + var payload struct { + Attempts int `json:"attempts"` + } + if err := json.Unmarshal([]byte(metrics), &payload); err != nil { + return 0 + } + return payload.Attempts +} + +func nativeCodeRequestReason(state nativeState) string { + if len(state.LastCodeResult) == 0 { + return "" + } + switch responseStatus(state.LastCodeResult) { + case "", "sent", "ok": + return "" + default: + return "server-send-request-error-unspecified" + } +} + +func nativeCodeClientMetricAttempts(attempts int) int { + if attempts < 1 { + return 1 + } + return attempts +} + +func nativeCodeClientMetrics(attempts int) string { + body, err := json.Marshal(struct { + Attempts int `json:"attempts"` + AppCampaignDownloadSource string `json:"app_campaign_download_source"` + }{ + Attempts: nativeCodeClientMetricAttempts(attempts), + AppCampaignDownloadSource: nativeDefaultAppCampaignDownloadSource, + }) + if err != nil { + return `{"attempts":1,"app_campaign_download_source":"unknown|unknown"}` + } + return string(body) } func nativeRegisterClientMetrics(method string) string { @@ -368,24 +519,29 @@ func nativeCodeEntryMethod(method string) string { } } +const nativeDefaultAppCampaignDownloadSource = "unknown|unknown" + const defaultRegistrationTokenHMACKeyHex = "44539b934347b6f12609296e69145b58309df94ed0a8a5a2d94078a8eaff87013e3d95a69644aa1b924646532c279f8bcd2855ab55f2c8bc1693adb7800c88ff" const defaultRegistrationTokenMessagePrefixHex = "" + - "30820332308202f0a00302010202044c2536a4300b06072a8648ce3804030500307c310b3009060355040613025553311330110603550408" + - "130a43616c69666f726e6961311430120603550407130b53616e746120436c61726131163014060355040a130d576861747341707020496e" + - "632e31143012060355040b130b456e67696e656572696e67311430120603550403130b427269616e204163746f6e301e170d313030363235" + - "3233303731365a170d3434303231353233303731365a307c310b3009060355040613025553311330110603550408130a43616c69666f726e" + - "6961311430120603550407130b53616e746120436c61726131163014060355040a130d576861747341707020496e632e3114301206035504" + - "0b130b456e67696e656572696e67311430120603550403130b427269616e204163746f6e308201b83082012c06072a8648ce380401308201" + - "1f02818100fd7f53811d75122952df4a9c2eece4e7f611b7523cef4400c31e3f80b6512669455d402251fb593d8d58fabfc5f5ba30f6cb9b" + - "556cd7813b801d346ff26660b76b9950a5a49f9fe8047b1022c24fbba9d7feb7c61bf83b57e7c6a8a6150f04fb83f6d3c51ec3023554135a" + - "169132f675f3ae2b61d72aeff22203199dd14801c70215009760508f15230bccb292b982a2eb840bf0581cf502818100f7e1a085d69b3dde" + - "cbbcab5c36b857b97994afbbfa3aea82f9574c0b3d0782675159578ebad4594fe67107108180b449167123e84c281613b7cf09328cc8a6e1" + - "3c167a8b547c8d28e0a3ae1e2bb3a675916ea37f0bfa213562f1fb627a01243bcca4f1bea8519089a883dfe15ae59f06928b665e807b5525" + - "64014c3bfecf492a0381850002818100d1198b4b81687bcf246d41a8a725f0a989a51bce326e84c828e1f556648bd71da487054d6de70fff" + - "4b49432b6862aa48fc2a93161b2c15a2ff5e671672dfb576e9d12aaff7369b9a99d04fb29d2bbbb2a503ee41b1ff37887064f41fe2805609" + - "063500a8e547349282d15981cdb58a08bede51dd7e9867295b3dfb45ffc6b259300b06072a8648ce3804030500032f00302c021400a602a7" + - "477acf841077237be090df436582ca2f0214350ce0268d07e71e55774ab4eacd4d071cd1efad228ddd386803c6f2480473cded35085d" + "30820332308202f0a00302010202044c2536a4300b06072a8648ce3804030500307c310b300906035504061302555331" + + "1330110603550408130a43616c69666f726e6961311430120603550407130b53616e746120436c617261311630140603" + + "55040a130d576861747341707020496e632e31143012060355040b130b456e67696e656572696e673114301206035504" + + "03130b427269616e204163746f6e301e170d3130303632353233303731365a170d3434303231353233303731365a307c" + + "310b3009060355040613025553311330110603550408130a43616c69666f726e6961311430120603550407130b53616e" + + "746120436c61726131163014060355040a130d576861747341707020496e632e31143012060355040b130b456e67696e" + + "656572696e67311430120603550403130b427269616e204163746f6e308201b83082012c06072a8648ce380401308201" + + "1f02818100fd7f53811d75122952df4a9c2eece4e7f611b7523cef4400c31e3f80b6512669455d402251fb593d8d58fa" + + "bfc5f5ba30f6cb9b556cd7813b801d346ff26660b76b9950a5a49f9fe8047b1022c24fbba9d7feb7c61bf83b57e7c6a8" + + "a6150f04fb83f6d3c51ec3023554135a169132f675f3ae2b61d72aeff22203199dd14801c70215009760508f15230bcc" + + "b292b982a2eb840bf0581cf502818100f7e1a085d69b3ddecbbcab5c36b857b97994afbbfa3aea82f9574c0b3d078267" + + "5159578ebad4594fe67107108180b449167123e84c281613b7cf09328cc8a6e13c167a8b547c8d28e0a3ae1e2bb3a675" + + "916ea37f0bfa213562f1fb627a01243bcca4f1bea8519089a883dfe15ae59f06928b665e807b552564014c3bfecf492a" + + "0381850002818100d1198b4b81687bcf246d41a8a725f0a989a51bce326e84c828e1f556648bd71da487054d6de70fff" + + "4b49432b6862aa48fc2a93161b2c15a2ff5e671672dfb576e9d12aaff7369b9a99d04fb29d2bbbb2a503ee41b1ff3788" + + "7064f41fe2805609063500a8e547349282d15981cdb58a08bede51dd7e9867295b3dfb45ffc6b259300b06072a8648ce" + + "3804030500032f00302c021400a602a7477acf841077237be090df436582ca2f0214350ce0268d07e71e55774ab4eacd" + + "4d071cd1efad228ddd386803c6f2480473cded35085d" func deriveDefaultRegistrationToken(phone string) string { key, err := hex.DecodeString(defaultRegistrationTokenHMACKeyHex) @@ -407,7 +563,7 @@ func existDeviceMap(state nativeState) map[string]string { return map[string]string{ "mistyped": "7", "offline_ab": `{"exposure":[],"exp_hash":[],"metrics":{}}`, - "client_metrics": `{"attempts":1,"app_campaign_download_source":"google-play|unknown","was_activated_from_stub":false}`, + "client_metrics": `{"attempts":1,"app_campaign_download_source":"unknown|unknown","was_activated_from_stub":false}`, "read_phone_permission_granted": "0", "sim_state": "1", "network_operator_name": fields["network_operator_name"], @@ -442,7 +598,10 @@ func parseExistProbeResult(data map[string]any) EngineProbeResult { baseProtocolRejected := existProtocolRejected(status, reason) invalidNumber := existInvalidNumberReason(reason) rateLimited := existRateLimitedReason(reason) - registered := !baseProtocolRejected && !blocked && !invalidNumber && !rateLimited && (waOldFallbackEligible(data) || existRegisteredSignal(status, reason, data)) + registered := !baseProtocolRejected && !blocked && !invalidNumber && !rateLimited && (waOldFallbackEligible(data) || accountTransferFallbackEligible(data) || existRegisteredSignal(status, reason, data)) + if registered { + methodStatuses = upsertVerificationMethodStatus(methodStatuses, "acc_tr", verificationWaitStatus{Present: true}) + } protocolRejected := baseProtocolRejected notRegistered := false registeredKnown := registered || invalidNumber @@ -597,6 +756,25 @@ func waProtocolError(data map[string]any, fallback string) error { return NewError(code, message, retryable) } +func accountTransferRegisterTerminalFailure(data map[string]any) bool { + reason := responseReason(data) + status := responseStatus(data) + switch reason { + case "mismatch", "bad_code", "bad_token", "fail_mismatch", "blocked", "fail_blocked", "missing", "fail_missing", "guessed_too_fast", "fail_guessed_too_fast", "security_code", "second_code", "device_confirm_or_second_code", "verified_standalone": + return true + case "too_recent", "too_many", "temporarily_unavailable": + return false + } + switch status { + case "rejected", "blocked", "fail", "failed": + return true + case "", "pending", "sent", "retry", "waiting", "temporarily_unavailable": + return false + default: + return false + } +} + func methodsFromStatuses(statuses []VerificationMethodStatus) []waappv1.VerificationDeliveryMethod { seen := map[waappv1.VerificationDeliveryMethod]struct{}{} out := make([]waappv1.VerificationDeliveryMethod, 0, len(statuses)) @@ -658,7 +836,7 @@ type verificationWaitStatus struct { Present bool } -var apkDefaultRegistrationMethodOrder = []string{"flash", "sms", "voice"} +var apkDefaultRegistrationMethodOrder = []string{"flash", "sms", "voice", "wa_old", "acc_tr", "send_sms", "email_otp"} func verificationMethodStatuses(data map[string]any, _ []waappv1.VerificationDeliveryMethod) []VerificationMethodStatus { out := []VerificationMethodStatus{} @@ -799,6 +977,15 @@ func waOldFallbackEligible(data map[string]any) bool { return false } +func accountTransferFallbackEligible(data map[string]any) bool { + for _, code := range fallbackVerificationMethodCodes(data) { + if code == "acc_tr" { + return verificationMethodEligibleForAPKUI(data, code) + } + } + return false +} + func verificationMethodEligibleForAPKUI(data map[string]any, code string) bool { switch code { case "sms", "voice", "flash": @@ -809,6 +996,11 @@ func verificationMethodEligibleForAPKUI(data map[string]any, code string) bool { return false } return eligibility != 0 && eligibility != 4 + case "acc_tr": + if verificationExplicitlyEligible(data, "pref_acc_tr_eligibility", "acc_tr_eligible", "account_transfer_eligible") { + return true + } + return waOldFallbackEligible(data) case "send_sms": return verificationExplicitlyEligible(data, "pref_send_sms_eligibility", "send_sms_eligible", "can_send_sms_to_wa") && !verificationExplicitlyExhausted(data, "send_sms_attempts_exhausted", "pref_send_sms_attempts_exhausted") case "email_otp": @@ -865,6 +1057,8 @@ func verificationMethodWaitValues(data map[string]any, code string) []any { return []any{data["flash_wait"], data["flash_wait_time"], data["flash_retry_time"], data["pref_flash_wait_time"], data["EXTRA_FLASH_RETRY_TIME"]} case "wa_old": return []any{data["wa_old_wait"], data["wa_old_retry_time"], data["pref_wa_old_wait_time"], data["EXTRA_WA_OLD_RETRY_TIME"]} + case "acc_tr": + return []any{data["acc_tr_wait"], data["account_transfer_wait"], data["pref_acc_tr_wait_time"], data["EXTRA_ACC_TR_RETRY_TIME"]} case "email_otp": return []any{data["email_otp_wait"], data["email_otp_retry_time"], data["pref_email_otp_wait_time"], data["EXTRA_EMAIL_OTP_RETRY_TIME"]} case "silent_auth": @@ -986,19 +1180,3 @@ func jsonValuePresent(value any) bool { } return true } - -func shouldSendNativeAdvertisingID(phone *waappv1.PhoneTarget) bool { - country := strings.ToUpper(strings.TrimSpace(phone.GetCountryIso2())) - if country == "" { - return true - } - _, blocked := nativeAdvertisingIDSuppressedCountries[country] - return !blocked -} - -var nativeAdvertisingIDSuppressedCountries = map[string]struct{}{ - "AT": {}, "BE": {}, "BG": {}, "CY": {}, "CZ": {}, "DE": {}, "DK": {}, "EE": {}, - "ES": {}, "FI": {}, "FR": {}, "GR": {}, "HR": {}, "HU": {}, "IE": {}, "IT": {}, - "LT": {}, "LU": {}, "LV": {}, "MT": {}, "NL": {}, "PL": {}, "PT": {}, "RO": {}, - "SE": {}, "SI": {}, "SK": {}, -} diff --git a/internal/app/native_registration_shape.go b/internal/app/native_registration_shape.go index b9c503e..246abe4 100644 --- a/internal/app/native_registration_shape.go +++ b/internal/app/native_registration_shape.go @@ -1,6 +1,8 @@ package app import ( + "encoding/json" + "fmt" "log" "net/url" "strconv" @@ -25,6 +27,8 @@ func logNativeRegistrationOrderedShape(kind string, phone *waappv1.PhoneTarget, len(params), registrationShapeFields(params), ) + logNativeRegistrationValueHashes(kind, phoneHash, method, params) + logNativeRegistrationWamsysGAShape(kind, phoneHash, method, params) } func logNativeRegistrationMapShape(kind string, phone *waappv1.PhoneTarget, method waappv1.VerificationDeliveryMethod, params map[string]string, rawKeys map[string]struct{}) { @@ -61,3 +65,206 @@ func registrationShapeValueLength(value string, raw bool) int { } return len([]byte(decoded)) } + +func logNativeRegistrationValueHashes(kind string, phoneHash string, method waappv1.VerificationDeliveryMethod, params orderedParams) { + values := registrationValueHashes(params) + if values == "" { + return + } + log.Printf( + "wa_registration_value_hashes kind=%s phone_hash=%s method=%s values=%s", + probeLogValue(kind), + phoneHash, + probeLogValue(registrationMethodName(method, "sms")), + values, + ) +} + +func registrationValueHashes(params orderedParams) string { + parts := make([]string, 0, len(params)) + for _, param := range params { + if !shouldLogRegistrationValueHash(param.key) { + continue + } + value := registrationHashValue(param.val, param.raw) + parts = append(parts, param.key+":"+strconv.Itoa(len([]byte(value)))+":"+stableID(value)) + } + return strings.Join(parts, ",") +} + +func registrationHashValue(value string, raw bool) string { + if !raw { + return value + } + decoded, err := url.PathUnescape(value) + if err != nil { + return value + } + return decoded +} + +func shouldLogRegistrationValueHash(key string) bool { + switch key { + case "fdid", "expid", "access_session_id", "id", "backup_token", "token", + "authkey", "e_ident", "e_keytype", "e_regid", "e_skey_id", "e_skey_val", "e_skey_sig", + "mistyped", "reason", "hasav", "client_metrics", "mcc", "mnc", "sim_mcc", "sim_mnc", + "education_screen_displayed", "prefer_sms_over_flash", "network_radio_type", "simnum", + "hasinrc", "pid", "rc", "sim_type", "airplane_mode_type", "cellular_strength", + "roaming_type", "push_code", "new_acc_uuid", "old_phone_number", "device_ram", "db", "recaptcha", + "fid", "preloads_app_manager_id", "preloads_attribution", "tos_version", "entrypoint", + "cred_token", "feo2_query_status", + "ab_hash", "gpia", "_ge", "_gi", "_gg", "_gp", "_ga", "_hp", "aid": + return true + default: + return false + } +} + +func logNativeRegistrationWamsysGAShape(kind string, phoneHash string, method waappv1.VerificationDeliveryMethod, params orderedParams) { + for _, param := range params { + if param.key != "_ga" { + continue + } + value := registrationHashValue(param.val, param.raw) + var shape struct { + BootID string `json:"bi"` + SourcePathAge int64 `json:"ap"` + DataPathAge int64 `json:"ai"` + ExternalPathAge int64 `json:"ae"` + MultiProcess bool `json:"mp"` + MultiUser bool `json:"mu"` + } + if err := json.Unmarshal([]byte(value), &shape); err != nil { + log.Printf( + "wa_registration_wamsys_ga_shape kind=%s phone_hash=%s method=%s len=%d hash=%s parse_error=%s", + probeLogValue(kind), + phoneHash, + probeLogValue(registrationMethodName(method, "sms")), + len([]byte(value)), + stableID(value), + probeLogValue(err.Error()), + ) + return + } + log.Printf( + "wa_registration_wamsys_ga_shape kind=%s phone_hash=%s method=%s len=%d hash=%s bi_len=%d ap=%d ai=%d ae=%d mp=%t mu=%t", + probeLogValue(kind), + phoneHash, + probeLogValue(registrationMethodName(method, "sms")), + len([]byte(value)), + stableID(value), + len([]byte(shape.BootID)), + shape.SourcePathAge, + shape.DataPathAge, + shape.ExternalPathAge, + shape.MultiProcess, + shape.MultiUser, + ) + return + } +} + +func logNativeGPIAPlaintextShape(input wamsysMaterialInput, label string, keySource string, fields []nativeGPIAJSONField) { + plaintext, err := renderNativeGPIAJSONObject(fields) + if err != nil { + log.Printf( + "wa_registration_gpia_plaintext_shape kind=%s phone_hash=%s label=%s error=%s", + probeLogValue(registrationRequestKindName(input.Kind)), + wamsysInputPhoneHash(input), + probeLogValue(label), + probeLogValue(err.Error()), + ) + return + } + log.Printf( + "wa_registration_gpia_plaintext_shape kind=%s phone_hash=%s label=%s key_source_len=%d key_source_hash=%s json_len=%d json_hash=%s keys=%s fields=%s", + probeLogValue(registrationRequestKindName(input.Kind)), + wamsysInputPhoneHash(input), + probeLogValue(label), + len([]byte(keySource)), + stableID(keySource), + len(plaintext), + stableID(string(plaintext)), + probeLogValue(nativeGPIAFieldKeys(fields)), + probeLogValue(nativeGPIAFieldShapes(fields)), + ) +} + +func logNativeWamsysGAPlaintextShape(input wamsysMaterialInput, keySource string, bootID string, fields []nativeGPIAJSONField) { + plaintext, err := renderNativeGPIAJSONObject(fields) + if err != nil { + log.Printf( + "wa_registration_wamsys_ga_plaintext_shape kind=%s phone_hash=%s error=%s", + probeLogValue(registrationRequestKindName(input.Kind)), + wamsysInputPhoneHash(input), + probeLogValue(err.Error()), + ) + return + } + log.Printf( + "wa_registration_wamsys_ga_plaintext_shape kind=%s phone_hash=%s key_source_len=%d key_source_hash=%s boot_id_len=%d boot_id_hash=%s json_len=%d json_hash=%s keys=%s fields=%s", + probeLogValue(registrationRequestKindName(input.Kind)), + wamsysInputPhoneHash(input), + len([]byte(keySource)), + stableID(keySource), + len([]byte(bootID)), + stableID(bootID), + len(plaintext), + stableID(string(plaintext)), + probeLogValue(nativeGPIAFieldKeys(fields)), + probeLogValue(nativeGPIAFieldShapes(fields)), + ) +} + +func nativeGPIAFieldKeys(fields []nativeGPIAJSONField) string { + keys := make([]string, 0, len(fields)) + for _, field := range fields { + keys = append(keys, field.Key) + } + return strings.Join(keys, "|") +} + +func nativeGPIAFieldShapes(fields []nativeGPIAJSONField) string { + parts := make([]string, 0, len(fields)) + for _, field := range fields { + parts = append(parts, nativeGPIAFieldShape(field)) + } + return strings.Join(parts, ",") +} + +func nativeGPIAFieldShape(field nativeGPIAJSONField) string { + switch value := field.Value.(type) { + case string: + return field.Key + ":string:" + strconv.Itoa(len([]byte(value))) + ":" + stableID(value) + case int: + return field.Key + ":number:int:" + strconv.Itoa(value) + case int64: + return field.Key + ":number:int:" + strconv.FormatInt(value, 10) + case bool: + return field.Key + ":bool:" + strconv.FormatBool(value) + case nil: + return field.Key + ":null" + default: + return field.Key + ":type:" + probeLogValue(strconv.Quote(fmt.Sprintf("%T", value))) + } +} + +func wamsysInputPhoneHash(input wamsysMaterialInput) string { + if input.Phone != nil && input.Phone.GetE164Number() != "" { + return stableID(input.Phone.GetE164Number()) + } + return "" +} + +func registrationRequestKindName(kind waappv1.RegistrationRequestKind) string { + switch kind { + case waappv1.RegistrationRequestKind_REGISTRATION_REQUEST_KIND_EXIST: + return "exist" + case waappv1.RegistrationRequestKind_REGISTRATION_REQUEST_KIND_CODE: + return "code" + case waappv1.RegistrationRequestKind_REGISTRATION_REQUEST_KIND_REGISTER: + return "register" + default: + return kind.String() + } +} diff --git a/internal/app/native_state.go b/internal/app/native_state.go index ed59d90..fc48721 100644 --- a/internal/app/native_state.go +++ b/internal/app/native_state.go @@ -9,9 +9,9 @@ import ( "encoding/json" "fmt" "math/big" - mrand "math/rand" "regexp" "sort" + "strconv" "strings" "time" @@ -23,28 +23,31 @@ const nativeStateSchema = "byte-v-forge-wa-app-native-state/v1" var nativeUserAgentDevicePattern = regexp.MustCompile(`(?:^|\s)Android/([^ ]+)\s+Device/([^- \t/]+)-([^/\s]+)`) type nativeState struct { - Schema string `json:"schema"` - CreatedAtUnix int64 `json:"created_at_unix"` - CC string `json:"cc"` - Phone string `json:"phone"` - AuthKey string `json:"authkey"` - PushName string `json:"push_name,omitempty"` - Profile nativePhoneProfile `json:"profile"` - KeyBundle nativeKeyBundle `json:"key_bundle"` - LastCodeParams map[string]string `json:"last_code_params,omitempty"` - LastCodeResult map[string]any `json:"last_code_result,omitempty"` - LastRegister map[string]any `json:"last_register,omitempty"` - RegistrationJID string `json:"registration_jid,omitempty"` - ChatRoutingInfo string `json:"chat_routing_info,omitempty"` - ChatConnection nativeChatConnectionState `json:"chat_connection,omitempty"` - ChatStatic nativeCurveKeyPair `json:"chat_static"` - Attestation nativeSoftwareAttestation `json:"attestation,omitempty"` - Signal nativeSignalState `json:"signal"` - AppState nativeAppState `json:"app_state,omitempty"` - ContactHints []waContactHint `json:"contact_hints,omitempty"` - MessagePayloads map[string]nativeMessagePayload `json:"message_payloads,omitempty"` - MessagePlainRef map[string]string `json:"message_plain_ref,omitempty"` - PrivacyTokens map[string]nativePrivacyToken `json:"privacy_tokens,omitempty"` + Schema string `json:"schema"` + CreatedAtUnix int64 `json:"created_at_unix"` + CC string `json:"cc"` + Phone string `json:"phone"` + AuthKey string `json:"authkey"` + PushName string `json:"push_name,omitempty"` + Profile nativePhoneProfile `json:"profile"` + KeyBundle nativeKeyBundle `json:"key_bundle"` + GenerateCodeAttempts int `json:"reg_attempts_generate_code,omitempty"` + LastCodeParams map[string]string `json:"last_code_params,omitempty"` + LastCodeResult map[string]any `json:"last_code_result,omitempty"` + LastRegister map[string]any `json:"last_register,omitempty"` + AccountTransfer nativeAccountTransferState `json:"account_transfer,omitempty"` + PreChatdAB nativePreChatdABState `json:"pre_chatd_ab,omitempty"` + RegistrationJID string `json:"registration_jid,omitempty"` + ChatRoutingInfo string `json:"chat_routing_info,omitempty"` + ChatConnection nativeChatConnectionState `json:"chat_connection,omitempty"` + ChatStatic nativeCurveKeyPair `json:"chat_static"` + Attestation nativeSoftwareAttestation `json:"attestation,omitempty"` + Signal nativeSignalState `json:"signal"` + AppState nativeAppState `json:"app_state,omitempty"` + ContactHints []waContactHint `json:"contact_hints,omitempty"` + MessagePayloads map[string]nativeMessagePayload `json:"message_payloads,omitempty"` + MessagePlainRef map[string]string `json:"message_plain_ref,omitempty"` + PrivacyTokens map[string]nativePrivacyToken `json:"privacy_tokens,omitempty"` } type nativePhoneProfile struct { @@ -156,6 +159,24 @@ type nativePrivacyToken struct { Timestamp int64 `json:"timestamp,omitempty"` } +type nativeAccountTransferState struct { + Codes []string `json:"codes,omitempty"` + CurrentIndex int `json:"current_index,omitempty"` + RequestedAtUnix int64 `json:"requested_at_unix,omitempty"` + ExpiresAtUnix int64 `json:"expires_at_unix,omitempty"` + RotationIntervalSec int64 `json:"rotation_interval_sec,omitempty"` + SessionID string `json:"session_id,omitempty"` + Certificate string `json:"certificate,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + PeerID string `json:"peer_id,omitempty"` + EncryptionKeyVersion string `json:"enc_key_version,omitempty"` + EncryptionAccountHash string `json:"enc_key_account_hash,omitempty"` + EncryptionKeySalt string `json:"enc_key_salt,omitempty"` + DeeplinkBase string `json:"deeplink_base,omitempty"` + AccountPhoneNumber string `json:"account_phone_number,omitempty"` + LastChallengeIssuedSec int64 `json:"last_challenge_issued_sec,omitempty"` +} + type nativeChatConnectionState struct { LastHost string `json:"last_host,omitempty"` LastPort int `json:"last_port,omitempty"` @@ -233,14 +254,6 @@ func newNativeState(phone *waappv1.PhoneTarget) (nativeState, error) { if err != nil { return nativeState{}, err } - chatStaticPublic, err := chatStatic.publicBytes() - if err != nil { - return nativeState{}, err - } - attestation, err := newNativeSoftwareAttestation(chatStaticPublic, time.Now().UTC()) - if err != nil { - return nativeState{}, err - } identity, err := newNativeCurveKeyPair() if err != nil { return nativeState{}, err @@ -287,7 +300,6 @@ func newNativeState(phone *waappv1.PhoneTarget) (nativeState, error) { AuthKey: chatStatic.Public, Profile: profile, ChatStatic: chatStatic, - Attestation: attestation, Signal: nativeSignalState{ RegistrationID: regID, Identity: identity, @@ -322,91 +334,35 @@ type nativeDeviceModel struct { } var nativeDeviceModels = []nativeDeviceModel{ - {Vendor: "OnePlus", Model: "LE2100", Android: "14", BuildDisplayID: "LE2100_14.0.0.605(CN01)", MinRAMGiB: 7.2, MaxRAMGiB: 7.8}, - {Vendor: "HUAWEI", Model: "TRT-AL00A", Android: "7.0", BuildDisplayID: "TRT-AL00A_C00B220(CN01)", MinRAMGiB: 2.8, MaxRAMGiB: 3.9}, {Vendor: "Xiaomi", Model: "M2007J3SC", Android: "11", BuildDisplayID: "M2007J3SC_11.0.14(CN01)", MinRAMGiB: 5.5, MaxRAMGiB: 7.8}, + {Vendor: "HUAWEI", Model: "TRT-AL00A", Android: "7.0", BuildDisplayID: "TRT-AL00A_C00B220(CN01)", MinRAMGiB: 2.8, MaxRAMGiB: 3.9}, {Vendor: "samsung", Model: "SM-G991B", Android: "13", BuildDisplayID: "SM-G991B_TP1A.014(EUX1)", MinRAMGiB: 6.8, MaxRAMGiB: 7.6}, {Vendor: "OPPO", Model: "CPH2305", Android: "12", BuildDisplayID: "CPH2305_12.1.0.210(EX1)", MinRAMGiB: 3.6, MaxRAMGiB: 7.4}, {Vendor: "vivo", Model: "V2145A", Android: "12", BuildDisplayID: "V2145A_12.0.8.7(CN01XX)", MinRAMGiB: 5.5, MaxRAMGiB: 7.7}, + {Vendor: "OnePlus", Model: "LE2100", Android: "14", BuildDisplayID: "LE2100_14.0.0.605(CN01)", MinRAMGiB: 11.24, MaxRAMGiB: 11.24}, } -var nativeOperators = map[string][][2]string{ - "US": {{"310", "260"}, {"310", "410"}, {"311", "480"}}, - "CN": {{"460", "00"}, {"460", "01"}, {"460", "11"}}, - "PL": {{"260", "01"}, {"260", "02"}, {"260", "06"}}, - "VN": {{"452", "04"}, {"452", "02"}, {"452", "01"}, {"452", "05"}, {"452", "07"}}, - "NONE": {{"", ""}}, -} - -func nativeProfileCountry(phone *waappv1.PhoneTarget) string { - if country := strings.ToUpper(strings.TrimSpace(phone.GetCountryIso2())); country != "" { - return country - } - switch phoneCC(phone) { - case "1": - return "US" - case "48": - return "PL" - case "84": - return "VN" - case "86": - return "CN" - default: - return "" - } -} - -var nativeRadioTypes = []string{"1", "2", "3", "9", "13", "20"} - -func nativeRandomRadioType(rng *mrand.Rand) string { - if rng == nil || len(nativeRadioTypes) == 0 { - return "1" - } - return nativeRadioTypes[rng.Intn(len(nativeRadioTypes))] -} +const nativeDefaultDeviceRAMGiB = "6.58" func buildNativePhoneProfile(phone *waappv1.PhoneTarget) nativePhoneProfile { - seed := int64(binary.BigEndian.Uint64(randomBytes(8))) - rng := mrand.New(mrand.NewSource(seed)) - model := nativeDeviceModels[rng.Intn(len(nativeDeviceModels))] - country := nativeProfileCountry(phone) - ops := nativeOperators[country] - if len(ops) == 0 { - ops = nativeOperators["NONE"] - } - op := ops[rng.Intn(len(ops))] - simOp := ops[rng.Intn(len(ops))] + model := newNativeRegistrationDeviceModel() expIDUUID, expID := uuidPair() - accessUUID, accessSessionID := uuidPair() + accessUUID, accessID := uuidPair() id := randomBytes(20) backup := randomBytes(20) phoneHash := sha256.Sum256([]byte(fullPhoneKey(phoneCC(phone), phoneNational(phone)))) - simnum := "0" - if op[0] != "" && rng.Intn(2) == 1 { - simnum = "1" - } - ram := model.MinRAMGiB + rng.Float64()*(model.MaxRAMGiB-model.MinRAMGiB) additionalFields := map[string]string{ - "network_radio_type": nativeRandomRadioType(rng), - "pid": fmt.Sprintf("%d", 10000+rng.Intn(50000)), - "simnum": simnum, + "network_radio_type": "1", + "simnum": "0", "hasinrc": "1", "rc": "0", - "device_ram": fmt.Sprintf("%.2f", ram), + "device_ram": nativeDeviceRAMGiB(model), "db": nativeDefaultDebugBridgeStatus, "recaptcha": `{"stage":"ABPROP_DISABLED"}`, "feo2_query_status": nativeDefaultFeo2QueryStatus, "network_operator_name": "", "sim_operator_name": "", } - if op[0] != "" { - additionalFields["mcc"] = op[0] - additionalFields["mnc"] = op[1] - } - if simOp[0] != "" { - additionalFields["sim_mcc"] = simOp[0] - additionalFields["sim_mnc"] = simOp[1] - } return nativePhoneProfile{ Schema: "ctf-whatsapp-phone-profile/v1", CreatedAtUnix: time.Now().UTC().Unix(), @@ -418,7 +374,7 @@ func buildNativePhoneProfile(phone *waappv1.PhoneTarget) nativePhoneProfile { FDID: newUUIDString(), ExpID: expID, ExpIDUUID: expIDUUID, - AccessSessionID: accessSessionID, + AccessSessionID: accessID, AccessSessionIDUUID: accessUUID, ID: pctBytes(id), IDHex: hex.EncodeToString(id), @@ -429,6 +385,20 @@ func buildNativePhoneProfile(phone *waappv1.PhoneTarget) nativePhoneProfile { } } +func nativeRegistrationEphemeralID() string { + return pctBytes(randomBytes(20)) +} + +func nativeRegistrationRequestID(state nativeState) string { + if nativeShouldRandomizeRegistrationRequestID(state) { + return nativeRegistrationEphemeralID() + } + if id := strings.TrimSpace(state.Profile.ID); id != "" { + return id + } + return nativeRegistrationEphemeralID() +} + func nativeAdvertisingID(state nativeState) string { if value := strings.TrimSpace(state.Profile.AdvertisingID); value != "" { return value @@ -477,6 +447,24 @@ func randomInt24() int32 { return int32(value.Int64() + 1) } +func randomIndex(length int) int { + if length <= 1 { + return 0 + } + value, err := rand.Int(rand.Reader, big.NewInt(int64(length))) + if err != nil { + return int(time.Now().UnixNano() % int64(length)) + } + return int(value.Int64()) +} + +func randomIntRange(minValue int, maxValue int) int { + if maxValue <= minValue { + return minValue + } + return minValue + randomIndex(maxValue-minValue+1) +} + func newUUIDString() string { raw := randomBytes(16) raw[6] = (raw[6] & 0x0f) | 0x40 @@ -551,8 +539,9 @@ func stableParamOrder(params map[string]string) []string { "sim_mcc", "sim_mnc", "education_screen_displayed", "prefer_sms_over_flash", "network_radio_type", "simnum", "hasinrc", "pid", "rc", "sim_type", "airplane_mode_type", "cellular_strength", "roaming_type", - "device_ram", "gpia", - "db", "recaptcha", "_ge", "_gi", "_gg", "_gp", "_ga", "aid", + "push_code", "new_acc_uuid", "old_phone_number", "device_ram", "gpia", + "db", "recaptcha", "fid", "preloads_app_manager_id", "preloads_attribution", + "tos_version", "entrypoint", "cred_token", "_ga", "_gi", "_gp", "_ge", "aid", "_gg", "feo2_query_status", "is_foa_fdid_app_installed", "language_selector_time_spent", "language_selector_clicked_count", } @@ -628,9 +617,89 @@ func normalizeNativePhoneProfile(profile nativePhoneProfile, userAgent string) n Model: profile.DeviceModel, Android: profile.AndroidVersion, }), device.BuildDisplayID) + if shouldNormalizeNativeAccessSessionID(profile) { + profile = normalizeNativeAccessSessionID(profile) + } + if len(profile.AdditionalMapFields) > 0 { + fields := make(map[string]string, len(profile.AdditionalMapFields)) + for key, value := range profile.AdditionalMapFields { + if isRuntimeNativeDeviceMapKey(key) { + continue + } + fields[key] = value + } + profile.AdditionalMapFields = fields + } + return profile +} + +func shouldNormalizeNativeAccessSessionID(profile nativePhoneProfile) bool { + return profile.AccessSessionID != "" || + profile.AccessSessionIDUUID != "" || + profile.FDID != "" || + profile.PhoneSHA256 != "" +} + +func normalizeNativeAccessSessionID(profile nativePhoneProfile) nativePhoneProfile { + if isUUIDText(profile.AccessSessionIDUUID) { + profile.AccessSessionID = nativeUUIDTextToB64u(profile.AccessSessionIDUUID) + return profile + } + if isUUIDText(profile.AccessSessionID) { + profile.AccessSessionIDUUID = profile.AccessSessionID + profile.AccessSessionID = nativeUUIDTextToB64u(profile.AccessSessionID) + return profile + } + if accessSessionIDUUID, ok := nativeUUIDB64uToText(profile.AccessSessionID); ok { + profile.AccessSessionIDUUID = accessSessionIDUUID + return profile + } + accessSessionIDUUID, accessSessionID := uuidPair() + profile.AccessSessionID = accessSessionID + profile.AccessSessionIDUUID = accessSessionIDUUID return profile } +func nativeUUIDTextToB64u(value string) string { + value = strings.TrimSpace(value) + if !isUUIDText(value) { + return "" + } + raw, err := hex.DecodeString(strings.ReplaceAll(value, "-", "")) + if err != nil || len(raw) != 16 { + return "" + } + return b64u(raw) +} + +func nativeUUIDB64uToText(value string) (string, bool) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil || len(raw) != 16 { + return "", false + } + return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:]), true +} + +func isUUIDText(value string) bool { + value = strings.TrimSpace(value) + if len(value) != 36 { + return false + } + for idx, ch := range value { + switch idx { + case 8, 13, 18, 23: + if ch != '-' { + return false + } + default: + if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F') { + return false + } + } + } + return true +} + func nativeDeviceModelFromUserAgent(userAgent string) (nativeDeviceModel, bool) { match := nativeUserAgentDevicePattern.FindStringSubmatch(strings.TrimSpace(userAgent)) if len(match) != 4 { @@ -643,6 +712,66 @@ func defaultNativeDeviceModel() nativeDeviceModel { return nativeDeviceModels[0] } +func newNativeRegistrationDeviceModel() nativeDeviceModel { + return randomNativeGenericAndroid11DeviceModel() +} + +func randomNativeGenericAndroid11DeviceModel() nativeDeviceModel { + model := randomChoice([]string{"X", "A", "M", "N", "Z"}) + randomDigits(4) + randomUpper(2) + branch := randomChoice([]string{"GX", "GL", "EEA", "IN", "LA"}) + return nativeDeviceModel{ + Vendor: randomNativeGenericVendor(), + Model: model, + Android: "11", + BuildDisplayID: model + "_11.0." + strconv.Itoa(randomIntRange(1, 9)) + "." + strconv.Itoa(randomIntRange(10, 999)) + "(" + branch + "01)", + MinRAMGiB: 3.5, + MaxRAMGiB: 7.8, + } +} + +func randomNativeGenericVendor() string { + return randomChoice([]string{"NOVA", "AERO", "ORBI", "LYRA", "VANTA", "ZENO", "NIMO", "KORA", "ALTO", "MEGA"}) + + randomChoice([]string{"Mobile", "Phone", "Tech", "One", "Digital", "Comms", "Labs", "Link"}) +} + +func randomChoice(values []string) string { + if len(values) == 0 { + return "" + } + return values[randomIndex(len(values))] +} + +func randomDigits(length int) string { + const alphabet = "0123456789" + var builder strings.Builder + builder.Grow(length) + for range length { + builder.WriteByte(alphabet[randomIndex(len(alphabet))]) + } + return builder.String() +} + +func randomUpper(length int) string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + var builder strings.Builder + builder.Grow(length) + for range length { + builder.WriteByte(alphabet[randomIndex(len(alphabet))]) + } + return builder.String() +} + +func nativeDeviceRAMGiB(model nativeDeviceModel) string { + if model.MinRAMGiB <= 0 || model.MaxRAMGiB < model.MinRAMGiB { + return nativeDefaultDeviceRAMGiB + } + if model.MaxRAMGiB == model.MinRAMGiB { + return fmt.Sprintf("%.2f", model.MinRAMGiB) + } + scaled := int(model.MinRAMGiB*100) + randomIndex(int((model.MaxRAMGiB-model.MinRAMGiB)*100)+1) + return fmt.Sprintf("%.2f", float64(scaled)/100) +} + func nativeBuildDisplayIDForModel(model nativeDeviceModel) string { for _, candidate := range nativeDeviceModels { if strings.EqualFold(candidate.Vendor, model.Vendor) && strings.EqualFold(candidate.Model, model.Model) && candidate.Android == model.Android { diff --git a/internal/app/native_text_message.go b/internal/app/native_text_message.go index 6acae59..f98ca6e 100644 --- a/internal/app/native_text_message.go +++ b/internal/app/native_text_message.go @@ -113,7 +113,7 @@ func (e *NativeEngine) applyTextMessageSendUpdate(ctx context.Context, clientPro if state == nil { return nil } - _, payloads := splitReceivedItems(items) + _, payloads, _ := splitReceivedItems(items) if !applyChatdReceiveState(state, input, payloads, update) { return nil } diff --git a/internal/app/number_probe.go b/internal/app/number_probe.go index 9d600cf..0a7d62f 100644 --- a/internal/app/number_probe.go +++ b/internal/app/number_probe.go @@ -89,7 +89,7 @@ func (s *Server) probeNumberSMSAttempt(ctx context.Context, payload map[string]a "fingerprint_persistence": "RANDOM_NOT_COMMITTED", "fingerprint": fingerprintSummary(phoneProfileToProto(phone, state.Profile)), } - probeResult := probeEngine.probeAccountWithState(ctx, EngineRegistrationInput{AppVersion: defaultWAAppVersion, Phone: phone}, state) + probeResult, _ := probeEngine.probeAccountWithState(ctx, EngineRegistrationInput{AppVersion: defaultWAAppVersion, Phone: phone}, state) account := probeResultMap(probeResult) sms := smsProbeMap(account) result := buildNumberProbeResult(payload, proxy, fingerprint, account, sms) @@ -113,7 +113,17 @@ func (s *Server) numberProbeProxy(ctx context.Context, payload map[string]any) ( if !useProxy { return route, "", waProxySummary(route, false), func() {}, nil } - return route, route.ProxyURL, waProxySummary(route, true), func() {}, nil + gateway := &actionGateway{server: s} + lease, leasedRoute, err := gateway.acquireRegistrationProxyLease(ctx, payload, route, numberProbeProxyRouteTTL) + if err != nil { + return WAProxyRoute{}, "", nil, func() {}, err + } + release := func() {} + if validRegistrationProxyLease(lease) { + route = leasedRoute + release = func() { gateway.releaseRegistrationProxyLease(context.Background(), lease) } + } + return route, route.ProxyURL, waProxySummary(route, true), release, nil } func buildNumberProbeResult(input map[string]any, proxy map[string]any, fingerprint map[string]any, account map[string]any, sms map[string]any) map[string]any { diff --git a/internal/app/ports.go b/internal/app/ports.go index 0a27c24..b29f6a2 100644 --- a/internal/app/ports.go +++ b/internal/app/ports.go @@ -88,6 +88,7 @@ type ProtocolEngine interface { PrepareClientProfile(context.Context, EngineProfileInput) error ProbeAccount(context.Context, EngineRegistrationInput) EngineProbeResult RequestVerificationCode(context.Context, EngineRegistrationInput) EngineCodeResult + RefreshAccountTransferChallenge(context.Context, EngineAccountTransferChallengeInput) EngineAccountTransferChallengeResult SubmitVerificationCode(context.Context, EngineSubmitInput) EngineRegisterResult CheckLoginState(context.Context, EngineLoginCheckInput) EngineLoginCheckResult ReceiveMessageBatch(context.Context, EngineMessageInput) EngineMessageBatchResult @@ -120,6 +121,11 @@ type EngineSubmitInput struct { CodeSecretRef string } +type EngineAccountTransferChallengeInput struct { + EngineRegistrationInput + VerificationRequestID string +} + type EngineLoginCheckInput struct { WAAccountID string ClientProfileID string @@ -218,14 +224,20 @@ const ( ) type EngineCodeResult struct { - Status waappv1.VerificationRequestStatus - ExpectedCodeLength int32 - ExpiresAt time.Time - RetryAfter time.Duration - MethodStatuses []VerificationMethodStatus - RawStatus string - RawReason string - Err error + Status waappv1.VerificationRequestStatus + ExpectedCodeLength int32 + ExpiresAt time.Time + RetryAfter time.Duration + MethodStatuses []VerificationMethodStatus + AccountTransferChallenge *waappv1.AccountTransferChallenge + RawStatus string + RawReason string + Err error +} + +type EngineAccountTransferChallengeResult struct { + Challenge *waappv1.AccountTransferChallenge + Err error } type EngineRegisterResult struct { @@ -281,9 +293,10 @@ type EngineTextMessageResult struct { } type EngineMessageBatchResult struct { - Messages []*waappv1.InboundMessage - Contacts []*waappv1.WAContact - Err error + Messages []*waappv1.InboundMessage + Contacts []*waappv1.WAContact + OTPMessages []*waappv1.OtpMessage + Err error } type EngineDecryptResult struct { diff --git a/internal/app/postgres_rows.go b/internal/app/postgres_rows.go index 83e485c..f990ca3 100644 --- a/internal/app/postgres_rows.go +++ b/internal/app/postgres_rows.go @@ -472,6 +472,7 @@ type otpMessageRow struct { otpRedacted string otpSecretRef string receivedAt time.Time + expiresAt sql.NullTime createdAt time.Time updatedAt time.Time } @@ -495,6 +496,7 @@ func (r otpMessageRow) toProto(includeSensitiveValue bool) *waappv1.OtpMessage { SourceParty: r.sourceParty, Otp: text, ReceivedAt: timestamppb.New(r.receivedAt.UTC()), + ExpiresAt: sqlTime(r.expiresAt), Audit: audit(r.createdAt, r.updatedAt), } } diff --git a/internal/app/postgres_store.go b/internal/app/postgres_store.go index 09f0016..6cd679b 100644 --- a/internal/app/postgres_store.go +++ b/internal/app/postgres_store.go @@ -532,10 +532,10 @@ func (s *PostgresStore) SaveOTPMessage(ctx context.Context, msg *waappv1.OtpMess otpID := firstNonEmpty(msg.GetOtpMessageId(), stableOTPMessageID(msg.GetWaAccountId(), msg.GetSourceParty(), otpValue)) redactedValue := firstNonEmpty(msg.GetOtp().GetRedactedValue(), redacted(otpValue)) secretRef := firstNonEmpty(msg.GetOtp().GetSecretRef(), "wa-otp:"+stableID(otpID)) - _, err := s.pool.Exec(ctx, `INSERT INTO wa_otp_messages (otp_message_id, wa_account_id, client_profile_id, registered_identity_id, message_id, candidate_id, source, source_party, otp_value, otp_redacted, otp_secret_ref, received_at, created_at, updated_at) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now()) -ON CONFLICT (otp_message_id) DO UPDATE SET client_profile_id=EXCLUDED.client_profile_id, registered_identity_id=EXCLUDED.registered_identity_id, message_id=EXCLUDED.message_id, candidate_id=EXCLUDED.candidate_id, source=EXCLUDED.source, source_party=EXCLUDED.source_party, otp_value=EXCLUDED.otp_value, otp_redacted=EXCLUDED.otp_redacted, otp_secret_ref=EXCLUDED.otp_secret_ref, received_at=EXCLUDED.received_at, updated_at=EXCLUDED.updated_at`, - otpID, msg.GetWaAccountId(), msg.GetClientProfileId(), msg.GetRegisteredIdentityId(), msg.GetMessageId(), msg.GetCandidateId(), source, msg.GetSourceParty(), otpValue, redactedValue, secretRef, timeFromProto(msg.GetReceivedAt())) + _, err := s.pool.Exec(ctx, `INSERT INTO wa_otp_messages (otp_message_id, wa_account_id, client_profile_id, registered_identity_id, message_id, candidate_id, source, source_party, otp_value, otp_redacted, otp_secret_ref, received_at, expires_at, created_at, updated_at) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,now(),now()) +ON CONFLICT (otp_message_id) DO UPDATE SET client_profile_id=EXCLUDED.client_profile_id, registered_identity_id=EXCLUDED.registered_identity_id, message_id=EXCLUDED.message_id, candidate_id=EXCLUDED.candidate_id, source=EXCLUDED.source, source_party=EXCLUDED.source_party, otp_value=EXCLUDED.otp_value, otp_redacted=EXCLUDED.otp_redacted, otp_secret_ref=EXCLUDED.otp_secret_ref, received_at=EXCLUDED.received_at, expires_at=EXCLUDED.expires_at, updated_at=EXCLUDED.updated_at`, + otpID, msg.GetWaAccountId(), msg.GetClientProfileId(), msg.GetRegisteredIdentityId(), msg.GetMessageId(), msg.GetCandidateId(), source, msg.GetSourceParty(), otpValue, redactedValue, secretRef, timeFromProto(msg.GetReceivedAt()), nullableProtoTime(msg.GetExpiresAt())) return err } @@ -553,7 +553,7 @@ func (s *PostgresStore) ListAccountOTPMessages(ctx context.Context, waAccountIDV items := []*waappv1.OtpMessage{} for rows.Next() { var r otpMessageRow - if err := rows.Scan(&r.id, &r.waAccountIDValue, &r.clientProfileID, &r.registeredIdentityID, &r.messageID, &r.candidateID, &r.source, &r.sourceParty, &r.otpValue, &r.otpRedacted, &r.otpSecretRef, &r.receivedAt, &r.createdAt, &r.updatedAt); err != nil { + if err := rows.Scan(&r.id, &r.waAccountIDValue, &r.clientProfileID, &r.registeredIdentityID, &r.messageID, &r.candidateID, &r.source, &r.sourceParty, &r.otpValue, &r.otpRedacted, &r.otpSecretRef, &r.receivedAt, &r.expiresAt, &r.createdAt, &r.updatedAt); err != nil { return nil, "", err } items = append(items, r.toProto(includeSensitiveValues)) @@ -568,7 +568,7 @@ func (s *PostgresStore) ListAccountOTPMessages(ctx context.Context, waAccountIDV } func (s *PostgresStore) queryOTPMessagePage(ctx context.Context, waAccountIDValue string, cursor keysetCursor, limit int) (pgx.Rows, error) { - const base = `SELECT o.otp_message_id,o.wa_account_id,o.client_profile_id,o.registered_identity_id,o.message_id,o.candidate_id,o.source,o.source_party,o.otp_value,o.otp_redacted,o.otp_secret_ref,o.received_at,o.created_at,o.updated_at + const base = `SELECT o.otp_message_id,o.wa_account_id,o.client_profile_id,o.registered_identity_id,o.message_id,o.candidate_id,o.source,o.source_party,o.otp_value,o.otp_redacted,o.otp_secret_ref,o.received_at,o.expires_at,o.created_at,o.updated_at FROM wa_otp_messages o WHERE o.wa_account_id=$1 AND NOT EXISTS ( SELECT 1 FROM wa_inbound_messages m diff --git a/internal/app/proxy_runtime_lease.go b/internal/app/proxy_runtime_lease.go new file mode 100644 index 0000000..26ff4b9 --- /dev/null +++ b/internal/app/proxy_runtime_lease.go @@ -0,0 +1,382 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + waappv1 "github.com/byte-v-forge/wa-app/gen/go/byte/v/forge/waapp/v1" +) + +const ( + proxyRuntimeLeasePurpose = "wa-app-registration" + proxyRuntimeLeaseHTTPTimeout = 15 * time.Second + proxyRuntimeLeaseReleaseTimeout = 5 * time.Second + proxyRuntimeLeaseDefaultMinimumTTL = 30 * time.Second + proxyRuntimeLeaseDefaultSelectionTries = 1 + + registrationProxyLeaseModeDisabled registrationProxyLeaseMode = "disabled" + registrationProxyLeaseModeOptional registrationProxyLeaseMode = "optional" + registrationProxyLeaseModeRequired registrationProxyLeaseMode = "required" +) + +type registrationProxyLeaseMode string + +func normalizeRegistrationProxyLeaseMode(mode string) registrationProxyLeaseMode { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "optional", "best_effort", "best-effort", "try", "enabled", "enable", "on", "true", "1": + return registrationProxyLeaseModeOptional + case "disabled", "disable", "off", "false", "0", "none": + return registrationProxyLeaseModeDisabled + case "required", "require", "strict", "force", "forced": + return registrationProxyLeaseModeRequired + default: + return registrationProxyLeaseModeOptional + } +} + +func (m registrationProxyLeaseMode) String() string { + return string(normalizeRegistrationProxyLeaseMode(string(m))) +} + +func (m registrationProxyLeaseMode) enabled() bool { + return normalizeRegistrationProxyLeaseMode(string(m)) != registrationProxyLeaseModeDisabled +} + +func (m registrationProxyLeaseMode) required() bool { + return normalizeRegistrationProxyLeaseMode(string(m)) == registrationProxyLeaseModeRequired +} + +func (s *Server) effectiveRegistrationProxyLeaseMode() registrationProxyLeaseMode { + if s == nil { + return registrationProxyLeaseModeOptional + } + return normalizeRegistrationProxyLeaseMode(string(s.registrationProxyLeaseMode)) +} + +func (s *Server) registrationProxyLeaseEnabled() bool { + return s.effectiveRegistrationProxyLeaseMode().enabled() +} + +func (s *Server) registrationProxyLeaseRequired() bool { + return s.effectiveRegistrationProxyLeaseMode().required() +} + +type proxyRuntimeLeaseClient struct { + apiBase string + authToken string + client *http.Client +} + +type registrationProxyLease struct { + LeaseID string `json:"lease_id"` + AccountID string `json:"account_id"` + Purpose string `json:"purpose"` + ProxyURL string `json:"proxy_url"` + ListenerID string `json:"listener_id,omitempty"` + CountryCode string `json:"country_code,omitempty"` + ExitRegion string `json:"exit_region,omitempty"` + ExitCity string `json:"exit_city,omitempty"` + ExpiresAtUnix int64 `json:"expires_at_unix,omitempty"` +} + +type proxyRuntimeLeaseAcquireInput struct { + AccountID string + Purpose string + CountryCode string + TTL time.Duration + JobKey string +} + +func newProxyRuntimeLeaseClient(apiBase string, authToken string) *proxyRuntimeLeaseClient { + apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") + if apiBase == "" { + return nil + } + return &proxyRuntimeLeaseClient{ + apiBase: apiBase, + authToken: strings.TrimSpace(authToken), + client: &http.Client{Timeout: proxyRuntimeLeaseHTTPTimeout}, + } +} + +func (c *proxyRuntimeLeaseClient) acquire(ctx context.Context, input proxyRuntimeLeaseAcquireInput) (registrationProxyLease, error) { + if c == nil || c.client == nil || c.apiBase == "" { + return registrationProxyLease{}, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease client is not configured", true) + } + accountID := strings.TrimSpace(input.AccountID) + if accountID == "" { + return registrationProxyLease{}, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease account is not configured", true) + } + purpose := firstNonEmpty(input.Purpose, proxyRuntimeLeasePurpose) + ttl := input.TTL + if ttl < proxyRuntimeLeaseDefaultMinimumTTL { + ttl = proxyRuntimeLeaseDefaultMinimumTTL + } + labels := map[string]any{ + "purpose": purpose, + "session_id": stableID(firstNonEmpty(input.JobKey, accountID+"|"+purpose)), + } + payload := map[string]any{ + "account_id": accountID, + "purpose": purpose, + "force_new": true, + "policy": map[string]any{ + "mode": "PROXY_SESSION_MODE_STICKY", + "sticky_ttl": fmt.Sprintf("%ds", int(ttl/time.Second)), + "upstream_kind": "PROXY_UPSTREAM_KIND_DYNAMIC_IP", + "rotation_mode": "PROXY_ROTATION_MODE_STICKY_SESSION", + "labels": labels, + }, + "selection_policy": map[string]any{ + "country_code": strings.ToUpper(strings.TrimSpace(input.CountryCode)), + "purpose": purpose, + "max_attempts": proxyRuntimeLeaseDefaultSelectionTries, + }, + } + body, err := c.postJSON(ctx, "/leases/acquire", payload) + if err != nil { + return registrationProxyLease{}, err + } + lease, err := c.parseAcquireResponse(accountID, purpose, body) + if err != nil { + return registrationProxyLease{}, err + } + return lease, nil +} + +func (c *proxyRuntimeLeaseClient) release(ctx context.Context, lease registrationProxyLease) error { + if c == nil || c.client == nil || c.apiBase == "" || strings.TrimSpace(lease.LeaseID) == "" { + return nil + } + payload := map[string]any{ + "lease_id": lease.LeaseID, + "account_id": lease.AccountID, + "purpose": firstNonEmpty(lease.Purpose, proxyRuntimeLeasePurpose), + } + _, err := c.postJSON(ctx, "/leases/release", payload) + return err +} + +func (c *proxyRuntimeLeaseClient) postJSON(ctx context.Context, path string, payload map[string]any) (map[string]any, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiBase+path, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } + resp, err := c.client.Do(req) + if err != nil { + return nil, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease request failed", true) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease request was rejected", true) + } + out := map[string]any{} + if len(strings.TrimSpace(string(body))) == 0 { + return out, nil + } + if err := json.Unmarshal(body, &out); err != nil { + return nil, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease response is invalid", true) + } + return out, nil +} + +func (c *proxyRuntimeLeaseClient) parseAcquireResponse(accountID string, purpose string, body map[string]any) (registrationProxyLease, error) { + leaseData := objectField(body, "lease") + egress := objectField(body, "egress") + if len(egress) == 0 { + egress = objectField(leaseData, "egress") + } + leaseID := firstNonEmpty(textField(leaseData, "lease_id"), textField(leaseData, "leaseId"), textField(objectField(egress, "labels"), "lease_id"), textField(objectField(egress, "labels"), "leaseId")) + if leaseID == "" { + return registrationProxyLease{}, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease id is empty", true) + } + proxyURL := proxyRuntimeLeaseProxyURL(egress) + if proxyURL == "" { + return registrationProxyLease{}, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease egress is invalid", true) + } + lease := registrationProxyLease{ + LeaseID: leaseID, + AccountID: firstNonEmpty(textField(leaseData, "account_id"), textField(leaseData, "accountId"), accountID), + Purpose: firstNonEmpty(textField(leaseData, "purpose"), purpose), + ProxyURL: proxyURL, + ListenerID: proxyRuntimeLeaseListenerID(leaseData, body), + CountryCode: strings.ToUpper(firstNonEmpty(proxyRuntimeLeaseExitText("country_code", egress, leaseData, body), proxyRuntimeLeaseExitText("countryCode", egress, leaseData, body))), + ExitRegion: strings.ToUpper(firstNonEmpty(proxyRuntimeLeaseExitText("region", egress, leaseData, body), proxyRuntimeLeaseExitText("exit_state", egress, leaseData, body), proxyRuntimeLeaseExitText("exitState", egress, leaseData, body))), + ExitCity: firstNonEmpty(proxyRuntimeLeaseExitText("city", egress, leaseData, body), proxyRuntimeLeaseExitText("exit_city", egress, leaseData, body), proxyRuntimeLeaseExitText("exitCity", egress, leaseData, body)), + ExpiresAtUnix: unixFromRFC3339(firstNonEmpty(textField(leaseData, "expires_at"), textField(leaseData, "expiresAt"))), + } + return lease, nil +} + +func proxyRuntimeLeaseProxyURL(egress map[string]any) string { + host := textField(egress, "host") + port := textField(egress, "port") + if host == "" || port == "" || port == "0" { + return "" + } + protocol := strings.ToUpper(textField(egress, "protocol")) + scheme := "http" + if strings.Contains(protocol, "SOCKS5") { + scheme = "socks5" + } + labels := objectField(egress, "labels") + username := firstNonEmpty(textField(labels, "proxy_username"), textField(labels, "proxyUsername")) + password := firstNonEmpty(textField(labels, "proxy_password"), textField(labels, "proxyPassword")) + out := &url.URL{Scheme: scheme, Host: host + ":" + port} + if username != "" || password != "" { + out.User = url.UserPassword(username, password) + } + return out.String() +} + +func proxyRuntimeLeaseListenerID(items ...map[string]any) string { + queue := append([]map[string]any{}, items...) + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + if item == nil { + continue + } + if value := firstNonEmpty(textField(item, "listener_id"), textField(item, "listenerId")); value != "" { + return value + } + for _, key := range []string{"listener", "egress_listener", "egressListener"} { + if nested := objectField(item, key); len(nested) > 0 { + queue = append(queue, nested) + } + } + } + return "" +} + +func proxyRuntimeLeaseExitText(key string, items ...map[string]any) string { + for _, item := range items { + if value := textField(item, key); value != "" { + return value + } + if labels := objectField(item, "labels"); len(labels) > 0 { + if value := textField(labels, key); value != "" { + return value + } + } + } + return "" +} + +func unixFromRFC3339(value string) int64 { + if value == "" { + return 0 + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return 0 + } + return parsed.UTC().Unix() +} + +func proxyRuntimeLeaseAccountIDFromProxyURL(proxyURL string) string { + parsed, err := parseOutboundProxyURL(proxyURL) + if err != nil || parsed == nil || parsed.User == nil { + return "" + } + return strings.TrimSpace(parsed.User.Username()) +} + +func proxyRuntimeLeaseRoute(lease registrationProxyLease, fallback WAProxyRoute) WAProxyRoute { + fallback.ProxyURL = lease.ProxyURL + fallback.ProxyMode = "PROXY_RUNTIME_LEASE" + fallback.RouteID = "proxy-runtime-lease-" + stableID(lease.LeaseID) + fallback.AccountID = firstNonEmpty(lease.AccountID, fallback.AccountID) + fallback.CountryCode = firstNonEmpty(lease.CountryCode, fallback.CountryCode) + return fallback +} + +func (g *actionGateway) acquireRegistrationProxyLease(ctx context.Context, payload map[string]any, route WAProxyRoute, ttl time.Duration) (registrationProxyLease, WAProxyRoute, error) { + if g == nil || g.server == nil || !g.server.registrationProxyLeaseEnabled() { + return registrationProxyLease{}, route, nil + } + if g.server.proxyRuntimeLease == nil { + return g.optionalRegistrationProxyLeaseError(route, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease client is not configured", true), "", payload) + } + accountID := registrationProxyLeaseAccountID(payload, route) + if accountID == "" { + return g.optionalRegistrationProxyLeaseError(route, NewError(waappv1.WaErrorCode_WA_ERROR_CODE_ROUTE_UNAVAILABLE, "proxy runtime lease account is not configured", true), "", payload) + } + lease, err := g.server.proxyRuntimeLease.acquire(ctx, proxyRuntimeLeaseAcquireInput{ + AccountID: accountID, + Purpose: proxyRuntimeLeasePurpose, + CountryCode: firstNonEmpty(route.CountryCode, proxyCountryCodeFromPayload(payload)), + TTL: ttl, + JobKey: registrationProxyLeaseJobKey(payload, accountID), + }) + if err != nil { + return g.optionalRegistrationProxyLeaseError(route, err, accountID, payload) + } + return lease, proxyRuntimeLeaseRoute(lease, route), nil +} + +func registrationProxyLeaseAccountID(payload map[string]any, route WAProxyRoute) string { + return firstNonEmpty( + textField(payload, "proxy_runtime_account_id"), + textField(objectField(payload, "proxy"), "proxy_runtime_account_id"), + proxyRuntimeLeaseAccountIDFromProxyURL(route.ProxyURL), + ) +} + +func registrationProxyLeaseJobKey(payload map[string]any, accountID string) string { + ctxData := actionContext(payload) + return firstNonEmpty( + ctxData.GetCorrelationId(), + ctxData.GetRequestId(), + textField(payload, "verification_request_id"), + stableID(firstNonEmpty(textField(objectField(payload, "phone"), "e164_number"), textField(payload, "wa_account_id"), accountID)), + ) +} + +func (g *actionGateway) optionalRegistrationProxyLeaseError(route WAProxyRoute, err error, accountID string, payload map[string]any) (registrationProxyLease, WAProxyRoute, error) { + if g == nil || g.server == nil || g.server.registrationProxyLeaseRequired() { + return registrationProxyLease{}, route, err + } + if accountID != "" { + log.Printf( + "wa_registration_proxy_lease_unavailable mode=%s account_hash=%s country_code=%s error=%s", + g.server.effectiveRegistrationProxyLeaseMode(), + stableID(accountID), + probeLogValue(firstNonEmpty(route.CountryCode, proxyCountryCodeFromPayload(payload))), + probeLogValue(ToProtoError(err).GetMessage()), + ) + } + return registrationProxyLease{}, route, nil +} + +func (g *actionGateway) releaseRegistrationProxyLease(ctx context.Context, lease registrationProxyLease) { + if g == nil || g.server == nil || g.server.proxyRuntimeLease == nil || strings.TrimSpace(lease.LeaseID) == "" { + return + } + releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), proxyRuntimeLeaseReleaseTimeout) + defer cancel() + if err := g.server.proxyRuntimeLease.release(releaseCtx, lease); err != nil { + log.Printf("wa_registration_proxy_lease_release_failed lease_hash=%s error=%s", stableID(lease.LeaseID), probeLogValue(ToProtoError(err).GetMessage())) + } +} + +func validRegistrationProxyLease(lease registrationProxyLease) bool { + return strings.TrimSpace(lease.LeaseID) != "" && strings.TrimSpace(lease.ProxyURL) != "" +} diff --git a/internal/app/registration_orchestrator.go b/internal/app/registration_orchestrator.go index efaf047..246a0f3 100644 --- a/internal/app/registration_orchestrator.go +++ b/internal/app/registration_orchestrator.go @@ -25,31 +25,31 @@ func (s *Server) StartRegistration(ctx context.Context, payload map[string]any) if reason := directRegistrationMethodUnsupportedReason(method); reason != "" { return rejectedRegistrationResult(basePayload, registrationMethodUnsupportedMap(method, reason)), nil } - - fingerprint, err := gateway.generateTransientFingerprint(ctx, basePayload) - if err != nil { - return nil, err - } - fingerprintRef := firstNonEmpty(textField(fingerprint, "fingerprint_ref"), textField(fingerprint, "transient_fingerprint_ref")) - state, err := gateway.loadTransientState(ctx, fingerprintRef) + phone := normalizePhone(phoneFromAction(basePayload)) + state, stateRef, reusedState, err := gateway.registrationAttemptState(ctx, phone) if err != nil { return nil, err } - runner, route, managedRoute, err := gateway.registrationRequestRunner(ctx, basePayload) + logRegistrationAttemptState(basePayload, phone, reusedState) + runner, route, managedRoute, proxyLease, err := gateway.registrationRequestRunner(ctx, basePayload) if err != nil { return nil, err } defer runner.CloseIdleConnections() + keepProxyLease := false defer func() { - _ = gateway.server.runtime.DeleteTransientState(context.Background(), fingerprintRef) + if !keepProxyLease { + gateway.releaseRegistrationProxyLease(context.Background(), proxyLease) + } }() - phone := normalizePhone(phoneFromAction(basePayload)) - probeResult := runner.probeAccountWithState(ctx, EngineRegistrationInput{AppVersion: defaultWAAppVersion, Phone: phone, DeliveryMethod: method, AuthCodeContext: authCodeContext}, state) + probeResult, state := runner.probeAccountWithState(ctx, EngineRegistrationInput{AppVersion: defaultWAAppVersion, Phone: phone, DeliveryMethod: method, AuthCodeContext: authCodeContext}, state) + _ = gateway.saveRegistrationAttemptState(context.Background(), stateRef, state) logRegistrationProbeResult(basePayload, phone, route, method, probeResult) if !registrationProbeAllowsMethod(probeResult, method) { return rejectedRegistrationResult(basePayload, registrationProbeFailureMap(probeResult, route, managedRoute)), nil } codeResult, updatedState := runner.requestVerificationCodeWithState(ctx, EngineRegistrationInput{AppVersion: defaultWAAppVersion, Phone: phone, DeliveryMethod: method, AuthCodeContext: authCodeContext}, state) + _ = gateway.saveRegistrationAttemptState(context.Background(), stateRef, updatedState) logRegistrationCodeResult(basePayload, phone, route, method, codeResult) if !verificationCodeRequestAccepted(codeResult) { return rejectedRegistrationResult(basePayload, registrationRequestFailureMap(codeResult, method, route, managedRoute)), nil @@ -67,6 +67,10 @@ func (s *Server) StartRegistration(ctx context.Context, payload map[string]any) } } record := gateway.server.newVerificationCodeRequestRecord(account, profile, method, codeResult) + challenge := codeResult.AccountTransferChallenge + if challenge != nil { + challenge.VerificationRequestId = record.GetVerificationRequestId() + } if err := gateway.server.store.SaveVerificationRequest(ctx, record); err != nil { _ = gateway.discardRejectedRegistration(context.Background(), basePayload, waAccountID(account), record.GetVerificationRequestId()) return nil, err @@ -76,11 +80,14 @@ func (s *Server) StartRegistration(ctx context.Context, payload map[string]any) WAAccountID: waAccountID(account), VerificationRequestID: verificationRequestID, CreatedAtUnix: time.Now().UTC().Unix(), + ProxyLease: proxyLease, } if err := gateway.saveRegistrationOTPWait(ctx, wait, registrationOTPWaitDefaultTTL); err != nil { _ = gateway.discardRejectedRegistration(context.Background(), basePayload, waAccountID(account), verificationRequestID) return nil, err } + keepProxyLease = true + _ = gateway.server.runtime.DeleteTransientState(context.Background(), stateRef) response := map[string]any{ "success": true, "status": record.GetStatus().String(), @@ -100,12 +107,65 @@ func (s *Server) StartRegistration(ctx context.Context, payload map[string]any) "phone_status": registrationCodeResultPhoneStatus(codeResult, method, false), "proxy": registrationOrchestratorProxySummary(registrationProxyRouteMap(route, managedRoute)), } + if challenge != nil { + response["account_transfer_challenge"] = protoMap(challenge) + response["registration_phase"] = "ACCOUNT_TRANSFER_WAITING" + } if seconds := durationSeconds(record.GetRetryAfter()); seconds > 0 { response["retry_after_seconds"] = seconds } return response, nil } +func (g *actionGateway) registrationAttemptState(ctx context.Context, phone *waappv1.PhoneTarget) (nativeState, string, bool, error) { + ref := registrationAttemptStateKey(phone) + if data, err := g.server.runtime.GetTransientState(ctx, ref); err == nil { + state, err := unmarshalNativeState(data) + if err == nil { + return state, ref, true, nil + } + _ = g.server.runtime.DeleteTransientState(ctx, ref) + } + engine, err := g.nativeEngine() + if err != nil { + return nativeState{}, "", false, err + } + state, err := engine.newState(phone) + if err != nil { + return nativeState{}, "", false, err + } + if err := g.saveRegistrationAttemptState(ctx, ref, state); err != nil { + return nativeState{}, "", false, err + } + return state, ref, false, nil +} + +func (g *actionGateway) saveRegistrationAttemptState(ctx context.Context, ref string, state nativeState) error { + data, err := marshalNativeState(state) + if err != nil { + return err + } + return g.server.runtime.SaveTransientState(ctx, ref, data, registrationAttemptStateTTL) +} + +func registrationAttemptStateKey(phone *waappv1.PhoneTarget) string { + return "wa-register-state:" + stableID(firstNonEmpty(phone.GetE164Number(), fullPhoneKey(phoneCC(phone), phoneNational(phone)))) +} + +func logRegistrationAttemptState(payload map[string]any, phone *waappv1.PhoneTarget, reused bool) { + phoneHash := "" + if phone != nil && phone.GetE164Number() != "" { + phoneHash = stableID(phone.GetE164Number()) + } + log.Printf( + "wa_registration_attempt_state correlation=%s phone_hash=%s reused=%t ttl_seconds=%d", + probeLogValue(actionContext(payload).GetCorrelationId()), + phoneHash, + reused, + int64(registrationAttemptStateTTL/time.Second), + ) +} + func logRegistrationCodeResult(payload map[string]any, phone *waappv1.PhoneTarget, route WAProxyRoute, method waappv1.VerificationDeliveryMethod, result EngineCodeResult) { phoneHash := "" if phone != nil && phone.GetE164Number() != "" { @@ -234,6 +294,10 @@ func registrationProbeAllowsMethod(result EngineProbeResult, method waappv1.Veri method == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_SMS { return registrationProbeMethodAvailable(result, waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_SMS) || result.CanSendSMS } + if method == waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + return result.Status == waappv1.AccountProbeStatus_ACCOUNT_PROBE_STATUS_REACHABLE && + (result.Registered || registrationProbeMethodAvailable(result, method) || registrationProbeMethodAvailable(result, waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_WA_OLD)) + } return registrationProbeMethodAvailable(result, method) } @@ -386,11 +450,13 @@ func registrationCodeResultPhoneStatus(result EngineCodeResult, method waappv1.V registrationPhaseValue = "OTP_COOLDOWN" } } + rawStatus := result.RawStatus + rawReason := result.RawReason return map[string]any{ "account_status": registrationCodeAccountStatus(failed), "account_flow": accountProbeFlowUnknown, - "account_raw_status": result.RawStatus, - "account_raw_reason": result.RawReason, + "account_raw_status": rawStatus, + "account_raw_reason": rawReason, "account_reachable": !failed, "request_failed": failed, "sms_status": smsStatus, @@ -403,8 +469,8 @@ func registrationCodeResultPhoneStatus(result EngineCodeResult, method waappv1.V "can_register": !failed, "registration_phase": registrationPhaseValue, "verification_status": result.Status.String(), - "verification_reason": result.RawReason, - "verification_outcome": result.RawStatus, + "verification_reason": rawReason, + "verification_outcome": rawStatus, } } diff --git a/internal/app/server.go b/internal/app/server.go index 6cbf516..2e46566 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -25,8 +25,10 @@ type Server struct { clock Clock ids IDGenerator - commonProxyURL string - longConnections *LongConnectionManager + commonProxyURL string + registrationProxyLeaseMode registrationProxyLeaseMode + proxyRuntimeLease *proxyRuntimeLeaseClient + longConnections *LongConnectionManager } func NewServer(store Store, runtime RuntimeState, runner ProtocolEngine, clock Clock, ids IDGenerator) *Server { @@ -45,6 +47,14 @@ func (s *Server) SetCommonProxyURL(common string) { s.commonProxyURL = strings.TrimSpace(common) } +func (s *Server) SetRegistrationProxyLeaseMode(mode string) { + s.registrationProxyLeaseMode = normalizeRegistrationProxyLeaseMode(mode) +} + +func (s *Server) SetProxyRuntimeLeaseClient(apiBase string, authToken string) { + s.proxyRuntimeLease = newProxyRuntimeLeaseClient(apiBase, authToken) +} + func (s *Server) RunLongConnections(ctx context.Context) error { if s == nil || s.longConnections == nil { return nil diff --git a/internal/app/server_messaging.go b/internal/app/server_messaging.go index b3ba5f9..d313c42 100644 --- a/internal/app/server_messaging.go +++ b/internal/app/server_messaging.go @@ -81,6 +81,11 @@ func (s *Server) receiveMessageBatch(ctx context.Context, req *waappv1.ReceiveMe if err := s.saveInboundMessagesForSession(ctx, session, result.Messages); err != nil { return &waappv1.ReceiveMessageBatchResponse{Session: session, Error: ToProtoError(err)}, nil } + for _, msg := range result.OTPMessages { + if err := s.store.SaveOTPMessage(ctx, msg); err != nil { + return &waappv1.ReceiveMessageBatchResponse{Session: session, Error: ToProtoError(err)}, nil + } + } if len(result.Contacts) > 0 { _ = s.store.SaveWAContacts(ctx, result.Contacts) } diff --git a/internal/app/server_registration.go b/internal/app/server_registration.go index 91bb016..ccfd976 100644 --- a/internal/app/server_registration.go +++ b/internal/app/server_registration.go @@ -43,10 +43,43 @@ func (s *Server) requestVerificationCode(ctx context.Context, req *waappv1.Reque } result := runner.RequestVerificationCode(ctx, EngineRegistrationInput{WAAccountID: waAccountID(account), ClientProfileID: profile.GetClientProfileId(), ProtocolProfileID: profile.GetProtocolProfileId(), AppVersion: s.clientProfileAppVersion(ctx, profile), Phone: account.GetPhone(), DeliveryMethod: method}) record := s.newVerificationCodeRequestRecord(account, profile, method, result) + challenge := result.AccountTransferChallenge + if challenge != nil { + challenge.VerificationRequestId = record.GetVerificationRequestId() + } if err := s.store.SaveVerificationRequest(ctx, record); err != nil { return &waappv1.RequestVerificationCodeResponse{Error: ToProtoError(err)}, nil } - return &waappv1.RequestVerificationCodeResponse{VerificationRequest: record, Error: record.GetLastError()}, nil + return &waappv1.RequestVerificationCodeResponse{VerificationRequest: record, AccountTransferChallenge: challenge, Error: record.GetLastError()}, nil +} + +func (s *Server) RefreshAccountTransferChallenge(ctx context.Context, req *waappv1.RefreshAccountTransferChallengeRequest) (*waappv1.RefreshAccountTransferChallengeResponse, error) { + if err := validateContext(req.GetContext()); err != nil { + return &waappv1.RefreshAccountTransferChallengeResponse{Error: ToProtoError(err)}, nil + } + verification, err := s.store.GetVerificationRequest(ctx, req.GetVerificationRequestId()) + if err != nil { + return &waappv1.RefreshAccountTransferChallengeResponse{Error: ToProtoError(err)}, nil + } + if verification.GetDeliveryMethod() != waappv1.VerificationDeliveryMethod_VERIFICATION_DELIVERY_METHOD_ACCOUNT_TRANSFER { + return &waappv1.RefreshAccountTransferChallengeResponse{Error: ToProtoError(NewError(waappv1.WaErrorCode_WA_ERROR_CODE_VALIDATION_FAILED, "verification request is not account transfer", false))}, nil + } + account, profile, err := s.waAccountAndProfile(ctx, verification.GetWaAccountId(), verification.GetClientProfileId()) + if err != nil { + return &waappv1.RefreshAccountTransferChallengeResponse{Error: ToProtoError(err)}, nil + } + result := s.runner.RefreshAccountTransferChallenge(ctx, EngineAccountTransferChallengeInput{ + EngineRegistrationInput: EngineRegistrationInput{ + WAAccountID: waAccountID(account), + ClientProfileID: profile.GetClientProfileId(), + ProtocolProfileID: profile.GetProtocolProfileId(), + AppVersion: s.clientProfileAppVersion(ctx, profile), + Phone: account.GetPhone(), + DeliveryMethod: verification.GetDeliveryMethod(), + }, + VerificationRequestID: verification.GetVerificationRequestId(), + }) + return &waappv1.RefreshAccountTransferChallengeResponse{AccountTransferChallenge: result.Challenge, Error: ToProtoError(result.Err)}, nil } func (s *Server) SubmitVerificationCode(ctx context.Context, req *waappv1.SubmitVerificationCodeRequest) (*waappv1.SubmitVerificationCodeResponse, error) { diff --git a/internal/app/tooling.go b/internal/app/tooling.go index c1d5234..de4ba7c 100644 --- a/internal/app/tooling.go +++ b/internal/app/tooling.go @@ -242,7 +242,7 @@ func (e *NativeEngine) BuildRegistrationRequest(ctx context.Context, req *waappv if kind != waappv1.RegistrationRequestKind_REGISTRATION_REQUEST_KIND_EXIST { params.set("method", firstNonEmpty(params.get("method"), methodName), false) } - wamsysCapture, err := e.wamsysProvider().RegistrationMaterial(ctx, wamsysMaterialInput{Capture: req.GetWamsysCapture(), Kind: kind, Phone: phone, State: state}) + wamsysCapture, err := e.wamsysProvider().RegistrationMaterial(ctx, wamsysMaterialInput{Capture: req.GetWamsysCapture(), Kind: kind, Phone: phone, State: state, Now: e.clock.Now()}) if err != nil { return nil, err } @@ -263,7 +263,7 @@ func (e *NativeEngine) BuildRegistrationRequest(ctx context.Context, req *waappv resp.Params = params.toProto(req.GetIncludeSensitiveValues()) resp.Plaintext = sensitiveOutput(plain, "registration-plaintext", req.GetIncludeSensitiveValues()) if req.GetEncryptRequest() { - if err := ensureNativeSoftwareAttestation(&state); err != nil { + if err := ensureNativeSoftwareAttestation(&state, e.clock.Now()); err != nil { return nil, err } envelope, err := buildWASafeEnvelope([]byte(plain), defaultWASafeServerPublicKeyHex, state.Attestation) diff --git a/internal/app/wamsys_gpia.go b/internal/app/wamsys_gpia.go index c7c38ae..51264e6 100644 --- a/internal/app/wamsys_gpia.go +++ b/internal/app/wamsys_gpia.go @@ -13,19 +13,22 @@ import ( ) const ( - nativeGPIAErrorCode = -2 - nativeGPIAPackageName = "com.whatsapp" - nativeGPIASourceSize = "141711087" - nativeGPIASourceDigest = "b3BumN//vPO0GypN5i+xXvNznZyGiXOT99Jip70omCg=" - nativeGPIACertDigest = "OKD31QX+GP7GT780Psqq8xDb15k=" - nativeGPIAClassesDigest = "qoblldcHz4lA84Sgs1QLZWPpd6YKG25zf0GwJZdTHXk=" - nativeGPIANativeLibDigest = "G9McgxRaSjtq92o7zx0fbf3Ak7+SPmxxNyvNXS01hlM=" - nativeGPIADataSODigest = "0j9kw9djlCtmCCavV7go2wwge+2os853ubiE7F7Dew4=" + nativeGPIAErrorCode = -2 + nativeGPIAPackageName = "com.whatsapp" + nativeGPIASourceSize = "141711087" + nativeGPIASourceDigest = "b3BumN//vPO0GypN5i+xXvNznZyGiXOT99Jip70omCg=" + // Full app-release APK SHA-256/Base64; native bootstrap stores this in + // global 0xc45a48 for GPIA sha256/_is. + nativeGPIASourceFullDigest = "vJrNuYDSuWUZ87O1W5+xs/2g74mwPA2JO+dkqjlJZG4=" + nativeGPIACertDigest = "OKD31QX+GP7GT780Psqq8xDb15k=" + nativeGPIAClassesDigest = "qoblldcHz4lA84Sgs1QLZWPpd6YKG25zf0GwJZdTHXk=" + nativeGPIANativeLibDigest = "G9McgxRaSjtq92o7zx0fbf3Ak7+SPmxxNyvNXS01hlM=" + nativeGPIADataSODigest = "SrL/HHWX9VAinH9OV4eloGSQLWSsUug93h5YGGad17s=" ) type nativeGPIAMaterial struct { Primary string - TokenCompact string + CodeCompact string DeviceCompact string } @@ -36,27 +39,30 @@ type nativeGPIAJSONField struct { func buildNativeGPIAErrorMaterial(input wamsysMaterialInput) (nativeGPIAMaterial, error) { sourceDir := nativeGPIASourceDir(input) - pathDigest := nativeGPIASHA256Base64([]byte(sourceDir)) keySource := nativeGPIAKeySource(input.State) - primary, err := encryptNativeGPIAJSON(keySource, []nativeGPIAJSONField{ + primaryFields := []nativeGPIAJSONField{ {Key: "sizeInBytes", Value: nativeGPIASourceSize}, {Key: "packageName", Value: nativeGPIAPackageName}, {Key: "code", Value: nativeGPIAErrorCode}, {Key: "shatr", Value: nativeGPIASourceDigest}, {Key: "p", Value: sourceDir}, {Key: "cert", Value: nativeGPIACertDigest}, - {Key: "sha256", Value: pathDigest}, - }) + {Key: "sha256", Value: nativeGPIASourceFullDigest}, + } + logNativeGPIAPlaintextShape(input, "primary_long", keySource, primaryFields) + primary, err := encryptNativeGPIAJSON(keySource, primaryFields) if err != nil { return nativeGPIAMaterial{}, err } - tokenCompact, err := encryptNativeGPIAJSON(keySource, []nativeGPIAJSONField{ + codeCompactFields := []nativeGPIAJSONField{ {Key: "_ic", Value: nativeGPIAErrorCode}, - }) + } + logNativeGPIAPlaintextShape(input, "token_compact", keySource, codeCompactFields) + codeCompact, err := encryptNativeGPIAJSON(keySource, codeCompactFields) if err != nil { return nativeGPIAMaterial{}, err } - deviceCompact, err := encryptNativeGPIAJSON(keySource, []nativeGPIAJSONField{ + deviceCompactFields := []nativeGPIAJSONField{ {Key: "_dh", Value: nativeGPIAClassesDigest}, {Key: "_iln", Value: nativeGPIADataSODigest}, {Key: "_isb", Value: nativeGPIASourceSize}, @@ -66,12 +72,14 @@ func buildNativeGPIAErrorMaterial(input wamsysMaterialInput) (nativeGPIAMaterial {Key: "_ln", Value: nativeGPIANativeLibDigest}, {Key: "_ist", Value: nativeGPIASourceDigest}, {Key: "_icr", Value: nativeGPIACertDigest}, - {Key: "_is", Value: pathDigest}, - }) + {Key: "_is", Value: nativeGPIASourceFullDigest}, + } + logNativeGPIAPlaintextShape(input, "device_compact", keySource, deviceCompactFields) + deviceCompact, err := encryptNativeGPIAJSON(keySource, deviceCompactFields) if err != nil { return nativeGPIAMaterial{}, err } - return nativeGPIAMaterial{Primary: primary, TokenCompact: tokenCompact, DeviceCompact: deviceCompact}, nil + return nativeGPIAMaterial{Primary: primary, CodeCompact: codeCompact, DeviceCompact: deviceCompact}, nil } func nativeGPIADisplayID(state nativeState) string { @@ -80,29 +88,18 @@ func nativeGPIADisplayID(state nativeState) string { } func nativeGPIASourceDir(input wamsysMaterialInput) string { - first := nativeGPIAInstallSegment(input, "source-dir-a") - second := nativeGPIAInstallSegment(input, "source-dir-b") - return "/data/app/~~" + first + "==/com.whatsapp-" + second + "==/base.apk" + return nativeStableGPIASourceDir(input.State) } -func nativeGPIAInstallSegment(input wamsysMaterialInput, label string) string { - seed := strings.Join([]string{ - "byte-v-forge-wa-gpia-source-dir/v1", - label, - phoneCC(input.Phone), - phoneNational(input.Phone), - input.State.Profile.PhoneSHA256, - input.State.Profile.FDID, - input.State.Profile.ExpIDUUID, - input.State.AuthKey, - }, "|") - sum := sha256.Sum256([]byte(seed)) - return base64.RawURLEncoding.EncodeToString(sum[:16]) +func nativeStableGPIASourceDir(state nativeState) string { + first := nativeStableInstallToken(state, "source-dir-prefix") + second := nativeStableInstallToken(state, "source-dir-package") + return "/data/app/~~" + first + "==/com.whatsapp-" + second + "==/base.apk" } -func nativeGPIASHA256Base64(value []byte) string { - sum := sha256.Sum256(value) - return base64.StdEncoding.EncodeToString(sum[:]) +func nativeStableInstallToken(state nativeState, label string) string { + sum := sha256.Sum256([]byte(nativeStableRuntimeSeed(state, label))) + return base64.RawURLEncoding.EncodeToString(sum[:16]) } func nativeGPIAKeySource(state nativeState) string { @@ -125,6 +122,10 @@ func encryptNativeGPIAJSON(keySource string, fields []nativeGPIAJSONField) (stri if err != nil { return "", err } + return encryptNativeGPIAData(keySource, plaintext) +} + +func encryptNativeGPIAData(keySource string, plaintext []byte) (string, error) { key := sha256.Sum256([]byte(keySource)) iv := randomBytes(aes.BlockSize) ciphertext, err := aesCBCPKCS7Encrypt(plaintext, key[:], iv) @@ -163,7 +164,7 @@ func renderNativeGPIAJSONObject(fields []nativeGPIAJSONField) ([]byte, error) { func renderNativeGPIAJSONValue(value any) ([]byte, error) { switch v := value.(type) { case string: - return json.Marshal(v) + return renderNativeGPIAJSONString(v) case int: return []byte(strconv.Itoa(v)), nil case int64: @@ -176,3 +177,11 @@ func renderNativeGPIAJSONValue(value any) ([]byte, error) { return nil, fmt.Errorf("unsupported native GPIA JSON value type %T", value) } } + +func renderNativeGPIAJSONString(value string) ([]byte, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + return []byte(strings.ReplaceAll(string(encoded), `/`, `\/`)), nil +} diff --git a/internal/app/wamsys_material.go b/internal/app/wamsys_material.go index bcf1add..5af63f7 100644 --- a/internal/app/wamsys_material.go +++ b/internal/app/wamsys_material.go @@ -2,12 +2,12 @@ package app import ( "context" - "crypto/hmac" "crypto/sha256" - "encoding/base64" "encoding/binary" + "encoding/hex" "fmt" "strings" + "time" waappv1 "github.com/byte-v-forge/wa-app/gen/go/byte/v/forge/waapp/v1" ) @@ -17,6 +17,7 @@ type wamsysMaterialInput struct { Kind waappv1.RegistrationRequestKind Phone *waappv1.PhoneTarget State nativeState + Now time.Time } type wamsysMaterialProvider interface { @@ -26,8 +27,22 @@ type wamsysMaterialProvider interface { type localWamsysMaterialProvider struct{} const ( - nativeWamsysSmallByteLength = 32 - nativeWamsysGAByteLength = 64 + // Native WAMSYS records path ages as time-now minus source/data/external + // filesystem mtimes. Fresh registration captures show data-dir age as a + // short running-session value, source-dir slightly older, and external-dir + // older than both. Do not bind these values to a long-lived profile age. + nativeWamsysAgeBucketSeconds = int64(300) + nativeWamsysFreshProfileMaxAgeSeconds = int64(600) + nativeWamsysDataAgeMinSeconds = int64(30) + nativeWamsysDataAgeBaseSeconds = int64(54) + nativeWamsysDataAgeSpreadSeconds = uint64(36) + nativeWamsysSourceAheadBaseSeconds = int64(8) + nativeWamsysSourceAheadSpreadSeconds = uint64(24) + nativeWamsysExternalAheadBaseSeconds = int64(8400) + nativeWamsysExternalAheadSpreadSeconds = uint64(1800) + // SHA-256/Base64 over Android 11 PackageInfo.requestedPermissions after + // native lexicographic sort and delimiter-free concatenation. + nativeWamsysRequestedPermissionsDigest = "NNj5BoWX+yvZBYEY46Ze+Ad6Ykk0Z27FjgSysvkzzCU=" ) func (localWamsysMaterialProvider) RegistrationMaterial(ctx context.Context, input wamsysMaterialInput) (*waappv1.WamsysCapture, error) { @@ -49,50 +64,154 @@ func buildLocalWamsysCapture(input wamsysMaterialInput) (*waappv1.WamsysCapture, if err != nil { return nil, err } + ga, err := buildLocalWamsysGA(input) + if err != nil { + return nil, err + } return &waappv1.WamsysCapture{MapParams: []*waappv1.WamsysMapParam{ {Key: "gpia", Value: []byte(gpia.Primary)}, - {Key: "_ge", Value: []byte(`{"sb":false,"sv":false}`)}, + {Key: "_ga", Value: ga}, {Key: "_gi", Value: []byte(gpia.DeviceCompact)}, - {Key: "_gg", Value: []byte(gpia.TokenCompact)}, - {Key: "_gp", Value: localWamsysBase64Bytes(deriveLocalWamsysBytes(input, "_gp", nativeWamsysSmallByteLength))}, - {Key: "_ga", Value: buildLocalWamsysGA(input)}, - {Key: "aid", Value: localWamsysBase64Bytes(deriveLocalWamsysBytes(input, "aid", nativeWamsysSmallByteLength))}, + {Key: "_gp", Value: []byte(nativeWamsysRequestedPermissionsDigest)}, + {Key: "_ge", Value: []byte(`{"sb":false,"sv":false}`)}, + {Key: "aid", Value: []byte(nativeWamsysAID(input.State))}, + {Key: "_gg", Value: []byte(gpia.CodeCompact)}, }}, nil } -func buildLocalWamsysGA(input wamsysMaterialInput) []byte { - bi := base64.StdEncoding.EncodeToString(deriveLocalWamsysBytes(input, "_ga.bi", nativeWamsysGAByteLength)) - return []byte(fmt.Sprintf(`{"ai":141,"ae":0,"ap":172,"bi":%q,"mp":false,"mu":false}`, bi)) +func nativeWamsysAID(state nativeState) string { + sum := sha256.Sum256([]byte(nativeSyntheticAndroidID(state))) + return b64Std(sum[:]) +} + +func nativeSyntheticAndroidID(state nativeState) string { + sum := sha256.Sum256([]byte(strings.Join([]string{ + "byte-v-forge-wa-wamsys-android-id/v1", + state.Profile.PhoneSHA256, + state.Profile.FDID, + state.Profile.ExpIDUUID, + state.Profile.AccessSessionIDUUID, + state.Profile.IDHex, + state.Profile.BackupTokenHex, + state.AuthKey, + }, "|"))) + return hex.EncodeToString(sum[:8]) +} + +func buildLocalWamsysGA(input wamsysMaterialInput) ([]byte, error) { + keySource := nativeGPIAKeySource(input.State) + bootID := nativeWamsysBootID(input) + bi, err := encryptNativeGPIAData(keySource, []byte(bootID)) + if err != nil { + return nil, err + } + pathAges := nativeWamsysPathAges(input) + fields := []nativeGPIAJSONField{ + {Key: "bi", Value: bi}, + {Key: "ap", Value: pathAges.Source}, + {Key: "ai", Value: pathAges.Data}, + {Key: "mp", Value: false}, + {Key: "ae", Value: pathAges.External}, + {Key: "mu", Value: false}, + } + logNativeWamsysGAPlaintextShape(input, keySource, bootID, fields) + return renderNativeGPIAJSONObject(fields) +} + +type nativeWamsysPathAgeSet struct { + Source int64 + Data int64 + External int64 +} + +func nativeWamsysPathAges(input wamsysMaterialInput) nativeWamsysPathAgeSet { + dataAge := nativeWamsysDataPathAgeSeconds(input) + sourceAge := dataAge + nativeWamsysRuntimeOffset(input, "source-data-age-delta", nativeWamsysSourceAheadBaseSeconds, nativeWamsysSourceAheadSpreadSeconds) + externalAge := dataAge + nativeWamsysRuntimeOffset(input, "external-data-age-delta", nativeWamsysExternalAheadBaseSeconds, nativeWamsysExternalAheadSpreadSeconds) + return nativeWamsysPathAgeSet{Source: sourceAge, Data: dataAge, External: externalAge} +} + +func nativeWamsysDataPathAgeSeconds(input wamsysMaterialInput) int64 { + createdUnix := nativeWamsysStateCreatedUnix(input.State) + nowUnix := nativeWamsysNow(input).Unix() + if createdUnix > 0 { + age := nowUnix - createdUnix + if age >= nativeWamsysDataAgeMinSeconds && age <= nativeWamsysFreshProfileMaxAgeSeconds { + return age + } + } + return nativeWamsysRuntimeOffset(input, "data-dir-age", nativeWamsysDataAgeBaseSeconds, nativeWamsysDataAgeSpreadSeconds) } -func localWamsysBase64Bytes(value []byte) []byte { - return []byte(base64.StdEncoding.EncodeToString(value)) +func nativeWamsysStateCreatedUnix(state nativeState) int64 { + if state.Profile.CreatedAtUnix > 0 { + return state.Profile.CreatedAtUnix + } + return state.CreatedAtUnix } -func deriveLocalWamsysBytes(input wamsysMaterialInput, label string, length int) []byte { +func nativeWamsysRuntimeOffset(input wamsysMaterialInput, label string, base int64, spread uint64) int64 { + if spread == 0 { + return base + } + bucket := nativeWamsysNow(input).Unix() / nativeWamsysAgeBucketSeconds seed := strings.Join([]string{ - "byte-v-forge-wa-wamsys-precision/v1", + "byte-v-forge-wa-wamsys-runtime-path-age/v1", label, + fmt.Sprintf("%d", input.Kind), + fmt.Sprintf("%d", bucket), phoneCC(input.Phone), phoneNational(input.Phone), input.State.Profile.PhoneSHA256, input.State.Profile.FDID, - input.State.Profile.ExpIDUUID, input.State.Profile.AccessSessionIDUUID, - input.State.Profile.IDHex, - input.State.Profile.BackupTokenHex, input.State.AuthKey, - input.State.KeyBundle.IdentityPublic, }, "|") - key := sha256.Sum256([]byte(seed)) - out := make([]byte, 0, length) - for counter := uint32(0); len(out) < length; counter++ { - mac := hmac.New(sha256.New, key[:]) - _, _ = mac.Write([]byte(label)) - _, _ = mac.Write(binary.BigEndian.AppendUint32(nil, counter)) - out = append(out, mac.Sum(nil)...) + sum := sha256.Sum256([]byte(seed)) + return base + int64(binary.BigEndian.Uint64(sum[:8])%spread) +} + +func nativeStableRuntimeSeed(state nativeState, label string) string { + return strings.Join([]string{ + "byte-v-forge-wa-native-runtime/v1", + strings.TrimSpace(label), + state.CC, + state.Phone, + state.Profile.PhoneSHA256, + state.Profile.FDID, + state.Profile.ExpIDUUID, + state.Profile.AccessSessionIDUUID, + state.AuthKey, + state.KeyBundle.IdentityPublic, + state.ChatStatic.Public, + }, "|") +} + +func nativeWamsysNow(input wamsysMaterialInput) time.Time { + now := input.Now + if now.IsZero() { + now = time.Now() } - return out[:length] + return now.UTC() +} + +func nativeWamsysBootID(input wamsysMaterialInput) string { + return nativeStableWamsysBootID(input.State) +} + +func nativeStableWamsysBootID(state nativeState) string { + sum := sha256.Sum256([]byte(nativeStableRuntimeSeed(state, "boot-id"))) + id := append([]byte(nil), sum[:16]...) + id[6] = (id[6] & 0x0f) | 0x40 + id[8] = (id[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(id) + return strings.Join([]string{ + encoded[0:8], + encoded[8:12], + encoded[12:16], + encoded[16:20], + encoded[20:32], + }, "-") } func (e *NativeEngine) applyRuntimeWamsys( @@ -103,15 +222,19 @@ func (e *NativeEngine) applyRuntimeWamsys( params map[string]string, rawKeys map[string]struct{}, ) error { - capture, err := e.wamsysProvider().RegistrationMaterial(ctx, wamsysMaterialInput{Kind: kind, Phone: phone, State: state}) + capture, err := e.wamsysProvider().RegistrationMaterial(ctx, wamsysMaterialInput{Kind: kind, Phone: phone, State: state, Now: e.clock.Now()}) if err != nil { return err } - applyOpaqueWamsysMapParams(params, rawKeys, capture) + excluded := map[string]struct{}{} + if !nativeShouldSendRegistrationGPIA(state) { + excluded["gpia"] = struct{}{} + } + applyOpaqueWamsysMapParams(params, rawKeys, capture, excluded) return nil } -func applyOpaqueWamsysMapParams(params map[string]string, rawKeys map[string]struct{}, capture *waappv1.WamsysCapture) { +func applyOpaqueWamsysMapParams(params map[string]string, rawKeys map[string]struct{}, capture *waappv1.WamsysCapture, excluded map[string]struct{}) { if capture == nil { return } @@ -120,6 +243,9 @@ func applyOpaqueWamsysMapParams(params map[string]string, rawKeys map[string]str if !isOpaqueWamsysMapKey(key) { continue } + if _, skip := excluded[key]; skip { + continue + } params[key] = pctBytes(item.GetValue()) rawKeys[key] = struct{}{} } diff --git a/internal/config/config.go b/internal/config/config.go index a733d42..97161b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,14 +7,17 @@ import ( ) type Config struct { - DashboardAuthPass string `env:"WA_APP_AUTH_PASSWORD"` - GRPCListenAddr string `env:"WA_APP_LISTEN_ADDR"` - DashboardHTTPAddr string `env:"WA_APP_DASHBOARD_HTTP_ADDR"` - DashboardStaticDir string `env:"WA_APP_DASHBOARD_STATIC_DIR"` - DataDir string `env:"WA_APP_DATA_DIR"` - CommonProxy string `env:"WA_COMMON_PROXY"` - PGDSN string `env:"WA_APP_PG_DSN"` - RedisURL string `env:"WA_APP_REDIS_URL"` + DashboardAuthPass string `env:"WA_APP_AUTH_PASSWORD"` + GRPCListenAddr string `env:"WA_APP_LISTEN_ADDR"` + DashboardHTTPAddr string `env:"WA_APP_DASHBOARD_HTTP_ADDR"` + DashboardStaticDir string `env:"WA_APP_DASHBOARD_STATIC_DIR"` + DataDir string `env:"WA_APP_DATA_DIR"` + CommonProxy string `env:"WA_COMMON_PROXY"` + RegistrationProxyLeaseMode string `env:"WA_REGISTRATION_PROXY_LEASE_MODE"` + ProxyRuntimeAPI string `env:"PROXY_RUNTIME_API_BASE_URL"` + ProxyRuntimeToken string `env:"PROXY_RUNTIME_SERVICE_AUTH_TOKEN"` + PGDSN string `env:"WA_APP_PG_DSN"` + RedisURL string `env:"WA_APP_REDIS_URL"` } func Load() Config { diff --git a/proto/byte/v/forge/waapp/v1/extraction.proto b/proto/byte/v/forge/waapp/v1/extraction.proto index 4fd9826..34379d2 100644 --- a/proto/byte/v/forge/waapp/v1/extraction.proto +++ b/proto/byte/v/forge/waapp/v1/extraction.proto @@ -80,6 +80,7 @@ message OtpMessage { SensitiveText otp = 9; google.protobuf.Timestamp received_at = 10; AuditStamp audit = 11; + google.protobuf.Timestamp expires_at = 12; } message DecryptMessageRequest { diff --git a/proto/byte/v/forge/waapp/v1/registration.proto b/proto/byte/v/forge/waapp/v1/registration.proto index 266617c..aab2fc6 100644 --- a/proto/byte/v/forge/waapp/v1/registration.proto +++ b/proto/byte/v/forge/waapp/v1/registration.proto @@ -16,6 +16,7 @@ option php_namespace = "ByteVForge\\WaApp\\V1"; service WaRegistrationService { rpc ProbeAccount(ProbeAccountRequest) returns (ProbeAccountResponse); rpc RequestVerificationCode(RequestVerificationCodeRequest) returns (RequestVerificationCodeResponse); + rpc RefreshAccountTransferChallenge(RefreshAccountTransferChallengeRequest) returns (RefreshAccountTransferChallengeResponse); rpc SubmitVerificationCode(SubmitVerificationCodeRequest) returns (SubmitVerificationCodeResponse); rpc GetRegistration(GetRegistrationRequest) returns (GetRegistrationResponse); rpc GetLoginState(GetLoginStateRequest) returns (GetLoginStateResponse); @@ -138,6 +139,18 @@ message VerificationCodeRequestRecord { repeated VerificationMethodStatus method_statuses = 11; } +message AccountTransferChallenge { + string verification_request_id = 1; + int32 code_count = 2; + int32 current_code_index = 3; + int32 current_code_length = 4; + google.protobuf.Duration rotation_interval = 5; + google.protobuf.Timestamp requested_at = 6; + google.protobuf.Timestamp expires_at = 7; + // Sensitive QR/deeplink payload. It carries the current account-transfer token. + SensitiveText qr_deeplink = 8; +} + message RegisteredIdentity { string registered_identity_id = 1; string wa_account_id = 2; @@ -208,6 +221,7 @@ message RequestVerificationCodeRequest { message RequestVerificationCodeResponse { VerificationCodeRequestRecord verification_request = 1; WaError error = 2; + AccountTransferChallenge account_transfer_challenge = 3; } message SubmitVerificationCodeRequest { @@ -220,6 +234,16 @@ message SubmitVerificationCodeRequest { } } +message RefreshAccountTransferChallengeRequest { + RequestContext context = 1; + string verification_request_id = 2; +} + +message RefreshAccountTransferChallengeResponse { + AccountTransferChallenge account_transfer_challenge = 1; + WaError error = 2; +} + message SubmitVerificationCodeResponse { RegistrationRecord registration = 1; WaError error = 2; diff --git a/scripts/wa_code_factor_suite.py b/scripts/wa_code_factor_suite.py new file mode 100755 index 0000000..95d7932 --- /dev/null +++ b/scripts/wa_code_factor_suite.py @@ -0,0 +1,781 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from dataclasses import dataclass, field, replace +from pathlib import Path +import random +import requests +import string +import time +from typing import Any +from urllib.parse import quote + +import wa_code_param_probe as probe +from wa_code_random_device_experiment import DeviceProfile, build_device + +ABPROP_URL = "https://y9yrsygcg6.execute-api.us-east-1.amazonaws.com/s/s?_=/v2/reg_onboard_abprop&" + + +@dataclass(frozen=True) +class FactorArm: + group: str + label: str + device_label: str = "xiaomi-a11" + transport: str = "requests" + patches: tuple[str, ...] = () + sets: tuple[str, ...] = () + omits: tuple[str, ...] = () + envelope: str = "signed" + preflight: str = "" + stable_install: bool = False + prefix: str = "" + app_version: str = "2.26.23.71" + + +@dataclass +class SuiteArgs: + proxy: str + timeout: float + show_fields: bool = False + show_response: bool = False + dry_run: bool = False + variant: str = "current" + set_param: list[str] = field(default_factory=list) + omit: list[str] = field(default_factory=list) + unsigned: bool = False + empty_h: bool = False + user_agent: str = "" + device_display_id: str = "" + device_ram: str = "" + transport: str = "requests" + + +@dataclass(frozen=True) +class ProxyRuntimeLease: + lease_id: str + account_id: str + purpose: str + proxy_url: str + listener_id: str = "" + exit_country: str = "" + exit_state: str = "" + exit_city: str = "" + + +class ProxyRuntimeLeaseClient: + def __init__(self, api_base: str, auth_token: str, timeout: float, egress_host: str, egress_port: int, egress_scheme: str) -> None: + self._api_base = api_base.rstrip("/") + self._timeout = max(timeout, 1) + self._egress_host = egress_host.strip() + self._egress_port = egress_port + self._egress_scheme = egress_scheme.strip() + self._session = requests.Session() + self._session.trust_env = False + if auth_token: + self._session.headers.update({"Authorization": "Bearer " + auth_token}) + + def acquire(self, account_id: str, purpose: str, ttl_seconds: int, job_key: str) -> ProxyRuntimeLease: + labels = { + "purpose": "wa-app-probe", + "job_id": job_key, + "session_id": probe.short_hash(job_key), + } + payload = { + "accountId": account_id, + "purpose": purpose, + "forceNew": True, + "policy": { + "mode": "PROXY_SESSION_MODE_STICKY", + "stickyTtl": f"{max(30, int(ttl_seconds or 120))}s", + "upstreamKind": "PROXY_UPSTREAM_KIND_DYNAMIC_IP", + "rotationMode": "PROXY_ROTATION_MODE_STICKY_SESSION", + "labels": labels, + }, + "selectionPolicy": {"purpose": purpose, "maxAttempts": 1}, + } + response = self._session.post(self._api_base + "/leases/acquire", json=payload, timeout=self._timeout) + response.raise_for_status() + body = response.json() or {} + lease = dict_value(body, "lease") + egress = dict_value(body, "egress") or dict_value(lease, "egress") + lease_id = text_value(lease, "leaseId", "lease_id") or text_value(dict_value(egress, "labels"), "lease_id", "leaseId") + if not lease_id: + raise RuntimeError("proxy-runtime lease_id is empty") + lease_account_id = text_value(lease, "accountId", "account_id") or account_id + lease_purpose = text_value(lease, "purpose") or purpose + return ProxyRuntimeLease( + lease_id=lease_id, + account_id=lease_account_id, + purpose=lease_purpose, + proxy_url=self._proxy_url(egress), + listener_id=proxy_runtime_listener_id(lease, body), + exit_country=proxy_runtime_exit_text(("exitCountry", "exit_country", "countryCode", "country_code"), egress, lease, body).upper(), + exit_state=proxy_runtime_exit_text(("exitState", "exit_state", "region", "state"), egress, lease, body).upper(), + exit_city=proxy_runtime_exit_text(("exitCity", "exit_city", "city"), egress, lease, body), + ) + + def release(self, lease: ProxyRuntimeLease) -> str: + payload = {"lease_id": lease.lease_id, "account_id": lease.account_id, "purpose": lease.purpose} + response = self._session.post(self._api_base + "/leases/release", json=payload, timeout=self._timeout) + response.raise_for_status() + return "released" + + def close(self) -> None: + self._session.close() + + def _proxy_url(self, egress: dict[str, Any]) -> str: + host = self._egress_host or text_value(egress, "host") + port = self._egress_port or int(text_value(egress, "port") or "0") + if not host or port <= 0: + raise RuntimeError("proxy-runtime lease egress is invalid") + protocol = text_value(egress, "protocol").upper() + scheme = self._egress_scheme or ("socks5h" if "SOCKS5" in protocol else "http") + labels = dict_value(egress, "labels") + username = text_value(labels, "proxy_username", "proxyUsername") + password = text_value(labels, "proxy_password", "proxyPassword") + auth = "" + if username or password: + auth = f"{quote(username, safe='-._~')}:{quote(password, safe='-._~')}@" + return f"{scheme}://{auth}{host}:{port}" + + +def dict_value(item: dict[str, Any], key: str) -> dict[str, Any]: + value = item.get(key) if isinstance(item, dict) else None + return value if isinstance(value, dict) else {} + + +def text_value(item: dict[str, Any], *keys: str) -> str: + if not isinstance(item, dict): + return "" + for key in keys: + value = item.get(key) + if value is not None: + return str(value).strip() + return "" + + +def proxy_runtime_listener_id(*items: dict[str, Any]) -> str: + queue = list(items) + while queue: + item = queue.pop(0) + if not isinstance(item, dict): + continue + value = text_value(item, "listenerId", "listener_id") + if value: + return value + for key in ("listener", "egressListener", "egress_listener"): + nested = item.get(key) + if isinstance(nested, dict): + queue.append(nested) + labels = item.get("labels") + if isinstance(labels, dict): + queue.append(labels) + return "" + + +def proxy_runtime_exit_text(keys: tuple[str, ...], *items: dict[str, Any]) -> str: + queue = list(items) + while queue: + item = queue.pop(0) + if not isinstance(item, dict): + continue + value = text_value(item, *keys) + if value: + return value + labels = item.get("labels") + if isinstance(labels, dict): + queue.append(labels) + return "" + + +def lease_log_fields(lease: ProxyRuntimeLease) -> dict[str, Any]: + out: dict[str, Any] = { + "lease_hash": probe.short_hash(lease.lease_id), + } + if lease.listener_id: + out["listener_hash"] = probe.short_hash(lease.listener_id) + if lease.exit_country: + out["lease_exit_country"] = lease.exit_country + if lease.exit_state: + out["lease_exit_state"] = lease.exit_state + if lease.exit_city: + out["lease_exit_city"] = lease.exit_city + return out + + +def classify(row: dict[str, Any]) -> str: + if row.get("error"): + return "transport_error" + status = str(row.get("status") or "").lower() + reason = str(row.get("reason") or "").lower() + if status in {"sent", "ok"}: + return "sent" + if reason == "no_routes": + return "no_routes" + if reason == "blocked": + return "blocked" + if reason == "too_recent": + return "too_recent" + if reason == "bad_token": + return "bad_token" + if row.get("request_failed"): + return "request_failed" + if status == "fail": + return "other_fail" + return "unknown" + + +def random_colombia_phone_with_prefix(prefix: str) -> tuple[str, str]: + return "57", prefix + "".join(random.choice(string.digits) for _ in range(7)) + + +def next_phone(arm: FactorArm) -> tuple[str, str]: + if arm.prefix: + return random_colombia_phone_with_prefix(arm.prefix) + return probe.random_colombia_phone() + + +def device_for_arm(arm: FactorArm) -> DeviceProfile: + return build_device(arm.device_label) + + +def args_for_arm(base: argparse.Namespace, arm: FactorArm) -> SuiteArgs: + device = device_for_arm(arm) + user_agent = device.user_agent + if arm.app_version != "2.26.23.71": + user_agent = user_agent.replace("WhatsApp/2.26.23.71", "WhatsApp/" + arm.app_version, 1) + return SuiteArgs( + proxy=base.proxy, + timeout=base.timeout, + show_fields=base.show_fields, + show_response=base.show_response, + dry_run=base.dry_run, + set_param=list(arm.sets), + omit=list(arm.omits), + unsigned=arm.envelope == "unsigned", + empty_h=arm.envelope == "empty-h", + user_agent=user_agent, + device_display_id=device.display_id, + device_ram=device.ram_gib, + transport=arm.transport, + ) + + +def config_for_arm(arm: FactorArm, args: SuiteArgs) -> probe.ShapeConfig: + config = probe.config_for_variant(args.variant) + for patch in arm.patches: + config = probe.apply_patch_name(config, patch) + return probe.apply_cli_config_overrides(config, args) + + +def stable_material(base_material: probe.ProbeMaterial, fresh: probe.ProbeMaterial) -> probe.ProbeMaterial: + return replace( + fresh, + fdid=base_material.fdid, + expid=base_material.expid, + expid_uuid=base_material.expid_uuid, + access_session_id=base_material.access_session_id, + access_session_id_uuid=base_material.access_session_id_uuid, + id_raw=base_material.id_raw, + backup_token_raw=base_material.backup_token_raw, + authkey=base_material.authkey, + key_bundle=base_material.key_bundle, + advertising_id=base_material.advertising_id, + created_at_unix=base_material.created_at_unix, + ) + + +def build_material(repo_root: Path, arm: FactorArm, stable_cache: dict[str, probe.ProbeMaterial]) -> probe.ProbeMaterial: + cc, national = next_phone(arm) + fresh = probe.new_probe_material(repo_root, cc, national) + if not arm.stable_install: + return fresh + base = stable_cache.get(arm.label) + if base is None: + stable_cache[arm.label] = fresh + return fresh + return stable_material(base, fresh) + + +def build_abprop_params(material: probe.ProbeMaterial) -> list[probe.Param]: + params: list[probe.Param] = [] + probe.add_param(params, "cc", material.cc) + probe.add_param(params, "in", material.national) + probe.add_param(params, "lg", "en") + probe.add_param(params, "lc", "US") + probe.add_param(params, "fdid", material.fdid) + probe.add_param(params, "expid", material.expid) + probe.add_param(params, "access_session_id", material.access_session_id) + probe.add_param(params, "authkey", material.authkey) + for key in ["e_ident", "e_keytype", "e_regid", "e_skey_id", "e_skey_val", "e_skey_sig"]: + probe.add_param(params, key, material.key_bundle[key]) + return params + + +def post_abprop(material: probe.ProbeMaterial, args: SuiteArgs) -> dict[str, Any]: + plain = probe.render_plain(build_abprop_params(material)) + envelope = probe.build_signed_wasafe_envelope(plain, material, "unsigned") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": args.user_agent, + "WaMsysRequest": "1", + "X-Forwarded-Host": "v.whatsapp.net", + } + try: + status, parsed = probe.post_form(args.transport, ABPROP_URL, headers, envelope.body, args.proxy, args.timeout) + if not isinstance(parsed, dict): + parsed = {"raw": parsed} + return { + "ab_http_status": status, + "ab_status": parsed.get("status"), + "ab_reason": parsed.get("reason") or parsed.get("failure_reason"), + "ab_has_hash": bool(parsed.get("ab_hash")), + "ab_has_exp_cfg": bool(parsed.get("exp_cfg")), + } + except Exception as exc: # noqa: BLE001 - CLI probe must summarize failures. + return {"ab_error": probe.sanitize_text(str(exc), args.proxy)} + + +def run_arm_once( + repo_root: Path, + base_args: argparse.Namespace, + arm: FactorArm, + stable_cache: dict[str, probe.ProbeMaterial], + lease_client: ProxyRuntimeLeaseClient | None = None, +) -> dict[str, Any]: + material = build_material(repo_root, arm, stable_cache) + args = args_for_arm(base_args, arm) + config = config_for_arm(arm, args) + preflight_result: dict[str, Any] = {} + lease: ProxyRuntimeLease | None = None + release_status = "" + try: + if lease_client is not None and not base_args.dry_run: + lease = lease_client.acquire( + base_args.proxy_runtime_account_id, + base_args.proxy_runtime_purpose, + base_args.proxy_runtime_ttl, + f"{base_args.run_id}:{arm.label}:{material.e164}:{time.time_ns()}", + ) + args.proxy = lease.proxy_url + if arm.preflight == "abprop": + preflight_result = post_abprop(material, args) + row = probe.post_code(material, config, args) + row.update(preflight_result) + if lease is not None: + row.update(lease_log_fields(lease)) + except Exception as exc: # noqa: BLE001 - CLI probe must summarize lease and network failures. + row = {"error": probe.sanitize_text(str(exc), args.proxy), "outcome": "transport_error"} + if lease is not None: + row.update(lease_log_fields(lease)) + finally: + if lease is not None and lease_client is not None: + try: + release_status = lease_client.release(lease) + except Exception as exc: # noqa: BLE001 - release failure must not hide request outcome. + release_status = "release_error:" + probe.sanitize_text(str(exc), args.proxy) + if release_status: + row["lease_release"] = release_status + row["group"] = arm.group + row["label"] = arm.label + row["transport"] = arm.transport + row["device_label"] = arm.device_label + row["app_version"] = arm.app_version + row["outcome"] = row.get("outcome") or classify(row) + if arm.prefix: + row["prefix"] = arm.prefix + if arm.stable_install: + row["stable_install"] = True + return row + + +def rate(numerator: int, denominator: int) -> float | None: + if denominator <= 0: + return None + return round(numerator / denominator, 4) + + +def summarize(rows: list[dict[str, Any]]) -> dict[str, Any]: + labels = sorted({str(row.get("label") or "") for row in rows}) + summary: dict[str, Any] = {} + for label in labels: + group = [row for row in rows if row.get("label") == label] + counts = { + key: 0 + for key in [ + "sent", + "no_routes", + "blocked", + "bad_token", + "too_recent", + "request_failed", + "transport_error", + "other_fail", + "unknown", + ] + } + for row in group: + outcome = str(row.get("outcome") or "unknown") + counts[outcome] = counts.get(outcome, 0) + 1 + total = len(group) + target = counts["sent"] + counts["no_routes"] + summary[label] = { + "group": str(group[0].get("group") or "") if group else "", + "total": total, + **counts, + "target_decisions": target, + "sent_rate_on_target": rate(counts["sent"], target), + } + return summary + + +def markdown_table(summary: dict[str, Any]) -> str: + headers = ["group", "label", "total", "sent", "no_routes", "blocked", "bad_token", "target", "sent/target"] + lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)] + for label, item in sorted(summary.items(), key=lambda pair: (str(pair[1].get("group")), pair[0])): + values = [ + str(item.get("group")), + label, + str(item.get("total", 0)), + str(item.get("sent", 0)), + str(item.get("no_routes", 0)), + str(item.get("blocked", 0)), + str(item.get("bad_token", 0)), + str(item.get("target_decisions", 0)), + str(item.get("sent_rate_on_target")), + ] + lines.append("| " + " | ".join(values) + " |") + return "\n".join(lines) + + +def factor_arms() -> list[FactorArm]: + ghcr_patches = ( + "gpia-error-minus-two", + "gpia-data-digest-ghcr", + "gpia-source-ghcr", + "gpia-json-no-slash-escape", + "wamsys-ghcr", + ) + wamsys_omits = ("gpia", "_ga", "_gi", "_gp", "_ge", "aid", "_gg") + co_locale_sets = ("lg=es", "lc=CO") + co_operator_patch = ("operator-co-732101",) + combo_arms = [] + for prefix in ("314", "350"): + combo_arms.extend( + [ + FactorArm( + "combo", + f"combo-{prefix}-current", + patches=co_operator_patch, + sets=co_locale_sets, + prefix=prefix, + ), + FactorArm( + "combo", + f"combo-{prefix}-ghcr", + patches=(*co_operator_patch, *ghcr_patches), + sets=co_locale_sets, + prefix=prefix, + ), + FactorArm( + "combo", + f"combo-{prefix}-omit-wamsys", + patches=co_operator_patch, + sets=co_locale_sets, + omits=wamsys_omits, + prefix=prefix, + ), + ] + ) + routing_base = FactorArm( + "routing", + "routing-baseline-350", + patches=co_operator_patch, + sets=co_locale_sets, + prefix="350", + ) + routing_arms = [ + routing_base, + replace(routing_base, label="routing-us-locale", sets=()), + replace(routing_base, label="routing-zero-operator", patches=()), + replace(routing_base, label="routing-operator-omit", patches=("operator-omit",)), + replace(routing_base, label="routing-simnum-one", sets=(*co_locale_sets, "simnum=1")), + replace(routing_base, label="routing-sim-type-zero", sets=(*co_locale_sets, "sim_type=0")), + replace(routing_base, label="routing-no-sim-signal", patches=(*co_operator_patch, "no-sim-signal")), + replace(routing_base, label="routing-radio-zero", sets=(*co_locale_sets, "network_radio_type=0")), + replace(routing_base, label="routing-radio-two", sets=(*co_locale_sets, "network_radio_type=2")), + replace(routing_base, label="routing-radio-thirteen", sets=(*co_locale_sets, "network_radio_type=13")), + replace(routing_base, label="routing-cellular-zero", sets=(*co_locale_sets, "cellular_strength=0")), + replace(routing_base, label="routing-cellular-three", sets=(*co_locale_sets, "cellular_strength=3")), + replace(routing_base, label="routing-roaming-one", sets=(*co_locale_sets, "roaming_type=1")), + replace(routing_base, label="routing-airplane-one", sets=(*co_locale_sets, "airplane_mode_type=1")), + replace(routing_base, label="routing-feo2-omit", omits=("feo2_query_status",)), + replace(routing_base, label="routing-feo2-security-error", sets=(*co_locale_sets, "feo2_query_status=error_security_exception")), + replace(routing_base, label="routing-mnc-102", sets=(*co_locale_sets, "mnc=102", "sim_mnc=102")), + replace(routing_base, label="routing-mnc-103", sets=(*co_locale_sets, "mnc=103", "sim_mnc=103")), + replace(routing_base, label="routing-mnc-123", sets=(*co_locale_sets, "mnc=123", "sim_mnc=123")), + replace(routing_base, label="routing-mnc-130", sets=(*co_locale_sets, "mnc=130", "sim_mnc=130")), + ] + candidate_arms = [ + FactorArm("candidate", "candidate-xiaomi-301-default", device_label="xiaomi-a11", prefix="301"), + FactorArm("candidate", "candidate-xiaomi-350-default", device_label="xiaomi-a11", prefix="350"), + FactorArm("candidate", "candidate-random-a11-301-default", device_label="random-generic-a11", prefix="301"), + FactorArm("candidate", "candidate-random-a11-350-default", device_label="random-generic-a11", prefix="350"), + ] + boost_base = FactorArm("boost", "boost-baseline-random-a11-350", device_label="random-generic-a11", prefix="350") + boost_arms = [ + boost_base, + replace(boost_base, label="boost-omit-wamsys", omits=wamsys_omits), + replace(boost_base, label="boost-ghcr-wamsys", patches=ghcr_patches), + replace(boost_base, label="boost-no-sim-signal", patches=("no-sim-signal",)), + replace(boost_base, label="boost-client-metrics-google-play", patches=("client-metrics-google-play",)), + replace( + boost_base, + label="boost-client-metrics-attempts-2", + sets=('client_metrics={"attempts":2,"app_campaign_download_source":"unknown|unknown"}',), + ), + replace(boost_base, label="boost-db-zero", patches=("db-zero",)), + replace(boost_base, label="boost-hasav-zero", sets=("hasav=0",)), + replace(boost_base, label="boost-hasinrc-zero", sets=("hasinrc=0",)), + replace(boost_base, label="boost-abprop-then-code", preflight="abprop"), + replace(boost_base, label="boost-transport-curl", transport="curl"), + replace(boost_base, label="boost-transport-curl-http1", transport="curl-http1.1"), + replace(boost_base, label="boost-random-xiaomi-like-a11", device_label="random-xiaomi-like-a11"), + replace(boost_base, label="boost-random-oppo-like-a12", device_label="random-oppo-like-a12"), + replace(boost_base, label="boost-consistent-generic-a11", device_label="consistent-generic-a11"), + replace(boost_base, label="boost-ram-450", device_label="ram-a11-450"), + replace(boost_base, label="boost-ram-650", device_label="ram-a11-650"), + replace(boost_base, label="boost-prefix-301", prefix="301"), + ] + return [ + *combo_arms, + *routing_arms, + *candidate_arms, + *boost_arms, + FactorArm("transport", "transport-requests"), + FactorArm("transport", "transport-curl", transport="curl"), + FactorArm("transport", "transport-curl-http1", transport="curl-http1.1"), + FactorArm("install", "install-fresh"), + FactorArm("install", "install-stable", stable_install=True), + FactorArm("integrity", "integrity-signed"), + FactorArm("integrity", "integrity-unsigned", envelope="unsigned"), + FactorArm("integrity", "integrity-empty-h", envelope="empty-h"), + FactorArm("integrity", "integrity-omit-wamsys", omits=wamsys_omits), + FactorArm("integrity", "integrity-ghcr-wamsys", patches=ghcr_patches), + FactorArm("number", "prefix-300", prefix="300"), + FactorArm("number", "prefix-301", prefix="301"), + FactorArm("number", "prefix-310", prefix="310"), + FactorArm("number", "prefix-314", prefix="314"), + FactorArm("number", "prefix-350", prefix="350"), + FactorArm("context", "context-zero"), + FactorArm("context", "context-co-operator", patches=("operator-co-732101",)), + FactorArm("context", "context-co-locale", patches=("operator-co-732101",), sets=("lg=es", "lc=CO")), + FactorArm("context", "context-no-sim-signal", patches=("no-sim-signal",)), + FactorArm("app", "app-current"), + FactorArm("app", "app-old-2.26.21.73", app_version="2.26.21.73"), + FactorArm("abprop", "abprop-code-only"), + FactorArm("abprop", "abprop-then-code", preflight="abprop"), + FactorArm("metrics", "metrics-default"), + FactorArm("metrics", "metrics-attempts-2", sets=('client_metrics={"attempts":2,"app_campaign_download_source":"unknown|unknown"}',)), + FactorArm("metrics", "metrics-google-play", patches=("client-metrics-google-play",)), + FactorArm("debug", "debug-db-one"), + FactorArm("debug", "debug-db-zero", patches=("db-zero",)), + FactorArm("debug", "debug-hasav-zero", sets=("hasav=0",)), + FactorArm("debug", "debug-hasinrc-zero", sets=("hasinrc=0",)), + FactorArm("device", "device-xiaomi-a11", device_label="xiaomi-a11"), + FactorArm("device", "device-random-generic-a11", device_label="random-generic-a11"), + FactorArm("device", "device-oneplus-a14", device_label="oneplus-known-a14"), + ] + + +def selected_arms(groups: set[str], labels: set[str]) -> list[FactorArm]: + arms = factor_arms() + if groups: + arms = [arm for arm in arms if arm.group in groups] + if labels: + arms = [arm for arm in arms if arm.label in labels] + return arms + + +def output_paths(args: argparse.Namespace) -> tuple[Path, Path]: + run_id = args.run_id or time.strftime("%Y%m%d-%H%M%S") + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir / f"{run_id}.ndjson", out_dir / f"{run_id}.summary.json" + + +def is_target_decision(row: dict[str, Any]) -> bool: + return row.get("outcome") in {"sent", "no_routes"} + + +def sleep_after_request(args: argparse.Namespace) -> None: + if not args.dry_run and args.sleep > 0: + time.sleep(args.sleep + random.random() * max(args.jitter, 0)) + + +def run_fixed_rounds( + repo_root: Path, + args: argparse.Namespace, + arms: list[FactorArm], + stable_cache: dict[str, probe.ProbeMaterial], + lease_client: ProxyRuntimeLeaseClient | None, + handle: Any, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for round_index in range(1, args.samples + 1): + round_arms = list(arms) + random.shuffle(round_arms) + for arm in round_arms: + row = run_arm_once(repo_root, args, arm, stable_cache, lease_client) + row["round"] = round_index + rows.append(row) + line = json.dumps(row, ensure_ascii=False, sort_keys=True) + print(line, flush=True) + handle.write(line + "\n") + handle.flush() + sleep_after_request(args) + return rows + + +def run_until_target_decisions( + repo_root: Path, + args: argparse.Namespace, + arms: list[FactorArm], + stable_cache: dict[str, probe.ProbeMaterial], + lease_client: ProxyRuntimeLeaseClient | None, + handle: Any, +) -> list[dict[str, Any]]: + max_samples = args.max_samples if args.max_samples > 0 else args.samples + sample_counts = {arm.label: 0 for arm in arms} + decision_counts = {arm.label: 0 for arm in arms} + rows: list[dict[str, Any]] = [] + batch_index = 0 + while True: + active_arms = [ + arm + for arm in arms + if sample_counts[arm.label] < max_samples and decision_counts[arm.label] < args.target_decisions + ] + if not active_arms: + return rows + batch_index += 1 + random.shuffle(active_arms) + for arm in active_arms: + row = run_arm_once(repo_root, args, arm, stable_cache, lease_client) + sample_counts[arm.label] += 1 + if is_target_decision(row): + decision_counts[arm.label] += 1 + row["round"] = batch_index + row["sample_index"] = sample_counts[arm.label] + row["target_decision_index"] = decision_counts[arm.label] + rows.append(row) + line = json.dumps(row, ensure_ascii=False, sort_keys=True) + print(line, flush=True) + handle.write(line + "\n") + handle.flush() + sleep_after_request(args) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run one-by-one SMS /v2/code factor probes with a Xiaomi Android 11 baseline.") + parser.add_argument("--samples", type=int, default=3) + parser.add_argument("--target-decisions", type=int, default=0, help="stop each arm after this many sent/no_routes decisions") + parser.add_argument("--max-samples", type=int, default=0, help="per-arm cap when --target-decisions is set; defaults to --samples") + parser.add_argument("--groups", default="", help="comma-separated factor groups") + parser.add_argument("--labels", default="", help="comma-separated exact labels") + parser.add_argument("--proxy", default="", help="HTTP proxy URL; WA_PROBE_PROXY_URL is used when omitted") + parser.add_argument("--lease-per-request", action="store_true", help="acquire and release a proxy-runtime dynamic lease for each outbound request") + parser.add_argument("--proxy-runtime-api-base", default="", help="proxy-runtime API base; PROXY_RUNTIME_API_BASE is used when omitted") + parser.add_argument("--proxy-runtime-auth-token", default="", help="proxy-runtime service auth token; PROXY_RUNTIME_AUTH_TOKEN is used when omitted") + parser.add_argument("--proxy-runtime-account-id", default="", help="proxy-runtime dynamic profile/account id") + parser.add_argument("--proxy-runtime-purpose", default="wa-app-probe") + parser.add_argument("--proxy-runtime-ttl", type=int, default=120) + parser.add_argument("--proxy-runtime-egress-host", default="", help="public data-plane host override for lease egress") + parser.add_argument("--proxy-runtime-egress-port", type=int, default=0, help="public data-plane port override for lease egress") + parser.add_argument("--proxy-runtime-egress-scheme", default="", help="public data-plane scheme override for lease egress") + parser.add_argument("--timeout", type=float, default=25) + parser.add_argument("--sleep", type=float, default=0.6) + parser.add_argument("--jitter", type=float, default=0.3) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--show-fields", action="store_true") + parser.add_argument("--show-response", action="store_true") + parser.add_argument("--out-dir", default=".temp/wa-code-param-experiments") + parser.add_argument("--run-id", default="") + args = parser.parse_args() + + args.proxy_runtime_api_base = (args.proxy_runtime_api_base or os.environ.get("PROXY_RUNTIME_API_BASE", "")).strip() + args.proxy_runtime_auth_token = (args.proxy_runtime_auth_token or os.environ.get("PROXY_RUNTIME_AUTH_TOKEN", "")).strip() + args.proxy_runtime_account_id = (args.proxy_runtime_account_id or os.environ.get("PROXY_RUNTIME_ACCOUNT_ID", "")).strip() + args.proxy_runtime_egress_host = (args.proxy_runtime_egress_host or os.environ.get("PROXY_RUNTIME_EGRESS_HOST", "")).strip() + args.proxy_runtime_egress_scheme = (args.proxy_runtime_egress_scheme or os.environ.get("PROXY_RUNTIME_EGRESS_SCHEME", "")).strip() + if args.proxy_runtime_egress_port <= 0: + args.proxy_runtime_egress_port = int(os.environ.get("PROXY_RUNTIME_EGRESS_PORT", "0") or "0") + args.proxy = probe.normalize_proxy(args.proxy or os.environ.get("WA_PROBE_PROXY_URL", "")) + if args.lease_per_request: + missing = [ + name + for name, value in { + "PROXY_RUNTIME_API_BASE": args.proxy_runtime_api_base, + "PROXY_RUNTIME_AUTH_TOKEN": args.proxy_runtime_auth_token, + "PROXY_RUNTIME_ACCOUNT_ID": args.proxy_runtime_account_id, + }.items() + if not value + ] + if missing and not args.dry_run: + print(json.dumps({"error": "missing " + ",".join(missing)}, ensure_ascii=False)) + return 2 + args.proxy = "" + if not args.proxy and not args.dry_run and not args.lease_per_request: + print(json.dumps({"error": "set WA_PROBE_PROXY_URL or pass --proxy"}, ensure_ascii=False)) + return 2 + groups = {item.strip() for item in args.groups.split(",") if item.strip()} + labels = {item.strip() for item in args.labels.split(",") if item.strip()} + arms = selected_arms(groups, labels) + if not arms: + print(json.dumps({"error": "no factor arms selected"}, ensure_ascii=False)) + return 2 + + repo_root = Path(__file__).resolve().parents[1] + stable_cache: dict[str, probe.ProbeMaterial] = {} + lease_client = ( + ProxyRuntimeLeaseClient( + args.proxy_runtime_api_base, + args.proxy_runtime_auth_token, + min(args.timeout, 10), + args.proxy_runtime_egress_host, + args.proxy_runtime_egress_port, + args.proxy_runtime_egress_scheme, + ) + if args.lease_per_request and not args.dry_run + else None + ) + ndjson_path, summary_path = output_paths(args) + try: + with ndjson_path.open("w", encoding="utf-8") as handle: + if args.target_decisions > 0: + rows = run_until_target_decisions(repo_root, args, arms, stable_cache, lease_client, handle) + else: + rows = run_fixed_rounds(repo_root, args, arms, stable_cache, lease_client, handle) + finally: + if lease_client is not None: + lease_client.close() + summary = summarize(rows) + payload = { + "samples_per_arm": args.samples, + "target_decisions": args.target_decisions, + "max_samples": args.max_samples, + "groups": sorted(groups) if groups else "all", + "labels": [arm.label for arm in arms], + "summary": summary, + } + summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps({"result_file": str(ndjson_path), "summary_file": str(summary_path), "summary": summary}, ensure_ascii=False, sort_keys=True)) + print(markdown_table(summary)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wa_code_param_experiment.py b/scripts/wa_code_param_experiment.py new file mode 100755 index 0000000..07866c4 --- /dev/null +++ b/scripts/wa_code_param_experiment.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import random +import subprocess +import sys +import time +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +PROBE_SCRIPT = SCRIPT_DIR / "wa_code_param_probe.py" +DEFAULT_PATCHES = [ + "client-metrics-google-play", + "db-zero", + "gpia-error-minus-two", + "gpia-data-digest-ghcr", + "gpia-source-ghcr", + "gpia-json-no-slash-escape", + "wamsys-order-ghcr", + "wamsys-values-ghcr", + "no-sim-signal", + "device-ghcr-defaults", + "operator-co-732101", +] +CRITICAL_PATCHES = ["gpia-error-minus-two", "no-sim-signal", "db-zero", "wamsys-values-ghcr"] + + +def parse_patches(value: str) -> list[str]: + value = value.strip() + if value == "all": + return list(DEFAULT_PATCHES) + if value == "critical": + return list(CRITICAL_PATCHES) + return [item.strip() for item in value.split(",") if item.strip()] + + +def classify(row: dict[str, Any]) -> str: + if row.get("error"): + return "transport_error" + status = str(row.get("status") or "").lower() + reason = str(row.get("reason") or "").lower() + if status in {"sent", "ok"}: + return "sent" + if reason == "no_routes": + return "no_routes" + if reason == "blocked": + return "blocked" + if reason == "too_recent": + return "too_recent" + if row.get("request_failed"): + return "request_failed" + if status == "fail": + return "other_fail" + return "unknown" + + +def run_probe_once(args: argparse.Namespace, label: str, patch: str, variant: str) -> dict[str, Any]: + cmd = [ + sys.executable, + str(PROBE_SCRIPT), + "--country", + args.country, + "--count", + "1", + "--variant", + variant, + "--timeout", + str(args.timeout), + "--sleep", + "0", + ] + if patch: + cmd.extend(["--patch", patch]) + if args.proxy: + cmd.extend(["--proxy", args.proxy]) + if args.dry_run: + cmd.append("--dry-run") + if args.show_fields: + cmd.append("--show-fields") + env = os.environ.copy() + if args.proxy: + env["WA_PROBE_PROXY_URL"] = args.proxy + proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, check=False) + row: dict[str, Any] | None = None + for line in proc.stdout.splitlines(): + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict) and "summary" not in parsed: + row = parsed + break + if row is None: + row = {"error": "probe produced no JSON row", "stdout_tail": proc.stdout[-500:]} + if proc.returncode != 0 and "error" not in row: + row["error"] = f"probe exited with {proc.returncode}" + row["label"] = label + row["patch"] = patch + row["base_variant"] = variant + row["outcome"] = classify(row) + return row + + +def rate(numerator: int, denominator: int) -> float | None: + if denominator <= 0: + return None + return round(numerator / denominator, 4) + + +def summarize(rows: list[dict[str, Any]]) -> dict[str, Any]: + labels = sorted({str(row.get("label") or "") for row in rows}) + summary: dict[str, Any] = {} + for label in labels: + group = [row for row in rows if row.get("label") == label] + counts = {key: 0 for key in ["sent", "no_routes", "blocked", "too_recent", "request_failed", "transport_error", "other_fail", "unknown"]} + for row in group: + outcome = str(row.get("outcome") or "unknown") + counts[outcome] = counts.get(outcome, 0) + 1 + total = len(group) + target = counts["sent"] + counts["no_routes"] + summary[label] = { + "total": total, + **counts, + "target_decisions": target, + "sent_rate": rate(counts["sent"], total), + "no_routes_rate": rate(counts["no_routes"], total), + "sent_rate_on_target": rate(counts["sent"], target), + "no_routes_rate_on_target": rate(counts["no_routes"], target), + } + return summary + + +def markdown_table(summary: dict[str, Any]) -> str: + headers = ["variant", "total", "sent", "no_routes", "blocked", "too_recent", "errors", "sent_rate", "no_routes_rate"] + lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)] + for label, item in sorted(summary.items(), key=lambda pair: pair[0]): + errors = int(item.get("transport_error", 0)) + int(item.get("request_failed", 0)) + values = [ + label, + str(item.get("total", 0)), + str(item.get("sent", 0)), + str(item.get("no_routes", 0)), + str(item.get("blocked", 0)), + str(item.get("too_recent", 0)), + str(errors), + str(item.get("sent_rate")), + str(item.get("no_routes_rate")), + ] + lines.append("| " + " | ".join(values) + " |") + return "\n".join(lines) + + +def output_paths(args: argparse.Namespace) -> tuple[Path, Path]: + run_id = args.run_id or time.strftime("%Y%m%d-%H%M%S") + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir / f"{run_id}.ndjson", out_dir / f"{run_id}.summary.json" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run one-variable WA /v2/code experiments with randomized order and fresh random phones.") + parser.add_argument("--country", default="CO", help="random phone country; default CO") + parser.add_argument("--samples", type=int, default=10, help="samples per variant") + parser.add_argument("--patches", default="all", help="all, critical, or comma-separated wa_code_param_probe patch names") + parser.add_argument("--include-ghcr", action="store_true", help="also run full ghcr-shaped request as a sanity arm") + parser.add_argument("--timeout", type=float, default=25) + parser.add_argument("--sleep", type=float, default=0.8, help="base sleep between individual requests") + parser.add_argument("--jitter", type=float, default=0.5, help="extra random sleep between requests") + parser.add_argument("--proxy", default="", help="HTTP proxy URL; WA_PROBE_PROXY_URL is used when omitted") + parser.add_argument("--allow-direct", action="store_true", help="allow running without proxy") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--show-fields", action="store_true") + parser.add_argument("--out-dir", default=".temp/wa-code-param-experiments") + parser.add_argument("--run-id", default="") + args = parser.parse_args() + + args.proxy = args.proxy or os.environ.get("WA_PROBE_PROXY_URL", "") + if not args.proxy and not args.allow_direct and not args.dry_run: + print(json.dumps({"error": "set WA_PROBE_PROXY_URL or pass --proxy; use --allow-direct only intentionally"}, ensure_ascii=False), file=sys.stderr) + return 2 + + patches = parse_patches(args.patches) + arms = [("current", "", "current")] + arms.extend(("current+" + patch, patch, "current") for patch in patches) + if args.include_ghcr: + arms.append(("ghcr", "", "ghcr")) + + ndjson_path, summary_path = output_paths(args) + rows: list[dict[str, Any]] = [] + with ndjson_path.open("w", encoding="utf-8") as handle: + for round_index in range(1, args.samples + 1): + round_arms = list(arms) + random.shuffle(round_arms) + for label, patch, variant in round_arms: + row = run_probe_once(args, label, patch, variant) + row["round"] = round_index + rows.append(row) + line = json.dumps(row, ensure_ascii=False, sort_keys=True) + print(line, flush=True) + handle.write(line + "\n") + handle.flush() + if not args.dry_run and args.sleep > 0: + time.sleep(args.sleep + random.random() * max(args.jitter, 0)) + + summary = summarize(rows) + payload = {"samples_per_variant": args.samples, "country": args.country, "arms": [arm[0] for arm in arms], "summary": summary} + summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps({"result_file": str(ndjson_path), "summary_file": str(summary_path), "summary": summary}, ensure_ascii=False, sort_keys=True)) + print(markdown_table(summary)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wa_code_param_probe.py b/scripts/wa_code_param_probe.py new file mode 100755 index 0000000..f4a07bb --- /dev/null +++ b/scripts/wa_code_param_probe.py @@ -0,0 +1,1260 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import hashlib +import hmac +import json +import os +import random +import re +import secrets +import string +import subprocess +import tempfile +import time +import uuid +import warnings +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL.*") + +import requests +import urllib3 +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.asymmetric import ec, utils as asymmetric_utils, x25519 +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat +from cryptography.x509.oid import NameOID, ObjectIdentifier + +import wa_exist_probe + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +SERVER_PUBLIC_KEY_HEX = wa_exist_probe.SERVER_PUBLIC_KEY_HEX +CODE_URL = wa_exist_probe.CODE_URL +USER_AGENT = "WhatsApp/2.26.23.71 Android/11 Device/Xiaomi-M2007J3SC" +DEVICE_DISPLAY_ID = "M2007J3SC_11.0.14(CN01)" +FORM_SAFE = set(string.ascii_letters + string.digits + "-._~") + +ANDROID_KEY_ATTESTATION_OID = ObjectIdentifier("1.3.6.1.4.1.11129.2.1.17") +NATIVE_ATTESTATION_PADDING_OID = ObjectIdentifier("1.3.6.1.4.1.11129.2.1.777") +NATIVE_ATTESTATION_ROOT_DER_LENGTH = 1312 +NATIVE_ATTESTATION_FIRST_INTERMEDIATE_DER_LENGTH = 920 +NATIVE_ATTESTATION_SECOND_INTERMEDIATE_DER_LENGTH = 505 +NATIVE_ATTESTATION_LEAF_DER_LENGTH = 685 +NATIVE_ATTESTATION_SIGNATURE_RAW_URL_LENGTH = 96 +NATIVE_ATTESTATION_SIGNATURE_MAX_ATTEMPTS = 64 + +NATIVE_GPIA_PACKAGE_NAME = "com.whatsapp" +NATIVE_GPIA_SOURCE_SIZE = "141711087" +NATIVE_GPIA_SOURCE_DIGEST = "b3BumN//vPO0GypN5i+xXvNznZyGiXOT99Jip70omCg=" +NATIVE_GPIA_SOURCE_FULL_DIGEST = "vJrNuYDSuWUZ87O1W5+xs/2g74mwPA2JO+dkqjlJZG4=" +NATIVE_GPIA_CERT_DIGEST = "OKD31QX+GP7GT780Psqq8xDb15k=" +NATIVE_GPIA_CLASSES_DIGEST = "qoblldcHz4lA84Sgs1QLZWPpd6YKG25zf0GwJZdTHXk=" +NATIVE_GPIA_NATIVE_LIB_DIGEST = "G9McgxRaSjtq92o7zx0fbf3Ak7+SPmxxNyvNXS01hlM=" +CURRENT_GPIA_DATA_SO_DIGEST = "SrL/HHWX9VAinH9OV4eloGSQLWSsUug93h5YGGad17s=" +GHCR_GPIA_DATA_SO_DIGEST = "0j9kw9djlCtmCCavV7go2wwge+2os853ubiE7F7Dew4=" +CURRENT_WAMSYS_REQUESTED_PERMISSIONS_DIGEST = "NNj5BoWX+yvZBYEY46Ze+Ad6Ykk0Z27FjgSysvkzzCU=" +CURRENT_WAMSYS_AGE_BUCKET_SECONDS = 300 +CURRENT_WAMSYS_FRESH_PROFILE_MAX_AGE_SECONDS = 600 +CURRENT_WAMSYS_DATA_AGE_MIN_SECONDS = 30 +CURRENT_WAMSYS_DATA_AGE_BASE_SECONDS = 54 +CURRENT_WAMSYS_DATA_AGE_SPREAD_SECONDS = 36 +CURRENT_WAMSYS_SOURCE_AHEAD_BASE_SECONDS = 8 +CURRENT_WAMSYS_SOURCE_AHEAD_SPREAD_SECONDS = 24 +CURRENT_WAMSYS_EXTERNAL_AHEAD_BASE_SECONDS = 8400 +CURRENT_WAMSYS_EXTERNAL_AHEAD_SPREAD_SECONDS = 1800 +REGISTRATION_REQUEST_KIND_CODE = 2 + +ARGENTINA_AREA_CODES = ("11", "221", "223", "261", "291", "341", "351", "381") +COLOMBIA_MOBILE_PREFIXES = ("300", "301", "302", "304", "305", "310", "311", "312", "313", "314", "315", "316", "317", "318", "320", "321", "322", "323", "350", "351") +SENSITIVE_KEY_RE = re.compile(r"(token|cookie|session|auth|key|sig|code|gpia|_g[aeigp]|aid)", re.I) + + +@dataclass(frozen=True) +class ShapeConfig: + name: str + client_metrics_source: str = "unknown|unknown" + db: str = "1" + device_ram: str = "6.58" + network_radio_type: str = "1" + device_display_id: str = DEVICE_DISPLAY_ID + pid_mode: str = "current" + operator_mode: str = "zero" + sim_signal: bool = True + gpia_error_code: int = -2 + gpia_data_so_digest: str = CURRENT_GPIA_DATA_SO_DIGEST + gpia_source_mode: str = "current" + gpia_escape_slash: bool = True + wamsys_order: str = "current" + wamsys_values: str = "current" + + +@dataclass(frozen=True) +class ProbeMaterial: + cc: str + national: str + fdid: str + expid: str + expid_uuid: str + access_session_id: str + access_session_id_uuid: str + id_raw: bytes + backup_token_raw: bytes + token: str + authkey: str + key_bundle: dict[str, str] + advertising_id: str + created_at_unix: int + phone_sha256: str + + @property + def e164(self) -> str: + return "+" + self.cc + self.national + + @property + def id_hex(self) -> str: + return self.id_raw.hex() + + @property + def backup_token_hex(self) -> str: + return self.backup_token_raw.hex() + + +@dataclass +class Param: + key: str + value: str + raw: bool = False + + +def pct_bytes(raw: bytes) -> str: + out: list[str] = [] + for value in raw: + ch = chr(value) + if ch in FORM_SAFE: + out.append(ch) + else: + out.append(f"%{value:02X}") + return "".join(out) + + +def quote_form(value: str) -> str: + return pct_bytes(value.encode("utf-8")) + + +def sha256_hex(value: str | bytes) -> str: + raw = value if isinstance(value, bytes) else value.encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def short_hash(value: str | bytes) -> str: + return sha256_hex(value)[:16] + + +def b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def b64std(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def decode_b64_any(value: str) -> bytes: + padded = value.strip() + "=" * ((4 - len(value.strip()) % 4) % 4) + try: + return base64.urlsafe_b64decode(padded) + except Exception: + return base64.b64decode(padded) + + +def b64u_uuid_to_text(value: str) -> str: + raw = decode_b64_any(value) + if len(raw) != 16: + return str(uuid.uuid4()) + return str(uuid.UUID(bytes=raw)) + + +def normalize_proxy(value: str) -> str: + value = value.strip() + if not value: + return "" + if "://" not in value: + return "http://" + value + return value + + +def sanitize_text(value: str, proxy_url: str = "") -> str: + text = value + if proxy_url: + text = text.replace(proxy_url, "") + text = re.sub(r"://[^/@\s]+@", "://@", text) + return text + + +def sanitize_response(value: Any) -> Any: + if isinstance(value, dict): + out: dict[str, Any] = {} + for key, item in value.items(): + if SENSITIVE_KEY_RE.search(str(key)): + out[str(key)] = "" + else: + out[str(key)] = sanitize_response(item) + return out + if isinstance(value, list): + return [sanitize_response(item) for item in value[:32]] + if isinstance(value, str) and len(value) > 180: + return value[:180] + "…" + return value + + +def random_argentina_phone() -> tuple[str, str]: + area = random.choice(ARGENTINA_AREA_CODES) + subscriber_len = 10 - len(area) + first = str(random.randint(2, 9)) + rest = "".join(str(random.randint(0, 9)) for _ in range(subscriber_len - 1)) + return "54", "9" + area + first + rest + + +def random_colombia_phone() -> tuple[str, str]: + prefix = random.choice(COLOMBIA_MOBILE_PREFIXES) + return "57", prefix + "".join(str(random.randint(0, 9)) for _ in range(7)) + + +def normalize_phone(value: str, default_cc: str) -> tuple[str, str]: + digits = re.sub(r"\D+", "", value) + if not digits: + raise ValueError("phone is empty") + if value.strip().startswith("+") and digits.startswith(default_cc) and len(digits) > len(default_cc): + return default_cc, digits[len(default_cc) :] + if digits.startswith(default_cc) and len(digits) > len(default_cc) + 6: + return default_cc, digits[len(default_cc) :] + return default_cc, digits + + +def phone_inputs(args: argparse.Namespace) -> list[tuple[str, str]]: + if args.phone: + return [normalize_phone(phone, args.cc) for phone in args.phone] + country = args.country.upper() + if country == "AR": + return [random_argentina_phone() for _ in range(args.count)] + if country == "CO": + return [random_colombia_phone() for _ in range(args.count)] + raise ValueError("only --country AR/CO random generation is implemented; pass --phone for custom numbers") + + +def uuid_pair() -> tuple[str, str]: + value = uuid.uuid4() + return str(value), b64u(value.bytes) + + +def new_probe_material(repo_root: Path, cc: str, national: str) -> ProbeMaterial: + state = wa_exist_probe.new_probe_state(repo_root, "+" + cc + national, cc, "mapped", {}) + expid_uuid = b64u_uuid_to_text(state.expid) + access_session_id_uuid = b64u_uuid_to_text(state.access_session_id) + return ProbeMaterial( + cc=state.cc, + national=state.national, + fdid=state.fdid, + expid=state.expid, + expid_uuid=expid_uuid, + access_session_id=state.access_session_id, + access_session_id_uuid=access_session_id_uuid, + id_raw=state.raw_id, + backup_token_raw=state.raw_backup_token, + token=state.token, + authkey=state.authkey, + key_bundle=state.key_bundle, + advertising_id=str(uuid.uuid4()), + created_at_unix=int(time.time()), + phone_sha256=sha256_hex(state.cc + state.national), + ) + + +def stable_seed(material: ProbeMaterial, label: str) -> str: + return "|".join( + [ + "byte-v-forge-wa-native-runtime/v1", + label.strip(), + material.cc, + material.national, + material.phone_sha256, + material.fdid, + material.expid_uuid, + material.access_session_id_uuid, + material.authkey, + material.key_bundle["e_ident"], + material.authkey, + ] + ) + + +def current_pid(material: ProbeMaterial) -> str: + _ = material + return str(os.getpid()) + + +def runtime_token_current(material: ProbeMaterial, label: str) -> str: + digest = hashlib.sha256(stable_seed(material, label).encode()).digest() + return b64u(digest[:16]) + + +def runtime_token_ghcr(material: ProbeMaterial, label: str) -> str: + seed = "|".join( + [ + "byte-v-forge-wa-gpia-source-dir/v1", + label, + material.cc, + material.national, + material.phone_sha256, + material.fdid, + material.expid_uuid, + material.authkey, + ] + ) + return b64u(hashlib.sha256(seed.encode()).digest()[:16]) + + +def gpia_source_dir(material: ProbeMaterial, config: ShapeConfig) -> str: + if config.gpia_source_mode == "ghcr": + first = runtime_token_ghcr(material, "source-dir-a") + second = runtime_token_ghcr(material, "source-dir-b") + else: + first = runtime_token_current(material, "source-dir-prefix") + second = runtime_token_current(material, "source-dir-package") + return f"/data/app/~~{first}==/com.whatsapp-{second}==/base.apk" + + +def gpia_key_source(material: ProbeMaterial) -> str: + public = decode_b64_any(material.authkey) + if len(public) == 32: + return b64std(public) + return "default" + + +def render_json_value(value: Any, escape_slash: bool) -> str: + if isinstance(value, str): + encoded = json.dumps(value, separators=(",", ":")) + return encoded.replace("/", r"\/") if escape_slash else encoded + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return "null" + if isinstance(value, int): + return str(value) + raise TypeError(f"unsupported JSON value type: {type(value)!r}") + + +def render_ordered_json(fields: list[tuple[str, Any]], escape_slash: bool) -> str: + parts = [] + for key, value in fields: + parts.append(json.dumps(key, separators=(",", ":")) + ":" + render_json_value(value, escape_slash)) + return "{" + ",".join(parts) + "}" + + +def aes_cbc_pkcs7_encrypt(key_source: str, plaintext: bytes) -> str: + key = hashlib.sha256(key_source.encode()).digest() + iv = secrets.token_bytes(16) + padder = padding.PKCS7(128).padder() + padded = padder.update(plaintext) + padder.finalize() + encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor() + ciphertext = encryptor.update(padded) + encryptor.finalize() + return b64std(iv + ciphertext) + + +def encrypt_gpia_json(key_source: str, fields: list[tuple[str, Any]], config: ShapeConfig) -> str: + plaintext = render_ordered_json(fields, config.gpia_escape_slash).encode("utf-8") + return aes_cbc_pkcs7_encrypt(key_source, plaintext) + + +def build_gpia(material: ProbeMaterial, config: ShapeConfig) -> dict[str, str]: + source_dir = gpia_source_dir(material, config) + key_source = gpia_key_source(material) + primary = encrypt_gpia_json( + key_source, + [ + ("sizeInBytes", NATIVE_GPIA_SOURCE_SIZE), + ("packageName", NATIVE_GPIA_PACKAGE_NAME), + ("code", config.gpia_error_code), + ("shatr", NATIVE_GPIA_SOURCE_DIGEST), + ("p", source_dir), + ("cert", NATIVE_GPIA_CERT_DIGEST), + ("sha256", NATIVE_GPIA_SOURCE_FULL_DIGEST), + ], + config, + ) + compact = encrypt_gpia_json(key_source, [("_ic", config.gpia_error_code)], config) + device = encrypt_gpia_json( + key_source, + [ + ("_dh", NATIVE_GPIA_CLASSES_DIGEST), + ("_iln", config.gpia_data_so_digest), + ("_isb", NATIVE_GPIA_SOURCE_SIZE), + ("_ip", NATIVE_GPIA_PACKAGE_NAME), + ("did", config.device_display_id), + ("_p", source_dir), + ("_ln", NATIVE_GPIA_NATIVE_LIB_DIGEST), + ("_ist", NATIVE_GPIA_SOURCE_DIGEST), + ("_icr", NATIVE_GPIA_CERT_DIGEST), + ("_is", NATIVE_GPIA_SOURCE_FULL_DIGEST), + ], + config, + ) + return {"gpia": primary, "_gg": compact, "_gi": device} + + +def derive_local_wamsys_bytes(material: ProbeMaterial, label: str, length: int) -> bytes: + seed = "|".join( + [ + "byte-v-forge-wa-wamsys-precision/v1", + label, + material.cc, + material.national, + material.phone_sha256, + material.fdid, + material.expid_uuid, + material.access_session_id_uuid, + material.id_hex, + material.backup_token_hex, + material.authkey, + material.key_bundle["e_ident"], + ] + ) + key = hashlib.sha256(seed.encode()).digest() + out = b"" + counter = 0 + while len(out) < length: + out += hmac.new(key, label.encode() + counter.to_bytes(4, "big"), hashlib.sha256).digest() + counter += 1 + return out[:length] + + +def current_boot_id(material: ProbeMaterial) -> str: + raw = bytearray(hashlib.sha256(stable_seed(material, "boot-id").encode()).digest()[:16]) + raw[6] = (raw[6] & 0x0F) | 0x40 + raw[8] = (raw[8] & 0x3F) | 0x80 + return str(uuid.UUID(bytes=bytes(raw))) + + +def current_wamsys_runtime_offset(material: ProbeMaterial, label: str, base: int, spread: int, now: int) -> int: + if spread <= 0: + return base + bucket = now // CURRENT_WAMSYS_AGE_BUCKET_SECONDS + seed = "|".join( + [ + "byte-v-forge-wa-wamsys-runtime-path-age/v1", + label, + str(REGISTRATION_REQUEST_KIND_CODE), + str(bucket), + material.cc, + material.national, + material.phone_sha256, + material.fdid, + material.access_session_id_uuid, + material.authkey, + ] + ) + return base + int.from_bytes(hashlib.sha256(seed.encode()).digest()[:8], "big") % spread + + +def current_wamsys_path_ages(material: ProbeMaterial, now: int | None = None) -> tuple[int, int, int]: + current = int(time.time()) if now is None else now + profile_age = current - material.created_at_unix + if CURRENT_WAMSYS_DATA_AGE_MIN_SECONDS <= profile_age <= CURRENT_WAMSYS_FRESH_PROFILE_MAX_AGE_SECONDS: + data_age = profile_age + else: + data_age = current_wamsys_runtime_offset( + material, + "data-dir-age", + CURRENT_WAMSYS_DATA_AGE_BASE_SECONDS, + CURRENT_WAMSYS_DATA_AGE_SPREAD_SECONDS, + current, + ) + source_age = data_age + current_wamsys_runtime_offset( + material, + "source-data-age-delta", + CURRENT_WAMSYS_SOURCE_AHEAD_BASE_SECONDS, + CURRENT_WAMSYS_SOURCE_AHEAD_SPREAD_SECONDS, + current, + ) + external_age = data_age + current_wamsys_runtime_offset( + material, + "external-data-age-delta", + CURRENT_WAMSYS_EXTERNAL_AHEAD_BASE_SECONDS, + CURRENT_WAMSYS_EXTERNAL_AHEAD_SPREAD_SECONDS, + current, + ) + return source_age, data_age, external_age + + +def build_current_ga(material: ProbeMaterial, config: ShapeConfig) -> str: + key_source = gpia_key_source(material) + boot_id = current_boot_id(material) + bi = aes_cbc_pkcs7_encrypt(key_source, boot_id.encode()) + source_age, data_age, external_age = current_wamsys_path_ages(material) + fields = [("bi", bi), ("ap", source_age), ("ai", data_age), ("mp", False), ("ae", external_age), ("mu", False)] + return render_ordered_json(fields, config.gpia_escape_slash) + + +def build_ghcr_ga(material: ProbeMaterial, config: ShapeConfig) -> str: + bi = b64std(derive_local_wamsys_bytes(material, "_ga.bi", 64)) + fields = [("ai", 141), ("ae", 0), ("ap", 172), ("bi", bi), ("mp", False), ("mu", False)] + return render_ordered_json(fields, config.gpia_escape_slash) + + +def current_android_id(material: ProbeMaterial) -> str: + seed = "|".join( + [ + "byte-v-forge-wa-wamsys-android-id/v1", + material.phone_sha256, + material.fdid, + material.expid_uuid, + material.access_session_id_uuid, + material.id_hex, + material.backup_token_hex, + material.authkey, + ] + ) + return hashlib.sha256(seed.encode()).digest()[:8].hex() + + +def current_wamsys_aid(material: ProbeMaterial) -> str: + return b64std(hashlib.sha256(current_android_id(material).encode()).digest()) + + +def build_wamsys(material: ProbeMaterial, config: ShapeConfig) -> dict[str, str]: + gpia = build_gpia(material, config) + if config.wamsys_values == "ghcr": + values = { + "gpia": gpia["gpia"], + "_ge": '{"sb":false,"sv":false}', + "_gi": gpia["_gi"], + "_gg": gpia["_gg"], + "_gp": b64std(derive_local_wamsys_bytes(material, "_gp", 32)), + "_ga": build_ghcr_ga(material, config), + "aid": b64std(derive_local_wamsys_bytes(material, "aid", 32)), + } + else: + values = { + "gpia": gpia["gpia"], + "_ga": build_current_ga(material, config), + "_gi": gpia["_gi"], + "_gp": CURRENT_WAMSYS_REQUESTED_PERMISSIONS_DIGEST, + "_ge": '{"sb":false,"sv":false}', + "aid": current_wamsys_aid(material), + "_gg": gpia["_gg"], + } + order = ["gpia", "_ge", "_gi", "_gg", "_gp", "_ga", "aid"] if config.wamsys_order == "ghcr" else ["gpia", "_ga", "_gi", "_gp", "_ge", "aid", "_gg"] + return {key: values[key] for key in order if key in values} + + +def operator_fields(config: ShapeConfig) -> dict[str, str]: + if config.operator_mode == "omit": + return {} + if config.operator_mode == "ar722310": + return {"mcc": "722", "mnc": "310", "sim_mcc": "722", "sim_mnc": "310"} + if config.operator_mode == "co732101": + return {"mcc": "732", "mnc": "101", "sim_mcc": "732", "sim_mnc": "101"} + return {"mcc": "000", "mnc": "000", "sim_mcc": "000", "sim_mnc": "000"} + + +def device_fields(material: ProbeMaterial, config: ShapeConfig) -> dict[str, str]: + fields = { + "mistyped": "7", + "reason": "", + "hasav": "2", + "client_metrics": json.dumps( + {"attempts": 1, "app_campaign_download_source": config.client_metrics_source}, + separators=(",", ":"), + ), + "education_screen_displayed": "false", + "prefer_sms_over_flash": "false", + "network_radio_type": config.network_radio_type, + "simnum": "0", + "hasinrc": "1", + "pid": "29418" if config.pid_mode == "ghcr" else current_pid(material), + "rc": "0", + "device_ram": config.device_ram, + "db": config.db, + "recaptcha": '{"stage":"ABPROP_DISABLED"}', + "feo2_query_status": "did_not_query", + } + fields.update(operator_fields(config)) + if config.sim_signal: + has_sim = fields.get("simnum") == "1" or fields.get("sim_mcc", "000") not in {"", "000"} + fields.update( + { + "sim_type": "1" if has_sim else "0", + "airplane_mode_type": "0", + "cellular_strength": "5", + "roaming_type": "0", + } + ) + return fields + + +def add_param(params: list[Param], key: str, value: str, raw: bool = False) -> None: + params.append(Param(key=key, value=value, raw=raw)) + + +def build_code_params(material: ProbeMaterial, config: ShapeConfig, args: argparse.Namespace) -> list[Param]: + fields = device_fields(material, config) + wamsys = build_wamsys(material, config) + params: list[Param] = [] + add_param(params, "cc", material.cc) + add_param(params, "in", material.national) + add_param(params, "lg", "en") + add_param(params, "lc", "US") + add_param(params, "fdid", material.fdid) + add_param(params, "expid", material.expid) + add_param(params, "access_session_id", material.access_session_id) + add_param(params, "id", pct_bytes(material.id_raw), raw=True) + add_param(params, "backup_token", pct_bytes(material.backup_token_raw), raw=True) + add_param(params, "token", material.token) + add_param(params, "method", "sms") + add_param(params, "advertising_id", material.advertising_id) + add_param(params, "authkey", material.authkey) + for key in ["e_ident", "e_keytype", "e_regid", "e_skey_id", "e_skey_val", "e_skey_sig"]: + add_param(params, key, material.key_bundle[key]) + for key in [ + "mistyped", + "reason", + "hasav", + "client_metrics", + "mcc", + "mnc", + "sim_mcc", + "sim_mnc", + "education_screen_displayed", + "prefer_sms_over_flash", + "network_radio_type", + "simnum", + "hasinrc", + "pid", + "rc", + "sim_type", + "airplane_mode_type", + "cellular_strength", + "roaming_type", + "device_ram", + ]: + if key in fields and (fields[key] != "" or key == "reason"): + add_param(params, key, pct_bytes(fields[key].encode()), raw=True) + add_param(params, "gpia", pct_bytes(wamsys["gpia"].encode()), raw=True) + add_param(params, "db", pct_bytes(fields["db"].encode()), raw=True) + add_param(params, "recaptcha", pct_bytes(fields["recaptcha"].encode()), raw=True) + for key, value in wamsys.items(): + if key == "gpia": + continue + add_param(params, key, pct_bytes(value.encode()), raw=True) + add_param(params, "feo2_query_status", pct_bytes(fields["feo2_query_status"].encode()), raw=True) + apply_param_overrides(params, args.set_param, args.omit) + return params + + +def apply_param_overrides(params: list[Param], sets: list[str], omits: list[str]) -> None: + omit_set = {item.strip() for item in omits if item.strip()} + if omit_set: + params[:] = [param for param in params if param.key not in omit_set] + for item in sets: + if "=" not in item: + raise ValueError(f"--set expects key=value, got {item!r}") + key, value = item.split("=", 1) + key = key.strip() + if not key: + raise ValueError("--set key is empty") + encoded = pct_bytes(value.encode()) + for param in params: + if param.key == key: + param.value = encoded + param.raw = True + break + else: + params.append(Param(key=key, value=encoded, raw=True)) + + +def render_plain(params: list[Param]) -> str: + return "&".join(f"{quote_form(param.key)}={param.value if param.raw else quote_form(param.value)}" for param in params) + + +def encrypt_wasafe(plain: str) -> str: + server = x25519.X25519PublicKey.from_public_bytes(bytes.fromhex(SERVER_PUBLIC_KEY_HEX)) + private = x25519.X25519PrivateKey.generate() + _ = private.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()) + public = private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + shared = private.exchange(server) + sealed = AESGCM(shared).encrypt(b"\x00" * 12, plain.encode("utf-8"), None) + return b64u(public + sealed) + + +def der_len(length: int) -> bytes: + if length < 0x80: + return bytes([length]) + raw = length.to_bytes((length.bit_length() + 7) // 8, "big") + return bytes([0x80 | len(raw)]) + raw + + +def der_tlv(tag: int, value: bytes) -> bytes: + return bytes([tag]) + der_len(len(value)) + value + + +def der_integer(value: int) -> bytes: + raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big") + if raw[0] & 0x80: + raw = b"\x00" + raw + return der_tlv(0x02, raw) + + +def der_enumerated(value: int) -> bytes: + raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big") + if raw[0] & 0x80: + raw = b"\x00" + raw + return der_tlv(0x0A, raw) + + +def der_octet_string(value: bytes) -> bytes: + return der_tlv(0x04, value) + + +def der_sequence(*items: bytes) -> bytes: + return der_tlv(0x30, b"".join(items)) + + +def native_android_key_attestation_challenge(material: ProbeMaterial) -> bytes: + return int(time.time()).to_bytes(8, "big") + b"\x1f" + decode_b64_any(material.authkey) + + +def native_android_key_attestation_extension(material: ProbeMaterial) -> bytes: + empty_authorization_list = der_sequence() + return der_sequence( + der_integer(3), + der_enumerated(1), + der_integer(4), + der_enumerated(1), + der_octet_string(native_android_key_attestation_challenge(material)), + der_octet_string(b""), + empty_authorization_list, + empty_authorization_list, + ) + + +def native_attestation_serial_name() -> x509.Name: + return x509.Name([x509.NameAttribute(NameOID.SERIAL_NUMBER, secrets.token_hex(16))]) + + +def native_attestation_leaf_name() -> x509.Name: + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Android Keystore Key")]) + + +def native_attestation_serial() -> int: + return secrets.randbits(128) or 1 + + +def native_cert_builder( + subject: x509.Name, + issuer: x509.Name, + public_key: ec.EllipticCurvePublicKey, + now: dt.datetime, + is_ca: bool, + path_length: int | None, + extensions: list[x509.ExtensionType], +) -> x509.CertificateBuilder: + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(public_key) + .serial_number(native_attestation_serial()) + .not_valid_before(now - dt.timedelta(minutes=1)) + .not_valid_after(now + dt.timedelta(days=365)) + .add_extension(x509.BasicConstraints(ca=is_ca, path_length=path_length), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=is_ca, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + ) + for extension in extensions: + builder = builder.add_extension(extension, critical=False) + return builder + + +def sign_padded_certificate( + subject: x509.Name, + issuer: x509.Name, + public_key: ec.EllipticCurvePublicKey, + signer_key: ec.EllipticCurvePrivateKey, + now: dt.datetime, + is_ca: bool, + path_length: int | None, + extensions: list[x509.ExtensionType], + target_length: int, +) -> bytes: + padding_length = 0 + best = b"" + for _ in range(24): + padded_extensions = list(extensions) + if padding_length > 0: + padded_extensions.append(x509.UnrecognizedExtension(NATIVE_ATTESTATION_PADDING_OID, secrets.token_bytes(padding_length))) + cert = native_cert_builder(subject, issuer, public_key, now, is_ca, path_length, padded_extensions).sign(signer_key, hashes.SHA256()) + der = cert.public_bytes(Encoding.DER) + best = der + diff = target_length - len(der) + if diff == 0: + return der + padding_length = max(0, padding_length + diff) + return best + + +@dataclass(frozen=True) +class WASafeEnvelope: + body: str + authorization: str + enc_hash: str + h_hash: str + + +def build_signed_wasafe_envelope(plain: str, material: ProbeMaterial, mode: str) -> WASafeEnvelope: + enc = encrypt_wasafe(plain) + if mode == "unsigned": + return WASafeEnvelope(body="ENC=" + enc, authorization="", enc_hash=short_hash(enc), h_hash="") + if mode == "empty": + return WASafeEnvelope(body="ENC=" + enc + "&H=", authorization="", enc_hash=short_hash(enc), h_hash="") + + now = dt.datetime.now(dt.timezone.utc) + leaf_key = ec.generate_private_key(ec.SECP256R1()) + root_key = ec.generate_private_key(ec.SECP256R1()) + first_key = ec.generate_private_key(ec.SECP256R1()) + second_key = ec.generate_private_key(ec.SECP256R1()) + + root_subject = native_attestation_serial_name() + first_subject = x509.Name( + [ + x509.NameAttribute(NameOID.SERIAL_NUMBER, secrets.token_hex(16)), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "TEE"), + ] + ) + second_subject = x509.Name( + [ + x509.NameAttribute(NameOID.SERIAL_NUMBER, secrets.token_hex(16)), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "TEE"), + ] + ) + leaf_subject = native_attestation_leaf_name() + + root_der = sign_padded_certificate( + root_subject, + root_subject, + root_key.public_key(), + root_key, + now, + True, + 2, + [], + NATIVE_ATTESTATION_ROOT_DER_LENGTH, + ) + first_der = sign_padded_certificate( + first_subject, + root_subject, + first_key.public_key(), + root_key, + now, + True, + 1, + [], + NATIVE_ATTESTATION_FIRST_INTERMEDIATE_DER_LENGTH, + ) + second_der = sign_padded_certificate( + second_subject, + first_subject, + second_key.public_key(), + first_key, + now, + True, + 0, + [], + NATIVE_ATTESTATION_SECOND_INTERMEDIATE_DER_LENGTH, + ) + leaf_der = sign_padded_certificate( + leaf_subject, + second_subject, + leaf_key.public_key(), + second_key, + now, + False, + None, + [x509.UnrecognizedExtension(ANDROID_KEY_ATTESTATION_OID, native_android_key_attestation_extension(material))], + NATIVE_ATTESTATION_LEAF_DER_LENGTH, + ) + chain_der = root_der + first_der + second_der + leaf_der + + digest = hashlib.sha256(enc.encode("ascii")).digest() + signature = b"" + for _ in range(NATIVE_ATTESTATION_SIGNATURE_MAX_ATTEMPTS): + candidate = leaf_key.sign(digest, ec.ECDSA(asymmetric_utils.Prehashed(hashes.SHA256()))) + signature = candidate + if len(b64u(candidate)) == NATIVE_ATTESTATION_SIGNATURE_RAW_URL_LENGTH: + break + h_value = b64u(signature) + return WASafeEnvelope( + body="ENC=" + enc + "&H=" + h_value, + authorization=base64.b64encode(chain_der).decode("ascii"), + enc_hash=short_hash(enc), + h_hash=short_hash(h_value), + ) + + +def summarize_response(data: dict[str, Any]) -> dict[str, Any]: + reason = str(data.get("reason") or data.get("failure_reason") or "") + status = str(data.get("status") or "") + return { + "status": status, + "reason": reason, + "no_routes": reason == "no_routes", + "request_failed": reason in {"missing_param", "bad_param", "bad_token", "old_version", "invalid_skey"}, + "length": data.get("length"), + "sms_wait": data.get("sms_wait"), + "send_sms_wait": data.get("send_sms_wait"), + "voice_wait": data.get("voice_wait"), + "wa_old_wait": data.get("wa_old_wait"), + "email_otp_wait": data.get("email_otp_wait"), + "flash_wait": data.get("flash_wait"), + } + + +def param_shape(params: list[Param]) -> str: + parts = [] + for param in params: + value = param.value + if param.raw: + try: + value = requests.utils.unquote(value) + except Exception: + pass + mode = "raw" if param.raw else "form" + parts.append(f"{param.key}:{len(value.encode())}:{mode}") + return ",".join(parts) + + +def param_value_hashes(params: list[Param]) -> str: + parts = [] + for param in params: + value = requests.utils.unquote(param.value) if param.raw else param.value + parts.append(f"{param.key}:{len(value.encode())}:{short_hash(value)}") + return ",".join(parts) + + +def post_code(material: ProbeMaterial, config: ShapeConfig, args: argparse.Namespace) -> dict[str, Any]: + params = build_code_params(material, config, args) + plain = render_plain(params) + shape = param_shape(params) + envelope_mode = "signed" + if args.unsigned: + envelope_mode = "unsigned" + if args.empty_h: + envelope_mode = "empty" + result: dict[str, Any] = { + "variant": config.name, + "phone_hash": short_hash(material.e164), + "phone_last4": material.e164[-4:], + "field_count": len(params), + "plain_len": len(plain), + "shape_hash": short_hash(shape), + "envelope_mode": envelope_mode, + } + if args.show_fields: + result["fields"] = shape + result["value_hashes"] = param_value_hashes(params) + if args.dry_run: + result["dry_run"] = True + return result + envelope = build_signed_wasafe_envelope(plain, material, envelope_mode) + result["enc_hash"] = envelope.enc_hash + if envelope.h_hash: + result["h_hash"] = envelope.h_hash + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": args.user_agent or USER_AGENT, + "WaMsysRequest": "1", + "X-Forwarded-Host": "v.whatsapp.net", + } + if envelope.authorization: + headers["Authorization"] = envelope.authorization + try: + response_status, parsed = post_form(args.transport, CODE_URL, headers, envelope.body, args.proxy, args.timeout) + if not isinstance(parsed, dict): + parsed = {"raw": parsed} + result["http_status"] = response_status + result.update(summarize_response(parsed)) + if args.show_response: + result["response"] = sanitize_response(parsed) + except Exception as exc: # noqa: BLE001 - command-line probe must summarize network failures. + result["error"] = sanitize_text(str(exc), args.proxy) + return result + + +def post_form(transport: str, url: str, headers: dict[str, str], body: str, proxy: str, timeout: float) -> tuple[int, Any]: + if transport == "requests": + proxies = {"http": proxy, "https": proxy} if proxy else None + response = requests.post(url, headers=headers, data=body, proxies=proxies, timeout=timeout, verify=False) + try: + parsed: Any = response.json() + except ValueError: + parsed = {"raw": response.text[:500]} + return response.status_code, parsed + if transport in {"curl", "curl-http1.1"}: + return post_form_curl(transport, url, headers, body, proxy, timeout) + raise ValueError(f"unknown transport: {transport}") + + +def post_form_curl(transport: str, url: str, headers: dict[str, str], body: str, proxy: str, timeout: float) -> tuple[int, Any]: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as body_file: + body_file.write(body) + body_path = body_file.name + try: + cmd = [ + "curl", + "--silent", + "--show-error", + "--insecure", + "--request", + "POST", + "--max-time", + str(max(timeout, 1)), + "--data-binary", + "@" + body_path, + "--write-out", + "\n%{http_code}", + ] + if transport == "curl-http1.1": + cmd.append("--http1.1") + if proxy: + cmd.extend(["--proxy", proxy]) + for key, value in headers.items(): + cmd.extend(["--header", f"{key}: {value}"]) + cmd.append(url) + proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if proc.returncode != 0: + raise RuntimeError(sanitize_text(proc.stderr[-500:], proxy)) + payload, _, status_text = proc.stdout.rpartition("\n") + try: + status = int(status_text.strip()) + except ValueError: + status = 0 + payload = proc.stdout + try: + parsed: Any = json.loads(payload) + except ValueError: + parsed = {"raw": payload[:500]} + return status, parsed + finally: + try: + os.unlink(body_path) + except OSError: + pass + + +def config_for_variant(name: str) -> ShapeConfig: + if name == "current": + return ShapeConfig(name="current") + if name == "ghcr": + return ShapeConfig( + name="ghcr", + client_metrics_source="google-play|unknown", + db="0", + device_ram="3.53", + pid_mode="ghcr", + sim_signal=False, + gpia_error_code=-2, + gpia_data_so_digest=GHCR_GPIA_DATA_SO_DIGEST, + gpia_source_mode="ghcr", + gpia_escape_slash=False, + wamsys_order="ghcr", + wamsys_values="ghcr", + ) + raise ValueError(f"unknown variant: {name}") + + +def apply_patch_name(config: ShapeConfig, patch: str) -> ShapeConfig: + patch = patch.strip() + patch_updates: dict[str, dict[str, Any]] = { + "client-metrics-google-play": {"client_metrics_source": "google-play|unknown"}, + "client-metrics-unknown": {"client_metrics_source": "unknown|unknown"}, + "db-zero": {"db": "0"}, + "db-one": {"db": "1"}, + "gpia-error-minus-two": {"gpia_error_code": -2}, + "gpia-error-1005": {"gpia_error_code": 1005}, + "gpia-data-digest-ghcr": {"gpia_data_so_digest": GHCR_GPIA_DATA_SO_DIGEST}, + "gpia-data-digest-current": {"gpia_data_so_digest": CURRENT_GPIA_DATA_SO_DIGEST}, + "gpia-source-ghcr": {"gpia_source_mode": "ghcr"}, + "gpia-source-current": {"gpia_source_mode": "current"}, + "gpia-json-no-slash-escape": {"gpia_escape_slash": False}, + "gpia-json-slash-escape": {"gpia_escape_slash": True}, + "wamsys-order-ghcr": {"wamsys_order": "ghcr"}, + "wamsys-order-current": {"wamsys_order": "current"}, + "wamsys-values-ghcr": {"wamsys_values": "ghcr"}, + "wamsys-values-current": {"wamsys_values": "current"}, + "wamsys-ghcr": {"wamsys_order": "ghcr", "wamsys_values": "ghcr"}, + "no-sim-signal": {"sim_signal": False}, + "sim-signal": {"sim_signal": True}, + "operator-ar-722310": {"operator_mode": "ar722310"}, + "operator-co-732101": {"operator_mode": "co732101"}, + "operator-zero": {"operator_mode": "zero"}, + "operator-omit": {"operator_mode": "omit"}, + "device-ghcr-defaults": {"device_ram": "3.53", "pid_mode": "ghcr", "network_radio_type": "1"}, + "device-current-defaults": {"device_ram": "6.58", "pid_mode": "current", "network_radio_type": "1"}, + } + updates = patch_updates.get(patch) + if updates is None: + raise ValueError(f"unknown patch: {patch}") + patched = replace(config, **updates) + return replace(patched, name=config.name + "+" + patch) + + +def patch_list(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def build_configs(args: argparse.Namespace) -> list[ShapeConfig]: + base = config_for_variant(args.variant) + patches = patch_list(args.patch) + if not args.matrix: + config = base + for patch in patches: + config = apply_patch_name(config, patch) + return [apply_cli_config_overrides(config, args)] + matrix = [base] + for patch in patches: + matrix.append(apply_patch_name(base, patch)) + return [apply_cli_config_overrides(config, args) for config in matrix] + + +def apply_cli_config_overrides(config: ShapeConfig, args: argparse.Namespace) -> ShapeConfig: + updates: dict[str, Any] = {} + if args.device_display_id: + updates["device_display_id"] = args.device_display_id + if args.device_ram: + updates["device_ram"] = args.device_ram + if not updates: + return config + return replace(config, **updates) + + +def list_patches() -> None: + for patch in [ + "client-metrics-google-play", + "client-metrics-unknown", + "db-zero", + "db-one", + "gpia-error-minus-two", + "gpia-error-1005", + "gpia-data-digest-ghcr", + "gpia-data-digest-current", + "gpia-source-ghcr", + "gpia-source-current", + "gpia-json-no-slash-escape", + "gpia-json-slash-escape", + "wamsys-order-ghcr", + "wamsys-order-current", + "wamsys-values-ghcr", + "wamsys-values-current", + "wamsys-ghcr", + "no-sim-signal", + "sim-signal", + "operator-ar-722310", + "operator-co-732101", + "operator-zero", + "operator-omit", + "device-ghcr-defaults", + "device-current-defaults", + ]: + print(patch) + + +def run(args: argparse.Namespace) -> int: + if args.list_patches: + list_patches() + return 0 + args.proxy = normalize_proxy(args.proxy or os.environ.get("WA_PROBE_PROXY_URL", "")) + repo_root = Path(__file__).resolve().parents[1] + shared_phones = phone_inputs(args) if (args.phone or args.reuse_phones) else None + configs = build_configs(args) + totals: dict[str, dict[str, int]] = {config.name: {"total": 0, "no_routes": 0, "ok_or_sent": 0, "errors": 0} for config in configs} + for config in configs: + phones = shared_phones if shared_phones is not None else phone_inputs(args) + for index, (cc, national) in enumerate(phones, start=1): + material = new_probe_material(repo_root, cc, national) + row = post_code(material, config, args) + row["probe"] = index + print(json.dumps(row, ensure_ascii=False, sort_keys=True), flush=True) + bucket = totals[config.name] + bucket["total"] += 1 + if row.get("no_routes"): + bucket["no_routes"] += 1 + if str(row.get("status", "")).lower() in {"ok", "sent"}: + bucket["ok_or_sent"] += 1 + if row.get("error"): + bucket["errors"] += 1 + if not args.dry_run and args.sleep > 0 and (index != len(phones) or config != configs[-1]): + time.sleep(args.sleep + random.random() * min(args.sleep, 0.5)) + print(json.dumps({"summary": totals}, ensure_ascii=False, sort_keys=True), flush=True) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Send WA /v2/code parameter probes with random AR/CO numbers and one-off shape patches.") + parser.add_argument("--country", default="AR", help="random phone country; supports AR and CO") + parser.add_argument("--cc", default="54", help="default country calling code for --phone") + parser.add_argument("--phone", action="append", default=[], help="specific phone; can repeat. If omitted, random numbers for --country are generated") + parser.add_argument("--count", type=int, default=5, help="random phone count when --phone is omitted") + parser.add_argument("--proxy", default="", help="HTTP proxy URL. Prefer WA_PROBE_PROXY_URL env to avoid shell history") + parser.add_argument("--timeout", type=float, default=25) + parser.add_argument("--sleep", type=float, default=0.8, help="sleep between outbound requests") + parser.add_argument("--variant", choices=["current", "ghcr"], default="current") + parser.add_argument("--patch", default="", help="comma-separated single-parameter patch names") + parser.add_argument("--matrix", action="store_true", help="run baseline plus each patch independently") + parser.add_argument("--reuse-phones", action="store_true", help="reuse the same random phones across matrix variants; default is fresh phones per variant") + parser.add_argument("--set", dest="set_param", action="append", default=[], help="override final raw param as key=value; can repeat") + parser.add_argument("--user-agent", default="", help="override request User-Agent for device UA experiments") + parser.add_argument("--device-display-id", default="", help="override GPIA device display ID used in _gi.did") + parser.add_argument("--device-ram", default="", help="override device_ram map parameter") + parser.add_argument("--omit", action="append", default=[], help="omit final param by key; can repeat") + parser.add_argument("--unsigned", action="store_true", help="send ENC without H/Authorization for no-auth envelope comparison") + parser.add_argument("--empty-h", action="store_true", help="send legacy ENC with an empty H value for regression comparison") + parser.add_argument("--dry-run", action="store_true", help="render request shape only; do not send") + parser.add_argument("--show-fields", action="store_true", help="print field order/lengths and value hashes") + parser.add_argument("--show-response", action="store_true", help="print sanitized response payload") + parser.add_argument("--transport", choices=["requests", "curl", "curl-http1.1"], default="requests") + parser.add_argument("--list-patches", action="store_true") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + return run(args) + except Exception as exc: # noqa: BLE001 - CLI entrypoint. + print(json.dumps({"error": sanitize_text(str(exc), getattr(args, "proxy", ""))}, ensure_ascii=False), flush=True) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wa_code_random_device_experiment.py b/scripts/wa_code_random_device_experiment.py new file mode 100755 index 0000000..273239d --- /dev/null +++ b/scripts/wa_code_random_device_experiment.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import random +import string +import subprocess +import sys +import time +from dataclasses import dataclass +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +PROBE_SCRIPT = SCRIPT_DIR / "wa_code_param_probe.py" +APP_VERSION = "2.26.23.71" + + +@dataclass(frozen=True) +class DeviceProfile: + label: str + vendor: str + model: str + android: str + display_id: str + ram_gib: str + + @property + def user_agent(self) -> str: + return f"WhatsApp/{APP_VERSION} Android/{self.android} Device/{self.vendor}-{self.model}" + + +CONTROL_DEVICES = { + "oppo-known-a12": DeviceProfile("oppo-known-a12", "OPPO", "CPH2305", "12", "CPH2305_12.1.0.210(EX1)", "5.42"), + "xiaomi-known-a11": DeviceProfile("xiaomi-known-a11", "Xiaomi", "M2007J3SC", "11", "M2007J3SC_11.0.14(CN01)", "6.58"), + "oneplus-known-a14": DeviceProfile("oneplus-known-a14", "OnePlus", "LE2100", "14", "LE2100_14.0.0.605(CN01)", "11.24"), +} +GENERIC_VENDOR = "VANTADigital" +GENERIC_MODEL = "A3820WF" +GENERIC_RAM = "5.50" +XIAOMI_MODEL = "M2007J3SC" +XIAOMI_RAM = "6.58" + + +def fixed_generic_profile(label: str, android: str, ram_gib: str = GENERIC_RAM, display_android: str | None = None) -> DeviceProfile: + did_android = display_android or android + return DeviceProfile( + label=label, + vendor=GENERIC_VENDOR, + model=GENERIC_MODEL, + android=android, + display_id=f"{GENERIC_MODEL}_{did_android}.0.4.210(GL01)", + ram_gib=ram_gib, + ) + + +def fixed_xiaomi_profile(label: str, android: str, ram_gib: str = XIAOMI_RAM, display_android: str | None = None) -> DeviceProfile: + did_android = display_android or android + return DeviceProfile( + label=label, + vendor="Xiaomi", + model=XIAOMI_MODEL, + android=android, + display_id=f"{XIAOMI_MODEL}_{did_android}.0.14(CN01)", + ram_gib=ram_gib, + ) + + +def fixed_generic_with_xiaomi_display(label: str) -> DeviceProfile: + return DeviceProfile(label, GENERIC_VENDOR, GENERIC_MODEL, "11", f"{XIAOMI_MODEL}_11.0.14(CN01)", GENERIC_RAM) + + +def fixed_xiaomi_with_generic_display(label: str) -> DeviceProfile: + return DeviceProfile(label, "Xiaomi", XIAOMI_MODEL, "11", f"{GENERIC_MODEL}_11.0.4.210(GL01)", XIAOMI_RAM) + + +def rand_digits(length: int) -> str: + return "".join(random.choice(string.digits) for _ in range(length)) + + +def rand_upper(length: int) -> str: + return "".join(random.choice(string.ascii_uppercase) for _ in range(length)) + + +def random_ram(min_value: float, max_value: float) -> str: + return f"{random.uniform(min_value, max_value):.2f}" + + +def random_brand() -> str: + prefixes = ["NOVA", "AERO", "ORBI", "LYRA", "VANTA", "ZENO", "NIMO", "KORA", "ALTO", "MEGA"] + suffixes = ["Mobile", "Phone", "Tech", "One", "Digital", "Comms", "Labs", "Link"] + return random.choice(prefixes) + random.choice(suffixes) + + +def random_generic_profile(label: str, android: str) -> DeviceProfile: + model = random.choice(["X", "A", "M", "N", "Z"]) + rand_digits(4) + rand_upper(2) + branch = random.choice(["GX", "GL", "EEA", "IN", "LA"]) + return DeviceProfile( + label=label, + vendor=random_brand(), + model=model, + android=android, + display_id=f"{model}_{android}.0.{random.randint(1, 9)}.{random.randint(10, 999)}({branch}01)", + ram_gib=random_ram(3.5, 7.8), + ) + + +def random_oppo_like_profile(label: str) -> DeviceProfile: + model = "CPH" + rand_digits(4) + return DeviceProfile( + label=label, + vendor="OPPO", + model=model, + android="12", + display_id=f"{model}_12.1.{random.randint(0, 5)}.{random.randint(100, 999)}(EX1)", + ram_gib=random_ram(3.6, 7.4), + ) + + +def random_xiaomi_like_profile(label: str) -> DeviceProfile: + model = "M" + rand_digits(7) + random.choice(["C", "G", "I", "K"]) + return DeviceProfile( + label=label, + vendor="Xiaomi", + model=model, + android="11", + display_id=f"{model}_11.0.{random.randint(1, 14)}(CN01)", + ram_gib=random_ram(5.5, 7.8), + ) + + +def build_device(label: str) -> DeviceProfile: + if label in CONTROL_DEVICES: + return CONTROL_DEVICES[label] + if label.startswith("generic-a") and label[9:].isdigit(): + android = label[9:] + return fixed_generic_profile(label, android) + if label.startswith("xiaomi-a") and label[8:].isdigit(): + android = label[8:] + return fixed_xiaomi_profile(label, android) + if label.startswith("ram-a11-"): + raw = label.removeprefix("ram-a11-") + if raw.isdigit(): + return fixed_generic_profile(label, "11", f"{int(raw) / 100:.2f}") + if label == "consistent-generic-a11": + return fixed_generic_profile(label, "11") + if label == "ua-a11-did-a12": + return fixed_generic_profile(label, "11", display_android="12") + if label == "ua-a12-did-a11": + return fixed_generic_profile(label, "12", display_android="11") + if label == "generic-ua-xiaomi-did-a11": + return fixed_generic_with_xiaomi_display(label) + if label == "xiaomi-ua-generic-did-a11": + return fixed_xiaomi_with_generic_display(label) + if label == "random-generic-a12": + return random_generic_profile(label, "12") + if label == "random-generic-a11": + return random_generic_profile(label, "11") + if label == "random-oppo-like-a12": + return random_oppo_like_profile(label) + if label == "random-xiaomi-like-a11": + return random_xiaomi_like_profile(label) + raise ValueError(f"unknown device label: {label}") + + +PRESET_LABELS = { + "all": [ + "oppo-known-a12", + "xiaomi-known-a11", + "oneplus-known-a14", + "random-oppo-like-a12", + "random-xiaomi-like-a11", + "random-generic-a12", + "random-generic-a11", + ], + "random": ["random-oppo-like-a12", "random-xiaomi-like-a11", "random-generic-a12", "random-generic-a11"], + "android-sweep": ["generic-a10", "generic-a11", "generic-a12", "generic-a13", "generic-a14"], + "ram-sweep": ["ram-a11-350", "ram-a11-450", "ram-a11-550", "ram-a11-650", "ram-a11-750", "ram-a11-1124"], + "xiaomi-android": ["xiaomi-a10", "xiaomi-a11", "xiaomi-a12", "xiaomi-a13", "xiaomi-a14"], + "consistency": [ + "consistent-generic-a11", + "ua-a11-did-a12", + "ua-a12-did-a11", + "generic-ua-xiaomi-did-a11", + "xiaomi-ua-generic-did-a11", + ], +} +PRESET_LABELS["factor-all"] = ( + PRESET_LABELS["android-sweep"] + + PRESET_LABELS["ram-sweep"] + + PRESET_LABELS["xiaomi-android"] + + PRESET_LABELS["consistency"] +) + + +def parse_labels(value: str) -> list[str]: + labels: list[str] = [] + for item in [part.strip() for part in value.strip().split(",") if part.strip()]: + labels.extend(PRESET_LABELS.get(item, [item])) + return labels + + +def classify(row: dict[str, Any]) -> str: + if row.get("error"): + return "transport_error" + status = str(row.get("status") or "").lower() + reason = str(row.get("reason") or "").lower() + if status in {"sent", "ok"}: + return "sent" + if reason == "no_routes": + return "no_routes" + if reason == "blocked": + return "blocked" + if reason == "too_recent": + return "too_recent" + if reason == "bad_token": + return "bad_token" + if row.get("request_failed"): + return "request_failed" + if status == "fail": + return "other_fail" + return "unknown" + + +def run_probe_once(args: argparse.Namespace, device: DeviceProfile) -> dict[str, Any]: + cmd = [ + sys.executable, + str(PROBE_SCRIPT), + "--country", + args.country, + "--count", + "1", + "--variant", + args.variant, + "--timeout", + str(args.timeout), + "--sleep", + "0", + "--user-agent", + device.user_agent, + "--device-display-id", + device.display_id, + "--device-ram", + device.ram_gib, + ] + if args.patch: + cmd.extend(["--patch", args.patch]) + if args.proxy: + cmd.extend(["--proxy", args.proxy]) + if args.dry_run: + cmd.append("--dry-run") + if args.show_fields: + cmd.append("--show-fields") + env = os.environ.copy() + if args.proxy: + env["WA_PROBE_PROXY_URL"] = args.proxy + proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, check=False) + row: dict[str, Any] | None = None + for line in proc.stdout.splitlines(): + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict) and "summary" not in parsed: + row = parsed + break + if row is None: + row = {"error": "probe produced no JSON row", "stdout_tail": proc.stdout[-500:]} + if proc.returncode != 0 and "error" not in row: + row["error"] = f"probe exited with {proc.returncode}" + row["label"] = device.label + row["vendor"] = device.vendor + row["model"] = device.model + row["android"] = device.android + row["display_id_hash"] = row.get("display_id_hash") or short_hash(device.display_id) + row["ram_gib"] = device.ram_gib + row["outcome"] = classify(row) + return row + + +def short_hash(value: str) -> str: + import hashlib + + return hashlib.sha256(value.encode()).hexdigest()[:16] + + +def rate(numerator: int, denominator: int) -> float | None: + if denominator <= 0: + return None + return round(numerator / denominator, 4) + + +def summarize(rows: list[dict[str, Any]]) -> dict[str, Any]: + labels = sorted({str(row.get("label") or "") for row in rows}) + summary: dict[str, Any] = {} + for label in labels: + group = [row for row in rows if row.get("label") == label] + counts = { + key: 0 + for key in [ + "sent", + "no_routes", + "blocked", + "bad_token", + "too_recent", + "request_failed", + "transport_error", + "other_fail", + "unknown", + ] + } + for row in group: + outcome = str(row.get("outcome") or "unknown") + counts[outcome] = counts.get(outcome, 0) + 1 + total = len(group) + target = counts["sent"] + counts["no_routes"] + summary[label] = { + "total": total, + **counts, + "target_decisions": target, + "sent_rate": rate(counts["sent"], total), + "sent_rate_on_target": rate(counts["sent"], target), + } + return summary + + +def markdown_table(summary: dict[str, Any]) -> str: + headers = ["variant", "total", "sent", "no_routes", "blocked", "bad_token", "target", "sent/target"] + lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)] + for label, item in sorted(summary.items(), key=lambda pair: pair[0]): + values = [ + label, + str(item.get("total", 0)), + str(item.get("sent", 0)), + str(item.get("no_routes", 0)), + str(item.get("blocked", 0)), + str(item.get("bad_token", 0)), + str(item.get("target_decisions", 0)), + str(item.get("sent_rate_on_target")), + ] + lines.append("| " + " | ".join(values) + " |") + return "\n".join(lines) + + +def output_paths(args: argparse.Namespace) -> tuple[Path, Path]: + run_id = args.run_id or time.strftime("%Y%m%d-%H%M%S") + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir / f"{run_id}.ndjson", out_dir / f"{run_id}.summary.json" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run SMS-only /v2/code experiments with known and random-generated Android device models.") + parser.add_argument("--country", default="CO", help="random phone country; default CO") + parser.add_argument("--samples", type=int, default=6, help="samples per device label") + parser.add_argument("--labels", default="all", help="all, random, android-sweep, ram-sweep, xiaomi-android, consistency, factor-all, or comma-separated labels") + parser.add_argument("--variant", choices=["current", "ghcr"], default="current") + parser.add_argument("--patch", default="", help="comma-separated wa_code_param_probe patch names") + parser.add_argument("--timeout", type=float, default=25) + parser.add_argument("--sleep", type=float, default=0.8) + parser.add_argument("--jitter", type=float, default=0.5) + parser.add_argument("--proxy", default="", help="HTTP proxy URL; WA_PROBE_PROXY_URL is used when omitted") + parser.add_argument("--allow-direct", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--show-fields", action="store_true") + parser.add_argument("--out-dir", default=".temp/wa-code-param-experiments") + parser.add_argument("--run-id", default="") + args = parser.parse_args() + + args.proxy = args.proxy or os.environ.get("WA_PROBE_PROXY_URL", "") + if not args.proxy and not args.allow_direct and not args.dry_run: + print(json.dumps({"error": "set WA_PROBE_PROXY_URL or pass --proxy; use --allow-direct only intentionally"}, ensure_ascii=False), file=sys.stderr) + return 2 + + labels = parse_labels(args.labels) + ndjson_path, summary_path = output_paths(args) + rows: list[dict[str, Any]] = [] + with ndjson_path.open("w", encoding="utf-8") as handle: + for round_index in range(1, args.samples + 1): + round_labels = list(labels) + random.shuffle(round_labels) + for label in round_labels: + device = build_device(label) + row = run_probe_once(args, device) + row["round"] = round_index + rows.append(row) + line = json.dumps(row, ensure_ascii=False, sort_keys=True) + print(line, flush=True) + handle.write(line + "\n") + handle.flush() + if not args.dry_run and args.sleep > 0: + time.sleep(args.sleep + random.random() * max(args.jitter, 0)) + + summary = summarize(rows) + payload = {"samples_per_label": args.samples, "country": args.country, "labels": labels, "summary": summary} + summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps({"result_file": str(ndjson_path), "summary_file": str(summary_path), "summary": summary}, ensure_ascii=False, sort_keys=True)) + print(markdown_table(summary)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wa_exist_probe.py b/scripts/wa_exist_probe.py index 09a9853..7e50990 100755 --- a/scripts/wa_exist_probe.py +++ b/scripts/wa_exist_probe.py @@ -266,7 +266,7 @@ def exist_device_map(state: ProbeState) -> dict[str, str]: return { "mistyped": "7", "offline_ab": '{"exposure":[],"exp_hash":[],"metrics":{}}', - "client_metrics": '{"attempts":1,"app_campaign_download_source":"google-play|unknown","was_activated_from_stub":false}', + "client_metrics": '{"attempts":1,"app_campaign_download_source":"unknown|unknown","was_activated_from_stub":false}', "read_phone_permission_granted": "0", "sim_state": "1", "network_operator_name": state.device_map["network_operator_name"], diff --git a/webui/src/dashboard/wa-account-add-model.ts b/webui/src/dashboard/wa-account-add-model.ts new file mode 100644 index 0000000..5d4a19e --- /dev/null +++ b/webui/src/dashboard/wa-account-add-model.ts @@ -0,0 +1,35 @@ +import type { WaWorkflowResponse } from './wa-api'; +import { accountReasonLabel, countdownLabel } from './wa-result-labels'; +import type { WaProbeStatus } from './wa-result-model'; +import { resolveWaPhoneTarget, type WaResolvedPhone } from './wa-utils'; + +export type WaAccountAddProbeState = { target: WaResolvedPhone; result: WaWorkflowResponse } | null; + +export function probeMatchesValues(probe: WaAccountAddProbeState, phone: string, countryCallingCode: string) { + if (!probe) return false; + return resolveWaPhoneTarget(phone, countryCallingCode).target?.e164 === probe.target.e164; +} + +export function workflowText(result: WaWorkflowResponse, key: keyof WaWorkflowResponse) { + const value = result[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +export function registrationFailureMessage(result: WaWorkflowResponse, status: WaProbeStatus) { + const detail = status.failureReason || result.error_message || result.status || ''; + const reason = accountReasonLabel(detail); + if (status.blocked) return '号码被拒绝/封禁'; + if (status.accountFlow === 'invalid_number') return reason || '号码无效'; + if (status.smsWaitSeconds && status.smsWaitSeconds > 0) return `请求冷却中,${countdownLabel(status.smsWaitSeconds)} 后重试`; + if (status.accountFlow === 'rate_limited') return reason || '请求冷却中'; + return reason || '注册失败'; +} + +export async function copyClipboardText(value: string, onDone: (message: string) => void, onError: (message: string) => void) { + try { + await navigator.clipboard.writeText(value); + onDone('已复制'); + } catch { + onError('复制失败'); + } +} diff --git a/webui/src/dashboard/wa-account-add.tsx b/webui/src/dashboard/wa-account-add.tsx index 485fefc..02a1cfb 100644 --- a/webui/src/dashboard/wa-account-add.tsx +++ b/webui/src/dashboard/wa-account-add.tsx @@ -1,26 +1,30 @@ import { useEffect, useState } from 'react'; -import { CheckCircle2, KeyRound, Search } from 'lucide-react'; +import { CheckCircle2, Search } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { probeWaPhoneSMS, registerWaPhone, submitWaRegistrationOTP, type WaWorkflowResponse } from './wa-api'; +import { probeMatchesValues, registrationFailureMessage, workflowText, type WaAccountAddProbeState } from './wa-account-add-model'; import { WhatsAppIcon } from './wa-brand-icon'; -import { accountReasonLabel } from './wa-result-labels'; +import { accountReasonLabel, countdownLabel } from './wa-result-labels'; import { waProbeStatus } from './wa-result-model'; import { WaRegistrationChannelButtons } from './wa-registration-channel-buttons'; import { WaRegistrationOtpCard, WA_REGISTRATION_OTP_LENGTH } from './wa-registration-otp-card'; -import { registrationAnyMethodAvailable, registrationChannelsHardBlocked, type SelectableRegistrationMethodOption } from './wa-registration-methods'; -import { WaResultPanel } from './wa-result-panel'; +import { + registrationAnyMethodAvailable, + registrationChannelsHardBlocked, + registrationMinimumCooldownSeconds, + type SelectableRegistrationMethodOption, +} from './wa-registration-methods'; import { resolveWaPhoneTarget, type WaResolvedPhone } from './wa-utils'; -type ProbeState = { target: WaResolvedPhone; result: WaWorkflowResponse } | null; -type PendingRegistration = { accountID: string }; +type PendingRegistration = { accountID: string; verificationRequestID: string }; type Props = { disabled?: boolean; onChanged: () => void | Promise; onDone: (message: string) => void; onError: (message: string) => void }; export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { const [phone, setPhone] = useState(''); const [countryCallingCode, setCountryCallingCode] = useState(''); - const [probe, setProbe] = useState(null); + const [probe, setProbe] = useState(null); const [pending, setPending] = useState(null); const [registrationResult, setRegistrationResult] = useState(null); const [registrationTarget, setRegistrationTarget] = useState(null); @@ -30,18 +34,19 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { const [busy, setBusy] = useState(false); const samePhone = probeMatchesValues(probe, phone, countryCallingCode); const currentTarget = resolveWaPhoneTarget(phone, countryCallingCode).target; + const hasPhoneTarget = Boolean(currentTarget); const registrationSamePhone = Boolean(registrationTarget && currentTarget?.e164 === registrationTarget.e164); const activeRegistrationResult = registrationSamePhone ? registrationResult : null; const status = waProbeStatus(activeRegistrationResult || (samePhone ? probe?.result : null)); const channelStatus = samePhone ? waProbeStatus(activeRegistrationResult || probe?.result) : null; const cooldownElapsedSeconds = Math.max(0, (clockNow - cooldownStartedAt) / 1000); const blocked = status.blocked === true; - const showChannels = Boolean(channelStatus && !pending); const channelsHardBlocked = registrationChannelsHardBlocked(channelStatus); + const nextCooldownSeconds = registrationMinimumCooldownSeconds(channelStatus, cooldownElapsedSeconds); const canRegister = samePhone && registrationAnyMethodAvailable(channelStatus, cooldownElapsedSeconds) && !channelsHardBlocked; const detected = samePhone && Boolean(channelStatus); const badgeVariant = pending ? 'default' : blocked ? 'destructive' : canRegister ? 'default' : detected ? 'secondary' : 'outline'; - const badgeLabel = pending ? '等待 OTP' : blocked ? '已封禁' : canRegister ? '可注册' : detected ? '无可直发' : '待检测'; + const badgeLabel = accountAddBadgeLabel(Boolean(pending), blocked, canRegister, nextCooldownSeconds, detected); useEffect(() => { const activeResult = activeRegistrationResult || (samePhone ? probe?.result : null); @@ -89,7 +94,7 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { async function startRegistration(method: SelectableRegistrationMethodOption) { const resolved = resolveWaPhoneTarget(phone, countryCallingCode); if (!resolved.target) return onError(resolved.error || '请输入手机号和国家拨号码'); - if (!samePhone || !channelStatus) return onError('请先检测'); + if (!samePhone || !channelStatus) return onError('请先检测验证通道'); setBusy(true); try { const result = await registerWaPhone(resolved.target.input, method.value); @@ -102,7 +107,8 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { return; } const accountID = workflowText(result, 'wa_account_id'); - if (accountID) setPending({ accountID }); + const verificationRequestID = workflowText(result, 'verification_request_id'); + if (accountID) setPending({ accountID, verificationRequestID }); setProbe(null); setOtp(''); onDone(accountID ? 'OTP 已发送' : '已发起'); @@ -118,15 +124,12 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { setCooldownStartedAt(now); setClockNow(now); } - return ( -
- 添加 WAAccount -
+
添加 WAAccount
- {pending ? : canRegister ? : null} + {canRegister ? : null} {badgeLabel}
@@ -144,35 +147,27 @@ export function WaAccountAdd({ disabled, onChanged, onDone, onError }: Props) { {probe && !samePhone && 号码已变化,请重新检测} - {showChannels && ( - - 通道 - void startRegistration(method)} /> - - )} + + 通道 + void startRegistration(method)} + /> + {pending && void submitOTP()} />} - {(activeRegistrationResult || probe || busy) && ( - - - - )}
); } -function probeMatchesValues(probe: ProbeState, phone: string, countryCallingCode: string) { - if (!probe) return false; - return resolveWaPhoneTarget(phone, countryCallingCode).target?.e164 === probe.target.e164; -} -function workflowText(result: WaWorkflowResponse, key: keyof WaWorkflowResponse) { - const value = result[key]; - return typeof value === 'string' ? value.trim() : ''; -} -function registrationFailureMessage(result: WaWorkflowResponse, status: ReturnType) { - const detail = status.failureReason || result.error_message || result.status || ''; - const reason = accountReasonLabel(detail); - if (status.blocked) return '号码被拒绝/封禁'; - if (status.accountFlow === 'invalid_number') return reason || '号码无效'; - if (status.accountFlow === 'rate_limited') return reason || '请求冷却中'; - return reason || '注册失败'; + +function accountAddBadgeLabel(pending: boolean, blocked: boolean, canRegister: boolean, cooldownSeconds: number, detected: boolean) { + if (pending) return '等待 OTP'; + if (blocked) return '已封禁'; + if (canRegister) return '可注册'; + if (cooldownSeconds > 0) return `冷却 ${countdownLabel(cooldownSeconds)}`; + if (detected) return '暂无可用'; + return '待检测'; } diff --git a/webui/src/dashboard/wa-account-change-number-card.tsx b/webui/src/dashboard/wa-account-change-number-card.tsx new file mode 100644 index 0000000..fd73635 --- /dev/null +++ b/webui/src/dashboard/wa-account-change-number-card.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { ArrowRightLeft, PhoneForwarded } from 'lucide-react'; +import type { WAAccount } from '../proto/byte/v/forge/waapp/v1/profile'; +import { Button } from '@/components/ui/button'; +import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { resolveWaPhoneTarget } from './wa-utils'; + +type Props = { + account: WAAccount; + busy?: boolean; + onError: (message: string) => void; +}; + +export function WaAccountChangeNumberCard({ account, busy, onError }: Props) { + const [countryCallingCode, setCountryCallingCode] = useState(''); + const [phone, setPhone] = useState(''); + const currentPhone = account.phone?.e164_number || '-'; + function startChangeNumber() { + const resolved = resolveWaPhoneTarget(phone, countryCallingCode); + if (!resolved.target) return onError(resolved.error || '请输入新手机号和国家拨号码'); + return onError('换绑手机号链路尚未接入:需要按 APK ChangeNumber/ChangeNumberOverview 链路补齐后端实现'); + } + return ( +
+
+
+
账号迁移 / 换绑手机号
+
对应已登录账号安全设置里的 Change number,不是注册侧旧设备验证。
+
+
+ + + 当前手机号 + + + + 新国家拨号码 + setCountryCallingCode(event.target.value)} disabled={busy} /> + + + 新手机号 + setPhone(event.target.value)} disabled={busy} /> + + + +
+ ); +} diff --git a/webui/src/dashboard/wa-account-security.tsx b/webui/src/dashboard/wa-account-security.tsx index 66f0223..2116653 100644 --- a/webui/src/dashboard/wa-account-security.tsx +++ b/webui/src/dashboard/wa-account-security.tsx @@ -5,6 +5,7 @@ import { AccountSettingsOperationStatus } from '../proto/byte/v/forge/waapp/v1/a import type { GetTwoFactorAuthStatusResponse } from '../proto/byte/v/forge/waapp/v1/account_settings'; import type { WaAccountProjection } from './wa-api'; import { getWaTwoFactorAuthStatus, requestWaAccountEmailOtp, setWaAccountEmail, setWaTwoFactorAuthSettings, verifyWaAccountEmailOtp, waAccountID, waKeys } from './wa-api'; +import { WaAccountChangeNumberCard } from './wa-account-change-number-card'; import { emailBadgeVariant, emailStatusLabel, @@ -142,6 +143,7 @@ export function WaAccountSecurityPanel({ account, onDone, onError }: Props) { {emailConfigured && !emailEditing ? : null} {emailFormVisible ? { setEmail(''); setEmailEditing(false); }} onSubmit={(event) => submit(event, emailSet.mutate)} /> : null} + {emailOtpVisible && (
邮箱 OTP
diff --git a/webui/src/dashboard/wa-api.ts b/webui/src/dashboard/wa-api.ts index 10b96c8..8876725 100644 --- a/webui/src/dashboard/wa-api.ts +++ b/webui/src/dashboard/wa-api.ts @@ -8,7 +8,7 @@ import type { VerificationDeliveryMethod } from '../proto/byte/v/forge/waapp/v1/ export const ACCOUNT_PAGE_SIZE = 100; export type WaPhoneInput = { region: string; phone: string; e164_number: string; country_calling_code: string; country_iso2: string }; -export type WaWorkflowResponse = { success?: boolean; passed?: boolean; request_failed?: boolean; status?: string; error_message?: string; reject_reason?: string; wa_account_id?: string; client_profile_id?: string; protocol_profile_id?: string; verification_request_id?: string; delivery_method?: string; method?: string; registration_phase?: string; method_statuses?: unknown[]; phone_status?: Record; account_probe?: Record; sms_probe?: Record; phone?: Record; proxy?: Record; verification_request?: Record; registration?: Record; login_state?: Record; check?: Record }; +export type WaWorkflowResponse = { success?: boolean; passed?: boolean; request_failed?: boolean; retry_after_seconds?: number; status?: string; error_message?: string; reject_reason?: string; wa_account_id?: string; client_profile_id?: string; protocol_profile_id?: string; verification_request_id?: string; delivery_method?: string; method?: string; registration_phase?: string; method_statuses?: unknown[]; phone_status?: Record; account_probe?: Record; sms_probe?: Record; phone?: Record; proxy?: Record; verification_request?: Record; account_transfer_challenge?: Record; registration?: Record; login_state?: Record; check?: Record }; export type WaConnectionState = LongConnectionState; export type WaConnectionFilters = { login_state_id?: string; wa_account_id?: string; client_profile_id?: string; registered_identity_id?: string }; export type WaAccountProjection = WAAccount; @@ -50,9 +50,10 @@ export async function getWaAccounts(cursor = '') { return { ...response, accounts }; } -export function getWaAccountOtpMessages(waAccountId: string, cursor = '') { - const params = new URLSearchParams({ wa_account_id: waAccountId, limit: '20' }); - if (cursor) params.set('cursor', cursor); +export function getWaAccountOtpMessages(waAccountId: string, options: { cursor?: string; limit?: number; includeSensitiveValues?: boolean } = {}) { + const params = new URLSearchParams({ wa_account_id: waAccountId, limit: String(options.limit || 20) }); + if (options.cursor) params.set('cursor', options.cursor); + if (options.includeSensitiveValues !== undefined) params.set('include_sensitive_values', String(options.includeSensitiveValues)); return getWaResponse(`/api/wa/account-otp-messages?${params}`); } @@ -121,6 +122,12 @@ export function submitWaRegistrationOTP(account: WAAccount | string, otp: string const accountID = typeof account === 'string' ? account : waAccountID(account); return api('/api/wa/actions/registration/resume-otp', { method: 'POST', body: JSON.stringify({ wa_account_id: accountID, otp }) }); } +export function refreshWaAccountTransferChallenge(verificationRequestID: string) { + return api('/api/wa/actions/registration/account-transfer/refresh', { method: 'POST', body: JSON.stringify({ verification_request_id: verificationRequestID }) }); +} +export function pollWaAccountTransferRegistration(verificationRequestID: string, waAccountID = '', maxAttempts = 1) { + return api('/api/wa/actions/registration/account-transfer/poll', { method: 'POST', body: JSON.stringify({ verification_request_id: verificationRequestID, wa_account_id: waAccountID, max_attempts: maxAttempts }) }); +} export async function setWaTwoFactorAuthSettings(account: WAAccount, pin: string) { return requireAccountSettingsResponse(await api('/api/wa/account-settings/2fa', { method: 'POST', body: JSON.stringify({ ...waAccountSettingsPayload(account), pin }) })); diff --git a/webui/src/dashboard/wa-contact-list.tsx b/webui/src/dashboard/wa-contact-list.tsx index 2df6d58..86ca93e 100644 --- a/webui/src/dashboard/wa-contact-list.tsx +++ b/webui/src/dashboard/wa-contact-list.tsx @@ -3,9 +3,11 @@ import { useMemo, useRef, useState } from 'react'; import { Loader2, Trash2 } from 'lucide-react'; import { NavLink } from 'react-router'; import { WAContactKind } from '../proto/byte/v/forge/waapp/v1/contacts'; +import type { OtpMessage } from '../proto/byte/v/forge/waapp/v1/extraction'; import type { WaContact } from './wa-chat-model'; import { formatChatTime } from './wa-chat-model'; import { WaContactAvatar } from './wa-contact-avatar'; +import { WaContactOtpBanner } from './wa-contact-otp-banner'; import { waContactPath } from './wa-route-paths'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -13,18 +15,19 @@ import { Button } from '@/components/ui/button'; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'; import { Input } from '@/components/ui/input'; -export function WaContactList({ accountID, contacts, selectedID, loading, error, deletingID, onOpenContact, onDeleteContact }: { accountID: string; contacts: WaContact[]; selectedID: string; loading: boolean; error?: string; deletingID?: string; onOpenContact: (contactID: string) => void; onDeleteContact: (contactID: string) => void }) { +export function WaContactList({ accountID, contacts, selectedID, loading, error, deletingID, otp, onOpenContact, onDeleteContact }: { accountID: string; contacts: WaContact[]; selectedID: string; loading: boolean; error?: string; deletingID?: string; otp?: OtpMessage; onOpenContact: (contactID: string) => void; onDeleteContact: (contactID: string) => void }) { const [query, setQuery] = useState(''); const visibleContacts = useMemo(() => filterContacts(contacts, query), [contacts, query]); const unreadCount = contacts.reduce((sum, contact) => sum + contact.unreadCount, 0); return ( -