feat(payments): trusted platform signal on the session
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
Record the execution platform (kind vk|telegram|direct + device subtype ios|android|web) on each session, captured at creation and carried gateway->backend as a trusted X-Platform header, so the upcoming store-compliance gate has an unforgeable execution context. - backend.sessions gains nullable platform_kind/platform_subtype columns (migration 00011, CHECK-constrained, jet regenerated); session.Platform captures them at mint, resolve returns them, middleware exposes platform(c). kind is derived from the establish endpoint, never a client field; the account-merge session mint inherits the caller's platform. - gateway derives the platform (VK subtype from the signed vk_platform via vkauth, Telegram/direct best-effort from the client) and injects X-Platform on every authenticated backend call through the request context. - ui submits a best-effort device subtype on the telegram/guest/email login requests (new FBS subtype field); VK is server-derived from the signed params. - an unattributed session is untrusted (view-only); VK/TG self-heal on the next cold-start re-mint, direct/email on re-login. Signal plumbing only, no user-visible change; X-Platform is inert until the gate consumes it.
This commit is contained in:
@@ -27,7 +27,7 @@ tests, done-criteria, and current status — without re-deriving decisions.
|
||||
| Stage | Title | Release | Status |
|
||||
|-------|-------|---------|--------|
|
||||
| E0 | Payments data foundation | 1 | DONE |
|
||||
| E1 | Trusted platform signal | 1 | TODO |
|
||||
| E1 | Trusted platform signal | 1 | DONE |
|
||||
| E2 | Currency + benefit core | 1 | TODO |
|
||||
| E3 | Wallet UI | 1 | TODO |
|
||||
| E4 | Durability (PITR) | 2 | TODO |
|
||||
@@ -210,56 +210,74 @@ role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
|
||||
|
||||
## E1 — Trusted platform signal
|
||||
|
||||
**Status:** TODO · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
|
||||
**Status:** DONE · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
|
||||
|
||||
**Goal.** Make the server know the execution platform from a trusted, unforgeable source,
|
||||
carried on the session and re-confirmed each cold start. This is the foundation the gate
|
||||
(E2) stands on; without it the gate is meaningless.
|
||||
carried on the session. This is the foundation the gate (E2) stands on; without it the gate is
|
||||
meaningless. Signal plumbing only — no user-visible change.
|
||||
|
||||
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` becomes a
|
||||
property of the **session**. On cold start the client submits the fresh wrapper signature
|
||||
(VK launch-params `sign` / TG `initData`); the gateway (or backend session-resolve)
|
||||
validates it and records/refreshes the session's platform. `direct` needs no signature — it
|
||||
is implied by a web/native session's creation path.
|
||||
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` is a property of
|
||||
the **session**, captured at creation. `kind` is always trusted — the gateway derives it from
|
||||
the validated establish path (VK launch / TG initData / a web-native session), never a client
|
||||
field. `subtype` is **cryptographically trusted only for VK** (it rides inside the signed
|
||||
`vk_platform` launch param); for telegram and direct it is client-reported best-effort, and the
|
||||
gate never relies on it (only the VK-iOS-frozen case is compliance-critical, and VK subtype is
|
||||
trusted). VK/TG re-mint a session on **every cold start**, so their platform is re-captured each
|
||||
launch; web/direct/email reuse the stored token, so their platform is captured once at creation
|
||||
(`direct` needs no signature).
|
||||
|
||||
**Validation stays at the gateway.** Both wrapper verifiers already live there and the backend
|
||||
cannot import either (separate modules under `internal/`, and the VK app secret is gateway-only):
|
||||
VK launch params via the in-process `gateway/internal/vkauth.Verify` (HMAC-SHA256 over the signed
|
||||
`vk_*` params under `GATEWAY_VK_APP_SECRET`, extended here to expose the signed `vk_platform` →
|
||||
a trusted `Subtype()`), TG `initData` via the validator RPC. The gateway hands the derived
|
||||
platform to the backend at establish; the backend persists it and returns it on resolve.
|
||||
|
||||
**Backend.**
|
||||
|
||||
- Session store (`backend/internal/session/`): add `platform` (kind+subtype) to the session
|
||||
row + `Session` struct; set it at creation and refresh it on a validated cold-start call.
|
||||
- Session resolve (`handlers_auth.go handleResolveSession` → `resolveResponse` DTO
|
||||
`dto.go`): return `platform` so the gateway can carry it.
|
||||
- Validation: TG `initData` validator already exists (`platform/telegram/internal/initdata`)
|
||||
— reuse. Add VK launch-params `sign` verification (HMAC over sorted params with the VK app
|
||||
secret) — new small verifier, unit-tested against known VK vectors.
|
||||
- Gateway (`gateway/internal/backendclient`): inject a trusted `X-Platform` header (mirror
|
||||
`X-User-ID` injection in `client.go do()`), sourced from the resolved session — never from
|
||||
the client request body.
|
||||
- Backend middleware: read `X-Platform` into context alongside `X-User-ID`
|
||||
(`middleware.go`); expose a typed accessor `platform(c)`.
|
||||
- **Fail-closed default:** absent/invalid/stale platform ⇒ context = untrusted; the payments
|
||||
interface treats untrusted as "view only" (enforced in E2's gate, but the signal plumbing
|
||||
lands here).
|
||||
- `backend.sessions` gains nullable `platform_kind` + `platform_subtype` columns (migration
|
||||
`00011`, CHECK-constrained, jet regenerated). A `session.Platform` value type + `session.Create`
|
||||
captures it; `Session` carries it through the store and the warm cache.
|
||||
- `handleResolveSession` → `resolveResponse` (`dto.go`) returns the platform; the establish
|
||||
handlers set `kind` from their own endpoint (`/sessions/telegram|vk|guest|email/*`) and
|
||||
`subtype` from the request (VK: gateway-extracted from the signed params; TG/direct: the client
|
||||
best-effort field, defaulting web). The account-merge session mint (`link.merge`) inherits the
|
||||
caller's platform from the request context.
|
||||
- Middleware (`middleware.go`) parses the gateway-injected `X-Platform` into the request context
|
||||
on the `s.user` group; `platform(c)` exposes it. Absent ⇒ untrusted (fail-closed).
|
||||
|
||||
**Client (`ui/`).** On cold start, submit the wrapper signature to the session-establish/
|
||||
refresh call: VK via `ui/src/lib/vk.ts` (launch params), TG via `ui/src/lib/telegram.ts`
|
||||
(`initData`). `direct` submits nothing (web/native). Compose the platform from
|
||||
`insideVK()` + `insideTelegram()` + `clientChannel()` + `vkPlatform()` (no single
|
||||
discriminator exists today — see `ui/src/lib/channel.ts`, note VK reads as `web` there).
|
||||
**Gateway.**
|
||||
|
||||
- `backendclient.WithPlatform(ctx, "<kind>/<subtype>")` + `do`/`getRaw` inject `X-Platform` on
|
||||
every authenticated backend call; `connectsrv.Execute` enriches the request context from the
|
||||
resolved session's platform (carried through the gateway session cache + `ResolveSession`).
|
||||
Never from a client request body.
|
||||
|
||||
**Client (`ui/`).** `platformSubtype()` (`ui/src/lib/platform.ts`) supplies a best-effort device
|
||||
subtype on the `auth.telegram` / `auth.guest` / `auth.email.login` requests (new FBS `subtype`
|
||||
field): Telegram's `WebApp.platform` inside a Mini App, else the Capacitor/web channel. VK sends
|
||||
nothing (the gateway derives the trusted subtype from the signed launch params).
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit (Go): VK sign verifier accepts valid / rejects tampered params; TG initData path
|
||||
(existing) covered; platform kind+subtype derivation.
|
||||
- integration: session carries platform; resolve returns it; stale/absent → untrusted.
|
||||
- UI: cold start submits the right signature per wrapper (mock).
|
||||
- unit (Go): `vkauth` exposes the signed `vk_platform` and maps iPhone/iPad → ios (the frozen
|
||||
case); the `X-Platform` middleware round-trips + reports untrusted when absent; the backend
|
||||
client injects `X-Platform` from the context and omits it when untrusted.
|
||||
- integration: a session carries its platform through create + a cold-cache (DB) resolve for each
|
||||
kind; an unattributed session is untrusted; the CHECK constraints bite; migration `00011`
|
||||
applies forward **and backward** on a throwaway PG.
|
||||
- UI: subtype normalization (vitest) + the new `subtype` field on the wire (codec unit test).
|
||||
|
||||
**Done-criteria.** A VK/TG session resolves to a trusted `{kind,subtype}`; a forged body
|
||||
cannot change it; `direct` sessions resolve to `direct`; untrusted path is reachable and
|
||||
observable. No user-visible change.
|
||||
**Done-criteria (met).** A VK/TG session resolves to a trusted `{kind, subtype}`; a forged body
|
||||
cannot change `kind` (nor the VK subtype); `direct` sessions resolve to `direct`; the untrusted
|
||||
path is reachable and observable via `platform(c)`. No user-visible change; all layers green.
|
||||
|
||||
**Notes/risks.** VK iOS must surface as `subtype=ios` (drives the frozen state in E2/E3).
|
||||
Old sessions predating this stage have no platform → untrusted → fail-closed (acceptable;
|
||||
re-cold-start fixes it). Keep the VK app secret in config/secret store, not code.
|
||||
**Notes/risks.** High blast-radius (auth/session table + broad gateway header threading): additive
|
||||
migration, no mixed-in refactors, `X-Platform` inert until E2 consumes it. **Sessions minted before
|
||||
E1 carry no platform → untrusted (view-only) until re-login**; VK/TG self-heal on the next
|
||||
cold-start re-mint, direct/email do not (accepted: Release 1 has no money, sessions cycle by
|
||||
Release 2). TG/direct subtype is not cryptographically trusted — E2 must keep the gate on `kind`
|
||||
+ the trusted VK subtype only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user