Помоги разобраться #37

Closed
opened 2026-05-23 22:27:32 +00:00 by owner · 4 comments
Collaborator

Как устроена аутентификация устройства между UI и gateway?

Как устроена аутентификация устройства между UI и gateway?
owner added the Kind/Question label 2026-05-23 22:27:32 +00:00
developer was assigned by owner 2026-05-23 22:27:32 +00:00
owner added the ready label 2026-05-23 22:28:15 +00:00
developer added
claude
working
and removed ready labels 2026-05-23 22:31:22 +00:00
Owner

@owner Привет! Разобралась, как устроена аутентификация устройства между UI и gateway. Ниже — полная картина со ссылками на код и документацию.

Коротко

Никаких cookie и токенов доступа. Аутентификация построена на device session + подписи каждого запроса ключом Ed25519:

  • при входе UI генерирует пару ключей Ed25519; приватный остаётся на устройстве, публичный регистрируется на сервере и привязывается к device_session_id;
  • каждый авторизованный запрос UI подписывает приватным ключом, а gateway проверяет подпись публичным ключом сессии;
  • ответы и push-события подписывает уже сам gateway своим ключом, а UI их проверяет.

Каноническое описание — docs/ARCHITECTURE.md §15 (строки 574–805) и docs/FUNCTIONAL.md §1 (строки 54–227).

1. Регистрация устройства (вход по коду из письма) — REST

Маршрут неаутентифицированный (сессии ещё нет):

  1. UI шлёт e-mail на POST /api/v1/public/auth/send-email-code → backend выдаёт challenge_id и отправляет код письмом.
  2. UI шлёт challenge_id + код + свежий публичный ключ Ed25519 (base64, сырые 32 байта) + таймзону на POST /api/v1/public/auth/confirm-email-code.
  3. Backend сверяет код, при первом входе создаёт аккаунт, создаёт device session, привязанную к публичному ключу, и возвращает device_session_id.

Код gateway: gateway/internal/restapi/public_auth.go (контракт AuthServiceClient.ConfirmEmailCode, поле ClientPublicKey, строки 41–106). UI: ui/frontend/src/api/auth.ts, состояние и персистенция — ui/frontend/src/lib/session-store.svelte.ts. Приватный ключ хранится неэкспортируемым через WebCrypto (ui/frontend/src/platform/store/webcrypto-keystore.ts); device_session_id и ключ переживают перезагрузку (IndexedDB), потеря хранилища = повторный вход (ui/docs/auth-flow.md).

2. Подпись каждого запроса (UI → gateway)

Транспорт авторизованного уровня — Connect / gRPC / gRPC-Web на одном h2c-порту. Конверт запроса (gateway/proto/edge/v1/edge_gateway.proto:14-27): protocol_version="v1", device_session_id, message_type, timestamp_ms, request_id, payload_bytes, payload_hash (сырой SHA-256 от payload), signature.

Подписываются не сами поля, а каноническая байтовая строка:

"galaxy-request-v1" || protocol_version || device_session_id || message_type || timestamp_ms || request_id || payload_hash

Кодирование: каждое строковое/байтовое поле — uvarint(len) + сырые байты; timestamp_ms — 8 байт big-endian; подпись — сырые 64 байта Ed25519. Реализация общая для клиента и сервера: ui/core/canon/request.go:53-73 (компилируется в WASM) и gateway/authn/request.go:60-80. Их побайтовая идентичность закреплена тестами gateway/authn/parity_with_ui_core_test.go и ui/frontend/tests/wasm-core-canon-parity.test.ts.

На стороне UI это делает GalaxyClient.executeCommand (ui/frontend/src/api/galaxy-client.ts:76-115): строит канонические байты через WASM-ядро, подписывает их WebCrypto-ключом и отправляет конверт.

3. Проверка на gateway

