SHA256
123 lines
18 KiB
JavaScript
123 lines
18 KiB
JavaScript
import { createTopBar } from '../components/topbar.js';
|
||
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 };
|
||
|
||
const POLL_MS=1800;
|
||
function h(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||
function short(v){const s=String(v||'');return s.length>20?`${s.slice(0,10)}…${s.slice(-8)}`:s;}
|
||
function stageText(s){return ({COPYING_CHAIN:'Копирование новой цепочки',CHAIN_READY:'Новая цепочка готова',ROTATING_PDA:'Ожидание Solana',PDA_ROTATED:'PDA обновлена',REBUILDING_SERVER:'Перестройка сервера',WALLET_MIGRATION:'Перенос средств',MESSAGE_MIGRATION:'Перешифрование сообщений',FINALIZING:'Завершение',COMPLETE:'Готово',NONE:'Новая ротация'})[s]||s;}
|
||
function input(label,type='password'){const wrap=document.createElement('label');wrap.className='stack';const t=document.createElement('span');t.className='field-label';t.textContent=label;const el=document.createElement('input');el.className='text-input';el.type=type;wrap.append(t,el);return {wrap,el};}
|
||
|
||
export function render({navigate,chrome}){
|
||
const screen=document.createElement('section');screen.className='stack';
|
||
chrome?.setTopbar(createTopBar({title:'Смена ключей',back:{label:'←',onClick:()=>navigate('my-blockchain-view')}}));
|
||
const root=document.createElement('div');root.className='stack';screen.append(root);
|
||
const client=new KeyRotationClient(authService);let disposed=false;let timer=null;let cachedOldBundle=null;let cachedNewBundle=null;
|
||
const login=String(state.session.login||'').trim(); const storagePwd=String(state.session.storagePwdInMemory||'').trim();
|
||
const solanaEndpoint=String(state.entrySettings.solanaServer||'').trim();
|
||
|
||
async function derive(password){return authService.derivePasswordKeyBundle(login,password,{onProgress:()=>{}});}
|
||
async function verifyOldPassword(bundle){
|
||
const pda=await readShineUserPda({login,solanaEndpoint});
|
||
if(bundle.rootPair.publicKeyB64!==bytesToBase64(pda.rootKey) || bundle.blockchainPair.publicKeyB64!==bytesToBase64(pda.blockchain.blockchainPublicKey) || bundle.clientPair.publicKeyB64!==bytesToBase64(pda.clientKey)) throw new Error('Текущий пароль не соответствует ключам PDA');
|
||
}
|
||
function setRoot(...nodes){root.replaceChildren(...nodes);}
|
||
function statusCard(s){const card=document.createElement('div');card.className='card stack';card.innerHTML=`<strong>${h(stageText(s.rotationStatus))}</strong><span class="meta-muted">${h(s.sourceBlockchainName||'')} → ${h(s.candidateBlockchainName||'')}</span><span class="meta-muted">Прогресс: ${Number(s.progressCurrent||0)} / ${Number(s.progressTotal||0)}</span>${s.lastError?`<span>${h(s.lastError)}</span>`:''}`;return card;}
|
||
function note(text){const c=document.createElement('div');c.className='card';c.textContent=text;return c;}
|
||
|
||
async function renderNone(){
|
||
const head=note('Выберите последний блок, которому вы доверяете. Всё до него будет точно перепубликовано новым blockchain key, после чего добавится TECH_FORK.');
|
||
const oldP=input('Текущий пароль'); const newP=input('Новый пароль'); const new2=input('Повторите новый пароль');
|
||
const reason=document.createElement('select');reason.className='text-input';for(const r of KEY_ROTATION_REASONS){const o=document.createElement('option');o.value=String(r.code);o.textContent=r.label;reason.append(o);}
|
||
const reasonWrap=document.createElement('label');reasonWrap.className='stack';reasonWrap.innerHTML='<span class="field-label">Причина</span>';reasonWrap.append(reason);
|
||
const comment=document.createElement('textarea');comment.className='text-input';comment.rows=3;comment.maxLength=1024;comment.placeholder='Необязательный комментарий для истории';
|
||
const list=document.createElement('div');list.className='stack';const more=document.createElement('button');more.className='secondary-btn';more.type='button';more.textContent='Показать более ранние блоки';
|
||
let selected=null,before=null,loading=false;
|
||
async function load(){if(loading)return;loading=true;more.disabled=true;try{const d=await client.getMyBlockchain({beforeBlock:before,limit:100});for(const b of d.blocks||[]){const btn=document.createElement('button');btn.type='button';btn.className='nav-row';btn.innerHTML=`<span class="nav-row__label">#${b.blockNumber} · ${new Date(Number(b.timestampMs)||0).toLocaleString('ru-RU')}</span><span class="nav-row__hint">${short(b.blockHash)}</span>`;btn.addEventListener('click',()=>{selected=b;list.querySelectorAll('button').forEach(x=>x.setAttribute('aria-pressed','false'));btn.setAttribute('aria-pressed','true');});if(selected==null && b.blockNumber===d.tipBlockNumber){selected=b;btn.setAttribute('aria-pressed','true');}list.append(btn);}before=d.nextBeforeBlock;more.hidden=before==null;}finally{loading=false;more.disabled=false;}}
|
||
more.addEventListener('click',()=>void load());
|
||
const start=document.createElement('button');start.className='primary-btn';start.type='button';start.textContent='Начать смену ключей';
|
||
const error=document.createElement('div');error.className='meta-muted';
|
||
start.addEventListener('click',async()=>{start.disabled=true;error.textContent='';try{if(!selected)throw new Error('Выберите последний доверенный блок');if(!oldP.el.value||!newP.el.value)throw new Error('Введите текущий и новый пароль');if(newP.el.value!==new2.el.value)throw new Error('Новые пароли не совпадают');if(oldP.el.value===newP.el.value)throw new Error('Новый пароль должен отличаться');cachedOldBundle=await derive(oldP.el.value);await verifyOldPassword(cachedOldBundle);cachedNewBundle=await derive(newP.el.value);const s=await client.start({newRootKey:cachedNewBundle.rootPair.publicKeyB64,newBlockchainKey:cachedNewBundle.blockchainPair.publicKeyB64,newClientKey:cachedNewBundle.clientPair.publicKeyB64,forkFromBlock:selected.blockNumber,forkFromHash:selected.blockHash,reasonCode:Number(reason.value),comment:comment.value});await runCopy(s); }catch(e){error.textContent=e?.message||String(e);start.disabled=false;}});
|
||
setRoot(head,oldP.wrap,newP.wrap,new2.wrap,reasonWrap,comment,list,more,start,error);void load();
|
||
}
|
||
|
||
async function askNewPasswordAndCopy(s){
|
||
const card=statusCard(s), p=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить копирование';const err=document.createElement('div');err.className='meta-muted';go.addEventListener('click',async()=>{go.disabled=true;try{cachedNewBundle=await derive(p.el.value);if(cachedNewBundle.blockchainPair.publicKeyB64!==s.newBlockchainKey)throw new Error('Этот пароль выводит другой новый blockchain key');await runCopy(s);}catch(e){err.textContent=e?.message||e;go.disabled=false;}});const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(card,note('Для продолжения на этом устройстве введите тот же новый пароль, который использовался при запуске.'),p.wrap,go,abort,err);
|
||
}
|
||
|
||
async function runCopy(s){
|
||
const card=statusCard(s), info=note('Создаётся новая копия выбранной части цепочки в Arweave/Turbo. Обычные записи аккаунта в это время заблокированы.');setRoot(card,info);
|
||
try{await client.copyCandidateChain({rotation:s,newBundle:cachedNewBundle,onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Прогресс: ${current} / ${total}`;}});await client.waitUntilPublished({onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Опубликовано: ${current} / ${total}`;}});await client.finishChain();await refresh();}catch(e){root.append(note(e?.message||String(e)));const retry=document.createElement('button');retry.className='primary-btn';retry.textContent='Повторить / продолжить';retry.addEventListener('click',()=>void refresh());root.append(retry);}
|
||
}
|
||
|
||
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('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый ключ восстановления временно используется для атомарной смены 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);}
|
||
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;
|
||
|
||
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;
|
||
}
|