Auth/UI: Argon2id derivation для login/register + блок Расширенные

This commit is contained in:
AidarKC 2026-05-13 02:10:42 +03:00
parent 8de4e95c6a
commit b55fd1571e
6 changed files with 107 additions and 12 deletions

View File

@ -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=<lowercaseLogin>|suffix=<keySuffix>"`
- `salt = first16bytes( SHA-256(saltSource) )`
- `keySuffix` = `root.key` / `bch.key` / `dev.key`
## Как проверять
1. На входе/регистрации открыть `Расширенные` и проверить отображение описания.
2. Проверить тестовый режим: оставить пароль пустым и убедиться, что вход работает по старому сценарию.
3. Проверить новый режим: ввести непустой пароль и выполнить вход/регистрацию.
4. Проверить, что одинаковый пароль при разных логинах даёт разные ключи (например, вход под двумя логинами с тем же паролем и проверка несовпадения производных ключей/сессий).
## Ожидаемый результат
- Непустой пароль использует Argon2id.
- Пустой пароль остаётся тестовым legacy-вариантом.
- UI показывает пользователю, как сейчас считается секрет.

View File

@ -1,2 +1,2 @@
client.version=1.2.46 client.version=1.2.47
server.version=1.2.40 server.version=1.2.41

View File

@ -35,6 +35,16 @@ export function render({ navigate }) {
hint.className = 'meta-muted'; hint.className = 'meta-muted';
hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.'; hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.';
const advanced = document.createElement('details');
advanced.className = 'card stack';
advanced.innerHTML = `
<summary>Расширенные</summary>
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">Если пароль пустой используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
`;
const status = document.createElement('p'); const status = document.createElement('p');
status.className = 'status-line is-unavailable'; status.className = 'status-line is-unavailable';
status.style.display = 'none'; status.style.display = 'none';
@ -49,7 +59,7 @@ export function render({ navigate }) {
`; `;
form.children[0].append(loginInput); form.children[0].append(loginInput);
form.children[1].append(passwordInput); form.children[1].append(passwordInput);
form.append(hint, status, testLoginsHint); form.append(hint, advanced, status, testLoginsHint);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'auth-footer-actions';

View File

@ -33,6 +33,17 @@ export function render({ navigate }) {
formError.className = 'status-line is-unavailable'; formError.className = 'status-line is-unavailable';
formError.style.display = 'none'; formError.style.display = 'none';
const advanced = document.createElement('details');
advanced.className = 'card stack';
advanced.innerHTML = `
<summary>Расширенные</summary>
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.</p>
<p class="meta-muted">Если пароль пустой используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
`;
const checkButton = document.createElement('button'); const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn'; checkButton.className = 'ghost-btn';
checkButton.type = 'button'; checkButton.type = 'button';
@ -73,7 +84,7 @@ export function render({ navigate }) {
`; `;
form.children[0].append(loginInput); form.children[0].append(loginInput);
form.children[1].append(passwordInput); form.children[1].append(passwordInput);
form.append(checkButton, statusText, formError); form.append(checkButton, statusText, advanced, formError);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'auth-footer-actions';

View File

@ -556,11 +556,12 @@ export class AuthService {
return payload.exists !== true; return payload.exists !== true;
} }
async derivePasswordKeyBundle(password) { async derivePasswordKeyBundle(login, password) {
const normalizedLogin = String(login ?? '');
const normalizedPassword = String(password ?? ''); const normalizedPassword = String(password ?? '');
const rootPair = await deriveEd25519FromPassword(normalizedPassword, 'root.key'); const rootPair = await deriveEd25519FromPassword(normalizedPassword, 'root.key', { login: normalizedLogin });
const blockchainPair = await deriveEd25519FromPassword(normalizedPassword, 'bch.key'); const blockchainPair = await deriveEd25519FromPassword(normalizedPassword, 'bch.key', { login: normalizedLogin });
const devicePair = await deriveEd25519FromPassword(normalizedPassword, 'dev.key'); const devicePair = await deriveEd25519FromPassword(normalizedPassword, 'dev.key', { login: normalizedLogin });
return { rootPair, blockchainPair, devicePair }; return { rootPair, blockchainPair, devicePair };
} }
@ -617,7 +618,7 @@ export class AuthService {
const isFree = await this.ensureLoginFree(cleanLogin); const isFree = await this.ensureLoginFree(cleanLogin);
if (!isFree) throw new Error('Этот логин уже занят'); 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', { const addResp = await this.ws.request('AddUser', {
login: cleanLogin, login: cleanLogin,
@ -640,7 +641,7 @@ export class AuthService {
const user = await this.getUser(cleanLogin); const user = await this.getUser(cleanLogin);
if (!user.exists) throw new Error('Пользователь не найден'); 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); const session = await this.createAuthSession(cleanLogin, keyBundle);
return { ...session, keyBundle }; return { ...session, keyBundle };
} }

View File

@ -1,5 +1,6 @@
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.'; 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() { function getCryptoApi() {
const api = globalThis.crypto; const api = globalThis.crypto;
@ -66,6 +67,33 @@ export async function derivePasswordSeed(password, suffix) {
return sha256Text(concat); 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) { function ed25519Pkcs8FromSeed(seed32) {
if (seed32.length !== 32) { if (seed32.length !== 32) {
throw new Error('Для Ed25519 нужен seed длиной 32 байта'); throw new Error('Для Ed25519 нужен seed длиной 32 байта');
@ -79,8 +107,17 @@ function ed25519Pkcs8FromSeed(seed32) {
return out; return out;
} }
export async function deriveEd25519FromPassword(password, suffix) { export async function deriveEd25519FromPassword(password, suffix, options = {}) {
const seed = await derivePasswordSeed(password, suffix); 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 pkcs8 = ed25519Pkcs8FromSeed(seed);
const subtle = getSubtleApi(); const subtle = getSubtleApi();
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']); const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);