Обновление архитектуры ключей и кошельков

This commit is contained in:
AidarKC
2026-10-01 17:11:38 +03:00
parent f452ce1f1e
commit 8d22602761
51 changed files with 565 additions and 898 deletions
+6 -25
View File
@@ -174,17 +174,7 @@ function createPasswordModal() {
<span class="field-label">Пароль аккаунта</span>
<input class="input" id="access-servers-password-input" type="password" autocomplete="current-password" placeholder="Введите пароль" />
</label>
<div class="stack" style="gap:0.45rem;">
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать blockchain key только сейчас</span>
</label>
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить blockchain key на этом устройстве</span>
</label>
</div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
<p class="meta-muted">После проверки пароля рабочие ключи будут восстановлены и сохранены на этом устройстве. Приватный ключ восстановления не сохраняется.</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
@@ -244,11 +234,7 @@ function createPasswordModal() {
inputEl.focus();
return;
}
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
close({
password,
saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
});
close({ password });
});
window.setTimeout(() => inputEl.focus(), 0);
});
@@ -494,10 +480,10 @@ export function render({navigate, chrome}) {
const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Рабочие ключи не найдены в локальном зашифрованном контейнере устройства.',
note: savedClient
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
: 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
? 'Ключ доступа уже сохранён. Из пароля будет восстановлен ключ блокчейна.'
: 'На устройстве не хватает ключа блокчейна и/или ключа доступа. Они будут восстановлены из пароля аккаунта.',
});
if (!passwordResult) {
throw new Error('Операция отменена пользователем.');
@@ -514,12 +500,7 @@ export function render({navigate, chrome}) {
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
}
if (passwordResult.saveBlockchain) {
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
saveRoot: false,
saveBlockchain: true,
});
}
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle);
return {
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
-1
View File
@@ -15,7 +15,6 @@ import {
getBalanceSol,
getSolanaWalletFromStoredSecret,
getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64,
transferSol,
} from '../services/solana-wallet-service.js';
-1
View File
@@ -7,7 +7,6 @@ import {
getBalanceSol,
getSolanaWalletFromStoredSecret,
getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64,
transferSol,
} from '../services/solana-wallet-service.js';
+24 -75
View File
@@ -9,17 +9,18 @@ export function render({navigate, chrome}) {
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
title: 'Подключить устройство',
back: { label: '←', onClick: () => navigate('device-view') },
}));
title: 'Подключить устройство',
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<p>Выберите, какие ключи передать на подключаемое устройство</p>
<label class="checkbox-row"><input type="checkbox" id="connect-root" ${state.deviceConnect.root ? 'checked' : ''} /> root key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-blockchain" ${state.deviceConnect.blockchain ? 'checked' : ''} /> blockchain key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> client key</label>
<div class="stack" style="gap:6px;">
<p class="field-label">Что будет передано</p>
<p class="meta-muted">Новое устройство автоматически получит ключ доступа и ключ блокчейна.</p>
<p class="meta-muted">Ключ восстановления никогда не передаётся и не сохраняется на устройствах.</p>
</div>
<p class="meta-muted" id="connect-keys-status">Проверяем ключи на этом устройстве...</p>
<div class="row">
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
@@ -31,24 +32,9 @@ export function render({navigate, chrome}) {
</div>
`;
const rootToggle = card.querySelector('#connect-root');
const blockchainToggle = card.querySelector('#connect-blockchain');
const deviceToggle = card.querySelector('#connect-device');
const statusEl = card.querySelector('#connect-keys-status');
const openQrBtn = card.querySelector('#open-qr');
const openPairBtn = card.querySelector('#open-pairing');
deviceToggle.checked = true;
rootToggle.addEventListener('change', () => {
state.deviceConnect.root = rootToggle.checked;
});
blockchainToggle.addEventListener('change', () => {
state.deviceConnect.blockchain = blockchainToggle.checked;
});
deviceToggle.addEventListener('change', () => {
state.deviceConnect.client = true;
deviceToggle.checked = true;
});
const helpModal = document.createElement('div');
helpModal.className = 'modal-shell';
@@ -61,15 +47,10 @@ export function render({navigate, chrome}) {
<button class="icon-btn" type="button" data-close="true" aria-label="Закрыть">✕</button>
</div>
<div class="stack" style="gap:6px;">
<p class="meta-muted">пользователь выбирает ключи для передачи</p>
<p class="meta-muted">передать можно только существующие ключи</p>
<p class="meta-muted">если ключа нет – он недоступен</p>
<p class="meta-muted">blockchain key – можно передать или нет</p>
<p class="meta-muted">root key – только если существует</p>
<p class="meta-muted">client key передаётся всегда</p>
<p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p>
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
<p class="meta-muted">client key (ключ доступа) передаётся всегда</p>
<p class="meta-muted">blockchain key (ключ блокчейна) передаётся всегда</p>
<p class="meta-muted">root/recovery private key никогда не передаётся</p>
<p class="meta-muted">QR-сценарий передаёт рабочие ключи напрямую между устройствами</p>
<p class="meta-muted">для сценария через сервер используйте кнопку «Подключить по коду»</p>
</div>
<button class="primary-btn" type="button" data-close="true">OK</button>
@@ -78,12 +59,9 @@ export function render({navigate, chrome}) {
const openHelp = () => {
helpModal.hidden = false;
helpModal.querySelector('.modal-dialog').focus();
};
const closeHelp = () => {
helpModal.hidden = true;
helpModal.querySelector('.modal-dialog')?.focus();
};
const closeHelp = () => { helpModal.hidden = true; };
card.querySelector('#tech-help').addEventListener('click', openHelp);
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
@@ -96,61 +74,32 @@ export function render({navigate, chrome}) {
throw new Error('Нет активной сессии');
}
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const hasRoot = Boolean(savedKeys.rootKey);
const hasBlockchain = Boolean(savedKeys.blockchainKey);
const hasClient = Boolean(savedKeys.clientKey);
rootToggle.disabled = !hasRoot;
blockchainToggle.disabled = !hasBlockchain;
deviceToggle.disabled = true;
state.deviceConnect.root = hasRoot && rootToggle.checked;
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
state.deviceConnect.client = hasClient;
rootToggle.checked = state.deviceConnect.root;
blockchainToggle.checked = state.deviceConnect.blockchain;
deviceToggle.checked = hasClient;
openQrBtn.disabled = !hasClient;
openPairBtn.disabled = !hasClient;
const available = [
hasClient ? 'client' : '',
hasBlockchain ? 'blockchain' : '',
hasRoot ? 'root' : '',
].filter(Boolean);
statusEl.textContent = available.length
? `На этом устройстве доступны: ${available.join(', ')}.`
: 'На этом устройстве нет сохранённых ключей для передачи.';
const ready = hasBlockchain && hasClient;
openQrBtn.disabled = !ready;
openPairBtn.disabled = !ready;
statusEl.textContent = ready
? 'Ключ доступа и ключ блокчейна готовы к безопасной передаче.'
: 'На этом устройстве не хватает рабочих ключей. Выполните вход по паролю заново.';
} catch {
rootToggle.disabled = true;
blockchainToggle.disabled = true;
deviceToggle.checked = false;
state.deviceConnect.root = false;
state.deviceConnect.blockchain = false;
state.deviceConnect.client = false;
openQrBtn.disabled = true;
openPairBtn.disabled = true;
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
statusEl.textContent = 'Не удалось прочитать рабочие ключи на этом устройстве.';
}
})();
helpModal.addEventListener('click', (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.dataset.close === 'true') {
closeHelp();
}
if (target instanceof HTMLElement && target.dataset.close === 'true') closeHelp();
});
helpModal.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeHelp();
}
if (event.key === 'Escape') closeHelp();
});
screen.append(card);
const modalRoot = document.getElementById('modal-root');
modalRoot?.append(helpModal);
screen.cleanup = () => {
helpModal.remove();
};
screen.cleanup = () => { helpModal.remove(); };
return screen;
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
promptPwaInstall,
} from '../services/pwa-install-service.js';
import { initPwaPush } from '../services/pwa-push-service.js';
import { getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
import { getArweaveWalletFromStoredBlockchainKey } from '../services/arweave-wallet-service.js';
import {
prepareAvatarImageFile,
uploadArweaveFile,
@@ -144,7 +144,7 @@ function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {
+ `(${optimized.width}x${optimized.height}, ${optimized.contentType})`,
);
walletCtx = await getArweaveWalletFromStoredClientKey({
walletCtx = await getArweaveWalletFromStoredBlockchainKey({
login: walletLogin,
storagePwd,
onStatus: (message) => setMeta(message),
+22 -125
View File
@@ -37,24 +37,18 @@ function pairingSessionKindLabel(sessionType) {
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
}
function buildTransferKeys(savedKeys, selection = {}) {
function buildTransferKeys(savedKeys) {
const keys = {
clientKey: String(savedKeys?.clientKey || '').trim(),
blockchainKey: '',
rootKey: '',
blockchainKey: String(savedKeys?.blockchainKey || '').trim(),
};
if (!keys.clientKey) {
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
}
if (selection.blockchain && savedKeys?.blockchainKey) {
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
}
if (selection.root && savedKeys?.rootKey) {
keys.rootKey = String(savedKeys.rootKey || '').trim();
if (!keys.clientKey || !keys.blockchainKey) {
throw new Error('На этом устройстве нет полного набора рабочих ключей. Выполните вход по паролю заново.');
}
return keys;
}
function requestCardHtml(request) {
const shortCode = formatPairingShortCode(request?.shortCode || '');
const client = String(request?.requesterClientPlatform || 'unknown');
@@ -166,45 +160,12 @@ export function render({navigate, chrome}) {
let settingsBusy = false;
let pairingPasswordConfigured = false;
let dialogMode = '';
let pendingTransferRequest = null;
chrome?.setTopbar(createTopBar({
title: 'Подключить по коду',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const transferDialog = document.createElement('div');
transferDialog.className = 'pairing-transfer-dialog';
transferDialog.hidden = true;
transferDialog.innerHTML = `
<div class="pairing-transfer-dialog__backdrop" data-transfer-action="cancel"></div>
<section class="card stack pairing-transfer-dialog__card" role="dialog" aria-modal="true" aria-labelledby="pairing-transfer-title">
<div class="stack" style="gap:5px;">
<h2 class="login-panel-title" id="pairing-transfer-title">Подключить и передать ключи</h2>
<p class="meta-muted">Client key передаётся всегда. Дополнительные ключи можно передать только если они есть на этом устройстве.</p>
</div>
<div class="pairing-transfer-key-list">
<label class="pairing-transfer-key is-required">
<input type="checkbox" checked disabled />
<span><strong>Client key</strong><small>Обязателен для клиентского устройства</small></span>
</label>
<label class="pairing-transfer-key" data-transfer-key="root">
<input type="checkbox" id="pairing-transfer-root" checked />
<span><strong>Root key</strong><small>Передать на новое устройство</small></span>
</label>
<label class="pairing-transfer-key" data-transfer-key="blockchain">
<input type="checkbox" id="pairing-transfer-blockchain" checked />
<span><strong>Blockchain key</strong><small>Передать на новое устройство</small></span>
</label>
</div>
<div class="row pairing-transfer-dialog__actions">
<button class="ghost-btn" type="button" data-transfer-action="cancel">Отмена</button>
<button class="primary-btn pairing-approve-btn" type="button" data-transfer-action="confirm">Подключить</button>
</div>
</section>
`;
screen.append(transferDialog);
const settingsCard = document.createElement('div');
settingsCard.className = 'card stack';
const passwordIcons = makePasswordToggleIcons();
@@ -212,8 +173,9 @@ export function render({navigate, chrome}) {
const keySummaryCard = document.createElement('div');
keySummaryCard.className = 'card stack';
keySummaryCard.innerHTML = `
<p class="field-label">Что передаётся при расширенном подключении</p>
<p class="field-label">Что передаётся новому устройству</p>
<p class="meta-muted" id="pairing-key-summary">Проверяем локальные ключи...</p>
<p class="meta-muted">Ключ восстановления никогда не передаётся.</p>
`;
const requestsCard = document.createElement('div');
@@ -449,13 +411,11 @@ export function render({navigate, chrome}) {
const loadSavedKeys = async () => {
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const available = [];
if (savedKeys?.clientKey) available.push('client');
if (savedKeys?.blockchainKey) available.push('blockchain');
if (savedKeys?.rootKey) available.push('root');
keySummaryEl.textContent = available.length > 1
? `Доступны для подключения: ${available.join(', ')}.`
: 'На этом устройстве доступен только client key.';
const hasClient = !!String(savedKeys?.clientKey || '').trim();
const hasBlockchain = !!String(savedKeys?.blockchainKey || '').trim();
keySummaryEl.textContent = hasClient && hasBlockchain
? 'Будут переданы: ключ доступа и ключ блокчейна.'
: 'На устройстве не хватает рабочего набора ключей. Выполните вход по паролю заново.';
};
const reloadRequests = async ({ silent = false } = {}) => {
@@ -478,8 +438,7 @@ export function render({navigate, chrome}) {
refreshBtn.disabled = flag;
};
const approveRequest = async (request, selection = {}) => {
const withExtras = !!selection.root || !!selection.blockchain;
const approveRequest = async (request) => {
let payload;
if (Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
@@ -495,81 +454,26 @@ export function render({navigate, chrome}) {
session: delegatedSession,
});
} else {
const keys = buildTransferKeys(savedKeys, selection);
const keys = buildTransferKeys(savedKeys);
payload = buildSecretsPayload({
login: state.session.login,
keys,
mode: withExtras ? 'with-extras' : 'device-only',
mode: 'full-device',
});
}
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
const sessionOnly = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
showToast(
withExtras
? 'Ключи переданы на новое устройство'
: sessionOnly
? 'Wallet-session выпущена для нового устройства'
: 'Новое устройство подключено',
);
setAuthInfo(
withExtras
? 'Заявка подтверждена, ключи переданы.'
: sessionOnly
? 'Заявка подтверждена, для нового устройства создана отдельная wallet-session без передачи постоянных ключей.'
: 'Заявка подтверждена без передачи доп. ключей.',
);
showToast(sessionOnly
? 'Wallet-session выпущена для нового устройства'
: 'Ключ доступа и ключ блокчейна переданы на новое устройство');
setAuthInfo(sessionOnly
? 'Заявка подтверждена, для нового устройства создана отдельная wallet-session без передачи постоянных ключей.'
: 'Заявка подтверждена. Рабочие ключи переданы, ключ восстановления не передавался.');
await refreshSessions().catch(() => {});
await reloadRequests({ silent: true });
};
const closeTransferDialog = () => {
pendingTransferRequest = null;
transferDialog.hidden = true;
};
const openTransferDialog = (request) => {
pendingTransferRequest = request;
const rootRow = transferDialog.querySelector('[data-transfer-key="root"]');
const blockchainRow = transferDialog.querySelector('[data-transfer-key="blockchain"]');
const rootInput = transferDialog.querySelector('#pairing-transfer-root');
const blockchainInput = transferDialog.querySelector('#pairing-transfer-blockchain');
const hasRoot = !!String(savedKeys?.rootKey || '').trim();
const hasBlockchain = !!String(savedKeys?.blockchainKey || '').trim();
rootRow.hidden = !hasRoot;
blockchainRow.hidden = !hasBlockchain;
rootInput.checked = hasRoot;
blockchainInput.checked = hasBlockchain;
transferDialog.hidden = false;
};
transferDialog.addEventListener('click', async (event) => {
const target = event.target instanceof Element ? event.target.closest('[data-transfer-action]') : null;
const action = String(target?.dataset?.transferAction || '');
if (!action) return;
if (action === 'cancel') {
closeTransferDialog();
return;
}
if (action !== 'confirm' || !pendingTransferRequest) return;
const request = pendingTransferRequest;
const confirmBtn = transferDialog.querySelector('[data-transfer-action="confirm"]');
confirmBtn.disabled = true;
try {
await approveRequest(request, {
root: !!transferDialog.querySelector('#pairing-transfer-root')?.checked,
blockchain: !!transferDialog.querySelector('#pairing-transfer-blockchain')?.checked,
});
closeTransferDialog();
} catch (error) {
const message = toUserMessage(error, 'Не удалось подключить устройство.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
confirmBtn.disabled = false;
}
});
passwordDialog.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
@@ -674,14 +578,7 @@ export function render({navigate, chrome}) {
buttons.forEach((btn) => { btn.disabled = true; });
try {
if (action === 'approve-device') {
const isWallet = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
const hasExtras = !!String(savedKeys?.rootKey || '').trim() || !!String(savedKeys?.blockchainKey || '').trim();
if (!isWallet && hasExtras) {
openTransferDialog(request);
buttons.forEach((btn) => { btn.disabled = false; });
return;
}
await approveRequest(request, {});
await approveRequest(request);
} else if (action === 'reject') {
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
showToast('Заявка отклонена', { kind: 'error' });
+9 -8
View File
@@ -14,9 +14,9 @@ export function render({navigate, chrome}) {
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
title: 'Показать QR-код',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
title: 'Показать QR-код',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack qr-card';
@@ -24,6 +24,7 @@ export function render({navigate, chrome}) {
<div class="qr-image" id="device-transfer-qr" aria-label="QR-код для переноса ключей"></div>
<p class="meta-muted" id="device-transfer-login">Логин: ...</p>
<p class="meta-muted" id="device-transfer-keys">Ключи: ...</p>
<p class="meta-muted">Ключ восстановления в QR-код не включается.</p>
<p class="status-line is-unavailable" id="device-transfer-status" style="display:none;"></p>
<button class="primary-btn" type="button" id="qr-ok">OK</button>
`;
@@ -40,11 +41,12 @@ export function render({navigate, chrome}) {
}
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const keys = {
clientKey: savedKeys.clientKey || '',
blockchainKey: state.deviceConnect.blockchain ? (savedKeys.blockchainKey || '') : '',
rootKey: state.deviceConnect.root ? (savedKeys.rootKey || '') : '',
clientKey: String(savedKeys.clientKey || '').trim(),
blockchainKey: String(savedKeys.blockchainKey || '').trim(),
};
if (!keys.clientKey) throw new Error('На этом устройстве нет client key');
if (!keys.clientKey || !keys.blockchainKey) {
throw new Error('На этом устройстве нет полного набора рабочих ключей');
}
const qrText = makeKeyTransferText({ login: state.session.login, keys });
qrEl.innerHTML = renderQrSvg(qrText);
@@ -60,7 +62,6 @@ export function render({navigate, chrome}) {
})();
card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
screen.append(card);
return screen;
}
+58 -3
View File
@@ -3,6 +3,7 @@ import { authService, state } from '../state.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { readShineUserPda } from '../services/shine-user-pda-service.js';
import { KeyRotationClient, KEY_ROTATION_REASONS } from '../services/key-rotation-service.js';
import { formatSol, getBalanceSol, getSolanaWalletFromStoredSecret, transferAllSol } from '../services/solana-wallet-service.js';
export const pageMeta = { id:'key-rotation-view', title:'Смена ключей', hideToolbar:true };
@@ -56,12 +57,66 @@ export function render({navigate,chrome}){
async function renderChainReady(s){
const oldP=input('Текущий пароль');const newP=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Изменить ключи в Solana';const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';const err=document.createElement('div');err.className='meta-muted';
go.addEventListener('click',async()=>{go.disabled=true;try{cachedOldBundle=await derive(oldP.el.value);cachedNewBundle=await derive(newP.el.value);await client.rotatePda({login,solanaEndpoint,oldBundle:cachedOldBundle,newBundle:cachedNewBundle,storagePwd});await refresh();}catch(e){err.textContent=e?.message||String(e);go.disabled=false;}});abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(statusCard(s),note('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый root разрешит атомарную смену root + client + blockchain fork, а новое PDA подпишет новый blockchain key.'),oldP.wrap,newP.wrap,go,abort,err);
go.addEventListener('click',async()=>{go.disabled=true;try{cachedOldBundle=await derive(oldP.el.value);cachedNewBundle=await derive(newP.el.value);await client.rotatePda({login,solanaEndpoint,oldBundle:cachedOldBundle,newBundle:cachedNewBundle,storagePwd});await refresh();}catch(e){err.textContent=e?.message||String(e);go.disabled=false;}});abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(statusCard(s),note('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый ключ восстановления временно используется для атомарной смены recovery + client + blockchain fork, а новое PDA подпишет новый blockchain key. Приватный ключ восстановления на устройстве не сохраняется.'),oldP.wrap,newP.wrap,go,abort,err);
}
function pollView(s){setRoot(statusCard(s),note(s.rotationStatus==='REBUILDING_SERVER'?'Сервер перестраивает текущую рабочую историю по новому fork.':'Ожидаем подтверждение нового состояния PDA в Solana.'));timer=setTimeout(()=>void refresh(),POLL_MS);}
function placeholderView(s,kind){const wallet=kind==='wallet';const text=wallet?'Перевод SOL со старого blockchain-wallet на новый пока не реализован. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.':'Перешифрование старых личных сообщений новым client key пока не реализовано. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.';const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить';go.addEventListener('click',async()=>{go.disabled=true;try{const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();}catch(e){go.disabled=false;root.append(note(e?.message||String(e)));}});setRoot(statusCard(s),note(text),go);}
async function renderWalletMigration(s){
const oldP=cachedOldBundle?null:input('Старый пароль');
const newP=cachedNewBundle?null:input('Новый пароль');
const info=note('После смены blockchain key меняется и Solana-кошелёк. На этом шаге можно перевести весь доступный SOL со старого кошелька на новый за вычетом сетевой комиссии.');
const warning=note('Сейчас автоматически переносится только SOL. Миграцию AR/Turbo при смене blockchain key нужно доработать отдельно.');
const details=document.createElement('div');details.className='card stack';details.innerHTML='<span class="meta-muted">Проверьте старый и новый кошельки.</span>';
const prepare=document.createElement('button');prepare.className='secondary-btn';prepare.type='button';prepare.textContent='Проверить кошельки';
const transfer=document.createElement('button');transfer.className='primary-btn';transfer.type='button';transfer.textContent='Перевести SOL и продолжить';transfer.disabled=true;
const skip=document.createElement('button');skip.className='secondary-btn';skip.type='button';skip.textContent='Пропустить перенос';
const err=document.createElement('div');err.className='meta-muted';
let oldWallet=null,newWallet=null,oldBalance=null;
async function refresh(){if(disposed)return;if(timer){clearTimeout(timer);timer=null;}try{const s=await client.status();const st=String(s.rotationStatus||'NONE');if(st==='NONE'){await renderNone();return;}if(st==='COPYING_CHAIN'){if(cachedNewBundle)await runCopy(s);else await askNewPasswordAndCopy(s);return;}if(st==='CHAIN_READY'){await renderChainReady(s);return;}if(['ROTATING_PDA','PDA_ROTATED','REBUILDING_SERVER'].includes(st)){pollView(s);return;}if(st==='WALLET_MIGRATION'){placeholderView(s,'wallet');return;}if(st==='MESSAGE_MIGRATION'){placeholderView(s,'messages');return;}if(st==='FINALIZING'){const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();return;}if(st==='COMPLETE'){setRoot(note('Смена ключей завершена.'));return;}setRoot(note(`Неизвестное состояние ротации: ${st}`));}catch(e){setRoot(note(`Не удалось прочитать состояние смены ключей: ${e?.message||e}`));}}
const resolveBundles=async()=>{
if(!cachedOldBundle){if(!oldP?.el.value)throw new Error('Введите старый пароль');cachedOldBundle=await derive(oldP.el.value);}
if(!cachedNewBundle){if(!newP?.el.value)throw new Error('Введите новый пароль');cachedNewBundle=await derive(newP.el.value);}
if(String(cachedOldBundle?.blockchainPair?.publicKeyB64||'')!==String(s.oldBlockchainKey||''))throw new Error('Старый пароль выводит другой blockchain key');
if(String(cachedNewBundle?.blockchainPair?.publicKeyB64||'')!==String(s.newBlockchainKey||''))throw new Error('Новый пароль выводит другой blockchain key');
};
const prepareWallets=async()=>{
await resolveBundles();
oldWallet=await getSolanaWalletFromStoredSecret(cachedOldBundle.blockchainPair.privatePkcs8B64);
newWallet=await getSolanaWalletFromStoredSecret(cachedNewBundle.blockchainPair.privatePkcs8B64);
const [oldB,newB]=await Promise.all([
getBalanceSol({endpoint:solanaEndpoint,address:oldWallet.address}),
getBalanceSol({endpoint:solanaEndpoint,address:newWallet.address}),
]);
oldBalance=oldB;
details.innerHTML=`<strong>Старый кошелёк</strong><span class="meta-muted">${h(oldWallet.address)}</span><span>${h(formatSol(oldB.sol,9))} SOL</span><strong>Новый кошелёк</strong><span class="meta-muted">${h(newWallet.address)}</span><span>${h(formatSol(newB.sol,9))} SOL</span>`;
transfer.textContent=Number(oldB.lamports||0)>0?'Перевести SOL и продолжить':'Средств нет — продолжить';
transfer.disabled=false;
};
prepare.addEventListener('click',async()=>{prepare.disabled=true;transfer.disabled=true;err.textContent='';try{await prepareWallets();}catch(e){err.textContent=e?.message||String(e);}finally{prepare.disabled=false;}});
transfer.addEventListener('click',async()=>{transfer.disabled=true;prepare.disabled=true;skip.disabled=true;err.textContent='';try{
if(!oldWallet||!newWallet||!oldBalance)await prepareWallets();
if(Number(oldBalance?.lamports||0)>0){
const result=await transferAllSol({endpoint:solanaEndpoint,fromKeypair:oldWallet.keypair,toAddress:newWallet.address});
details.insertAdjacentHTML('beforeend',`<span class="meta-muted">${result.skipped?'Перевод не потребовался.':`Переведено ${h(formatSol(Number(result.lamports)/1_000_000_000,9))} SOL. Tx: ${h(result.signature)}`}</span>`);
}
const next=await client.continuePlaceholder();
if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}
await refresh();
}catch(e){err.textContent=e?.message||String(e);transfer.disabled=false;prepare.disabled=false;skip.disabled=false;}});
skip.addEventListener('click',async()=>{skip.disabled=true;transfer.disabled=true;prepare.disabled=true;err.textContent='';try{
const next=await client.continuePlaceholder();
if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}
await refresh();
}catch(e){err.textContent=e?.message||String(e);skip.disabled=false;transfer.disabled=!oldWallet;prepare.disabled=false;}});
const nodes=[statusCard(s),info,warning];if(oldP)nodes.push(oldP.wrap);if(newP)nodes.push(newP.wrap);nodes.push(details,prepare,transfer,skip,err);setRoot(...nodes);
if(cachedOldBundle&&cachedNewBundle)void prepareWallets().catch(e=>{err.textContent=e?.message||String(e);});
}
function placeholderView(s){const text='Перешифрование старых личных сообщений новым client key пока не реализовано. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.';const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить';go.addEventListener('click',async()=>{go.disabled=true;try{const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();}catch(e){go.disabled=false;root.append(note(e?.message||String(e)));}});setRoot(statusCard(s),note(text),go);}
async function refresh(){if(disposed)return;if(timer){clearTimeout(timer);timer=null;}try{const s=await client.status();const st=String(s.rotationStatus||'NONE');if(st==='NONE'){await renderNone();return;}if(st==='COPYING_CHAIN'){if(cachedNewBundle)await runCopy(s);else await askNewPasswordAndCopy(s);return;}if(st==='CHAIN_READY'){await renderChainReady(s);return;}if(['ROTATING_PDA','PDA_ROTATED','REBUILDING_SERVER'].includes(st)){pollView(s);return;}if(st==='WALLET_MIGRATION'){await renderWalletMigration(s);return;}if(st==='MESSAGE_MIGRATION'){placeholderView(s);return;}if(st==='FINALIZING'){const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();return;}if(st==='COMPLETE'){setRoot(note('Смена ключей завершена.'));return;}setRoot(note(`Неизвестное состояние ротации: ${st}`));}catch(e){setRoot(note(`Не удалось прочитать состояние смены ключей: ${e?.message||e}`));}}
void refresh();screen.cleanup=()=>{disposed=true;if(timer)clearTimeout(timer);};return screen;
}
-103
View File
@@ -1,103 +0,0 @@
import { createTopBar } from '../components/topbar.js';
import { authorizeSession, state } from '../state.js';
export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack';
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
rootToggle.addEventListener('change', () => {
state.keyStorage.saveRoot = rootToggle.checked;
if (rootToggle.checked) {
window.alert('Мы советуем не сохранять главный ключ на устройстве, он используется только для смены паролей и основных настроек.');
}
});
const blockchainToggle = document.createElement('input');
blockchainToggle.type = 'checkbox';
blockchainToggle.checked = state.keyStorage.saveBlockchain;
blockchainToggle.addEventListener('change', () => {
state.keyStorage.saveBlockchain = blockchainToggle.checked;
});
const deviceToggle = document.createElement('input');
deviceToggle.type = 'checkbox';
deviceToggle.checked = true;
deviceToggle.disabled = true;
card.innerHTML = `
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Root Key</span></label>
<p class="meta-muted key-storage-option__description">Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.</p>
<input class="input" type="text" value="${state.keyStorage.rootKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне Сияния.</p>
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
<p class="meta-muted key-storage-option__description">Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.</p>
<input class="input" type="text" value="${state.keyStorage.clientKey}" />
</div>
<p class="key-storage-note key-storage-note--strong">Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.</p>
`;
card.children[0].querySelector('label').prepend(rootToggle);
card.children[1].querySelector('label').prepend(blockchainToggle);
card.children[2].querySelector('label').prepend(deviceToggle);
const rootInput = card.children[0].querySelector('.input');
rootInput.addEventListener('input', () => {
state.keyStorage.rootKey = rootInput.value;
});
const blockchainInput = card.children[1].querySelector('.input');
blockchainInput.addEventListener('input', () => {
state.keyStorage.blockchainKey = blockchainInput.value;
});
const deviceInput = card.children[2].querySelector('.input');
deviceInput.addEventListener('input', () => {
state.keyStorage.clientKey = deviceInput.value;
});
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('login-password-view'));
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
okButton.type = 'button';
okButton.textContent = 'OK';
okButton.addEventListener('click', () => {
authorizeSession();
navigate('profile-view');
});
actions.append(cancelButton, okButton);
screen.append(
createTopBar({
title: 'Какие ключи сохранить',
back: { label: '←', onClick: () => navigate('login-password-view') },
}),
card,
actions,
);
return screen;
}
+2 -2
View File
@@ -143,8 +143,8 @@ export function render({ navigate }) {
const parseTransferText = (text) => {
try {
const transfer = parseKeyTransferText(text);
if (!transfer.keys.clientKey) {
throw new Error('В QR-коде нет client key для входа');
if (!transfer.keys.clientKey || !transfer.keys.blockchainKey) {
throw new Error('В QR-коде нет полного набора рабочих ключей');
}
showTransfer(transfer);
} catch (error) {
+4 -1
View File
@@ -157,6 +157,9 @@ export function render({ navigate }) {
};
const finalizeAuthorizedLogin = async (keys, login) => {
if (!String(keys?.clientKey || '').trim() || !String(keys?.blockchainKey || '').trim()) {
throw new Error('Новое устройство должно получить ключ доступа и ключ блокчейна.');
}
const session = await authService.createSessionFromImportedSecrets(login, keys);
const addingProfile = isAddingProfileLogin();
if (!addingProfile) {
@@ -309,7 +312,7 @@ export function render({ navigate }) {
login,
passwordHash,
requesterSessionKey: requesterMaterial.sessionKey,
payloadType: 3,
payloadType: 2,
});
activePairingId = String(payload?.pairingId || '');
+30 -9
View File
@@ -1,12 +1,17 @@
import { createTopBar } from '../components/topbar.js';
import {
authService,
authorizeSession,
clearAuthMessages,
consumeAuthReturnPage,
refreshSessions,
setAuthBusy,
setAuthError,
setAuthInfo,
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { clearStoredMessages } from '../services/message-store.js';
import { emptyPasswordWords, PASSWORD_MAX_LENGTH } from '../services/password-words.js';
export const pageMeta = { id: 'login-password-view', title: 'Введите пароль', showAppChrome: false };
@@ -135,17 +140,33 @@ export function render({ navigate }) {
enterButton.textContent = 'Проверяем пароль…';
const result = await authService.createAuthSession(currentLogin, keyBundle);
// Существующую дальнейшую логику после успешного пароля сохраняем без изменений.
state.registrationDraft.flowType = 'login';
state.registrationDraft.login = result.login;
state.registrationDraft.password = password;
// В текущей модели оба рабочих ключа всегда сохраняются автоматически.
// Recovery/root private key остаётся только в памяти этого шага и в key-vault не попадает.
await authService.persistSelectedKeys(result.login, result.storagePwd, keyBundle);
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
await clearStoredMessages(result.login).catch(() => {});
authorizeSession({
login: result.login,
sessionId: result.sessionId,
storagePwd: result.storagePwd,
});
state.loginDraft.login = result.login;
state.loginDraft.password = '';
state.loginDraft.passwordMode = 'single';
state.loginDraft.passwordWords = emptyPasswordWords();
state.registrationDraft.flowType = '';
state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = emptyPasswordWords();
state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
navigate('registration-keys-view');
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
state.registrationDraft.pendingSessionMaterial = null;
await refreshSessions();
setAuthInfo(`Вы вошли как @${result.login}.`);
navigate(consumeAuthReturnPage('profile-view'));
} catch (error) {
overlay.style.display = 'none';
const message = toUserMessage(error, 'Не удалось выполнить вход.');
+1 -1
View File
@@ -318,7 +318,7 @@ export function render({ navigate }) {
const subtitle = document.createElement('p');
subtitle.className = 'meta-muted';
subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены root/recovery key, blockchain key и client key.';
subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены ключ восстановления, ключ блокчейна и ключ доступа.';
const details = document.createElement('p');
details.className = 'meta-muted';
+9 -9
View File
@@ -9,9 +9,9 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Где ключи',
title: 'У кого хранятся ключи?',
paragraphs: [
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
'Сияние не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
'Рабочие приватные ключи хранятся только у вас: на вашем устройстве или на доверенных устройствах, которые вы контролируете сами.',
'Ключ доступа и ключ блокчейна сохраняются на авторизованном устройстве в зашифрованном контейнере. Приватный ключ восстановления на устройстве не сохраняется: он вычисляется из пароля только на время операций, где действительно нужен.',
'Сияние не хранит ваши приватные ключи на сервере. В будущем для повышенной защиты рабочие ключи можно будет вынести на отдельное программируемое устройство с открытым кодом, например ESP32-контроллер.',
],
},
{
@@ -30,7 +30,7 @@ export const REGISTRATION_FAQ_TOPICS = [
title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
'Уже из этого секрета детерминированно строятся три основных ключа: root/recovery key, blockchain key и client key.',
'Из этого секрета детерминированно строятся три ключа: ключ восстановления (Recovery), ключ блокчейна и ключ доступа.',
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
],
},
@@ -39,10 +39,10 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Три ключа',
title: 'Зачем нужны три ключа?',
paragraphs: [
'Главный ключ нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Ключ публикаций нужен для подписания действий и записей в блокчейне Сияния.',
'Ключ устройства нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
'Ключ восстановления (Recovery key) нужен для восстановления и самых критичных операций с учётной записью. Его приватная часть никогда не сохраняется на устройстве и при необходимости временно вычисляется из пароля.',
'Ключ блокчейна постоянно хранится на авторизованном устройстве. Он подписывает записи и действия в блокчейне Сияния и является исходным ключом для штатных пользовательских кошельков.',
'Ключ доступа (client key) также постоянно хранится на устройстве. Он используется для входа на сервер, работы устройства и защищённых личных сообщений.',
'Сейчас после обычного входа оба рабочих ключа — ключ блокчейна и ключ доступа — сохраняются автоматически. Отдельного ограниченного режима в текущей версии нет.',
],
},
{
@@ -93,7 +93,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
'Ключ устройства передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны главный ключ и/или ключ публикаций, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
'При подтверждении нового устройства автоматически передаются два рабочих ключа: ключ доступа и ключ блокчейна. Приватный ключ восстановления не передаётся никогда.',
],
},
{
-204
View File
@@ -1,204 +0,0 @@
import { createTopBar } from '../components/topbar.js';
import {
authService,
cancelAddProfileLogin,
consumeAuthReturnPage,
authorizeSession,
refreshSessions,
resetRegistrationFlow,
setAuthError,
setAuthInfo,
state,
} from '../state.js';
import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack keys-screen';
const isLoginFlow = state.registrationDraft.flowType === 'login';
const normalizedLogin = (state.registrationDraft.login || '').trim();
const displayLogin = normalizedLogin ? `@${normalizedLogin.replace(/^@/, '')}` : '@new.user';
const lead = document.createElement('div');
lead.className = 'keys-lead';
const title = document.createElement('h1');
title.className = 'keys-lead__title';
title.textContent = isLoginFlow ? `Вы вошли как ${displayLogin}` : `${displayLogin} зарегистрирован`;
const question = document.createElement('p');
question.className = 'keys-lead__text';
question.textContent = 'Какие ключи хранить на этом устройстве?';
const questionHint = document.createElement('p');
questionHint.className = 'keys-lead__text';
questionHint.textContent = 'Они хранятся в зашифрованном виде.';
lead.append(title, question, questionHint);
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const createKeyRow = (toggle, titleText, descriptionText) => {
const row = document.createElement('label');
row.className = 'keys-row';
const text = document.createElement('span');
text.className = 'keys-row__text';
const name = document.createElement('span');
name.className = 'keys-row__title';
name.textContent = titleText;
const description = document.createElement('span');
description.className = 'keys-row__hint';
description.textContent = descriptionText;
text.append(name, description);
toggle.className = 'switch';
row.append(text, toggle);
return row;
};
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
const blockchainToggle = document.createElement('input');
blockchainToggle.type = 'checkbox';
blockchainToggle.checked = state.keyStorage.saveBlockchain;
const deviceToggle = document.createElement('input');
deviceToggle.type = 'checkbox';
deviceToggle.checked = true;
deviceToggle.disabled = true;
const list = document.createElement('div');
list.className = 'keys-list';
list.append(
createKeyRow(rootToggle, 'Главный ключ', 'Смена пароля, восстановление доступа, важные настройки'),
createKeyRow(blockchainToggle, 'Ключ публикаций', 'Подписывает ваши записи и действия'),
createKeyRow(deviceToggle, 'Ключ устройства', 'Вход с этого устройства – нужен всегда'),
);
const simpleNote = document.createElement('p');
simpleNote.className = 'keys-note';
simpleNote.textContent = 'Не уверены – оставьте все включёнными.';
const actions = document.createElement('div');
actions.className = 'keys-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'secondary-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', async () => {
if (state.authReturnHash === '/profiles') {
await cancelAddProfileLogin();
navigate('profiles-view');
return;
}
resetRegistrationFlow();
navigate('start-view');
});
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
okButton.type = 'button';
okButton.textContent = 'Готово';
okButton.addEventListener('click', async () => {
status.style.display = 'none';
try {
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
}
state.keyStorage.saveRoot = rootToggle.checked;
state.keyStorage.saveBlockchain = blockchainToggle.checked;
await authService.persistSelectedKeys(
state.registrationDraft.login,
state.registrationDraft.storagePwd,
state.registrationDraft.pendingKeyBundle,
{
saveRoot: state.keyStorage.saveRoot,
saveBlockchain: state.keyStorage.saveBlockchain,
},
);
await authService.persistSessionMaterial(
state.registrationDraft.login,
state.registrationDraft.pendingSessionMaterial,
);
if (!state.keyStorage.saveRoot && state.registrationDraft.pendingKeyBundle) {
state.registrationDraft.pendingKeyBundle.rootPair = null;
}
if (!state.keyStorage.saveBlockchain && state.registrationDraft.pendingKeyBundle) {
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
}
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
authorizeSession({
login: state.registrationDraft.login,
sessionId: state.registrationDraft.sessionId,
storagePwd: state.registrationDraft.storagePwd,
});
state.loginDraft.login = state.registrationDraft.login;
state.loginDraft.password = '';
state.loginDraft.passwordMode = 'single';
state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.flowType = '';
state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.passwordWordsLinked = false;
state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
state.registrationDraft.pendingSessionMaterial = null;
state.registrationDraft.preGeneratedKeyBundle = null;
state.registrationPayment.walletAddress = '';
state.registrationPayment.balanceSOL = '0.0000';
await refreshSessions();
setAuthInfo(isLoginFlow
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
navigate(consumeAuthReturnPage('profile-view'));
} catch (error) {
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
setAuthError(message);
status.textContent = message;
status.style.display = '';
}
});
actions.append(okButton, cancelButton);
screen.append(
createTopBar({
title: 'Сохранение ключей',
back: {
label: '←',
onClick: async () => {
if (state.authReturnHash === '/profiles') {
await cancelAddProfileLogin();
navigate('profiles-view');
return;
}
resetRegistrationFlow();
navigate('start-view');
},
},
}),
lead,
list,
simpleNote,
status,
actions,
);
return screen;
}
+3 -12
View File
@@ -95,18 +95,9 @@ async function completeRegistrationLogin({ navigate, keyBundle }) {
state.registrationDraft.password,
);
state.keyStorage.saveRoot = Boolean(state.keyStorage.saveRoot);
state.keyStorage.saveBlockchain = Boolean(state.keyStorage.saveBlockchain);
await authService.persistSelectedKeys(
result.login,
result.storagePwd,
keyBundle,
{
saveRoot: state.keyStorage.saveRoot,
saveBlockchain: state.keyStorage.saveBlockchain,
},
);
// После регистрации оба рабочих ключа сохраняются автоматически.
// Приватный Recovery/root key не записывается на устройство.
await authService.persistSelectedKeys(result.login, result.storagePwd, keyBundle);
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
+41 -50
View File
@@ -11,38 +11,41 @@ export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
const visible = {
root: false,
blockchain: false,
device: false,
};
const keys = {
root: '',
blockchain: '',
device: '',
};
const visible = { blockchain: false, access: false };
const keys = { blockchain: '', access: '' };
chrome?.setTopbar(createTopBar({
title: 'Ключи этого устройства',
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'nav-list key-list';
title: 'Ключи этого устройства',
back: { label: '←', onClick: () => navigate('device-view') },
}));
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Загружаем сохранённые ключи...';
const renderField = (id, label, hint = '') => {
const card = document.createElement('div');
card.className = 'nav-list key-list';
const recovery = document.createElement('div');
recovery.className = 'key-card stack';
recovery.innerHTML = `
<div class="row">
<span class="field-label">Ключ восстановления
<small class="key-card__hint">Управление учётной записью, восстановление и критическая смена ключей</small>
</span>
</div>
<div class="key-value key-value--compact">Не хранится на устройстве</div>
<p class="meta-muted">Приватный Recovery key создаётся из пароля только на время защищённых операций и не записывается в хранилище устройства.</p>
`;
const renderStoredField = (id, label, hint) => {
const row = document.createElement('div');
row.className = 'key-card stack';
const eyeIcon = iconHtml('eye', false, 'key-toggle-icon');
const eyeOffIcon = iconHtml('eyeOff', false, 'key-toggle-icon');
row.innerHTML = `
<div class="row">
<span class="field-label">${label}${hint ? `<small class="key-card__hint">${hint}</small>` : ''}</span>
<span class="field-label">${label}<small class="key-card__hint">${hint}</small></span>
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeIcon}</button>
</div>
<div class="key-value key-value--compact" data-value="${id}">*****</div>
@@ -53,15 +56,16 @@ export function render({navigate, chrome}) {
};
card.append(
renderField('root', 'Главный ключ', 'Восстановление аккаунта и важные настройки'),
renderField('blockchain', 'Ключ публикаций', 'Подпись ваших записей'),
renderField('device', 'Ключ устройства', 'Вход с этого устройства'),
recovery,
renderStoredField('blockchain', 'Ключ блокчейна', 'Подпись записей в Сиянии и управление вашими кошельками'),
renderStoredField('access', 'Ключ доступа', 'Вход на сервер, работа устройства и защищённые личные сообщения'),
);
const setMissingState = (id) => {
const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
const field = btnEl?.closest('.key-card');
if (!valueEl || !btnEl) return;
valueEl.textContent = 'нет данных';
btnEl.disabled = true;
btnEl.innerHTML = field?._eyeOffIcon || '';
@@ -73,59 +77,46 @@ export function render({navigate, chrome}) {
const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
const field = btnEl?.closest('.key-card');
if (!valueEl || !btnEl) return;
if (!keys[id]) {
setMissingState(id);
return;
}
valueEl.textContent = visible[id] ? keys[id] : '*****';
btnEl.disabled = false;
btnEl.innerHTML = visible[id]
? field?._eyeOffIcon || ''
: field?._eyeIcon || '';
btnEl.innerHTML = visible[id] ? field?._eyeOffIcon || '' : field?._eyeIcon || '';
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
};
card.querySelectorAll('[data-toggle]').forEach((button) => {
button.addEventListener('click', () => {
const { toggle } = button.dataset;
if (!keys[toggle]) return;
visible[toggle] = !visible[toggle];
updateField(toggle);
const id = button.dataset.toggle;
if (!keys[id]) return;
visible[id] = !visible[id];
updateField(id);
});
});
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
['blockchain', 'access'].forEach(updateField);
(async () => {
try {
if (!state.session.login || !state.session.storagePwdInMemory) {
throw new Error('Нет активной сессии для чтения ключей');
}
const savedKeys = await loadEncryptedUserSecrets(
state.session.login,
state.session.storagePwdInMemory,
);
const rootSeed32 = savedKeys.rootKey ? extractSeed32FromPkcs8B64(savedKeys.rootKey) : null;
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const blockchainSeed32 = savedKeys.blockchainKey ? extractSeed32FromPkcs8B64(savedKeys.blockchainKey) : null;
const clientSeed32 = savedKeys.clientKey ? extractSeed32FromPkcs8B64(savedKeys.clientKey) : null;
keys.root = rootSeed32 ? bytesToBase58(rootSeed32) : '';
keys.blockchain = blockchainSeed32 ? bytesToBase58(blockchainSeed32) : '';
keys.device = clientSeed32 ? bytesToBase58(clientSeed32) : '';
if (keys.root || keys.blockchain || keys.device) {
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';
} else {
status.textContent = 'На этом устройстве нет сохранённых ключей.';
}
} catch (error) {
status.textContent = 'На этом устройстве нет сохранённых ключей.';
keys.access = clientSeed32 ? bytesToBase58(clientSeed32) : '';
status.textContent = keys.blockchain && keys.access
? 'На устройстве сохранены оба рабочих ключа. Ключ восстановления хранится только как публичная часть в учётной записи и приватно на устройстве не сохраняется.'
: 'На устройстве отсутствует часть рабочих ключей. Выполните вход по паролю заново.';
} catch {
status.textContent = 'Не удалось прочитать рабочие ключи этого устройства.';
}
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
['blockchain', 'access'].forEach(updateField);
})();
screen.append(status, card);
+21 -22
View File
@@ -12,7 +12,7 @@ import {
formatAr,
getArweaveBalance,
getArweaveTopupSiteUrl,
getArweaveWalletFromStoredClientKey,
getArweaveWalletFromStoredBlockchainKey,
transferAr,
} from '../services/arweave-wallet-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
@@ -496,39 +496,38 @@ export function render({navigate, chrome}) {
async function resolveWalletSigningMaterial() {
const { login, storagePwd } = sessionArgsOrThrow();
let saved;
let saved = null;
try {
saved = await loadEncryptedUserSecrets(login, storagePwd);
} catch {
saved = null;
}
let rootKey = String(saved?.rootKey || '').trim();
let blockchainKey = String(saved?.blockchainKey || '').trim();
const blockchainKey = String(saved?.blockchainKey || '').trim();
const clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
if (blockchainKey && clientKey) {
return {
rootPrivatePkcs8B64: '',
blockchainPrivatePkcs8B64: blockchainKey,
clientPrivatePkcs8B64: clientKey,
};
}
const password = window.prompt(
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
'На этом устройстве не найден полный набор рабочих ключей. Введите пароль аккаунта, чтобы восстановить ключ доступа и ключ блокчейна:',
'',
);
if (password == null) throw new Error('Операция отменена пользователем');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
const shouldSave = window.confirm(
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
);
if (shouldSave) {
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
}
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
await authService.persistSelectedKeys(login, storagePwd, keyBundle);
return {
rootPrivatePkcs8B64: '',
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
};
}
function styleSupportInputField(field) {
if (!field) return;
field.style.color = 'var(--text-primary)';
@@ -2024,7 +2023,7 @@ export function render({navigate, chrome}) {
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
Сияние берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Сияние берёт ваш локальный ключ блокчейна и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;
@@ -2126,14 +2125,14 @@ export function render({navigate, chrome}) {
try {
let wasFirstTimeGeneration = false;
arweaveWalletCtx = await getArweaveWalletFromStoredClientKey({
arweaveWalletCtx = await getArweaveWalletFromStoredBlockchainKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
wasFirstTimeGeneration = true;
setStatus('Подождите – ваш Arweave-ключ вычисляется из client key. Это происходит только один раз, потом будет мгновенно.');
setStatus('Подождите – ваш Arweave-ключ вычисляется из ключа блокчейна. Это происходит только один раз, потом будет мгновенно.');
return;
}
setStatus(text);