Цепочка middleware собирается в gateway/internal/grpcapi/server.go:127-153, порядок (снаружи внутрь):

  1. Валидация конверта + protocol_version == "v1"envelope.go.
  2. Поиск сессии по device_session_idsession_lookup.go. Резолв идёт через in-memory LRU-кэш (gateway/internal/session/), на промахе — синхронный REST к backend /api/v1/internal/sessions/{id}. Запись содержит UserID, ClientPublicKey, Status. Неизвестная или revoked сессия → отказ.
  3. Проверка payload_hash = SHA-256(payload_bytes)payload_hash.go.
  4. Проверка подписиsignature.goauthn.VerifyRequestSignature (Ed25519 публичным ключом из сессии).
  5. Свежесть + анти-реплейfreshness_replay.go: timestamp_ms в пределах ±окна (симметрично, по умолчанию ±5 минут), затем резервирование (device_session_id, request_id) в Redis до timestamp_ms + окно; повтор → отказ.
  6. Rate limit + policyrate_limit.go.
  7. Маршрутизация по message_type вниз к backend; в заголовке прокидывается UserID, backend доверяет вердикту gateway и не выводит личность из тела (command_routing.go:70-92, docs/FUNCTIONAL.md §1.4).

4. Обратное направление (gateway → UI)

