added ability to check shared devices

This commit is contained in:
onysd 2026-08-06 02:12:46 +03:00
parent 7d0daef923
commit 72db66d4aa
14 changed files with 456 additions and 23 deletions

View file

@ -157,6 +157,33 @@ type AuthorizationRow struct {
ActiveAt time.Time
}
// SharedDeviceAccount is one account whose authorizations matched a
// SharedDeviceGroup's device fingerprint.
type SharedDeviceAccount struct {
UserID int64
Phone string
Username string
FirstName string
LastName string
ActiveAt time.Time
}
// SharedDeviceGroup is a device fingerprint (device_model + system_version +
// platform + ip) shared by more than one distinct account's authorizations --
// a heuristic multi-accounting signal, not proof: device_model/system_version
// are client-reported and spoofable, and ip alone collides naturally behind
// NAT, shared wifi, or carrier CGNAT. Treat this as a lead to investigate, not
// a verdict.
type SharedDeviceGroup struct {
DeviceModel string
SystemVersion string
Platform string
IP string
AccountCount int
LastActiveAt time.Time
Accounts []SharedDeviceAccount
}
type AuditLogRow struct {
ID int64
CommandID string
@ -787,6 +814,88 @@ LIMIT $3`, beforeActiveUS, beforeID, limit+1)
return out, hasMore, nil
}
// ListSharedDeviceGroups pages through device fingerprints (device_model +
// system_version + platform + ip) that more than one distinct account has
// authorized from -- see SharedDeviceGroup's doc comment on why this is a
// heuristic, not a verdict. Pagination is plain offset/limit over the
// aggregated group list (not the raw authorizations table): the number of
// *groups* is expected to stay small relative to total session count, so an
// offset scan over the pre-aggregated CTE is cheap even though offset
// pagination over raw rows elsewhere in this file deliberately uses a keyset
// cursor instead.
func (s *readStore) ListSharedDeviceGroups(ctx context.Context, offset, limit int) ([]SharedDeviceGroup, bool, error) {
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
if offset < 0 {
offset = 0
}
rows, err := s.pool.Query(ctx, `
WITH device_groups AS (
SELECT device_model, system_version, platform, ip,
count(DISTINCT user_id)::int AS account_count,
max(active_at) AS last_active_at
FROM authorizations
WHERE device_model <> ''
GROUP BY device_model, system_version, platform, ip
HAVING count(DISTINCT user_id) > 1
ORDER BY max(active_at) DESC, device_model, system_version, platform, ip
LIMIT $1 OFFSET $2
),
members AS (
SELECT DISTINCT ON (a.device_model, a.system_version, a.platform, a.ip, a.user_id)
a.device_model, a.system_version, a.platform, a.ip, a.user_id, a.active_at
FROM authorizations a
JOIN device_groups g ON a.device_model = g.device_model AND a.system_version = g.system_version
AND a.platform = g.platform AND a.ip = g.ip
ORDER BY a.device_model, a.system_version, a.platform, a.ip, a.user_id, a.active_at DESC
)
SELECT g.device_model, g.system_version, g.platform, g.ip, g.account_count, g.last_active_at,
m.user_id, m.active_at, u.phone, u.username, u.first_name, u.last_name
FROM device_groups g
JOIN members m ON m.device_model = g.device_model AND m.system_version = g.system_version
AND m.platform = g.platform AND m.ip = g.ip
JOIN users u ON u.id = m.user_id
ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.ip, m.active_at DESC`,
limit+1, offset)
if err != nil {
return nil, false, fmt.Errorf("list shared device groups: %w", err)
}
defer rows.Close()
groups := make([]SharedDeviceGroup, 0, limit+1)
for rows.Next() {
var (
deviceModel, systemVersion, platform, ip string
accountCount int
lastActiveAt time.Time
acc SharedDeviceAccount
)
if err := rows.Scan(&deviceModel, &systemVersion, &platform, &ip, &accountCount, &lastActiveAt,
&acc.UserID, &acc.ActiveAt, &acc.Phone, &acc.Username, &acc.FirstName, &acc.LastName); err != nil {
return nil, false, err
}
if len(groups) == 0 {
groups = append(groups, SharedDeviceGroup{DeviceModel: deviceModel, SystemVersion: systemVersion, Platform: platform, IP: ip, AccountCount: accountCount, LastActiveAt: lastActiveAt})
} else if last := &groups[len(groups)-1]; last.DeviceModel != deviceModel || last.SystemVersion != systemVersion || last.Platform != platform || last.IP != ip {
groups = append(groups, SharedDeviceGroup{DeviceModel: deviceModel, SystemVersion: systemVersion, Platform: platform, IP: ip, AccountCount: accountCount, LastActiveAt: lastActiveAt})
}
last := &groups[len(groups)-1]
last.Accounts = append(last.Accounts, acc)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
hasMore := len(groups) > limit
if hasMore {
groups = groups[:limit]
}
return groups, hasMore, nil
}
func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
var out AccountDetail
err := s.pool.QueryRow(ctx, `

View file

@ -0,0 +1,109 @@
package main
import (
"context"
"fmt"
"testing"
"time"
)
// ListSharedDeviceGroups is hand-written SQL grouping authorizations by a
// device fingerprint tuple, so the grouping logic (which accounts land in
// which group, and that a lone different device never leaks in) can only be
// proven against the real schema. Gated on TELESRV_TEST_POSTGRES_DSN like the
// rest of this package's integration tests.
func TestReadStoreSharedDeviceGroups(t *testing.T) {
store, pool := verificationReadStore(t)
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
sharedDeviceModel := "Integration-Test-Device-" + suffix
sharedSystemVersion := "Test OS 1.0"
sharedPlatform := "test-platform"
sharedIP := "203.0.113." + suffix[len(suffix)-2:]
userA := 3_700_000_000 + time.Now().UnixNano()%1_000_000
userB := userA + 1
userC := userA + 2 // different device -- must never appear in the shared group.
t.Cleanup(func() {
for _, id := range []int64{userA, userB, userC} {
_, _ = pool.Exec(ctx, `DELETE FROM authorizations WHERE user_id=$1`, id)
_, _ = pool.Exec(ctx, `DELETE FROM auth_keys WHERE auth_key_id=$1`, id)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
}
})
for i, id := range []int64{userA, userB, userC} {
if _, err := pool.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', $5, now(), now())`,
id, id, fmt.Sprintf("+1890%s%d", suffix, i), fmt.Sprintf("Shared%d", i), fmt.Sprintf("shared%d%s", i, suffix)); err != nil {
t.Fatalf("seed user %d: %v", id, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, id); err != nil {
t.Fatalf("seed auth key %d: %v", id, err)
}
}
// userA and userB authorize from the same device fingerprint; userC from a
// distinct one, so it must not be pulled into the shared group.
if _, err := pool.Exec(ctx, `
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userA, sharedDeviceModel, sharedSystemVersion, sharedPlatform, sharedIP); err != nil {
t.Fatalf("seed authorization A: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userB, sharedDeviceModel, sharedSystemVersion, sharedPlatform, sharedIP); err != nil {
t.Fatalf("seed authorization B: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userC, "Other-Device-"+suffix, sharedSystemVersion, sharedPlatform, sharedIP+".other"); err != nil {
t.Fatalf("seed authorization C: %v", err)
}
groups, _, err := store.ListSharedDeviceGroups(ctx, 0, 200)
if err != nil {
t.Fatalf("ListSharedDeviceGroups: %v", err)
}
var found *SharedDeviceGroup
for i := range groups {
if groups[i].DeviceModel == sharedDeviceModel {
found = &groups[i]
break
}
}
if found == nil {
t.Fatalf("seeded device fingerprint %q not found among %d groups", sharedDeviceModel, len(groups))
}
if found.AccountCount != 2 {
t.Fatalf("AccountCount = %d, want 2", found.AccountCount)
}
if found.SystemVersion != sharedSystemVersion || found.Platform != sharedPlatform || found.IP != sharedIP {
t.Fatalf("group fingerprint = %+v, want system_version=%q platform=%q ip=%q", found, sharedSystemVersion, sharedPlatform, sharedIP)
}
if len(found.Accounts) != 2 {
t.Fatalf("Accounts = %#v, want exactly 2 members", found.Accounts)
}
seen := map[int64]bool{}
for _, acc := range found.Accounts {
seen[acc.UserID] = true
if acc.UserID == userC {
t.Fatalf("userC (different device) leaked into the shared group: %#v", found.Accounts)
}
}
if !seen[userA] || !seen[userB] {
t.Fatalf("Accounts = %#v, want both userA=%d and userB=%d present", found.Accounts, userA, userB)
}
// A lone device (userC's) must never itself form a "shared" group.
for i := range groups {
if groups[i].DeviceModel == "Other-Device-"+suffix {
t.Fatalf("a device with only one distinct account formed a group: %+v", groups[i])
}
}
}

View file

@ -54,6 +54,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
@ -632,6 +633,33 @@ func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
})
}
func (s *server) handleSharedDeviceGroupsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
offset, _ := parseInt(r.URL.Query().Get("offset"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
groups, hasMore, err := s.read.ListSharedDeviceGroups(r.Context(), offset, limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"limit": limit,
"offset": offset,
"rows": groups,
"has_more": hasMore,
"next_offset": offset + limit,
})
}
func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-Defd2Shn.js"></script>
<script type="module" crossorigin src="/assets/index-CvaJc5Uc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C7vmH2zd.css">
</head>
<body>

View file

@ -5,6 +5,7 @@ import type {
AccountRatingListResponse,
AccountStatsResponse,
AccountStorageListResponse,
SharedDeviceGroupListResponse,
StorageStatsResponse,
AdminLoginResult,
AdminSession,
@ -154,6 +155,7 @@ export const api = {
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`),
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),

View file

@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search, Smartphone } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
@ -99,17 +99,22 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
title={"Accounts"}
eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
actions={
<button
className="btn"
type="button"
onClick={() => {
void loadFresh();
void loadStats();
}}
disabled={busy}
>
<RefreshCw size={15} /> {"Refresh"}
</button>
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/accounts/shared-devices")}>
<Smartphone size={15} /> {"Shared devices"}
</button>
<button
className="btn"
type="button"
onClick={() => {
void loadFresh();
void loadStats();
}}
disabled={busy}
>
<RefreshCw size={15} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}

