feat: reveal, export and restore identity cookies

Three asks, one shape: a jar could go into this instance and never come
back out of it, in any form.

**Reveal.** The drawer showed masked values, and a mask cannot be
compared against a browser's cookies - which is what somebody debugging
a jar is doing. Values are now readable through a route of its own:
operator role rather than any admin reader, an audit line per read, and
still masked on the view the drawer loads on every open. The disclosure
is a decision somebody made and a record somebody can read, which the
shell-and-hand-rolled-decrypt it replaces was not. Both copy shapes are
there because both are the next step: the Cookie header goes into curl,
the JSON goes into another tool.

**Export.** Select identities, get a document. An operator who had built
a pool that works could not move it to a second instance, keep a copy
before a risky change, or hand one identity to somebody debugging - the
only route out was the database. The result is a credential file and
says so in a field the importer ignores, so whoever finds it later knows
what they are holding.

**Restore.** The same document back in, through a route that is not the
paste route. Re-pasting an exported jar re-infers the browser from a
user agent no longer beside it, so the restored identity signs with a
different fingerprint from the one the platform first saw - which is
precisely the difference risk control looks for. Entries are judged one
at a time: a jar that expired since the export does not stop the rest,
and a file from a newer build is refused rather than read on a guess,
because dropping a field it does not know about is invisible until it
is not.
This commit is contained in:
Evil0ctal
2026-09-09 18:18:15 -07:00
parent ca21ec941f
commit daeecc62df
9 changed files with 850 additions and 33 deletions
+306 -14
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import uuid
from collections.abc import Mapping, Sequence
from dataclasses import replace
from datetime import UTC, datetime
from types import MappingProxyType
from typing import Any, Final
@@ -27,7 +28,13 @@ from dtk.api.deps import Principal
from dtk.api.routes import operations
from dtk.api.routes.openapi import ACCEPTED_RESPONSES, CREATED_RESPONSES, I18N_KEY
from dtk.api.routes.operations import Maintenance
from dtk.api.routes.schemas import IdentityImport, MintRequest, RetireRequest
from dtk.api.routes.schemas import (
IdentityBundle,
IdentityExport,
IdentityImport,
MintRequest,
RetireRequest,
)
from dtk.api.routes.support import (
DEFAULT_ADMIN_PAGE_SIZE,
MAX_ADMIN_PAGE_SIZE,
@@ -51,6 +58,8 @@ from dtk.identity.importing import (
ImportReport,
build_report,
mask_value,
session_expiry,
to_cookie_header,
)
from dtk.identity.pool import IdentityPool
from dtk.scheduler.health import score
@@ -123,6 +132,35 @@ def _session_health(platform: str, cookies: Mapping[str, str]) -> dict[str, Any]
_SIGNING_COOKIES: Final[frozenset[str]] = frozenset({*UIFID_COOKIE_NAMES, VERIFY_FP_COOKIE})
#: The export document's shape. Bumped when an entry gains or loses a field,
#: so an importer can refuse a file it would silently read wrong rather than
#: dropping a fingerprint and producing an identity the platform can tell apart
#: from the browser it came from.
EXPORT_VERSION: Final = 1
def _text(value: Any) -> str | None:
"""A field from an imported document, when it is a string worth passing on."""
return value if isinstance(value, str) and value else None
def _decrypt(request: Request, identity: Identity) -> dict[str, str] | None:
"""This identity's jar, or None when it cannot be read.
None is a real answer rather than an empty jar: the secret key was rotated
without re-encrypting, the identity cannot sign anything either, and an
empty mapping would render as "holds no cookies" - a different fault with a
different fix.
"""
if not identity.cookies_encrypted:
return {}
try:
header = request.app.state.cipher.decrypt(identity.cookies_encrypted, aad=str(identity.id))
except Exception:
return None
return _parse_cookies(header)
def _cookie_role(name: str) -> str:
"""Why this cookie matters, using the sets the rest of the build judges by.
@@ -534,19 +572,9 @@ async def identity_cookies(
if identity is None:
raise NotFound("no such identity", details={"identity_id": str(identity_id)})
cookies: dict[str, str] = {}
readable = True
if identity.cookies_encrypted:
try:
cookies = _parse_cookies(
request.app.state.cipher.decrypt(identity.cookies_encrypted, aad=str(identity.id))
)
except Exception:
# A jar this instance can no longer decrypt is a real state - the
# secret key was rotated without re-encrypting - and it is worth
# saying so rather than rendering an identity that appears to hold
# nothing.
readable = False
decrypted = _decrypt(request, identity)
readable = decrypted is not None
cookies = decrypted or {}
return ok(
request,
@@ -576,6 +604,270 @@ async def identity_cookies(
)
@router.post(
"/import/bundle",
summary="Import an export document",
openapi_extra={I18N_KEY: "identities_import_bundle", **CREATED_RESPONSES},
)
async def import_bundle(
request: Request,
body: IdentityBundle,
principal: Principal = Depends(manage_pool),
) -> Any:
"""Restore identities from a file this build exported.
The other import takes a paste, which is what somebody has when they copy a
jar out of a browser. This takes what somebody has when they exported a
pool: many identities at once, each already carrying the fingerprint it was
minted or imported with.
Carrying the fingerprint is the point. Re-pasting an exported jar through
the paste route infers the browser from a user agent that is no longer
beside it, so a restored identity signs with a different fingerprint from
the one the platform first saw it with - which is exactly the difference
risk control is looking for.
Every entry is judged on its own. One jar that has expired since it was
exported does not stop the rest: the response says what was stored, what
was skipped and why, and `dry_run` answers that question without writing.
**Parameters**
- `version` - the document's shape. A newer one is refused rather than
read on a guess.
- `identities` - the entries, as `POST /identities/export` wrote them.
- `proxy_id` - bind every restored identity to this egress. Optional.
- `dry_run` - report without storing.
**Returns**
`stored`, and one result per entry: its platform, whether it was usable,
and the id it was given.
"""
if body.version > EXPORT_VERSION:
raise InvalidParam(
"this file was written by a newer build than this instance",
details={"version": body.version, "supported": EXPORT_VERSION},
)
proxy_id = _as_uuid(body.proxy_id, "proxy_id") if body.proxy_id else None
lang = language(request)
results: list[dict[str, Any]] = []
stored = 0
for index, entry in enumerate(body.identities):
outcome: dict[str, Any] = {"index": index}
platform_value = entry.get("platform")
cookies = entry.get("cookies")
if not isinstance(cookies, dict) or not cookies:
# A retired identity exports with an empty jar on purpose, so this
# is the ordinary case rather than a malformed file.
results.append({**outcome, "usable": False, "reason": "no_cookies"})
continue
try:
platform = Platform(str(platform_value))
except ValueError:
results.append({**outcome, "usable": False, "reason": "unknown_platform"})
continue
report = build_report(
to_cookie_header({str(k): str(v) for k, v in cookies.items()}),
platform,
user_agent=_text(entry.get("user_agent")),
language=_text(entry.get("language")),
timezone=_text(entry.get("timezone")),
)
outcome |= {
"platform": platform.value,
"authenticated": report.authenticated,
"usable": report.usable,
"missing_required": list(report.missing_required),
"warnings": _warnings(report, lang),
}
if not report.usable:
results.append({**outcome, "reason": "unusable"})
continue
if body.dry_run:
results.append(outcome)
continue
identity_id = await _pool(request).add(
request.state.db,
platform=platform,
cookies=report.cookies,
fingerprint=report.fingerprint,
source=IdentitySource.IMPORTED,
proxy_id=proxy_id,
authenticated=report.authenticated,
)
stored += 1
results.append({**outcome, "identity_id": str(identity_id)})
if not body.dry_run and stored:
await audit(
request,
principal,
"identity.bundle_imported",
target_type="identity",
target_id=",".join(
str(result["identity_id"]) for result in results if "identity_id" in result
)[:512],
detail={"stored": stored, "submitted": len(body.identities)},
)
return ok(
request,
{"stored": stored, "submitted": len(body.identities), "results": results},
status_code=201 if stored else 200,
)
@router.get(
"/{identity_id}/cookies/reveal",
summary="Read one identity's cookies in full",
openapi_extra={I18N_KEY: "identities_cookies_reveal"},
)
async def reveal_cookies(
request: Request,
identity_id: uuid.UUID = Path(description="The identity to read."),
principal: Principal = Depends(manage_pool),
) -> Any:
"""The jar as it will be sent, values and all.
A deliberate exception to this module's rule, and the reason it is a route
of its own rather than a flag on the inventory: the masked view is what the
drawer loads on every open and this is what somebody asks for on purpose.
Separating them means the audit line records the second and is not drowned
by the first, and the wider scope - operator, not any admin reader - is
enforced by the dependency rather than by a branch inside a handler.
Why it exists at all: an operator debugging a jar ends up comparing it
against a browser's, and a mask cannot be compared. The alternative on
offer was a shell and a hand-rolled decrypt, which is the same disclosure
with no audit line and no scope check in front of it.
**Returns**
The cookies as a mapping, the same jar as one ``Cookie:`` header, and the
expiry read out of the session cookie where there is one.
"""
identity = await request.state.db.get(Identity, identity_id)
if identity is None:
raise NotFound("no such identity", details={"identity_id": str(identity_id)})
cookies = _decrypt(request, identity)
if cookies is None:
raise InvalidParam(
"this identity's cookies cannot be decrypted with the current secret key"
)
await audit(
request,
principal,
"identity.cookies_revealed",
target_type="identity",
target_id=str(identity_id),
detail={"platform": identity.platform, "count": len(cookies)},
)
return ok(
request,
{
"identity_id": str(identity.id),
"platform": identity.platform,
"authenticated": identity.authenticated,
"cookies": cookies,
"header": to_cookie_header(cookies),
"expires_at": iso(session_expiry(cookies)),
},
)
@router.post(
"/export",
summary="Export identities as a file",
openapi_extra={I18N_KEY: "identities_export"},
)
async def export_identities(
request: Request,
body: IdentityExport,
principal: Principal = Depends(manage_pool),
) -> Any:
"""Hand back whole identities - jars, fingerprints and all - as a document.
The console could import a jar and never get one back out, so an operator
who had built a working pool could not move it to a second instance, keep a
copy before a risky change, or hand one identity to somebody debugging.
The only route out was the database.
**What comes back is a credential file.** Every jar in it works until the
platform expires it, and a logged-in one is somebody's account. It is
audited by identity, and the document says so in a field the importer
ignores, so a person who finds one later knows what they are holding.
**Parameters**
- `identity_ids` - which identities. Between one and 200.
**Returns**
A document with a `version`, the export time, and one entry per identity.
Retired identities export as an entry with an empty jar rather than being
dropped: silently returning fewer than were asked for is how an operator
finds out mid-restore.
"""
wanted = [_as_uuid(value, "identity_ids") for value in body.identity_ids]
rows = (
(await request.state.db.execute(select(Identity).where(Identity.id.in_(wanted))))
.scalars()
.all()
)
found = {row.id: row for row in rows}
missing = [str(value) for value in wanted if value not in found]
if missing:
raise NotFound("no such identity", details={"identity_ids": missing})
entries = []
for identity_id in wanted:
identity = found[identity_id]
cookies = _decrypt(request, identity) or {}
fingerprint = identity.fingerprint or {}
entries.append(
{
"identity_id": str(identity.id),
"platform": identity.platform,
"source": identity.source,
"authenticated": identity.authenticated,
"minted_at": iso(identity.minted_at),
"retired_at": iso(identity.retired_at),
"cookies": cookies,
"user_agent": fingerprint.get("user_agent"),
"language": fingerprint.get("language"),
"timezone": fingerprint.get("timezone"),
}
)
await audit(
request,
principal,
"identity.exported",
target_type="identity",
target_id=",".join(str(value) for value in wanted[:20]),
detail={"count": len(entries)},
)
return ok(
request,
{
"version": EXPORT_VERSION,
"exported_at": iso(datetime.now(UTC)),
"warning": (
"This file contains working credentials. Anyone holding it can "
"make requests as these identities."
),
"identities": entries,
},
)
@router.post(
"/{identity_id}/test",
summary="Probe one identity",
+29
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
@@ -112,6 +113,34 @@ class IdentityImport(Body):
dry_run: bool = False
class IdentityExport(Body):
"""Which identities to write into an export document.
Bounded at 200 because the response carries every jar in full and is
assembled in memory; a pool larger than that is exported in pages, which is
a request the caller can see the size of.
"""
identity_ids: list[str] = Field(min_length=1, max_length=200)
class IdentityBundle(Body):
"""An export document, on its way back in.
`version` is checked rather than trusted: a file from a future build may
describe fields this one would drop silently, and dropping a fingerprint
turns a working identity into one the platform can tell apart from the
browser it was taken from.
"""
version: int
identities: list[dict[str, Any]] = Field(min_length=1, max_length=200)
proxy_id: str | None = None
#: Preview only, exactly as on a paste: the console says what it found
#: before anything is written.
dry_run: bool = False
class RetireRequest(Body):
reason: str = Field(default="retired from the console", max_length=256)
+12
View File
@@ -374,10 +374,22 @@
"summary": "What one identity's jar holds",
"description": "Cookie names, roles and masked values for one identity. Never the values themselves."
},
"identities_cookies_reveal": {
"summary": "Read one identity's cookies in full",
"description": "The jar as it will be sent, values included. Audited."
},
"identities_export": {
"summary": "Export identities as a file",
"description": "Whole identities, jars included. The result is a credential file."
},
"identities_import": {
"summary": "Import a logged-in cookie jar",
"description": "Detect the paste format, report what was understood, then store it."
},
"identities_import_bundle": {
"summary": "Import an export document",
"description": "Restore identities from a file this build exported, fingerprints and all."
},
"identities_list": {
"summary": "List identities with state and health",
"description": "The identity pool, newest first."
+12
View File
@@ -374,10 +374,22 @@
"summary": "查看某个身份的 Cookie 组成",
"description": "返回该身份持有的 Cookie 名称、作用分类与掩码后的值,绝不返回真实值。"
},
"identities_cookies_reveal": {
"summary": "读取某个身份的完整 Cookie",
"description": "返回将要发出的完整 Cookie,含真实值。该操作会被审计记录。"
},
"identities_export": {
"summary": "导出身份为文件",
"description": "导出完整身份,包含 Cookie。产物是一份凭证文件。"
},
"identities_import": {
"summary": "导入已登录的 Cookie",
"description": "识别粘贴格式,报告解析结果,然后加密存储。"
},
"identities_import_bundle": {
"summary": "导入身份文件",
"description": "从本程序导出的文件恢复身份,连同指纹一并还原。"
},
"identities_list": {
"summary": "列出身份及其状态与健康度",
"description": "身份池,最新的在前。"
@@ -136,3 +136,108 @@ async def test_an_unknown_identity_is_a_404_rather_than_an_empty_jar(client: Any
)
assert response.status_code == 404
assert envelope(response)["error"]["code"] == "NOT_FOUND"
# --------------------------------------------------------------------------
# Reveal, export, restore
#
# The masked inventory above is what the drawer loads on every open. These are
# the routes somebody asks for on purpose, and they hand back credentials - so
# what is pinned here is that they do it under the wider scope, that the export
# says what it is, and that a document survives the round trip with the
# fingerprint attached.
# --------------------------------------------------------------------------
async def test_the_reveal_hands_back_the_jar_as_it_will_be_sent(client: Any) -> None:
await signed_in(client)
identity_id = await make_identity(client)
response = await client.get(f"/api/v1/admin/identities/{identity_id}/cookies/reveal")
assert response.status_code == 200, response.text
data = envelope(response)["data"]
assert data["cookies"]["ttwid"] == TTWID
assert data["cookies"]["UIFID_TEMP"] == UIFID
# The header is the same jar in the shape curl takes, not a second source
# of truth about what it holds.
for name, value in data["cookies"].items():
assert f"{name}={value}" in data["header"]
async def test_an_export_says_what_it_is(client: Any) -> None:
"""The document is a credential file and has to read as one.
A file found on a laptop a year from now is the case this is for: without
a line saying what it holds, an export looks like configuration.
"""
await signed_in(client)
identity_id = await make_identity(client)
response = await client.post(
"/api/v1/admin/identities/export", json={"identity_ids": [identity_id]}
)
assert response.status_code == 200, response.text
document = envelope(response)["data"]
assert document["version"] == 1
assert "credentials" in document["warning"]
entry = document["identities"][0]
assert entry["cookies"]["ttwid"] == TTWID
# The fingerprint travels with the jar. Without it a restore re-infers the
# browser and signs as a different client than the platform first saw.
assert entry["user_agent"] == CHROME_UA
async def test_a_document_survives_the_round_trip(client: Any) -> None:
await signed_in(client)
exported = await client.post(
"/api/v1/admin/identities/export",
json={"identity_ids": [await make_identity(client)]},
)
document = envelope(exported)["data"]
response = await client.post(
"/api/v1/admin/identities/import/bundle",
json={"version": document["version"], "identities": document["identities"]},
)
assert response.status_code == 201, response.text
result = envelope(response)["data"]
assert result["stored"] == 1
assert result["results"][0]["authenticated"] is True
async def test_a_file_from_a_newer_build_is_refused_rather_than_guessed_at(client: Any) -> None:
"""Reading it anyway would drop the fields this build does not know about.
Dropping a fingerprint is not a visible failure - it produces an identity
that works and signs as somebody else, which is the failure risk control is
built to find.
"""
await signed_in(client)
response = await client.post(
"/api/v1/admin/identities/import/bundle",
json={"version": 99, "identities": [{"platform": "douyin", "cookies": {"ttwid": "x"}}]},
)
assert response.status_code == 400
assert envelope(response)["error"]["code"] == "INVALID_PARAM"
async def test_an_entry_that_cannot_be_used_does_not_stop_the_others(client: Any) -> None:
await signed_in(client)
exported = await client.post(
"/api/v1/admin/identities/export",
json={"identity_ids": [await make_identity(client)]},
)
document = envelope(exported)["data"]
# A retired identity exports with an empty jar; this is that entry.
document["identities"].insert(0, {"platform": "douyin", "cookies": {}})
response = await client.post(
"/api/v1/admin/identities/import/bundle",
json={"version": document["version"], "identities": document["identities"]},
)
result = envelope(response)["data"]
assert result["stored"] == 1
assert result["submitted"] == 2
assert result["results"][0]["reason"] == "no_cookies"
+7
View File
@@ -45,6 +45,13 @@ export const paths = {
reset: (id: string) => `${API_V1}/admin/identities/${encodeURIComponent(id)}/reset`,
/** Cookie names, roles and masked values. Never the values themselves. */
cookies: (id: string) => `${API_V1}/admin/identities/${encodeURIComponent(id)}/cookies`,
/** The same jar with its real values. Audited; operator role only. */
reveal: (id: string) =>
`${API_V1}/admin/identities/${encodeURIComponent(id)}/cookies/reveal`,
/** Whole identities as a document. The result is a credential file. */
export: `${API_V1}/admin/identities/export`,
/** An export document on its way back in, fingerprints included. */
importBundle: `${API_V1}/admin/identities/import/bundle`,
},
proxies: {
list: `${API_V1}/admin/proxies`,
+25 -2
View File
@@ -412,7 +412,15 @@
"empty": "This identity holds no cookies at all, which means it can only ever be refused.",
"wiped": "Retired identities have their jar wiped, so there is nothing left to show.",
"missing": "Missing a cookie this build treats as required: {names}. Requests on this identity will be refused whatever else is in the jar.",
"maskNote": "Values are shown as their first and last four characters. The full values are never sent to this page - the jar is the credential, and a logged-in one is somebody's account."
"maskNote": "Values are shown as their first and last four characters. The full values are never sent to this page - the jar is the credential, and a logged-in one is somebody's account.",
"reveal": "Show the real values",
"revealFailed": "Could not read this jar",
"revealed": "These are working credentials. Anyone who sees this screen can make requests as this identity. Reading them was recorded in the audit log.",
"copyHeader": "Copy as a Cookie header",
"copyJson": "Copy as JSON",
"copiedHeader": "Cookie header copied",
"copiedJson": "Cookies copied as JSON",
"hide": "Hide them again"
},
"cookieRole": {
"required": "required",
@@ -465,7 +473,22 @@
"UIFIDTEMP": "The visitor id a Douyin mint produces, under another of the spellings the SDK accepts.",
"uifidtemp": "The visitor id a Douyin mint produces, under another of the spellings the SDK accepts."
},
"drawerTitle": "Identity"
"drawerTitle": "Identity",
"export": {
"action": "{count, plural, one {Export 1} other {Export #}}",
"done": "{count, plural, one {1 identity exported} other {# identities exported}}",
"failed": "Could not export these identities"
},
"restore": {
"label": "Restore from a file",
"hint": "A document written by Export. Cookies and fingerprints come back together, which re-pasting them cannot do.",
"loaded": "{file} · {count, plural, one {1 identity} other {# identities}}. The paste form below is off while a file is loaded.",
"submit": "{count, plural, one {Restore 1 identity} other {Restore # identities}}",
"done": "{count, plural, =0 {Nothing was restored} one {1 identity restored} other {# identities restored}}",
"skipped": "{count, plural, one {1 was skipped - its jar had expired or was empty.} other {# were skipped - their jars had expired or were empty.}}",
"failed": "Could not restore from this file",
"unreadable": "This is not a file Export wrote."
}
},
"proxy": {
"column": {
+25 -2
View File
@@ -412,7 +412,15 @@
"empty": "这个身份一条 Cookie 都没有,发出去只会被拒。",
"wiped": "已退休的身份会清空 Cookie,所以这里没有内容可看。",
"missing": "缺少本程序视为必需的 Cookie:{names}。不管其余部分如何,用这个身份的请求都会被拒。",
"maskNote": "只显示每个值的首尾各四位。完整值从不会发到这个页面——Cookie 本身就是凭证,带登录态的那份等于别人的账号。"
"maskNote": "只显示每个值的首尾各四位。完整值从不会发到这个页面——Cookie 本身就是凭证,带登录态的那份等于别人的账号。",
"reveal": "显示真实内容",
"revealFailed": "读不出这份 Cookie",
"revealed": "这些是可用的凭证。看到这个屏幕的人都能以这个身份发请求。本次读取已写入审计日志。",
"copyHeader": "复制为 Cookie 请求头",
"copyJson": "复制为 JSON",
"copiedHeader": "已复制 Cookie 请求头",
"copiedJson": "已按 JSON 复制 Cookie",
"hide": "重新隐藏"
},
"cookieRole": {
"required": "必需",
@@ -465,7 +473,22 @@
"UIFIDTEMP": "抖音铸造出来的访客 id,SDK 接受的另一种拼写。",
"uifidtemp": "抖音铸造出来的访客 id,SDK 接受的另一种拼写。"
},
"drawerTitle": "身份详情"
"drawerTitle": "身份详情",
"export": {
"action": "导出 {count} 个",
"done": "已导出 {count} 个身份",
"failed": "导出失败"
},
"restore": {
"label": "从文件恢复",
"hint": "「导出」写出的文件。Cookie 和指纹会一起还原,重新粘贴做不到这一点。",
"loaded": "{file} · {count} 个身份。载入文件后,下面的粘贴表单会关闭。",
"submit": "恢复 {count} 个身份",
"done": "{count, plural, =0 {没有恢复任何身份} other {已恢复 # 个身份}}",
"skipped": "{count} 个被跳过——它们的 Cookie 已过期或为空。",
"failed": "无法从这个文件恢复",
"unreadable": "这不是「导出」写出的文件。"
}
},
"proxy": {
"column": {
+329 -15
View File
@@ -16,6 +16,7 @@ import {
LockIcon,
Banner,
Drawer,
InfoIcon,
MaskedSecret,
Modal,
PageHeader,
@@ -27,7 +28,7 @@ import {
useToast,
type Column,
} from '@/components'
import { apiDelete, apiPost, apiPut, waitForTask, type ApiError } from '@/lib/api'
import { apiDelete, apiGet, apiPost, apiPut, waitForTask, type ApiError } from '@/lib/api'
import { cn } from '@/lib/cn'
import { paths } from '@/lib/endpoints'
import { MISSING } from '@/lib/format'
@@ -41,7 +42,14 @@ import {
type Outcome,
type Platform,
} from '@/lib/types'
import { useApiMutation, useApiQuery, useFormatters, useInvalidate, useSession } from '@/hooks'
import {
useApiMutation,
useApiQuery,
useCopy,
useFormatters,
useInvalidate,
useSession,
} from '@/hooks'
import styles from './identities.module.css'
@@ -115,6 +123,59 @@ interface CookieInventory {
cookies: CookieEntry[]
}
/** What `POST /admin/identities/export` writes, and what the file import reads. */
interface IdentityBundleFile {
version: number
exported_at: string
warning: string
identities: Array<Record<string, unknown>>
}
interface BundleImportResult {
stored: number
submitted: number
results: Array<{
index: number
platform?: string
usable?: boolean
reason?: string
identity_id?: string
warnings?: string[]
}>
}
/**
* Hand the browser a JSON file.
*
* The same shape the archive export and the comment save use. Revoked a tick
* later rather than immediately: Safari cancels an in-flight save otherwise.
*/
function saveJson(data: unknown, filename: string): void {
const href = URL.createObjectURL(
new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
)
const anchor = document.createElement('a')
anchor.href = href
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => {
URL.revokeObjectURL(href)
}, 1_000)
}
/** `GET /admin/identities/{id}/cookies/reveal` - the jar, values and all. */
interface RevealedJar {
identity_id: string
platform: Platform
authenticated: boolean
cookies: Record<string, string>
/** The same jar as one `Cookie:` header, ready to paste into curl. */
header: string
expires_at: string | null
}
interface ProxyOption {
id: string
label?: string | null
@@ -914,6 +975,32 @@ export default function Identities() {
},
]
/**
* Write the selected identities to a file.
*
* The pool could be filled and never emptied: an operator who had built one
* that works could not move it to a second instance, keep a copy before a
* risky change, or hand one identity to somebody debugging. The only way out
* was the database.
*
* What lands in the downloads folder is a credential file. The name says the
* date so two exports do not overwrite each other, and the document carries
* a warning field for whoever finds it later.
*/
const exporting = useApiMutation<IdentityBundleFile, string[]>(
(ids) => apiPost<IdentityBundleFile>(paths.identities.export, { identity_ids: ids }),
{
onSuccess: (bundle) => {
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
saveJson(bundle, `dtk-identities-${stamp}.json`)
toast.success(t('console:identity.export.done', { count: bundle.identities.length }))
},
onError: (error) => {
toast.apiError(error, t('console:identity.export.failed'))
},
},
)
const selectedRows = rows.filter((row) => selected.has(row.id))
// The pool is not empty, this view is. Offering "mint your first identity"
// here would answer a question nobody asked and hide the one that matters:
@@ -1105,6 +1192,18 @@ export default function Identities() {
})}
</Button>
) : null}
{selected.size > 0 ? (
<Button
size="sm"
variant="secondary"
loading={exporting.isPending}
onClick={() => {
exporting.mutate([...selected])
}}
>
{t('console:identity.export.action', { count: selected.size })}
</Button>
) : null}
{selected.size > 0 ? (
<Button
size="sm"
@@ -1472,6 +1571,11 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
const [userAgent, setUserAgent] = useState('')
const [proxyId, setProxyId] = useState('')
const [acknowledged, setAcknowledged] = useState(false)
//: A file this build exported, once it has been read and understood. The
//: paste path and this one cannot both be armed: they mean different things
//: - one jar typed in, or a pool restored - and a dialog that guessed which
//: was intended would guess wrong on the day it mattered.
const [bundle, setBundle] = useState<{ file: string; doc: IdentityBundleFile } | null>(null)
const [preview, setPreview] = useState<{
loading: boolean
report?: CookieReport
@@ -1513,6 +1617,55 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
}
}, [open, cookies, platform, userAgent])
/**
* Restore a whole document, fingerprints and all.
*
* Not the paste route with the jars re-serialised through it: that infers
* the browser from a user agent no longer beside the cookies, so a restored
* identity would sign with a different fingerprint from the one the platform
* first saw it with - which is the difference risk control looks for.
*/
const restore = useApiMutation<BundleImportResult, void>(
() =>
apiPost<BundleImportResult>(paths.identities.importBundle, {
version: bundle?.doc.version ?? 0,
identities: bundle?.doc.identities ?? [],
proxy_id: proxyId || null,
dry_run: false,
}),
{
onSuccess: async (result) => {
const failed = result.submitted - result.stored
toast.success(t('console:identity.restore.done', { count: result.stored }), {
description: failed
? t('console:identity.restore.skipped', { count: failed })
: undefined,
})
close()
await onDone()
},
onError: (error) => {
toast.apiError(error, t('console:identity.restore.failed'))
},
},
)
/** Read a chosen file and check it is one of ours before arming the button. */
const readBundle = async (file: File): Promise<void> => {
try {
const parsed: unknown = JSON.parse(await file.text())
const doc = parsed as IdentityBundleFile
if (typeof doc?.version !== 'number' || !Array.isArray(doc.identities)) {
throw new Error('shape')
}
setBundle({ file: file.name, doc })
setCookies('')
} catch {
setBundle(null)
toast.error(t('console:identity.restore.unreadable'))
}
}
const store = useApiMutation<ImportResponse, void>(
() =>
apiPost<ImportResponse>(paths.identities.import, {
@@ -1558,16 +1711,29 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
<Button variant="ghost" onClick={close}>
{t('common:action.cancel')}
</Button>
<Button
variant="primary"
loading={store.isPending}
disabled={!canStore}
onClick={() => {
store.mutate()
}}
>
{t('console:identity.import.submit')}
</Button>
{bundle ? (
<Button
variant="primary"
loading={restore.isPending}
disabled={!acknowledged}
onClick={() => {
restore.mutate()
}}
>
{t('console:identity.restore.submit', { count: bundle.doc.identities.length })}
</Button>
) : (
<Button
variant="primary"
loading={store.isPending}
disabled={!canStore}
onClick={() => {
store.mutate()
}}
>
{t('console:identity.import.submit')}
</Button>
)}
</>
}
>
@@ -1591,6 +1757,49 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
<p className="u-xs u-secondary">{t('console:identity.import.risk.dedicated')}</p>
</div>
{/* A file first, because restoring a pool is one action and pasting a
jar is a dozen. Everything below is for the paste, and a loaded
file hides it rather than leaving two half-filled forms. */}
<div className="u-stack-sm">
<label className="u-xs u-secondary">{t('console:identity.restore.label')}</label>
<input
type="file"
accept="application/json,.json"
className="u-xs"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void readBundle(file)
// Cleared so choosing the same file twice fires again, which is
// what somebody who just fixed the file expects.
event.target.value = ''
}}
/>
<span className="u-xs u-muted">{t('console:identity.restore.hint')}</span>
</div>
{bundle ? (
<Banner tone="accent" icon={<InfoIcon size={14} />}>
<div className="u-stack-sm">
<span>
{t('console:identity.restore.loaded', {
file: bundle.file,
count: bundle.doc.identities.length,
})}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
setBundle(null)
}}
>
{t('common:action.clear')}
</Button>
</div>
</Banner>
) : null}
{bundle ? null : (
<Select
label={t('console:field.platform')}
value={platform}
@@ -1599,6 +1808,7 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
}}
options={PLATFORMS.map((value) => ({ value, label: value }))}
/>
)}
<Textarea
label={t('console:identity.import.cookies')}
@@ -1828,6 +2038,112 @@ const HISTORY_LIMIT = 200
* and the first and last four characters, which is enough to say whether two
* identities hold the same jar.
*/
/**
* The masked jar, and a way past the mask.
*
* Masked by default because the drawer opens on every click and a screenshot
* of an admin panel should not be a credential. Revealed on purpose, through a
* route of its own that writes an audit line and demands the operator role -
* so the disclosure is a decision somebody made and a record somebody can
* read, rather than the shell-and-hand-rolled-decrypt it replaces.
*
* Both copy shapes are here because both are what people actually do next: the
* header goes into curl, the JSON goes into another tool.
*/
function RevealRow({ identity }: { identity: Identity }) {
const { t } = useTranslation(['console', 'common'])
const toast = useToast()
const { copy } = useCopy()
const [jar, setJar] = useState<RevealedJar | null>(null)
const reveal = useApiMutation<RevealedJar, void>(
() => apiGet<RevealedJar>(paths.identities.reveal(identity.id)),
{
onSuccess: (result) => {
setJar(result)
},
onError: (error) => {
toast.apiError(error, t('console:identity.contents.revealFailed'))
},
},
)
// Cleared when the drawer moves to a different identity, or one jar would be
// shown under another's name.
useEffect(() => {
setJar(null)
}, [identity.id])
if (!jar) {
return (
<div className="u-stack-sm">
<p className="u-xs u-muted" style={{ margin: 0 }}>
{t('console:identity.contents.maskNote')}
</p>
<Button
size="sm"
variant="secondary"
loading={reveal.isPending}
onClick={() => {
reveal.mutate()
}}
>
{t('console:identity.contents.reveal')}
</Button>
</div>
)
}
return (
<div className="u-stack-sm">
<Banner tone="caution" icon={<AlertIcon size={14} />}>
{t('console:identity.contents.revealed')}
</Banner>
<ul className={styles.cookieList}>
{Object.entries(jar.cookies).map(([name, value]) => (
<li key={name} className={styles.cookieRow}>
<span className={styles.cookieHead}>
<span className="u-mono">{name}</span>
</span>
<CopyableId value={value} wrap />
</li>
))}
</ul>
<div className="u-row u-wrap">
<Button
size="sm"
variant="secondary"
onClick={() => {
void copy(jar.header)
toast.success(t('console:identity.contents.copiedHeader'))
}}
>
{t('console:identity.contents.copyHeader')}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
void copy(JSON.stringify(jar.cookies, null, 2))
toast.success(t('console:identity.contents.copiedJson'))
}}
>
{t('console:identity.contents.copyJson')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setJar(null)
}}
>
{t('console:identity.contents.hide')}
</Button>
</div>
</div>
)
}
function IdentityContents({
identity,
jar,
@@ -1929,9 +2245,7 @@ function IdentityContents({
))}
</ul>
<p className="u-xs u-muted" style={{ margin: 0 }}>
{t('console:identity.contents.maskNote')}
</p>
<RevealRow identity={identity} />
</>
)}
</div>