Ответы и push-события подписывает gateway своим ключом Ed25519 (PKCS#8 PEM), а UI проверяет их доверенным публичным ключом (VITE_GATEWAY_RESPONSE_PUBLIC_KEY): сверяет совпадение request_id, payload_hash и подпись (galaxy-client.ts:117-148). Каноны ответа и события — "galaxy-response-v1" … и "galaxy-event-v1" … (ARCHITECTURE §15). Есть ещё неподписанный keepalive gateway.heartbeat, который UI игнорирует до проверки подписи.

5. Отзыв сессии

Backend атомарно ставит status=revoked, пишет аудит-строку и публикует session_invalidation. Gateway по этому событию помечает запись в кэше revoked и закрывает активные push-стримы; следующий lookup тоже вернёт revoked (TTL — страховка на случай потери события). UI: любое не-abort закрытие стрима трактуется как отзыв → signOut("revoked") (ui/frontend/src/lib/revocation-watcher.ts, docs/FUNCTIONAL.md §1.5).

Маленькое наблюдение (не баг)

В docs/ARCHITECTURE.md §15 «Verification order» (строки 762–763) подпись (шаг 4) указана перед проверкой payload_hash (шаг 5), а в коде наоборот: payload_hash.go обёрнут снаружи signature.go (server.go:131-132), то есть hash проверяется раньше подписи. На безопасность это не влияет (оба отказа происходят до обработки payload), но формально порядок в доке и в коде расходится. Если захочешь это поправить — заведи отдельную issue, и я приведу формулировку §15 в соответствие с кодом (это security-документ, править его в рамках вопроса не буду).

Готова углубиться в любой из шагов, если нужно.

@owner Привет! Разобралась, как устроена аутентификация устройства между UI и gateway. Ниже — полная картина со ссылками на код и документацию. ## Коротко Никаких cookie и токенов доступа. Аутентификация построена на **device session** + **подписи каждого запроса ключом Ed25519**: - при входе UI генерирует пару ключей Ed25519; приватный остаётся на устройстве, публичный регистрируется на сервере и привязывается к `device_session_id`; - каждый авторизованный запрос UI подписывает приватным ключом, а gateway проверяет подпись публичным ключом сессии; - ответы и push-события подписывает уже сам gateway своим ключом, а UI их проверяет. Каноническое описание — `docs/ARCHITECTURE.md` §15 (строки 574–805) и `docs/FUNCTIONAL.md` §1 (строки 54–227). ## 1. Регистрация устройства (вход по коду из письма) — REST Маршрут неаутентифицированный (сессии ещё нет): 1. UI шлёт e-mail на `POST /api/v1/public/auth/send-email-code` → backend выдаёт `challenge_id` и отправляет код письмом. 2. UI шлёт `challenge_id` + код + **свежий публичный ключ Ed25519** (base64, сырые 32 байта) + таймзону на `POST /api/v1/public/auth/confirm-email-code`. 3. Backend сверяет код, при первом входе создаёт аккаунт, **создаёт device session, привязанную к публичному ключу**, и возвращает `device_session_id`. Код gateway: `gateway/internal/restapi/public_auth.go` (контракт `AuthServiceClient.ConfirmEmailCode`, поле `ClientPublicKey`, строки 41–106). UI: `ui/frontend/src/api/auth.ts`, состояние и персистенция — `ui/frontend/src/lib/session-store.svelte.ts`. Приватный ключ хранится неэкспортируемым через WebCrypto (`ui/frontend/src/platform/store/webcrypto-keystore.ts`); `device_session_id` и ключ переживают перезагрузку (IndexedDB), потеря хранилища = повторный вход (`ui/docs/auth-flow.md`). ## 2. Подпись каждого запроса (UI → gateway) Транспорт авторизованного уровня — Connect / gRPC / gRPC-Web на одном h2c-порту. Конверт запроса (`gateway/proto/edge/v1/edge_gateway.proto:14-27`): `protocol_version="v1"`, `device_session_id`, `message_type`, `timestamp_ms`, `request_id`, `payload_bytes`, `payload_hash` (сырой SHA-256 от payload), `signature`. Подписываются не сами поля, а **каноническая байтовая строка**: ```text "galaxy-request-v1" || protocol_version || device_session_id || message_type || timestamp_ms || request_id || payload_hash ``` Кодирование: каждое строковое/байтовое поле — `uvarint(len)` + сырые байты; `timestamp_ms` — 8 байт big-endian; подпись — сырые 64 байта Ed25519. Реализация общая для клиента и сервера: `ui/core/canon/request.go:53-73` (компилируется в WASM) и `gateway/authn/request.go:60-80`. Их побайтовая идентичность закреплена тестами `gateway/authn/parity_with_ui_core_test.go` и `ui/frontend/tests/wasm-core-canon-parity.test.ts`. На стороне UI это делает `GalaxyClient.executeCommand` (`ui/frontend/src/api/galaxy-client.ts:76-115`): строит канонические байты через WASM-ядро, подписывает их WebCrypto-ключом и отправляет конверт. ## 3. Проверка на gateway Цепочка middleware собирается в `gateway/internal/grpcapi/server.go:127-153`, порядок (снаружи внутрь): 1. **Валидация конверта** + `protocol_version == "v1"` — `envelope.go`. 2. **Поиск сессии** по `device_session_id` — `session_lookup.go`. Резолв идёт через in-memory LRU-кэш (`gateway/internal/session/`), на промахе — синхронный REST к backend `/api/v1/internal/sessions/{id}`. Запись содержит `UserID`, `ClientPublicKey`, `Status`. Неизвестная или revoked сессия → отказ. 3. **Проверка payload_hash** = `SHA-256(payload_bytes)` — `payload_hash.go`. 4. **Проверка подписи** — `signature.go` → `authn.VerifyRequestSignature` (Ed25519 публичным ключом из сессии). 5. **Свежесть + анти-реплей** — `freshness_replay.go`: `timestamp_ms` в пределах ±окна (симметрично, по умолчанию ±5 минут), затем резервирование `(device_session_id, request_id)` в Redis до `timestamp_ms + окно`; повтор → отказ. 6. **Rate limit + policy** — `rate_limit.go`. 7. **Маршрутизация** по `message_type` вниз к backend; в заголовке прокидывается `UserID`, backend доверяет вердикту gateway и не выводит личность из тела (`command_routing.go:70-92`, `docs/FUNCTIONAL.md` §1.4). ## 4. Обратное направление (gateway → UI) Ответы и push-события подписывает gateway своим ключом Ed25519 (PKCS#8 PEM), а UI проверяет их доверенным публичным ключом (`VITE_GATEWAY_RESPONSE_PUBLIC_KEY`): сверяет совпадение `request_id`, `payload_hash` и подпись (`galaxy-client.ts:117-148`). Каноны ответа и события — `"galaxy-response-v1" …` и `"galaxy-event-v1" …` (ARCHITECTURE §15). Есть ещё неподписанный keepalive `gateway.heartbeat`, который UI игнорирует до проверки подписи. ## 5. Отзыв сессии Backend атомарно ставит `status=revoked`, пишет аудит-строку и публикует `session_invalidation`. Gateway по этому событию помечает запись в кэше revoked и закрывает активные push-стримы; следующий lookup тоже вернёт revoked (TTL — страховка на случай потери события). UI: любое не-abort закрытие стрима трактуется как отзыв → `signOut("revoked")` (`ui/frontend/src/lib/revocation-watcher.ts`, `docs/FUNCTIONAL.md` §1.5). ## Маленькое наблюдение (не баг) В `docs/ARCHITECTURE.md` §15 «Verification order» (строки 762–763) подпись (шаг 4) указана **перед** проверкой `payload_hash` (шаг 5), а в коде наоборот: `payload_hash.go` обёрнут снаружи `signature.go` (`server.go:131-132`), то есть hash проверяется раньше подписи. На безопасность это не влияет (оба отказа происходят до обработки payload), но формально порядок в доке и в коде расходится. Если захочешь это поправить — заведи отдельную issue, и я приведу формулировку §15 в соответствие с кодом (это security-документ, править его в рамках вопроса не буду). Готова углубиться в любой из шагов, если нужно.
Owner
Working log (issue-agent) — click to expand

State: answered (owner satisfied; awaiting owner to close the issue)
Updated: 2026-05-24T00:05Z
Branch / PR:
Confirmed: Answer-only Kind/Question issue (Russian): "how is device authentication structured between UI and gateway?". Full grounded answer already posted (comment 147) and accepted by the owner ("Понял, спасибо, закрывай issue").
Open question:
Decisions: Owner asked me to close the issue. Per absolute safety rule 8 I never create or close issues (owner-only — the owner's own deliberate setup). Replied (comment 156, Russian, warm voice, tagged author) explaining I cannot close it and asking the owner to close it themselves; kept claude/blocked (owner's turn = close). No code change. Re-offered to align docs/ARCHITECTURE.md §15 verification-order with the code under a separate issue if wanted.
Next: Owner to close the issue. If the owner instead asks a follow-up, answer again and re-block. Never close the issue myself.

<!-- issue-agent:worklog --> <details> <summary>Working log (issue-agent) — click to expand</summary> **State:** answered (owner satisfied; awaiting owner to close the issue) **Updated:** 2026-05-24T00:05Z **Branch / PR:** — **Confirmed:** Answer-only `Kind/Question` issue (Russian): "how is device authentication structured between UI and gateway?". Full grounded answer already posted (comment 147) and accepted by the owner ("Понял, спасибо, закрывай issue"). **Open question:** — **Decisions:** Owner asked me to close the issue. Per absolute safety rule 8 I never create or close issues (owner-only — the owner's own deliberate setup). Replied (comment 156, Russian, warm voice, tagged author) explaining I cannot close it and asking the owner to close it themselves; kept `claude/blocked` (owner's turn = close). No code change. Re-offered to align `docs/ARCHITECTURE.md` §15 verification-order with the code under a separate issue if wanted. **Next:** Owner to close the issue. If the owner instead asks a follow-up, answer again and re-block. Never close the issue myself. </details>
developer added
claude
blocked
and removed
claude
working
labels 2026-05-23 22:34:30 +00:00
Author
Collaborator

Понял, спасибо, закрывай issue :)