View file

@ -3,6 +3,7 @@ import { AccountDetailPage } from "./AccountDetailPage";
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
import { AccountRatingsPage } from "./AccountRatingsPage";
import { AccountsPage } from "./AccountsPage";
import { SharedDevicesPage } from "./SharedDevicesPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
@ -100,6 +101,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (moderationCaseID) {
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
}
if (route.path === "/accounts/shared-devices") {
return <SharedDevicesPage navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}

View file

@ -0,0 +1,131 @@
import { ArrowLeft, ChevronDown, ChevronRight, Loader2, RefreshCw, Smartphone } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, SectionHead } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { SharedDeviceGroup } from "../types";
// SharedDevicesPage surfaces authorizations that look like they came from the
// same physical device but belong to different accounts -- a lead worth
// investigating for multi-accounting, not a verdict: device_model and
// system_version are self-reported by the client and easy to spoof, and a
// matching IP alone is common and innocent behind NAT, shared wifi, or
// carrier CGNAT. Treat every group here as "worth a look", not "guilty".
export function SharedDevicesPage({ navigate }: { navigate: Navigate }) {
const [groups, setGroups] = useState<SharedDeviceGroup[]>([]);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const at = next ? offset : 0;
const params = new URLSearchParams({ limit: "20", offset: String(at) });
try {
const result = await api.sharedDeviceGroups(params);
const page = result.rows ?? [];
setGroups((current) => (next ? [...current, ...page] : page));
setOffset(result.next_offset);
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const totalFlaggedAccounts = groups.reduce((sum, group) => sum + group.AccountCount, 0);
return (
<PageFrame
title={"Shared Devices"}
eyebrow={"Multi-account signal — device/IP overlap across different accounts"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/accounts")}>
<ArrowLeft size={15} /> {"Back to accounts"}
</button>
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Device groups on page"} value={String(groups.length)} />
<Metric label={"Accounts flagged on page"} value={String(totalFlaggedAccounts)} tone="warn" />
</div>
<p className="about-text">
{"Each card below is a device fingerprint (device model + OS + platform + IP) that more than one account has authorized from. "}
{"device_model/system_version are self-reported by the client, and IP alone can collide innocently -- use this as a lead, not a verdict."}
</p>
<div className="stacked-sections">
{groups.map((group) => (
<section className="section-block" key={`${group.DeviceModel}|${group.SystemVersion}|${group.Platform}|${group.IP}`}>
<SectionHead
title={group.DeviceModel || "Unknown device"}
text={`${group.Platform || "unknown platform"} ${group.SystemVersion} · ${group.IP} · last active ${formatDate(group.LastActiveAt)}`}
action={<Badge tone="warn"><Smartphone size={12} /> {`${group.AccountCount} accounts`}</Badge>}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th className="avatar-col"></th>
<th>{"User ID"}</th>
<th>{"Phone"}</th>
<th>{"Username"}</th>
<th>{"Name"}</th>
<th>{"Active from this device"}</th>
<th></th>
</tr>
</thead>
<tbody>
{group.Accounts.map((account) => (
<tr key={account.UserID}>
<td className="avatar-col"><Avatar id={account.UserID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} /></td>
<td className="mono">{account.UserID}</td>
<td>{displayPhone(account.Phone)}</td>
<td>{displayUsername(account.Username) || "-"}</td>
<td>{displayName(account) || "-"}</td>
<td>{formatDate(account.ActiveAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${account.UserID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
{groups.length === 0 && (
<div className="table-wrap">
<table className="data-table">
<tbody>
<EmptyRow colSpan={7} />
</tbody>
</table>
</div>
)}
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}

View file

@ -22,6 +22,7 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
if (pathname.startsWith("/account-ratings")) return "Account Rating";
if (pathname.startsWith("/storage")) return "Storage";
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
if (pathname.startsWith("/accounts")) return "Accounts";
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
if (pathname.startsWith("/bots")) return "Bots";

View file

@ -789,6 +789,39 @@ export type AccountStatsResponse = {
online: number;
};
// SharedDeviceAccount is one account whose authorizations matched a
// SharedDeviceGroup's device fingerprint.
export type SharedDeviceAccount = {
UserID: number;
Phone: string;
Username: string;
FirstName: string;
LastName: string;
ActiveAt: string;
};
// SharedDeviceGroup is a device fingerprint (device model + OS + platform +
// IP) shared by more than one distinct account -- a heuristic multi-account
// signal, not proof (device_model/system_version are client-reported and
// spoofable, and IP alone collides behind NAT/shared wifi/carrier CGNAT).
export type SharedDeviceGroup = {
DeviceModel: string;
SystemVersion: string;
Platform: string;
IP: string;
AccountCount: number;
LastActiveAt: string;
Accounts: SharedDeviceAccount[];
};
export type SharedDeviceGroupListResponse = {
limit: number;
offset: number;
rows: SharedDeviceGroup[];
has_more: boolean;
next_offset: number;
};
export type StorageStatsResponse = {
PhysicalBytes: string;
LogicalBytes: string;

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_authorizations_device_fingerprint;

View file

@ -0,0 +1,10 @@
-- Supports the admin console's "accounts sharing a device" report, which
-- groups authorizations by (device_model, system_version, platform, ip) and
-- looks for groups spanning more than one distinct user_id. Without this
-- index that GROUP BY is a full sequential scan of authorizations, a table
-- that grows roughly with active-session count and is never pruned except on
-- explicit revoke.
CREATE INDEX idx_authorizations_device_fingerprint
ON public.authorizations (device_model, system_version, platform, ip)
WHERE device_model <> '';