diff --git a/Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-login-register.md b/Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-login-register.md new file mode 100644 index 0000000..a41f650 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-login-register.md @@ -0,0 +1,36 @@ +# Argon2id для входа/регистрации + блок «Расширенные» + +Статус: `pending` + +## Что сделано + +- При непустом пароле derivation ключей переведён на Argon2id. +- В derivation участвуют и логин, и пароль. +- Для пустого пароля оставлен прежний тестовый режим (старый детерминированный вариант), чтобы сохранить текущий тестовый сценарий. +- На экранах входа и регистрации добавлен блок `Расширенные` с кратким описанием схемы и параметров. + +## Параметры Argon2id (текущий профиль) + +- `t = 3` +- `m = 262144 KiB` (256 MB) +- `p = 1` +- `dkLen = 32` + +Формат salt: + +- `saltSource = "shine-auth-v2|login=|suffix="` +- `salt = first16bytes( SHA-256(saltSource) )` +- `keySuffix` = `root.key` / `bch.key` / `dev.key` + +## Как проверять + +1. На входе/регистрации открыть `Расширенные` и проверить отображение описания. +2. Проверить тестовый режим: оставить пароль пустым и убедиться, что вход работает по старому сценарию. +3. Проверить новый режим: ввести непустой пароль и выполнить вход/регистрацию. +4. Проверить, что одинаковый пароль при разных логинах даёт разные ключи (например, вход под двумя логинами с тем же паролем и проверка несовпадения производных ключей/сессий). + +## Ожидаемый результат + +- Непустой пароль использует Argon2id. +- Пустой пароль остаётся тестовым legacy-вариантом. +- UI показывает пользователю, как сейчас считается секрет. diff --git a/VERSION.properties b/VERSION.properties index 7775ae4..db4c44e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.46 -server.version=1.2.40 +client.version=1.2.47 +server.version=1.2.41 diff --git a/shine-UI/js/pages/login-password-view.js b/shine-UI/js/pages/login-password-view.js index 393ba4a..d982601 100644 --- a/shine-UI/js/pages/login-password-view.js +++ b/shine-UI/js/pages/login-password-view.js @@ -35,6 +35,16 @@ export function render({ navigate }) { hint.className = 'meta-muted'; hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.'; + const advanced = document.createElement('details'); + advanced.className = 'card stack'; + advanced.innerHTML = ` + Расширенные +

Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.

+

Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).

+

Для тесто оставьте пустой пароль.

+

Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.

+ `; + const status = document.createElement('p'); status.className = 'status-line is-unavailable'; status.style.display = 'none'; @@ -49,7 +59,7 @@ export function render({ navigate }) { `; form.children[0].append(loginInput); form.children[1].append(passwordInput); - form.append(hint, status, testLoginsHint); + form.append(hint, advanced, status, testLoginsHint); const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index d49be5d..c69e0d2 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -33,6 +33,17 @@ export function render({ navigate }) { formError.className = 'status-line is-unavailable'; formError.style.display = 'none'; + const advanced = document.createElement('details'); + advanced.className = 'card stack'; + advanced.innerHTML = ` + Расширенные +

Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.

+

В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.

+

Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).

+

Для тесто оставьте пустой пароль.

+

Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.

+ `; + const checkButton = document.createElement('button'); checkButton.className = 'ghost-btn'; checkButton.type = 'button'; @@ -73,7 +84,7 @@ export function render({ navigate }) { `; form.children[0].append(loginInput); form.children[1].append(passwordInput); - form.append(checkButton, statusText, formError); + form.append(checkButton, statusText, advanced, formError); const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index beb4a2b..38d79fe 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -556,11 +556,12 @@ export class AuthService { return payload.exists !== true; } - async derivePasswordKeyBundle(password) { + async derivePasswordKeyBundle(login, password) { + const normalizedLogin = String(login ?? ''); const normalizedPassword = String(password ?? ''); - const rootPair = await deriveEd25519FromPassword(normalizedPassword, 'root.key'); - const blockchainPair = await deriveEd25519FromPassword(normalizedPassword, 'bch.key'); - const devicePair = await deriveEd25519FromPassword(normalizedPassword, 'dev.key'); + const rootPair = await deriveEd25519FromPassword(normalizedPassword, 'root.key', { login: normalizedLogin }); + const blockchainPair = await deriveEd25519FromPassword(normalizedPassword, 'bch.key', { login: normalizedLogin }); + const devicePair = await deriveEd25519FromPassword(normalizedPassword, 'dev.key', { login: normalizedLogin }); return { rootPair, blockchainPair, devicePair }; } @@ -617,7 +618,7 @@ export class AuthService { const isFree = await this.ensureLoginFree(cleanLogin); if (!isFree) throw new Error('Этот логин уже занят'); - const keyBundle = await this.derivePasswordKeyBundle(password); + const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password); const addResp = await this.ws.request('AddUser', { login: cleanLogin, @@ -640,7 +641,7 @@ export class AuthService { const user = await this.getUser(cleanLogin); if (!user.exists) throw new Error('Пользователь не найден'); - const keyBundle = await this.derivePasswordKeyBundle(password); + const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password); const session = await this.createAuthSession(cleanLogin, keyBundle); return { ...session, keyBundle }; } diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index 762066c..055dd8b 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -1,5 +1,6 @@ const encoder = new TextEncoder(); const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.'; +import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js'; function getCryptoApi() { const api = globalThis.crypto; @@ -66,6 +67,33 @@ export async function derivePasswordSeed(password, suffix) { return sha256Text(concat); } +function normalizeLoginForKdf(login) { + return String(login || '').trim().toLowerCase(); +} + +async function makeArgon2Salt(login, suffix) { + const normalizedLogin = normalizeLoginForKdf(login); + const normalizedSuffix = String(suffix || '').trim(); + const saltSource = `shine-auth-v2|login=${normalizedLogin}|suffix=${normalizedSuffix}`; + const digest = await sha256Text(saltSource); + return digest.slice(0, 16); +} + +async function derivePasswordSeedArgon2id({ login, password, suffix }) { + const normalizedLogin = normalizeLoginForKdf(login); + const normalizedPassword = String(password ?? ''); + const normalizedSuffix = String(suffix || '').trim(); + const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix); + const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`); + const out = await argon2idAsync(passBytes, salt, { + t: 3, + m: 262144, + p: 1, + dkLen: 32, + }); + return new Uint8Array(out); +} + function ed25519Pkcs8FromSeed(seed32) { if (seed32.length !== 32) { throw new Error('Для Ed25519 нужен seed длиной 32 байта'); @@ -79,8 +107,17 @@ function ed25519Pkcs8FromSeed(seed32) { return out; } -export async function deriveEd25519FromPassword(password, suffix) { - const seed = await derivePasswordSeed(password, suffix); +export async function deriveEd25519FromPassword(password, suffix, options = {}) { + const normalizedPassword = String(password ?? ''); + const normalizedLogin = String(options?.login ?? ''); + const useLegacyEmptyPassword = normalizedPassword.length === 0; + const seed = useLegacyEmptyPassword + ? await derivePasswordSeed(normalizedPassword, suffix) + : await derivePasswordSeedArgon2id({ + login: normalizedLogin, + password: normalizedPassword, + suffix, + }); const pkcs8 = ed25519Pkcs8FromSeed(seed); const subtle = getSubtleApi(); const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);