Понял, спасибо, закрывай issue :)
Owner

@owner Рада, что картинка сложилась! :)

Вот только закрыть issue сама не могу — у меня это жёстко зашито: заводить и закрывать задачи может только ты, мне оставлены чтение, комментарии и метки. Это твоя же настройка (страховка, чтобы автономный агент ничего лишнего не прибил), так что тут я честно связана по рукам.

С моей стороны всё готово, так что закрой, пожалуйста, сам — это один клик. Метку claude/blocked оставляю: ход за тобой.

И на всякий случай напомню: если захочешь выровнять §15 в docs/ARCHITECTURE.md с кодом (тот мелкий нюанс с порядком проверки payload_hash и подписи) — заведи отдельную issue, и я аккуратно всё поправлю.

@owner Рада, что картинка сложилась! :) Вот только закрыть issue сама не могу — у меня это жёстко зашито: заводить и закрывать задачи может только ты, мне оставлены чтение, комментарии и метки. Это твоя же настройка (страховка, чтобы автономный агент ничего лишнего не прибил), так что тут я честно связана по рукам. С моей стороны всё готово, так что закрой, пожалуйста, сам — это один клик. Метку `claude/blocked` оставляю: ход за тобой. И на всякий случай напомню: если захочешь выровнять §15 в `docs/ARCHITECTURE.md` с кодом (тот мелкий нюанс с порядком проверки `payload_hash` и подписи) — заведи отдельную issue, и я аккуратно всё поправлю.
owner closed this issue 2026-05-24 00:14:32 +00:00
developer added
claude
done
and removed
claude
blocked
labels 2026-05-24 07:27:25 +00:00
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/galaxy-game#37