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

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

Всё работает
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`
- последующих перевходов в уже созданную сессию
В 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-примеры запросов и ответов.

View File

@ -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 {

View File

@ -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;

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.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);

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.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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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) {}
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
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() {

View File

@ -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);