Доделал сервер и документ для разработчиков по

Авторификациии и серверам

Всё работает
This commit is contained in:
AidarKC 2026-03-27 16:29:19 +03:00
parent 2f9cf2bff1
commit 51de9779e3
10 changed files with 326 additions and 109 deletions

View File

@ -84,6 +84,8 @@
- `SessionLogin` - `SessionLogin`
- последующих перевходов в уже созданную сессию - последующих перевходов в уже созданную сессию
В API клиент передаёт `sessionKey` целиком одной строкой, и сервер хранит `active_sessions.session_key` тоже целиком одной строкой.
### `storagePwd` ### `storagePwd`
`storagePwd` тоже **генерируется и передаётся клиентом** при создании сессии. `storagePwd` тоже **генерируется и передаётся клиентом** при создании сессии.
@ -204,6 +206,7 @@ rsa2048/MIIBIjANBgkqh...
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET", "storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET",
"timeMs": 1774600000123, "timeMs": 1774600000123,
"authNonce": "8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11", "authNonce": "8f2f0f71-0b1c-4ab2-8f5d-0bc5d6f6aa11",
"deviceKey": "BASE64_DEVICE_PUBLIC_KEY",
"signatureB64": "BASE64_SIGNATURE_BY_DEVICE_KEY", "signatureB64": "BASE64_SIGNATURE_BY_DEVICE_KEY",
"clientInfo": "Android 15; Pixel 9" "clientInfo": "Android 15; Pixel 9"
} }
@ -230,6 +233,21 @@ AUTH_CREATE_SESSION:alice:ed25519/BASE64_PUBLIC_KEY:BASE64_OR_APP_SPECIFIC_SECRE
- снижается риск подмены `session_key` между клиентом и сервером; - снижается риск подмены `session_key` между клиентом и сервером;
- `storagePwd` становится частью подтверждённого набора параметров создания сессии. - `storagePwd` становится частью подтверждённого набора параметров создания сессии.
### Дополнительная проверка `deviceKey`
Перед проверкой подписи сервер должен:
1. загрузить актуальный `device_key` пользователя;
2. сравнить его со значением `payload.deviceKey`;
3. только после совпадения ключей проверять подпись.
Если ключ не совпадает, сервер должен возвращать ошибку о том, что ключ не соответствует актуальной версии.
На будущее:
- для сценария обновления `device_key` желательно добавить дополнительную проверку актуального ключа через Solana;
- если и после этого ключ не подтверждается, сервер всё равно должен возвращать ошибку о несовпадении актуального ключа.
### Успешный ответ ### Успешный ответ
```json ```json
@ -289,6 +307,7 @@ AUTH_CREATE_SESSION:alice:ed25519/BASE64_PUBLIC_KEY:BASE64_OR_APP_SPECIFIC_SECRE
"requestId": "slogin-001", "requestId": "slogin-001",
"payload": { "payload": {
"sessionId": "sess_7c5e5c4b", "sessionId": "sess_7c5e5c4b",
"sessionKey": "ed25519/BASE64_PUBLIC_KEY",
"timeMs": 1774600010456, "timeMs": 1774600010456,
"signatureB64": "BASE64_SIGNATURE_BY_SESSION_KEY", "signatureB64": "BASE64_SIGNATURE_BY_SESSION_KEY",
"clientInfo": "Android 15; Pixel 9" "clientInfo": "Android 15; Pixel 9"
@ -308,6 +327,16 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66 SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66
``` ```
### Дополнительная проверка `sessionKey`
Перед проверкой подписи сервер должен:
1. загрузить `active_sessions.session_key` по `sessionId`;
2. сравнить его со значением `payload.sessionKey`;
3. только после совпадения ключей проверять подпись.
Если ключ не совпадает, сервер должен возвращать ошибку о том, что ключ не соответствует актуальной версии.
Успешный ответ: Успешный ответ:
```json ```json
@ -364,6 +393,8 @@ SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66
### `CloseActiveSession` ### `CloseActiveSession`
Доступно только после успешного `SessionLogin`.
Запрос: Запрос:
```json ```json
@ -398,6 +429,8 @@ SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66
- `400 BAD_REQUEST` — не хватает поля или неверный формат. - `400 BAD_REQUEST` — не хватает поля или неверный формат.
- `401 UNAUTHORIZED` — challenge не был пройден или соединение не авторизовано. - `401 UNAUTHORIZED` — challenge не был пройден или соединение не авторизовано.
- `403 INVALID_SIGNATURE` — подпись не прошла проверку. - `403 INVALID_SIGNATURE` — подпись не прошла проверку.
- `403 DEVICE_KEY_NOT_ACTUAL` — присланный `deviceKey` не совпадает с актуальным ключом пользователя.
- `403 SESSION_KEY_NOT_ACTUAL` — присланный `sessionKey` не совпадает с актуальным ключом сессии.
- `404 SESSION_NOT_FOUND` — сессия не существует или уже закрыта. - `404 SESSION_NOT_FOUND` — сессия не существует или уже закрыта.
- `409 NONCE_ALREADY_USED` — challenge уже использован. - `409 NONCE_ALREADY_USED` — challenge уже использован.
- `410 CHALLENGE_EXPIRED` — nonce устарел. - `410 CHALLENGE_EXPIRED` — nonce устарел.
@ -422,32 +455,33 @@ SESSION_LOGIN:sess_7c5e5c4b:1774600010456:0e5bb0f4-c7d8-4efb-b44d-bf31a6126c66
По текущему состоянию кода сервер уже использует схему: По текущему состоянию кода сервер уже использует схему:
- `AuthChallenge(login)` - `AuthChallenge(login)`
- `CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo)` - `CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, deviceKey, signatureB64, clientInfo)`
- `SessionChallenge(sessionId)` - `SessionChallenge(sessionId)`
- `SessionLogin(sessionId, timeMs, signatureB64, clientInfo)` - `SessionLogin(sessionId, sessionKey, timeMs, signatureB64, clientInfo)`
Текущая строка подписи для `CreateAuthSession` в коде: Текущая строка подписи для `CreateAuthSession` в коде:
```text
AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}
```
То есть **сейчас** `sessionPubKeyB64` и `storagePwd` ещё не входят в preimage подписи.
### Рекомендуемый путь миграции
1. Ввести новую версию контракта `CreateAuthSession`.
2. Добавить поле `sessionKey` вместо `sessionPubKeyB64`.
3. На сервере распознавать префикс алгоритма в `sessionKey`.
4. Перейти на подпись строки:
```text ```text
AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} 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`. - Да, клиент сам создаёт `session_key`.
- Да, клиент сам передаёт `storagePwd`. - Да, клиент сам передаёт `storagePwd`.
- Для `session_key` имеет смысл ввести префикс алгоритма, например `ed25519/...`. - Для `session_key` имеет смысл ввести префикс алгоритма, например `ed25519/...`.
- Для `CreateAuthSession` клиент должен дополнительно передавать `deviceKey`, а сервер должен сверять его с актуальным ключом пользователя.
- Для `SessionLogin` клиент должен дополнительно передавать `sessionKey`, а сервер должен сверять его с актуальным ключом сессии.
- Для `CreateAuthSession` рекомендуется подписывать не только `login`, `timeMs` и `authNonce`, но также `sessionKey` и `storagePwd`. - Для `CreateAuthSession` рекомендуется подписывать не только `login`, `timeMs` и `authNonce`, но также `sessionKey` и `storagePwd`.
- Для разработчиков клиентов лучше сразу документировать API через полные JSON-примеры запросов и ответов. - Для разработчиков клиентов лучше сразу документировать API через полные JSON-примеры запросов и ответов.

View File

@ -20,7 +20,7 @@ import java.sql.Statement;
* *
* v2 (sessions): * v2 (sessions):
* - active_sessions.session_pwd удалён * - active_sessions.session_pwd удалён
* - active_sessions.session_key хранит публичный ключ сессии (sessionPubKeyB64) * - active_sessions.session_key хранит публичный ключ сессии целиком одной строкой
*/ */
public final class DatabaseInitializer { public final class DatabaseInitializer {

View File

@ -8,7 +8,7 @@ public class ActiveSessionEntry {
private String sessionId; private String sessionId;
private String login; private String login;
/** session_key: публичный ключ сессии (base64 от 32 байт). */ /** session_key: публичный ключ сессии целиком одной строкой, например ed25519/BASE64_PUBLIC_KEY. */
private String sessionKey; private String sessionKey;
private String storagePwd; private String storagePwd;

View File

@ -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.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; 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.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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import server.ws.WsConnectionUtils; import server.ws.WsConnectionUtils;
import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.ActiveSessionEntry; import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.SolanaUserEntry; import shine.db.entities.SolanaUserEntry;
import shine.geo.ClientInfoService; import shine.geo.ClientInfoService;
@ -31,12 +33,12 @@ import java.sql.SQLException;
* *
* Логика авторизации (v2): * Логика авторизации (v2):
* - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...)
* - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя, * - Клиент генерирует sessionKey, хранит приватный ключ у себя,
* отправляет на сервер ТОЛЬКО sessionPubKeyB64. * отправляет на сервер sessionKey целиком одной строкой.
* - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key. * - Сервер сохраняет sessionKey в active_sessions.session_key как есть.
* *
* Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8):
* AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce} * AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
* *
* На выходе: * На выходе:
* - создаётся запись active_sessions * - создаётся запись active_sessions
@ -70,8 +72,54 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err; return err;
} }
SolanaUserEntry user = ctx.getSolanaUser(); SolanaUserEntry userFromContext = ctx.getSolanaUser();
String login = user.getLogin(); 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()) { if (login == null || login.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
@ -95,40 +143,38 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err; return err;
} }
String sessionPubKeyB64 = req.getSessionPubKeyB64(); String sessionKey = req.getSessionKey();
if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { if (sessionKey == null || sessionKey.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"EMPTY_SESSION_PUBKEY", "EMPTY_SESSION_KEY",
"Пустой sessionPubKeyB64" "Пустой sessionKey"
); );
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey"); WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session key");
return err; return err;
} }
// Проверим, что sessionPubKeyB64 декодируется в 32 байта sessionKey = AuthKeyUtils.normalize(sessionKey, "sessionKey");
byte[] sessionPubKey32;
try { 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) { } catch (IllegalArgumentException e) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"BAD_BASE64", "BAD_BASE64",
"Некорректный base64 в sessionPubKeyB64" "Некорректный формат sessionKey"
); );
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64"); WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session key format");
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");
return err; return err;
} }
@ -163,8 +209,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
clientInfoFromClient = clientInfoFromClient.substring(0, 50); clientInfoFromClient = clientInfoFromClient.substring(0, 50);
} }
String devicePubKeyB64 = user.getDeviceKey(); String deviceKeyFromDb = user.getDeviceKey();
if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) { if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
@ -176,16 +222,73 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
} }
String authNonce = ctx.getAuthNonce(); 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; boolean sigOk;
try { try {
sigOk = verifyCreateSessionSignature( sigOk = verifyCreateSessionSignature(
user,
login, login,
sessionKey,
storagePwd,
authNonce, authNonce,
timeMs, timeMs,
deviceKeyFromDb,
signatureB64 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) { } catch (IllegalArgumentException ex) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
@ -239,7 +342,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
activeSessionEntry = new ActiveSessionEntry( activeSessionEntry = new ActiveSessionEntry(
sessionId, sessionId,
login, login,
sessionPubKeyB64, // session_key (pubkey) sessionKey, // session_key (pubkey string as-is)
storagePwd, storagePwd,
now, now,
now, now,
@ -283,18 +386,24 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
} }
private static boolean verifyCreateSessionSignature( private static boolean verifyCreateSessionSignature(
SolanaUserEntry user,
String login, String login,
String sessionKey,
String storagePwd,
String authNonce, String authNonce,
long timeMs, long timeMs,
String deviceKey,
String signatureB64 String signatureB64
) throws IllegalArgumentException { ) throws IllegalArgumentException {
// deviceKey (pub, 32) byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(deviceKey, "deviceKey");
byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey()); byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
byte[] signature64 = Base64Ws.decode(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); byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
return Ed25519Util.verify(preimage, signature64, publicKey32); return Ed25519Util.verify(preimage, signature64, publicKey32);

View File

@ -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.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request; 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.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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.ActiveSessionsDAO;
@ -30,7 +31,7 @@ import java.sql.SQLException;
* - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL). * - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL).
* - SessionLogin проверяет подпись sessionKey над строкой: * - SessionLogin проверяет подпись sessionKey над строкой:
* SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
* - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes). * - sessionKey берём из запроса и сверяем с active_sessions.session_key.
* *
* При успехе: * При успехе:
* - ctx становится AUTH_STATUS_USER * - 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; ActiveSessionEntry session;
try { try {
session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
@ -121,8 +133,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
); );
} }
String sessionPubKeyB64 = session.getSessionKey(); // это pubKey (Base64(32)) String sessionKeyFromDb = session.getSessionKey();
if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { if (sessionKeyFromDb == null || sessionKeyFromDb.isBlank()) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.SERVER_DATA_ERROR, WireCodes.Status.SERVER_DATA_ERROR,
@ -130,12 +142,29 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
"В сессии не задан session_key" "В сессии не задан 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(); String nonce = ctx.getSessionLoginNonce();
boolean sigOk; boolean sigOk;
try { 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) { } catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
@ -243,17 +272,15 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
} }
private static boolean verifySessionLoginSignature( private static boolean verifySessionLoginSignature(
String sessionPubKeyB64, String sessionKey,
String sessionId, String sessionId,
long timeMs, long timeMs,
String nonce, String nonce,
String signatureB64 String signatureB64
) throws IllegalArgumentException { ) throws IllegalArgumentException {
// pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(sessionKey, "sessionKey");
byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64);
// signature: Base64(64) через единую утилиту WS-протокола
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce;

View File

@ -7,32 +7,48 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
* *
* Шаги: * Шаги:
* 1) AuthChallenge(login) -> authNonce * 1) AuthChallenge(login) -> authNonce
* 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo) * 2) CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, deviceKey, signatureB64, clientInfo)
* *
* Подпись deviceKey делается над строкой (UTF-8): * Подпись deviceKey делается над строкой (UTF-8):
* AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} * AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
* *
* Важно: * Важно:
* - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64). * - sessionKey генерируется на клиенте и передаётся на сервер целиком одной строкой.
* - В БД active_sessions.session_key хранится sessionPubKeyB64. * - В БД active_sessions.session_key хранится sessionKey целиком одной строкой.
*/ */
public class Net_CreateAuthSession_Request extends Net_Request { public class Net_CreateAuthSession_Request extends Net_Request {
private String login;
/** Клиентский пароль для хранения данных (base64 от 32 байт). */ /** Клиентский пароль для хранения данных (base64 от 32 байт). */
private String storagePwd; private String storagePwd;
/** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */ /** Публичный ключ сессии в API-формате, например ed25519/BASE64_PUBLIC_KEY. */
private String sessionPubKeyB64; private String sessionKey;
/** Время на стороне клиента (мс с 1970-01-01). */ /** Время на стороне клиента (мс с 1970-01-01). */
private long timeMs; private long timeMs;
/** Nonce из AuthChallenge. */
private String authNonce;
/** Публичный ключ устройства пользователя. */
private String deviceKey;
/** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */
private String signatureB64; private String signatureB64;
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
private String clientInfo; private String clientInfo;
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public String getStoragePwd() { public String getStoragePwd() {
return storagePwd; return storagePwd;
} }
@ -41,12 +57,12 @@ public class Net_CreateAuthSession_Request extends Net_Request {
this.storagePwd = storagePwd; this.storagePwd = storagePwd;
} }
public String getSessionPubKeyB64() { public String getSessionKey() {
return sessionPubKeyB64; return sessionKey;
} }
public void setSessionPubKeyB64(String sessionPubKeyB64) { public void setSessionKey(String sessionKey) {
this.sessionPubKeyB64 = sessionPubKeyB64; this.sessionKey = sessionKey;
} }
public long getTimeMs() { public long getTimeMs() {
@ -57,6 +73,22 @@ public class Net_CreateAuthSession_Request extends Net_Request {
this.timeMs = timeMs; 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() { public String getSignatureB64() {
return signatureB64; return signatureB64;
} }

View File

@ -4,7 +4,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** /**
* Шаг 2 входа в существующую сессию (v2): * Шаг 2 входа в существующую сессию (v2):
* SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER * SessionLogin(sessionId, sessionKey, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER
* *
* Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8): * Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8):
* SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} * 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 { public class Net_SessionLogin_Request extends Net_Request {
private String sessionId; private String sessionId;
private String sessionKey;
private long timeMs; private long timeMs;
private String signatureB64; private String signatureB64;
@ -28,6 +29,14 @@ public class Net_SessionLogin_Request extends Net_Request {
this.sessionId = sessionId; this.sessionId = sessionId;
} }
public String getSessionKey() {
return sessionKey;
}
public void setSessionKey(String sessionKey) {
this.sessionKey = sessionKey;
}
public long getTimeMs() { public long getTimeMs() {
return timeMs; return timeMs;
} }

View File

@ -20,7 +20,7 @@ import static org.junit.jupiter.api.Assertions.*;
* - и после завершения оставить в БД 3 активных сессии (S1,S2,S3) * - и после завершения оставить в БД 3 активных сессии (S1,S2,S3)
* *
* Протокол v2: * Протокол v2:
* - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + sessionPubKey) * - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + deviceKey + sessionKey)
* - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey)) * - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey))
* - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin) * - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin)
*/ */
@ -109,16 +109,15 @@ public class IT_02_Sessions {
String authNonce = JsonParsers.authNonce(nonceResp); String authNonce = JsonParsers.authNonce(nonceResp);
assertNotNull(authNonce, "authNonce must not be null for " + label); assertNotNull(authNonce, "authNonce must not be null for " + label);
// для тестов: sessionKey = deviceKey (в реале будет отдельный keypair) String sessionKey = TestConfig.sessionKey(login);
String sessionPubKeyB64 = TestConfig.devicePublicKeyB64(login);
// storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его) // storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его)
String storagePwd = TestConfig.fakeStoragePwd(); String storagePwd = TestConfig.fakeStoragePwd();
// шаг 2: CreateAuthSession (device подпись + sessionPubKey) // шаг 2: CreateAuthSession (device подпись + deviceKey + sessionKey)
String createResp = ws.call( String createResp = ws.call(
"CreateAuthSession(" + label + ")", "CreateAuthSession(" + label + ")",
JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionPubKeyB64), JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionKey),
t t
); );
assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200"); assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200");
@ -128,10 +127,9 @@ public class IT_02_Sessions {
r.ok("Создана сессия " + label + ": sessionId=" + sid); r.ok("Создана сессия " + label + ": sessionId=" + sid);
// для тестов используем devicePriv как sessionPriv byte[] sessionPrivKey = TestConfig.getSessionPrivatKey(login);
byte[] sessionPrivKey = TestConfig.getDevicePrivatKey(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"); assertNotNull(nonce, "SessionChallenge nonce must not be null");
// шаг 2: SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) // шаг 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"); assertEquals(200, JsonParsers.status(loginResp), "SessionLogin must be 200");
String storagePwd = JsonParsers.storagePwd(loginResp); String storagePwd = JsonParsers.storagePwd(loginResp);
@ -153,5 +151,5 @@ public class IT_02_Sessions {
r.ok(label + ": SessionLogin OK, storagePwd verified"); r.ok(label + ": SessionLogin OK, storagePwd verified");
} }
private record Session(String sessionId, byte[] sessionPrivKey, String storagePwd) {} private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {}
} }

View File

@ -119,6 +119,7 @@ public final class TestConfig {
// NEW: session pub b64 helper // NEW: session pub b64 helper
public static String sessionPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSessionPublicKey(login)); } 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" ============ // ============ backward-compatible helpers for "user1" ============
public static String BCH_NAME() { return getBlockchainName(LOGIN()); } 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 SESSION_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN()); }
public static String SESSION2_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN2()); } public static String SESSION2_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN2()); }
public static String SESSION3_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN3()); } 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 ============ // ============ misc ============
public static String fakeStoragePwd() { public static String fakeStoragePwd() {

View File

@ -104,20 +104,15 @@ public final class JsonBuilders {
} }
// ---------------- CreateAuthSession (v2) ---------------- // ---------------- CreateAuthSession (v2) ----------------
// v2: sessionKey генерируется/хранится на клиенте, на сервер отправляем sessionPubKeyB64 (base64). // Подпись CreateAuthSession делается deviceKey над строкой:
// // preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce
// ВАЖНО (новое правило):
// Подпись CreateAuthSession делается ТОЛЬКО deviceKey над строкой:
// preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce
//
// storagePwd и sessionPubKeyB64 НЕ входят в preimage.
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(); long timeMs = System.currentTimeMillis();
// подпись делаем devicePrivKey
byte[] devicePriv = TestConfig.getDevicePrivatKey(login); 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"); String requestId = TestIds.next("create");
return """ return """
@ -125,18 +120,24 @@ public final class JsonBuilders {
"op": "CreateAuthSession", "op": "CreateAuthSession",
"requestId": "%s", "requestId": "%s",
"payload": { "payload": {
"login": "%s",
"storagePwd": "%s", "storagePwd": "%s",
"sessionPubKeyB64": "%s", "sessionKey": "%s",
"timeMs": %d, "timeMs": %d,
"authNonce": "%s",
"deviceKey": "%s",
"signatureB64": "%s", "signatureB64": "%s",
"clientInfo": "%s" "clientInfo": "%s"
} }
} }
""".formatted( """.formatted(
requestId, requestId,
login,
storagePwd, storagePwd,
sessionPubKeyB64, sessionKey,
timeMs, timeMs,
authNonce,
deviceKey,
sigB64, sigB64,
TestConfig.TEST_CLIENT_INFO TestConfig.TEST_CLIENT_INFO
); );
@ -161,7 +162,7 @@ public final class JsonBuilders {
// Подпись SessionLogin по-прежнему делается sessionPrivKey: // Подпись SessionLogin по-прежнему делается sessionPrivKey:
// preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce // 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(); long timeMs = System.currentTimeMillis();
String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey); String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey);
@ -172,12 +173,13 @@ public final class JsonBuilders {
"requestId": "%s", "requestId": "%s",
"payload": { "payload": {
"sessionId": "%s", "sessionId": "%s",
"sessionKey": "%s",
"timeMs": %d, "timeMs": %d,
"signatureB64": "%s", "signatureB64": "%s",
"clientInfo": "%s" "clientInfo": "%s"
} }
} }
""".formatted(requestId, sessionId, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); """.formatted(requestId, sessionId, sessionKey, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO);
} }
// ---------------- ListSessions ---------------- // ---------------- ListSessions ----------------
@ -226,11 +228,11 @@ public final class JsonBuilders {
/** /**
* Подпись CreateAuthSession(v2): * Подпись CreateAuthSession(v2):
* preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce * preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce
* подписываем devicePrivKey. * подписываем devicePrivKey.
*/ */
public static String signAuthCreateSession(String login, long timeMs, String authNonce, byte[] devicePrivKey) { public static String signAuthCreateSession(String login, String sessionKey, String storagePwd, long timeMs, String authNonce, byte[] devicePrivKey) {
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); byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
byte[] sig = Ed25519Util.sign(preimage, devicePrivKey); byte[] sig = Ed25519Util.sign(preimage, devicePrivKey);
return Base64.getEncoder().encodeToString(sig); return Base64.getEncoder().encodeToString(sig);