From 51de9779e37a0bc616ed78742d0679594135e75092eeff2338093bf3d85dc4f1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 27 Mar 2026 16:29:19 +0300 Subject: [PATCH] 27 03 25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Доделал сервер и документ для разработчиков по Авторификациии и серверам Всё работает --- Dev_Docs/API/01_Auth_and_Sessions_API.md | 72 +++++-- .../java/shine/db/DatabaseInitializer.java | 2 +- .../shine/db/entities/ActiveSessionEntry.java | 4 +- .../auth/Net_CreateAuthSession__Handler.java | 181 ++++++++++++++---- .../auth/Net_SessionLogin_Handler.java | 45 ++++- .../Net_CreateAuthSession_Request.java | 54 ++++-- .../entyties/Net_SessionLogin_Request.java | 13 +- .../java/test/it/cases/IT_02_Sessions.java | 20 +- src/test/java/test/it/utils/TestConfig.java | 6 +- .../java/test/it/utils/json/JsonBuilders.java | 38 ++-- 10 files changed, 326 insertions(+), 109 deletions(-) diff --git a/Dev_Docs/API/01_Auth_and_Sessions_API.md b/Dev_Docs/API/01_Auth_and_Sessions_API.md index 5cfd09d..b372389 100644 --- a/Dev_Docs/API/01_Auth_and_Sessions_API.md +++ b/Dev_Docs/API/01_Auth_and_Sessions_API.md @@ -84,6 +84,8 @@ - `SessionLogin` - последующих перевходов в уже созданную сессию +В API клиент передаёт `sessionKey` целиком одной строкой, и сервер хранит `active_sessions.session_key` тоже целиком одной строкой. + ### `storagePwd` `storagePwd` тоже **генерируется и передаётся клиентом** при создании сессии. @@ -204,6 +206,7 @@ rsa2048/MIIBIjANBgkqh... "storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET", "timeMs": 1774600000123, "authNonce": "8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11", + "deviceKey": "BASE64_DEVICE_PUBLIC_KEY", "signatureB64": "BASE64_SIGNATURE_BY_DEVICE_KEY", "clientInfo": "Android 15; Pixel 9" } @@ -230,6 +233,21 @@ AUTH_CREATE_SESSION:alice:ed25519/BASE64_PUBLIC_KEY:BASE64_OR_APP_SPECIFIC_SECRE - снижается риск подмены `session_key` между клиентом и сервером; - `storagePwd` становится частью подтверждённого набора параметров создания сессии. +### Дополнительная проверка `deviceKey` + +Перед проверкой подписи сервер должен: + +1. загрузить актуальный `device_key` пользователя; +2. сравнить его со значением `payload.deviceKey`; +3. только после совпадения ключей проверять подпись. + +Если ключ не совпадает, сервер должен возвращать ошибку о том, что ключ не соответствует актуальной версии. + +На будущее: + +- для сценария обновления `device_key` желательно добавить дополнительную проверку актуального ключа через Solana; +- если и после этого ключ не подтверждается, сервер всё равно должен возвращать ошибку о несовпадении актуального ключа. + ### Успешный ответ ```json @@ -289,6 +307,7 @@ AUTH_CREATE_SESSION:alice:ed25519/BASE64_PUBLIC_KEY:BASE64_OR_APP_SPECIFIC_SECRE "requestId": "slogin-001", "payload": { "sessionId": "sess_7c5e5c4b", + "sessionKey": "ed25519/BASE64_PUBLIC_KEY", "timeMs": 1774600010456, "signatureB64": "BASE64_SIGNATURE_BY_SESSION_KEY", "clientInfo": "Android 15; Pixel 9" @@ -308,6 +327,16 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66 ``` +### Дополнительная проверка `sessionKey` + +Перед проверкой подписи сервер должен: + +1. загрузить `active_sessions.session_key` по `sessionId`; +2. сравнить его со значением `payload.sessionKey`; +3. только после совпадения ключей проверять подпись. + +Если ключ не совпадает, сервер должен возвращать ошибку о том, что ключ не соответствует актуальной версии. + Успешный ответ: ```json @@ -364,6 +393,8 @@ SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66 ### `CloseActiveSession` +Доступно только после успешного `SessionLogin`. + Запрос: ```json @@ -398,6 +429,8 @@ SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66 - `400 BAD_REQUEST` — не хватает поля или неверный формат. - `401 UNAUTHORIZED` — challenge не был пройден или соединение не авторизовано. - `403 INVALID_SIGNATURE` — подпись не прошла проверку. +- `403 DEVICE_KEY_NOT_ACTUAL` — присланный `deviceKey` не совпадает с актуальным ключом пользователя. +- `403 SESSION_KEY_NOT_ACTUAL` — присланный `sessionKey` не совпадает с актуальным ключом сессии. - `404 SESSION_NOT_FOUND` — сессия не существует или уже закрыта. - `409 NONCE_ALREADY_USED` — challenge уже использован. - `410 CHALLENGE_EXPIRED` — nonce устарел. @@ -422,32 +455,33 @@ SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66 По текущему состоянию кода сервер уже использует схему: - `AuthChallenge(login)` -- `CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo)` +- `CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, deviceKey, signatureB64, clientInfo)` - `SessionChallenge(sessionId)` -- `SessionLogin(sessionId, timeMs, signatureB64, clientInfo)` +- `SessionLogin(sessionId, sessionKey, timeMs, signatureB64, clientInfo)` Текущая строка подписи для `CreateAuthSession` в коде: -```text -AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce} -``` - -То есть **сейчас** `sessionPubKeyB64` и `storagePwd` ещё не входят в preimage подписи. - -### Рекомендуемый путь миграции - -1. Ввести новую версию контракта `CreateAuthSession`. -2. Добавить поле `sessionKey` вместо `sessionPubKeyB64`. -3. На сервере распознавать префикс алгоритма в `sessionKey`. -4. Перейти на подпись строки: - ```text AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} ``` -5. На переходный период поддерживать оба варианта: - - legacy: без `sessionKey` и `storagePwd` в подписи; - - vNext: с полным набором полей. +Перед проверкой подписи сервер также должен сверять: + +- `payload.deviceKey` с актуальным `solana_users.device_key`; +- `payload.sessionKey` с актуальным `active_sessions.session_key`. + +### Рекомендуемый путь миграции + +1. Ввести новую версию контракта `CreateAuthSession`. +2. На сервере хранить `session_key` целиком одной строкой. +3. На сервере распознавать префикс алгоритма в `sessionKey`. +4. В `CreateAuthSession` передавать и сверять `deviceKey`. +5. В `SessionLogin` передавать и сверять `sessionKey`. +6. Использовать подпись строки: + +```text +AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} +``` --- @@ -467,5 +501,7 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} - Да, клиент сам создаёт `session_key`. - Да, клиент сам передаёт `storagePwd`. - Для `session_key` имеет смысл ввести префикс алгоритма, например `ed25519/...`. +- Для `CreateAuthSession` клиент должен дополнительно передавать `deviceKey`, а сервер должен сверять его с актуальным ключом пользователя. +- Для `SessionLogin` клиент должен дополнительно передавать `sessionKey`, а сервер должен сверять его с актуальным ключом сессии. - Для `CreateAuthSession` рекомендуется подписывать не только `login`, `timeMs` и `authNonce`, но также `sessionKey` и `storagePwd`. - Для разработчиков клиентов лучше сразу документировать API через полные JSON-примеры запросов и ответов. diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 732e61c..1e37d0f 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -20,7 +20,7 @@ import java.sql.Statement; * * v2 (sessions): * - active_sessions.session_pwd удалён - * - active_sessions.session_key хранит публичный ключ сессии (sessionPubKeyB64) + * - active_sessions.session_key хранит публичный ключ сессии целиком одной строкой */ public final class DatabaseInitializer { diff --git a/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java b/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java index 4e55416..2a2bb07 100644 --- a/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java +++ b/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java @@ -8,7 +8,7 @@ public class ActiveSessionEntry { private String sessionId; private String login; - /** session_key: публичный ключ сессии (base64 от 32 байт). */ + /** session_key: публичный ключ сессии целиком одной строкой, например ed25519/BASE64_PUBLIC_KEY. */ private String sessionKey; private String storagePwd; @@ -92,4 +92,4 @@ public class ActiveSessionEntry { public String getUserLanguage() { return userLanguage; } public void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index 11dd424..a25a972 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -10,10 +10,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.ws.WsConnectionUtils; import shine.db.dao.ActiveSessionsDAO; +import shine.db.dao.SolanaUsersDAO; import shine.db.entities.ActiveSessionEntry; import shine.db.entities.SolanaUserEntry; import shine.geo.ClientInfoService; @@ -31,12 +33,12 @@ import java.sql.SQLException; * * Логика авторизации (v2): * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) - * - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя, - * отправляет на сервер ТОЛЬКО sessionPubKeyB64. - * - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key. + * - Клиент генерирует sessionKey, хранит приватный ключ у себя, + * отправляет на сервер sessionKey целиком одной строкой. + * - Сервер сохраняет sessionKey в active_sessions.session_key как есть. * * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce} + * AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} * * На выходе: * - создаётся запись active_sessions @@ -70,8 +72,54 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - SolanaUserEntry user = ctx.getSolanaUser(); - String login = user.getLogin(); + SolanaUserEntry userFromContext = ctx.getSolanaUser(); + String loginFromContext = userFromContext.getLogin(); + String login = req.getLogin(); + if (login == null || login.isBlank()) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_LOGIN", + "Пустой login" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty login"); + return err; + } + if (!login.equals(loginFromContext)) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "LOGIN_MISMATCH", + "login не соответствует контексту AuthChallenge" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: login mismatch"); + return err; + } + + SolanaUserEntry user; + try { + user = SolanaUsersDAO.getInstance().getByLogin(login); + } catch (SQLException e) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "DB_ERROR_USER_LOOKUP", + "Ошибка БД при получении пользователя" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db user lookup"); + return err; + } + if (user == null) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "USER_NOT_FOUND", + "Пользователь не найден" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: user not found"); + return err; + } + if (login == null || login.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( req, @@ -95,40 +143,38 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - String sessionPubKeyB64 = req.getSessionPubKeyB64(); - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { + String sessionKey = req.getSessionKey(); + if (sessionKey == null || sessionKey.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( req, WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_PUBKEY", - "Пустой sessionPubKeyB64" + "EMPTY_SESSION_KEY", + "Пустой sessionKey" ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey"); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session key"); return err; } - // Проверим, что sessionPubKeyB64 декодируется в 32 байта - byte[] sessionPubKey32; + sessionKey = AuthKeyUtils.normalize(sessionKey, "sessionKey"); try { - sessionPubKey32 = Base64Ws.decode(sessionPubKeyB64); + AuthKeyUtils.parseEd25519PublicKey(sessionKey, "sessionKey"); + } catch (UnsupportedOperationException e) { + Net_Response err = NetExceptionResponseFactory.error( + req, + 422, + "UNSUPPORTED_KEY_ALGORITHM", + "sessionKey prefix is not supported" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: unsupported session key algorithm"); + return err; } catch (IllegalArgumentException e) { Net_Response err = NetExceptionResponseFactory.error( req, WireCodes.Status.BAD_REQUEST, "BAD_BASE64", - "Некорректный base64 в sessionPubKeyB64" + "Некорректный формат sessionKey" ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64"); - return err; - } - if (sessionPubKey32.length != 32) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SESSION_PUBKEY_LEN", - "sessionPubKey должен быть 32 байта" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey length"); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session key format"); return err; } @@ -163,8 +209,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { clientInfoFromClient = clientInfoFromClient.substring(0, 50); } - String devicePubKeyB64 = user.getDeviceKey(); - if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) { + String deviceKeyFromDb = user.getDeviceKey(); + if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( req, WireCodes.Status.BAD_REQUEST, @@ -176,16 +222,73 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { } String authNonce = ctx.getAuthNonce(); + String authNonceFromReq = req.getAuthNonce(); + if (authNonceFromReq == null || authNonceFromReq.isBlank()) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_AUTH_NONCE", + "Пустой authNonce" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty authNonce"); + return err; + } + if (!authNonce.equals(authNonceFromReq)) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "AUTH_NONCE_MISMATCH", + "authNonce не соответствует контексту AuthChallenge" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: authNonce mismatch"); + return err; + } + + String deviceKeyFromReq = req.getDeviceKey(); + if (deviceKeyFromReq == null || deviceKeyFromReq.isBlank()) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_DEVICE_KEY", + "Пустой deviceKey" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty deviceKey"); + return err; + } + deviceKeyFromReq = deviceKeyFromReq.trim(); + + // TODO: для ротации device_key стоит дополнительно сверять актуальное значение через Solana. + if (!deviceKeyFromReq.equals(deviceKeyFromDb)) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "DEVICE_KEY_NOT_ACTUAL", + "device_key не соответствует актуальной версии" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: device key mismatch"); + return err; + } boolean sigOk; try { sigOk = verifyCreateSessionSignature( - user, login, + sessionKey, + storagePwd, authNonce, timeMs, + deviceKeyFromDb, signatureB64 ); + } catch (UnsupportedOperationException ex) { + Net_Response err = NetExceptionResponseFactory.error( + req, + 422, + "UNSUPPORTED_KEY_ALGORITHM", + "deviceKey algorithm is not supported" + ); + WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: unsupported device key algorithm"); + return err; } catch (IllegalArgumentException ex) { Net_Response err = NetExceptionResponseFactory.error( req, @@ -239,7 +342,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { activeSessionEntry = new ActiveSessionEntry( sessionId, login, - sessionPubKeyB64, // session_key (pubkey) + sessionKey, // session_key (pubkey string as-is) storagePwd, now, now, @@ -283,18 +386,24 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { } private static boolean verifyCreateSessionSignature( - SolanaUserEntry user, String login, + String sessionKey, + String storagePwd, String authNonce, long timeMs, + String deviceKey, String signatureB64 ) throws IllegalArgumentException { - // deviceKey (pub, 32) - byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey()); - byte[] signature64 = Base64Ws.decode(signatureB64); + byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(deviceKey, "deviceKey"); + byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; + String preimageStr = "AUTH_CREATE_SESSION:" + + login + ":" + + sessionKey + ":" + + storagePwd + ":" + + timeMs + ":" + + authNonce; byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); return Ed25519Util.verify(preimage, signature64, publicKey32); @@ -305,4 +414,4 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { RANDOM.nextBytes(buf); return Base64Ws.encode(buf); } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java index a0a5af6..f04cd18 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; @@ -30,7 +31,7 @@ import java.sql.SQLException; * - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL). * - SessionLogin проверяет подпись sessionKey над строкой: * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes). + * - sessionKey берём из запроса и сверяем с active_sessions.session_key. * * При успехе: * - ctx становится AUTH_STATUS_USER @@ -100,6 +101,17 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { ); } + String sessionKeyFromReq = req.getSessionKey(); + if (sessionKeyFromReq == null || sessionKeyFromReq.isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "EMPTY_SESSION_KEY", + "Пустой sessionKey" + ); + } + sessionKeyFromReq = AuthKeyUtils.normalize(sessionKeyFromReq, "sessionKey"); + ActiveSessionEntry session; try { session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); @@ -121,8 +133,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { ); } - String sessionPubKeyB64 = session.getSessionKey(); // это pubKey (Base64(32)) - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { + String sessionKeyFromDb = session.getSessionKey(); + if (sessionKeyFromDb == null || sessionKeyFromDb.isBlank()) { return NetExceptionResponseFactory.error( req, WireCodes.Status.SERVER_DATA_ERROR, @@ -130,12 +142,29 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { "В сессии не задан session_key" ); } + sessionKeyFromDb = sessionKeyFromDb.trim(); + + if (!sessionKeyFromReq.equals(sessionKeyFromDb)) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "SESSION_KEY_NOT_ACTUAL", + "session_key не соответствует актуальной версии" + ); + } String nonce = ctx.getSessionLoginNonce(); boolean sigOk; try { - sigOk = verifySessionLoginSignature(sessionPubKeyB64, sessionId, timeMs, nonce, signatureB64); + sigOk = verifySessionLoginSignature(sessionKeyFromReq, sessionId, timeMs, nonce, signatureB64); + } catch (UnsupportedOperationException e) { + return NetExceptionResponseFactory.error( + req, + 422, + "UNSUPPORTED_KEY_ALGORITHM", + "sessionKey prefix is not supported" + ); } catch (IllegalArgumentException e) { return NetExceptionResponseFactory.error( req, @@ -243,17 +272,15 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { } private static boolean verifySessionLoginSignature( - String sessionPubKeyB64, + String sessionKey, String sessionId, long timeMs, String nonce, String signatureB64 ) throws IllegalArgumentException { - // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) - byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); + byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(sessionKey, "sessionKey"); - // signature: Base64(64) через единую утилиту WS-протокола byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; @@ -261,4 +288,4 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { return Ed25519Util.verify(preimage, signature64, publicKey32); } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java index b0d7794..cda4f80 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java @@ -7,32 +7,48 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; * * Шаги: * 1) AuthChallenge(login) -> authNonce - * 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo) + * 2) CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, deviceKey, signatureB64, clientInfo) * * Подпись deviceKey делается над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} + * AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} * * Важно: - * - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64). - * - В БД active_sessions.session_key хранится sessionPubKeyB64. + * - sessionKey генерируется на клиенте и передаётся на сервер целиком одной строкой. + * - В БД active_sessions.session_key хранится sessionKey целиком одной строкой. */ public class Net_CreateAuthSession_Request extends Net_Request { + private String login; + /** Клиентский пароль для хранения данных (base64 от 32 байт). */ private String storagePwd; - /** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */ - private String sessionPubKeyB64; + /** Публичный ключ сессии в API-формате, например ed25519/BASE64_PUBLIC_KEY. */ + private String sessionKey; /** Время на стороне клиента (мс с 1970-01-01). */ private long timeMs; + /** Nonce из AuthChallenge. */ + private String authNonce; + + /** Публичный ключ устройства пользователя. */ + private String deviceKey; + /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ private String signatureB64; /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ private String clientInfo; + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + public String getStoragePwd() { return storagePwd; } @@ -41,12 +57,12 @@ public class Net_CreateAuthSession_Request extends Net_Request { this.storagePwd = storagePwd; } - public String getSessionPubKeyB64() { - return sessionPubKeyB64; + public String getSessionKey() { + return sessionKey; } - public void setSessionPubKeyB64(String sessionPubKeyB64) { - this.sessionPubKeyB64 = sessionPubKeyB64; + public void setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; } public long getTimeMs() { @@ -57,6 +73,22 @@ public class Net_CreateAuthSession_Request extends Net_Request { this.timeMs = timeMs; } + public String getAuthNonce() { + return authNonce; + } + + public void setAuthNonce(String authNonce) { + this.authNonce = authNonce; + } + + public String getDeviceKey() { + return deviceKey; + } + + public void setDeviceKey(String deviceKey) { + this.deviceKey = deviceKey; + } + public String getSignatureB64() { return signatureB64; } @@ -72,4 +104,4 @@ public class Net_CreateAuthSession_Request extends Net_Request { public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java index 2b98f80..e1f1d4d 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java @@ -4,7 +4,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; /** * Шаг 2 входа в существующую сессию (v2): - * SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER + * SessionLogin(sessionId, sessionKey, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER * * Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8): * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} @@ -14,6 +14,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; public class Net_SessionLogin_Request extends Net_Request { private String sessionId; + private String sessionKey; private long timeMs; private String signatureB64; @@ -28,6 +29,14 @@ public class Net_SessionLogin_Request extends Net_Request { this.sessionId = sessionId; } + public String getSessionKey() { + return sessionKey; + } + + public void setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + } + public long getTimeMs() { return timeMs; } @@ -51,4 +60,4 @@ public class Net_SessionLogin_Request extends Net_Request { public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; } -} \ No newline at end of file +} diff --git a/src/test/java/test/it/cases/IT_02_Sessions.java b/src/test/java/test/it/cases/IT_02_Sessions.java index 26d6279..8fe426b 100644 --- a/src/test/java/test/it/cases/IT_02_Sessions.java +++ b/src/test/java/test/it/cases/IT_02_Sessions.java @@ -20,7 +20,7 @@ import static org.junit.jupiter.api.Assertions.*; * - и после завершения оставить в БД 3 активных сессии (S1,S2,S3) * * Протокол v2: - * - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + sessionPubKey) + * - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + deviceKey + sessionKey) * - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey)) * - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin) */ @@ -109,16 +109,15 @@ public class IT_02_Sessions { String authNonce = JsonParsers.authNonce(nonceResp); assertNotNull(authNonce, "authNonce must not be null for " + label); - // для тестов: sessionKey = deviceKey (в реале будет отдельный keypair) - String sessionPubKeyB64 = TestConfig.devicePublicKeyB64(login); + String sessionKey = TestConfig.sessionKey(login); // storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его) String storagePwd = TestConfig.fakeStoragePwd(); - // шаг 2: CreateAuthSession (device подпись + sessionPubKey) + // шаг 2: CreateAuthSession (device подпись + deviceKey + sessionKey) String createResp = ws.call( "CreateAuthSession(" + label + ")", - JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionPubKeyB64), + JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionKey), t ); assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200"); @@ -128,10 +127,9 @@ public class IT_02_Sessions { r.ok("Создана сессия " + label + ": sessionId=" + sid); - // для тестов используем devicePriv как sessionPriv - byte[] sessionPrivKey = TestConfig.getDevicePrivatKey(login); + byte[] sessionPrivKey = TestConfig.getSessionPrivatKey(login); - return new Session(sid, sessionPrivKey, storagePwd); + return new Session(sid, sessionKey, sessionPrivKey, storagePwd); } } @@ -143,7 +141,7 @@ public class IT_02_Sessions { assertNotNull(nonce, "SessionChallenge nonce must not be null"); // шаг 2: SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) - String loginResp = ws.call("SessionLogin " + label, JsonBuilders.sessionLogin(s.sessionId, nonce, s.sessionPrivKey), t); + String loginResp = ws.call("SessionLogin " + label, JsonBuilders.sessionLogin(s.sessionId, s.sessionKey, nonce, s.sessionPrivKey), t); assertEquals(200, JsonParsers.status(loginResp), "SessionLogin must be 200"); String storagePwd = JsonParsers.storagePwd(loginResp); @@ -153,5 +151,5 @@ public class IT_02_Sessions { r.ok(label + ": SessionLogin OK, storagePwd verified"); } - private record Session(String sessionId, byte[] sessionPrivKey, String storagePwd) {} -} \ No newline at end of file + private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {} +} diff --git a/src/test/java/test/it/utils/TestConfig.java b/src/test/java/test/it/utils/TestConfig.java index 2e49145..e461f21 100644 --- a/src/test/java/test/it/utils/TestConfig.java +++ b/src/test/java/test/it/utils/TestConfig.java @@ -119,6 +119,7 @@ public final class TestConfig { // NEW: session pub b64 helper public static String sessionPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSessionPublicKey(login)); } + public static String sessionKey(String login) { return "ed25519/" + sessionPublicKeyB64(login); } // ============ backward-compatible helpers for "user1" ============ public static String BCH_NAME() { return getBlockchainName(LOGIN()); } @@ -143,6 +144,9 @@ public final class TestConfig { public static String SESSION_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN()); } public static String SESSION2_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN2()); } public static String SESSION3_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN3()); } + public static String SESSION_KEY() { return sessionKey(LOGIN()); } + public static String SESSION2_KEY() { return sessionKey(LOGIN2()); } + public static String SESSION3_KEY() { return sessionKey(LOGIN3()); } // ============ misc ============ public static String fakeStoragePwd() { @@ -154,4 +158,4 @@ public final class TestConfig { if (v == null) throw new IllegalStateException("No key in " + mapName + " for login=" + login); return v.clone(); } -} \ No newline at end of file +} diff --git a/src/test/java/test/it/utils/json/JsonBuilders.java b/src/test/java/test/it/utils/json/JsonBuilders.java index eec0d4b..bda46b0 100644 --- a/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/src/test/java/test/it/utils/json/JsonBuilders.java @@ -104,20 +104,15 @@ public final class JsonBuilders { } // ---------------- CreateAuthSession (v2) ---------------- - // v2: sessionKey генерируется/хранится на клиенте, на сервер отправляем sessionPubKeyB64 (base64). - // - // ВАЖНО (новое правило): - // Подпись CreateAuthSession делается ТОЛЬКО deviceKey над строкой: - // preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce - // - // storagePwd и sessionPubKeyB64 НЕ входят в preimage. + // Подпись CreateAuthSession делается deviceKey над строкой: + // preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce - public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionPubKeyB64) { + public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey) { long timeMs = System.currentTimeMillis(); - // подпись делаем devicePrivKey byte[] devicePriv = TestConfig.getDevicePrivatKey(login); - String sigB64 = signAuthCreateSession(login, timeMs, authNonce, devicePriv); + String deviceKey = TestConfig.devicePublicKeyB64(login); + String sigB64 = signAuthCreateSession(login, sessionKey, storagePwd, timeMs, authNonce, devicePriv); String requestId = TestIds.next("create"); return """ @@ -125,18 +120,24 @@ public final class JsonBuilders { "op": "CreateAuthSession", "requestId": "%s", "payload": { + "login": "%s", "storagePwd": "%s", - "sessionPubKeyB64": "%s", + "sessionKey": "%s", "timeMs": %d, + "authNonce": "%s", + "deviceKey": "%s", "signatureB64": "%s", "clientInfo": "%s" } } """.formatted( requestId, + login, storagePwd, - sessionPubKeyB64, + sessionKey, timeMs, + authNonce, + deviceKey, sigB64, TestConfig.TEST_CLIENT_INFO ); @@ -161,7 +162,7 @@ public final class JsonBuilders { // Подпись SessionLogin по-прежнему делается sessionPrivKey: // preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce - public static String sessionLogin(String sessionId, String nonce, byte[] sessionPrivKey) { + public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey) { long timeMs = System.currentTimeMillis(); String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey); @@ -172,12 +173,13 @@ public final class JsonBuilders { "requestId": "%s", "payload": { "sessionId": "%s", + "sessionKey": "%s", "timeMs": %d, "signatureB64": "%s", "clientInfo": "%s" } } - """.formatted(requestId, sessionId, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); + """.formatted(requestId, sessionId, sessionKey, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); } // ---------------- ListSessions ---------------- @@ -226,11 +228,11 @@ public final class JsonBuilders { /** * Подпись CreateAuthSession(v2): - * preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce + * preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce * подписываем devicePrivKey. */ - public static String signAuthCreateSession(String login, long timeMs, String authNonce, byte[] devicePrivKey) { - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; + public static String signAuthCreateSession(String login, String sessionKey, String storagePwd, long timeMs, String authNonce, byte[] devicePrivKey) { + String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce; byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); byte[] sig = Ed25519Util.sign(preimage, devicePrivKey); return Base64.getEncoder().encodeToString(sig); @@ -247,4 +249,4 @@ public final class JsonBuilders { byte[] sig = Ed25519Util.sign(preimage, sessionPrivKey); return Base64.getEncoder().encodeToString(sig); } -} \ No newline at end of file +}