Auth/UI: Argon2id derivation для login/register + блок Расширенные
This commit is contained in:
parent
8de4e95c6a
commit
b55fd1571e
@ -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 показывает пользователю, как сейчас считается секрет.
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.46
|
client.version=1.2.47
|
||||||
server.version=1.2.40
|
server.version=1.2.41
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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']);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user