homeserver: рендейм subserver→homeserver, документ деривации ключей, запрет пустого пароля

Основное (наша работа в этой сессии):
- Переименование «subserver» → «homeserver» по всему проекту: основной ESP32-скетч
  (папка shine_subserver_ui → shine_homeserver_ui, .ino, flash-скрипт, режим burn.sh
  homeserver-ui), скетч lvgl_nav_minimal_test (ключ homeserver.key:<имя>), spec-доки
  reference/*, формат PDA (терминология session_type=100 «Homeserver пользователя»),
  константа SESSION_TYPE_HOMESERVER в JS и Rust (значение 100 не менялось, формат не затронут),
  pending/future доки, AGENTS.md, DAO-док. Сохранены отдельный lvgl_subserver_touch_test и
  историческая пометка о рендейме в DERIVATION.md.
- Новый источник истины по деривации ключей: Dev_Docs/Keys/DERIVATION.md (Argon2id-секрет из
  пароля, формула Ed25519(SHA-256(base64(secret)|suffix)), суффиксы root/bch/dev/homeserver.key,
  Solana-ключ = dev.key). Уточнены роли root (главный/master) и dev (пополняемый кошелёк) в
  Dev_Docs/Keys/README.md.
- UI: убран легаси-путь пустого пароля (derivePasswordSeed и др.), deriveMasterSecretFromPassword
  бросает ошибку на пустом пароле, register-view блокирует пустой пароль; экран пополнения
  переведён на канонический device-адрес из preGeneratedKeyBundle (удалён расходящийся
  deriveWalletFromPassword).

Включены также параллельные правки Solana-аудита №3 (были в рабочем дереве, переплетены в lib.rs):
- shine_users: defense-in-depth «строгий список аккаунтов» (require!(it.next().is_none()))
  в init/update economy config и create/update user PDA, плюс описание в doc/programs/shine_users.md;
- Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AidarKC
2026-06-12 21:16:12 +04:00
co-authored by Claude Opus 4.8
parent cf6a2830c8
commit 42dcf6970d
32 changed files with 517 additions and 251 deletions
+5
View File
@@ -176,6 +176,11 @@ export function render({ navigate }) {
const prevPassword = String(state.registrationDraft.password || '');
const nextLogin = String(loginInput.value.trim());
const nextPassword = String(passwordInput.value || '');
if (nextPassword.length === 0) {
formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.';
formError.style.display = '';
return;
}
const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword;
state.registrationDraft.login = nextLogin;
+19 -5
View File
@@ -1,7 +1,6 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import {
deriveWalletFromPassword,
formatSol,
getBalanceSol,
getTopupSiteUrl,
@@ -10,6 +9,21 @@ import {
export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей.
// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес
// напрямую из пароля, иначе он расходится с device-ключом регистрации.
async function deviceWalletAddressFromBundle() {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle || !keyBundle.devicePair) {
throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.');
}
const raw = atob(keyBundle.devicePair.publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4');
return new PublicKey(bytes).toBase58();
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -55,7 +69,7 @@ export function render({ navigate }) {
</div>
<a class="link-card" id="topup-site-link" href="${getTopupSiteUrl(state.registrationPayment.walletAddress || '')}" target="_blank" rel="noreferrer">Открыть сайт пополнения</a>
<div class="card stack" style="padding:12px; max-width:320px;">
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (wallet.key)</div>
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (device key = Solana wallet)</div>
</div>
`;
card.children[3].append(walletRow);
@@ -103,9 +117,9 @@ export function render({ navigate }) {
(async () => {
try {
if (!walletValue.value) {
const wallet = await deriveWalletFromPassword(String(state.registrationDraft.password ?? ''));
state.registrationPayment.walletAddress = wallet.address;
walletValue.value = wallet.address;
const address = await deviceWalletAddressFromBundle();
state.registrationPayment.walletAddress = address;
walletValue.value = address;
}
const topupSiteLink = card.querySelector('#topup-site-link');
if (topupSiteLink instanceof HTMLAnchorElement) {
+2 -48
View File
@@ -116,12 +116,6 @@ export async function sha256Text(text) {
return sha256Bytes(utf8Bytes(text));
}
export async function derivePasswordSeed(password, suffix) {
const base = await sha256Text(password || '');
const concat = `${bytesToBase64(base)}${suffix}`;
return sha256Text(concat);
}
function normalizeLoginForKdf(login) {
return String(login || '').trim().toLowerCase();
}
@@ -134,21 +128,6 @@ async function makeArgon2Salt(login, suffix) {
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: 2,
m: 65536,
p: 1,
dkLen: 32,
});
return new Uint8Array(out);
}
async function deriveMasterSecretArgon2id({ login, password, onProgress }) {
const normalizedLogin = normalizeLoginForKdf(login);
const normalizedPassword = String(password ?? '');
@@ -177,38 +156,13 @@ function ed25519Pkcs8FromSeed(seed32) {
return out;
}
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']);
const jwk = await subtle.exportKey('jwk', privateKey);
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
return {
privateKey,
publicKeyB64: bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))),
privatePkcs8B64: bytesToBase64(pkcs8),
};
}
export async function deriveMasterSecretFromPassword(password, options = {}) {
const normalizedPassword = String(password ?? '');
const normalizedLogin = String(options?.login ?? '');
const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : undefined;
if (normalizedPassword.length === 0) {
const legacy = await derivePasswordSeed(normalizedPassword, 'master.secret');
if (onProgress) onProgress(1);
return legacy;
// Пустой пароль запрещён: упрощённый легаси-путь убран, регистрация/вход требуют непустой пароль.
throw new Error('Пустой пароль запрещён: регистрация и вход требуют непустой пароль.');
}
return deriveMasterSecretArgon2id({
login: normalizedLogin,
@@ -25,7 +25,7 @@ const BLOCK_TYPE_SESSIONS = 50;
const BLOCK_TYPE_TRUSTED_STATE = 70;
const SESSIONS_MODE_MIXED = 1;
const SESSION_TYPE_USER = 1;
const SESSION_TYPE_SUBSERVER = 100;
const SESSION_TYPE_HOMESERVER = 100;
let solanaLibPromise = null;
function loadSolanaLib() {
@@ -1,4 +1,3 @@
import { deriveEd25519FromPassword } from './crypto-utils.js';
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
@@ -68,17 +67,6 @@ async function keypairFromPkcs8(pkcs8B64) {
return solana.Keypair.fromSeed(seed32);
}
export async function deriveWalletFromPassword(password) {
const keyBundle = await deriveEd25519FromPassword(String(password ?? ''), 'dev.key');
const keypair = await keypairFromPkcs8(keyBundle.privatePkcs8B64);
return {
address: keypair.publicKey.toBase58(),
keypair,
devicePublicKeyB64: keyBundle.publicKeyB64,
devicePrivatePkcs8B64: keyBundle.privatePkcs8B64,
};
}
export async function createRandomSolanaWallet() {
const solana = await loadSolanaLib();
const keypair = solana.Keypair.generate();