SHA256
GPT: сгенерено и не проверено (смена ключей пользователя)
This commit is contained in:
@@ -16,6 +16,7 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
|
||||
import { initPwaPush } from './services/pwa-push-service.js';
|
||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||
import { showToast } from './services/channels-ux.js';
|
||||
import { KeyRotationClient } from './services/key-rotation-service.js';
|
||||
import {
|
||||
handleCallPushAction,
|
||||
handleIncomingCallInvite,
|
||||
@@ -73,6 +74,8 @@ import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as profilesView from './pages/profiles-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202609260900';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as myBlockchainView from './pages/my-blockchain-view.js';
|
||||
import * as keyRotationView from './pages/key-rotation-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as advancedSettingsView from './pages/advanced-settings-view.js';
|
||||
@@ -142,6 +145,8 @@ const routes = {
|
||||
'profiles-view': profilesView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'my-blockchain-view': myBlockchainView,
|
||||
'key-rotation-view': keyRotationView,
|
||||
'access-servers-view': accessServersView,
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'advanced-settings-view': advancedSettingsView,
|
||||
@@ -1376,6 +1381,21 @@ function attachPageScrollToBottom(pageId, screen) {
|
||||
});
|
||||
}
|
||||
|
||||
async function redirectToActiveKeyRotationIfNeeded() {
|
||||
if (!state.session.isAuthorized || state.session.isLocalDemo) return false;
|
||||
try {
|
||||
const rotation = await new KeyRotationClient(authService).status();
|
||||
const status = String(rotation?.rotationStatus || 'NONE');
|
||||
if (status !== 'NONE' && getRoute().pageId !== 'key-rotation-view') {
|
||||
navigate('key-rotation-view');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[key-rotation] status check failed', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||
const route = getRoute();
|
||||
@@ -1566,6 +1586,7 @@ async function init() {
|
||||
setSessionAuthorizedHandler(() => {
|
||||
void ensureSessionRuntimeStarted();
|
||||
void processPendingCallPushActionIfPossible();
|
||||
void redirectToActiveKeyRotationIfNeeded();
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -290,7 +290,7 @@ export function openArweaveAttachmentManager({
|
||||
mode = 'attachment',
|
||||
historyPurpose = '',
|
||||
uploadTransport = 'turbo',
|
||||
turboKeySource = 'client',
|
||||
turboKeySource = 'blockchain',
|
||||
dialogTitle = '',
|
||||
uploadButtonLabel = '',
|
||||
initialFile = null,
|
||||
@@ -406,20 +406,9 @@ export function openArweaveAttachmentManager({
|
||||
turboKeyChoices = await getStoredSolanaWalletChoices({
|
||||
login: cleanLogin,
|
||||
storagePwd: cleanStoragePwd,
|
||||
includeRoot: true,
|
||||
});
|
||||
turboKeyChoices.sort((a, b) => {
|
||||
if (a?.keySource === b?.keySource) return 0;
|
||||
if (a?.keySource === 'client') return -1;
|
||||
if (b?.keySource === 'client') return 1;
|
||||
return 0;
|
||||
});
|
||||
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
|
||||
selectedTurboKeySource = String(
|
||||
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|
||||
|| turboKeyChoices[0]?.keySource
|
||||
|| 'client'
|
||||
);
|
||||
selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain');
|
||||
}
|
||||
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
|
||||
} catch (error) {
|
||||
|
||||
@@ -177,14 +177,14 @@ function createPasswordModal() {
|
||||
<div class="stack" style="gap:0.45rem;">
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
||||
<span>Использовать главный ключ только сейчас</span>
|
||||
<span>Использовать blockchain key только сейчас</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="save" />
|
||||
<span>Сохранить главный ключ на этом устройстве</span>
|
||||
<span>Сохранить blockchain key на этом устройстве</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</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>
|
||||
@@ -247,7 +247,7 @@ function createPasswordModal() {
|
||||
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
|
||||
close({
|
||||
password,
|
||||
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||
saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||
});
|
||||
});
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
@@ -482,48 +482,49 @@ export function render({navigate, chrome}) {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
const savedRoot = String(saved?.rootKey || '').trim();
|
||||
const savedBlockchain = String(saved?.blockchainKey || '').trim();
|
||||
const savedClient = String(saved?.clientKey || '').trim();
|
||||
if (savedRoot && savedClient) {
|
||||
if (savedBlockchain && savedClient) {
|
||||
return {
|
||||
rootPrivatePkcs8B64: savedRoot,
|
||||
blockchainPrivatePkcs8B64: savedBlockchain,
|
||||
clientPrivatePkcs8B64: savedClient,
|
||||
clientAddress: await clientAddressFromPrivatePkcs8(savedClient),
|
||||
payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain),
|
||||
};
|
||||
}
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
|
||||
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
||||
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
|
||||
: 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
||||
});
|
||||
if (!passwordResult) {
|
||||
throw new Error('Операция отменена пользователем.');
|
||||
}
|
||||
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
|
||||
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
||||
const derivedBlockchainPublic = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
|
||||
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
|
||||
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
|
||||
const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey;
|
||||
if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) {
|
||||
throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.');
|
||||
}
|
||||
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
||||
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
|
||||
}
|
||||
|
||||
if (passwordResult.saveRoot) {
|
||||
if (passwordResult.saveBlockchain) {
|
||||
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
|
||||
saveRoot: true,
|
||||
saveBlockchain: false,
|
||||
saveRoot: false,
|
||||
saveBlockchain: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
|
||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64),
|
||||
payerAddress: clientAddressFromPublicB64(keyBundle.blockchainPair.publicKeyB64),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -550,7 +551,7 @@ export function render({navigate, chrome}) {
|
||||
const tx = await updateShineUserPdaOnSolana({
|
||||
login: sessionLogin,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64,
|
||||
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
|
||||
accessServers: normalizedList,
|
||||
});
|
||||
@@ -562,7 +563,7 @@ export function render({navigate, chrome}) {
|
||||
refreshAddButton();
|
||||
} catch (error) {
|
||||
if (isInsufficientFundsForRentError(error)) {
|
||||
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
||||
showTopupRequiredStatus(target, signingMaterial?.payerAddress);
|
||||
} else {
|
||||
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getSolanaWalletFromStoredSecret,
|
||||
getWalletFromStoredClientKey,
|
||||
getWalletFromStoredBlockchainKey,
|
||||
getWalletFromStoredRootKey,
|
||||
solanaAddressFromPublicKeyBase64,
|
||||
transferSol,
|
||||
@@ -138,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
if (!root) return;
|
||||
|
||||
const recipientChoices = [
|
||||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
||||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
||||
].filter(Boolean);
|
||||
|
||||
root.innerHTML = `
|
||||
@@ -166,10 +164,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
|
||||
|
||||
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
|
||||
<select class="select" id="channel-support-sender-key">
|
||||
<option value="client-key">Client key</option>
|
||||
<option value="root-key">Root key</option>
|
||||
</select>
|
||||
<div class="channel-support-address" id="channel-support-sender-key">Blockchain key</div>
|
||||
<div class="channel-support-address" id="channel-support-sender-address">—</div>
|
||||
|
||||
<div class="channel-support-balance-row">
|
||||
@@ -247,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
};
|
||||
|
||||
const resolveSenderWallet = async () => {
|
||||
const keyId = String(senderSelect?.value || 'client-key');
|
||||
const keyId = 'blockchain-key';
|
||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
try {
|
||||
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
|
||||
} catch {
|
||||
const password = window.prompt(
|
||||
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
|
||||
'',
|
||||
);
|
||||
if (password == null) throw new Error('Операция отменена.');
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
|
||||
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
|
||||
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
|
||||
}
|
||||
} else {
|
||||
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
|
||||
}
|
||||
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
|
||||
|
||||
walletCache.set(keyId, wallet);
|
||||
return wallet;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getSolanaWalletFromStoredSecret,
|
||||
getWalletFromStoredClientKey,
|
||||
getWalletFromStoredBlockchainKey,
|
||||
getWalletFromStoredRootKey,
|
||||
solanaAddressFromPublicKeyBase64,
|
||||
transferSol,
|
||||
@@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||
const channelName = String(channel?.channelName || selector?.channelName || '').trim();
|
||||
const recipientChoices = [
|
||||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
||||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
||||
].filter(Boolean);
|
||||
|
||||
content.innerHTML = `
|
||||
@@ -169,10 +167,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
<section class="channel-donate-section">
|
||||
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
|
||||
<select class="select" id="channel-donate-sender-key">
|
||||
<option value="client-key">Client key</option>
|
||||
<option value="root-key">Root key</option>
|
||||
</select>
|
||||
<div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
|
||||
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
|
||||
|
||||
<div class="channel-donate-balance-row">
|
||||
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
|
||||
const resolveSenderWallet = async () => {
|
||||
const keyId = String(senderSelect?.value || 'client-key');
|
||||
const keyId = 'blockchain-key';
|
||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
try {
|
||||
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
|
||||
} catch {
|
||||
const password = window.prompt(
|
||||
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
|
||||
'',
|
||||
);
|
||||
if (password == null) throw new Error('Операция отменена.');
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
|
||||
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
|
||||
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
|
||||
}
|
||||
} else {
|
||||
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
|
||||
}
|
||||
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
|
||||
|
||||
walletCache.set(keyId, wallet);
|
||||
return wallet;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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';
|
||||
|
||||
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('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый root разрешит атомарную смену root + 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 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}`));}}
|
||||
void refresh();screen.cleanup=()=>{disposed=true;if(timer)clearTimeout(timer);};return screen;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService } from '../state.js';
|
||||
import { KeyRotationClient } from '../services/key-rotation-service.js';
|
||||
|
||||
export const pageMeta = { id: 'my-blockchain-view', title: 'Мой блокчейн' };
|
||||
|
||||
const TYPE_LABELS = new Map([
|
||||
['0:0','Начало блокчейна'], ['0:2','Смена ключей / fork'],
|
||||
['1:1','Публикация'], ['1:2','Ответ'], ['1:3','Редактирование'],
|
||||
['2:1','Реакция'], ['3:1','Связь'], ['4:1','Статус'],
|
||||
]);
|
||||
function shortHash(v){const s=String(v||'');return s.length>18?`${s.slice(0,9)}…${s.slice(-7)}`:s;}
|
||||
function eventLabel(item){return TYPE_LABELS.get(`${item.msgType}:${item.msgSubType}`)||`Событие ${item.msgType}/${item.msgSubType}`;}
|
||||
function formatDate(ms){try{return new Date(Number(ms)||0).toLocaleString('ru-RU');}catch{return '';}}
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
const screen=document.createElement('section'); screen.className='stack';
|
||||
chrome?.setTopbar(createTopBar({ title:'Мой блокчейн', back:{label:'←',onClick:()=>navigate('settings-view')} }));
|
||||
const intro=document.createElement('div'); intro.className='card stack';
|
||||
intro.innerHTML=`<strong>История ваших действий</strong><span class="meta-muted">Это текущая активная версия вашего блокчейна. Хэши записей сохраняют логическую идентичность при fork.</span>`;
|
||||
const rotate=document.createElement('button'); rotate.type='button'; rotate.className='primary-btn'; rotate.textContent='Сменить пароль / ключи'; rotate.addEventListener('click',()=>navigate('key-rotation-view'));
|
||||
intro.append(rotate); screen.append(intro);
|
||||
|
||||
const list=document.createElement('div'); list.className='stack'; screen.append(list);
|
||||
const more=document.createElement('button'); more.type='button'; more.className='secondary-btn'; more.textContent='Показать более ранние записи'; more.hidden=true; screen.append(more);
|
||||
const client=new KeyRotationClient(authService); let before=null; let loading=false; let disposed=false;
|
||||
|
||||
async function load(reset=false){
|
||||
if(loading||disposed)return; loading=true; more.disabled=true;
|
||||
if(reset){list.innerHTML='';before=null;}
|
||||
try{
|
||||
const data=await client.getMyBlockchain({beforeBlock:before,limit:50,includeBlockBytes:false});
|
||||
if(reset){const head=document.createElement('div');head.className='card stack';head.innerHTML=`<strong>${data.blockchainName||'—'}</strong><span class="meta-muted">Последний блок: #${Number(data.tipBlockNumber??-1)} · ${shortHash(data.tipBlockHash)}</span>`;list.append(head);}
|
||||
for(const item of data.blocks||[]){
|
||||
const card=document.createElement('div');card.className='card stack';
|
||||
const target=item.toLogin?`<span class="meta-muted">Цель: ${escapeHtml(item.toLogin)} #${item.toBlockNumber??'—'} · ${shortHash(item.toBlockHash)}</span>`:'';
|
||||
card.innerHTML=`<div><strong>#${item.blockNumber} · ${escapeHtml(eventLabel(item))}</strong></div><span class="meta-muted">${formatDate(item.timestampMs)} · hash ${shortHash(item.blockHash)}</span>${target}`;
|
||||
list.append(card);
|
||||
}
|
||||
before=data.nextBeforeBlock;
|
||||
more.hidden=before==null;
|
||||
if(!(data.blocks||[]).length && reset){const empty=document.createElement('div');empty.className='card';empty.textContent='Записей пока нет.';list.append(empty);}
|
||||
}catch(e){const err=document.createElement('div');err.className='card';err.textContent=`Не удалось загрузить блокчейн: ${e?.message||e}`;list.append(err);}
|
||||
finally{loading=false;more.disabled=false;}
|
||||
}
|
||||
more.addEventListener('click',()=>void load(false)); void load(true);
|
||||
screen.cleanup=()=>{disposed=true;}; return screen;
|
||||
}
|
||||
function escapeHtml(value){return String(value??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
@@ -126,7 +126,7 @@ export function render({ navigate }) {
|
||||
// Blockchain key
|
||||
const bchSep = document.createElement('p');
|
||||
bchSep.className = 'field-label';
|
||||
bchSep.textContent = 'Blockchain key';
|
||||
bchSep.textContent = 'Blockchain key (= Solana wallet)';
|
||||
card.append(bchSep);
|
||||
card.append(makePublicField({
|
||||
label: 'Blockchain — публичный (base58)',
|
||||
@@ -140,7 +140,7 @@ export function render({ navigate }) {
|
||||
// Client key
|
||||
const devSep = document.createElement('p');
|
||||
devSep.className = 'field-label';
|
||||
devSep.textContent = 'Client key (= Solana wallet)';
|
||||
devSep.textContent = 'Client key';
|
||||
card.append(devSep);
|
||||
card.append(makePublicField({
|
||||
label: 'Client — публичный (base58)',
|
||||
|
||||
@@ -234,7 +234,7 @@ export function render({ navigate }) {
|
||||
const deriveUserWalletAddress = async () => {
|
||||
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
|
||||
if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.');
|
||||
const { publicKeyB64 } = keyBundle.clientPair;
|
||||
const { publicKeyB64 } = keyBundle.blockchainPair;
|
||||
const raw = atob(publicKeyB64);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
@@ -387,7 +387,7 @@ export function render({ navigate }) {
|
||||
await refreshBalance({ addressOverride: walletAddress });
|
||||
} catch (error) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`;
|
||||
status.textContent = `Не удалось подготовить blockchain key: ${error?.message || 'unknown'}`;
|
||||
status.style.display = '';
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -73,6 +73,7 @@ export function render({navigate, chrome}) {
|
||||
</div>
|
||||
<div class="nav-list">
|
||||
${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
|
||||
${row('settings-my-blockchain', 'Мой блокчейн', 'Все ваши подписанные события и смена ключей')}
|
||||
${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
|
||||
${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
|
||||
${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')}
|
||||
@@ -104,6 +105,7 @@ export function render({navigate, chrome}) {
|
||||
});
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-my-blockchain').addEventListener('click', () => navigate('my-blockchain-view'));
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
|
||||
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
|
||||
@@ -14,15 +14,14 @@ const DEVNET_ENDPOINT = 'https://api.devnet.solana.com';
|
||||
const SENDER_PRIVATE_32_BASE58 = '6xqAuKYvA8qrCdAkcw7Y8aMgvBnYk8JLxWLma5BzbAvu';
|
||||
const REGISTRATION_TOPUP_AMOUNT_SOL = 0.02;
|
||||
|
||||
// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей.
|
||||
// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес
|
||||
// напрямую из пароля, иначе он расходится с device-ключом регистрации.
|
||||
async function clientWalletAddressFromBundle() {
|
||||
// Канонический Solana-кошелёк пользователя = текущий blockchain key.
|
||||
// Тот же адрес используется как fee payer для регистрации и обычных PDA update.
|
||||
async function blockchainWalletAddressFromBundle() {
|
||||
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
|
||||
if (!keyBundle || !keyBundle.clientPair) {
|
||||
if (!keyBundle || !keyBundle.blockchainPair) {
|
||||
throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.');
|
||||
}
|
||||
const raw = atob(keyBundle.clientPair.publicKeyB64);
|
||||
const raw = atob(keyBundle.blockchainPair.publicKeyB64);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
|
||||
const { PublicKey } = await loadSolanaWeb3();
|
||||
@@ -73,7 +72,7 @@ export function render({ navigate }) {
|
||||
card.innerHTML = `
|
||||
<p class="auth-copy">Можете или пополнить счёт тестовыми соланами и продолжить регистрацию.</p>
|
||||
<div class="card stack" style="padding:12px; max-width:320px;">
|
||||
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (client key = Solana wallet)</div>
|
||||
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (blockchain key = Solana wallet)</div>
|
||||
</div>
|
||||
<div class="stack" style="gap:6px;">
|
||||
<p class="meta-muted">Или можете отдельно открыть страницу тестового пополнения.</p>
|
||||
@@ -139,7 +138,7 @@ export function render({ navigate }) {
|
||||
(async () => {
|
||||
try {
|
||||
if (!walletValue.value) {
|
||||
const address = await clientWalletAddressFromBundle();
|
||||
const address = await blockchainWalletAddressFromBundle();
|
||||
state.registrationPayment.walletAddress = address;
|
||||
walletValue.value = address;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getTopupSiteUrl,
|
||||
getWalletFromStoredClientKey,
|
||||
getWalletFromStoredBlockchainKey,
|
||||
transferSol,
|
||||
} from '../services/solana-wallet-service.js';
|
||||
import {
|
||||
@@ -983,7 +983,7 @@ export function render({navigate, chrome}) {
|
||||
<h2 style="margin:0;">Купить билет на сумму в долларах</h2>
|
||||
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||
Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата.
|
||||
Покупка подписывается вашим client key и отклоняется, если курс уходит дальше допустимого порога.
|
||||
Оплата выполняется с текущего blockchain key. Технические подписи протокола покупки остаются отдельной частью операции.
|
||||
</p>
|
||||
`;
|
||||
|
||||
@@ -1169,7 +1169,7 @@ export function render({navigate, chrome}) {
|
||||
currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||
}
|
||||
if (!walletCtx) {
|
||||
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
|
||||
walletAddress = walletCtx.address;
|
||||
}
|
||||
setRecipientFromWallet();
|
||||
@@ -1265,7 +1265,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
if (!walletCtx) {
|
||||
try {
|
||||
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
|
||||
walletAddress = walletCtx.address;
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setRecipientFromWallet();
|
||||
@@ -1867,7 +1867,7 @@ export function render({navigate, chrome}) {
|
||||
addressCard.className = 'card';
|
||||
addressCard.style.padding = '10px';
|
||||
addressCard.innerHTML = `
|
||||
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (client.key)</p>
|
||||
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (blockchain.key)</p>
|
||||
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
|
||||
`;
|
||||
const addressEl = addressCard.querySelector('#wallet-address-value');
|
||||
@@ -1932,7 +1932,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
if (!walletCtx?.keypair) {
|
||||
setStatus('Перевод недоступен: client.key не загружен.');
|
||||
setStatus('Перевод недоступен: blockchain.key не загружен.');
|
||||
return;
|
||||
}
|
||||
const toAddress = window.prompt('Введите адрес получателя (Solana):', '');
|
||||
@@ -1968,17 +1968,17 @@ export function render({navigate, chrome}) {
|
||||
});
|
||||
|
||||
content.append(backBtn, card, actions);
|
||||
setStatus('Инициализация client.key...');
|
||||
setStatus('Инициализация blockchain.key...');
|
||||
|
||||
try {
|
||||
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
|
||||
if (modeToken !== activeModeToken) return;
|
||||
walletAddress = walletCtx.address;
|
||||
addressEl.textContent = walletAddress;
|
||||
await refreshBalance();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
addressEl.textContent = 'client.key недоступен';
|
||||
addressEl.textContent = 'blockchain.key недоступен';
|
||||
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
@@ -2146,7 +2146,7 @@ export function render({navigate, chrome}) {
|
||||
await refreshBalance();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
addressEl.textContent = 'client.key недоступен';
|
||||
addressEl.textContent = 'blockchain.key недоступен';
|
||||
clearArweaveSecretsInMemory();
|
||||
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ const PRETTY_PATHS = new Map([
|
||||
['network-view', 'network'],
|
||||
['notifications-view', 'notifications'],
|
||||
['settings-view', 'settings'],
|
||||
['my-blockchain-view', 'settings/blockchain'],
|
||||
['key-rotation-view', 'settings/key-rotation'],
|
||||
['access-servers-view', 'settings/access-servers'],
|
||||
['server-settings-view', 'settings/servers'],
|
||||
['arweave-uploads-view', 'settings/arweave-uploads'],
|
||||
@@ -324,6 +326,8 @@ export function parseRouteFromPath(pathname = '') {
|
||||
if (pageId === 'settings') {
|
||||
const sub = decodePart(segments[1] || '').toLowerCase();
|
||||
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
|
||||
if (sub === 'blockchain') return { pageId: 'my-blockchain-view', params: {} };
|
||||
if (sub === 'key-rotation') return { pageId: 'key-rotation-view', params: {} };
|
||||
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
|
||||
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
|
||||
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
|
||||
@@ -499,6 +503,8 @@ function resolveToolbarTab(pageId) {
|
||||
pageId === 'profiles-view' ||
|
||||
pageId === 'wallet-view' ||
|
||||
pageId === 'settings-view' ||
|
||||
pageId === 'my-blockchain-view' ||
|
||||
pageId === 'key-rotation-view' ||
|
||||
pageId === 'access-servers-view' ||
|
||||
pageId === 'developer-settings-view' ||
|
||||
pageId === 'advanced-settings-view' ||
|
||||
|
||||
@@ -123,3 +123,81 @@ export async function createAns104DataItem({ owner32, privateKey, data, tags = [
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
function readUint64LE(bytes, offset) {
|
||||
if (!(bytes instanceof Uint8Array) || offset < 0 || offset + 8 > bytes.length) throw new Error('ANS-104 truncated uint64');
|
||||
let value = 0n;
|
||||
for (let i = 7; i >= 0; i -= 1) value = (value << 8n) | BigInt(bytes[offset + i]);
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('ANS-104 uint64 exceeds JS safe integer');
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
/** Parse enough of an Ed25519 ANS-104 DataItem to re-sign its exact SHiNE payload. */
|
||||
export function parseAns104DataItem(rawBytes) {
|
||||
const raw = rawBytes instanceof Uint8Array ? rawBytes : Uint8Array.from(rawBytes || []);
|
||||
let p = 0;
|
||||
if (raw.length < 2 + 64 + 32 + 1 + 1 + 8 + 8) throw new Error('ANS-104 DataItem is too short');
|
||||
const sigType = new DataView(raw.buffer, raw.byteOffset, raw.byteLength).getUint16(p, true); p += 2;
|
||||
if (sigType !== ANS104_ED25519_SIGNATURE_TYPE) throw new Error(`Unsupported ANS-104 signature type: ${sigType}`);
|
||||
const signature = raw.slice(p, p + 64); p += 64;
|
||||
const owner32 = raw.slice(p, p + 32); p += 32;
|
||||
const targetPresent = raw[p++] !== 0;
|
||||
const target = targetPresent ? raw.slice(p, p + 32) : new Uint8Array(0);
|
||||
if (targetPresent) p += 32;
|
||||
const anchorPresent = raw[p++] !== 0;
|
||||
const anchor = anchorPresent ? raw.slice(p, p + 32) : new Uint8Array(0);
|
||||
if (anchorPresent) p += 32;
|
||||
const tagsCount = readUint64LE(raw, p); p += 8;
|
||||
const tagsBytesLength = readUint64LE(raw, p); p += 8;
|
||||
if (p + tagsBytesLength > raw.length) throw new Error('ANS-104 tags are truncated');
|
||||
const rawTags = raw.slice(p, p + tagsBytesLength); p += tagsBytesLength;
|
||||
const data = raw.slice(p);
|
||||
return { sigType, signature, owner32, target, anchor, tagsCount, rawTags, data, raw };
|
||||
}
|
||||
|
||||
async function ans104SigningMessageRaw({ owner32, target = new Uint8Array(0), anchor = new Uint8Array(0), rawTags, data }) {
|
||||
if (!(owner32 instanceof Uint8Array) || owner32.length !== 32) throw new Error('ANS-104 owner must be 32 bytes');
|
||||
if (!(target instanceof Uint8Array) || (target.length !== 0 && target.length !== 32)) throw new Error('ANS-104 target must be empty or 32 bytes');
|
||||
if (!(anchor instanceof Uint8Array) || (anchor.length !== 0 && anchor.length !== 32)) throw new Error('ANS-104 anchor must be empty or 32 bytes');
|
||||
if (!(rawTags instanceof Uint8Array)) throw new Error('ANS-104 rawTags must be Uint8Array');
|
||||
if (!(data instanceof Uint8Array)) throw new Error('ANS-104 data must be Uint8Array');
|
||||
return deepHash([
|
||||
TE.encode('dataitem'),
|
||||
TE.encode('1'),
|
||||
TE.encode(String(ANS104_ED25519_SIGNATURE_TYPE)),
|
||||
owner32,
|
||||
target,
|
||||
anchor,
|
||||
rawTags,
|
||||
data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign a DataItem while preserving exact raw tags/target/anchor/data bytes.
|
||||
* Used by key rotation so SHA-256(SHiNE Frame) is unchanged across forks.
|
||||
*/
|
||||
export async function createAns104DataItemWithRawParts({
|
||||
owner32,
|
||||
privateKey,
|
||||
data,
|
||||
rawTags,
|
||||
tagsCount,
|
||||
target = new Uint8Array(0),
|
||||
anchor = new Uint8Array(0),
|
||||
}) {
|
||||
const signingMessage = await ans104SigningMessageRaw({ owner32, target, anchor, rawTags, data });
|
||||
const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, signingMessage));
|
||||
if (signature.length !== 64) throw new Error(`Unexpected Ed25519 signature length: ${signature.length}`);
|
||||
return concatBytes(
|
||||
uint16LE(ANS104_ED25519_SIGNATURE_TYPE),
|
||||
signature,
|
||||
owner32,
|
||||
Uint8Array.of(target.length ? 1 : 0), target,
|
||||
Uint8Array.of(anchor.length ? 1 : 0), anchor,
|
||||
uint64LE(tagsCount),
|
||||
uint64LE(rawTags.length),
|
||||
rawTags,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -553,31 +553,31 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
|
||||
);
|
||||
}
|
||||
|
||||
function makeReactionLikeBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for like');
|
||||
function makeReactionLikeBodyBytes({ toLogin, toBlockNumber, toBlockHashHex }) {
|
||||
const cleanLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin) throw new Error('toLogin is required for like');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for like');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex))
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for reply');
|
||||
function makeTextReplyBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin) throw new Error('toLogin is required for reply');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
@@ -587,9 +587,9 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Reply text is required');
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
@@ -598,8 +598,8 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -607,9 +607,9 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for rating');
|
||||
function makeTextRatingBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin) throw new Error('toLogin is required for rating');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
@@ -619,9 +619,9 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Rating text is required');
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
@@ -630,8 +630,8 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -639,18 +639,18 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
|
||||
);
|
||||
}
|
||||
|
||||
function makeStatusActionBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text = '' }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for status action');
|
||||
function makeStatusActionBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text = '' }) {
|
||||
const cleanLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin) throw new Error('toLogin is required for status action');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for status action');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(String(text || '').trim());
|
||||
@@ -659,8 +659,8 @@ function makeStatusActionBodyBytes({ toBlockchainName, toBlockNumber, toBlockHas
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -673,7 +673,7 @@ function makeTextRepostBodyBytes({
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockchainName,
|
||||
toLogin,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
text,
|
||||
@@ -681,11 +681,11 @@ function makeTextRepostBodyBytes({
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Комментарий к репосту обязателен');
|
||||
|
||||
const bch = String(toBlockchainName || '').trim();
|
||||
if (!bch) throw new Error('toBlockchainName is required for repost');
|
||||
const bchBytes = utf8Bytes(bch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const cleanTargetLogin = String(toLogin || '').trim();
|
||||
if (!cleanTargetLogin) throw new Error('toLogin is required for repost');
|
||||
const loginBytes = utf8Bytes(cleanTargetLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
@@ -703,8 +703,8 @@ function makeTextRepostBodyBytes({
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -717,11 +717,16 @@ function makeTextEditPostBodyBytes({
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toLogin,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
text,
|
||||
}) {
|
||||
const message = String(text || '').trim();
|
||||
const cleanTargetLogin = String(toLogin || '').trim();
|
||||
if (!cleanTargetLogin) throw new Error('toLogin is required for edit post');
|
||||
const loginBytes = utf8Bytes(cleanTargetLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('toLogin must be 1..255 bytes');
|
||||
const targetBlockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number for edit post');
|
||||
@@ -736,6 +741,8 @@ function makeTextEditPostBodyBytes({
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(targetBlockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -743,8 +750,12 @@ function makeTextEditPostBodyBytes({
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
|
||||
function makeTextEditReplyBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
|
||||
const message = String(text || '').trim();
|
||||
const cleanTargetLogin = String(toLogin || '').trim();
|
||||
if (!cleanTargetLogin) throw new Error('toLogin is required for edit reply');
|
||||
const loginBytes = utf8Bytes(cleanTargetLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('toLogin must be 1..255 bytes');
|
||||
const targetBlockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number for edit reply');
|
||||
@@ -755,6 +766,8 @@ function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(targetBlockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -767,21 +780,21 @@ function makeConnectionBodyBytes({
|
||||
prevLineNumber = -1,
|
||||
prevLineHashHex = ZERO64,
|
||||
thisLineNumber = -1,
|
||||
toBlockchainName,
|
||||
toLogin,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
}) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for connection');
|
||||
const cleanTargetLogin = String(toLogin || '').trim();
|
||||
if (!cleanTargetLogin) throw new Error('toLogin is required for connection');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for connection');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanTargetLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
@@ -789,8 +802,8 @@ function makeConnectionBodyBytes({
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex))
|
||||
);
|
||||
@@ -938,13 +951,20 @@ function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHas
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
const cleanBch = String(target?.blockchainName || '').trim();
|
||||
const cleanBlockNumber = Number(target?.blockNumber);
|
||||
const cleanBlockHash = String(target?.blockHash || '').trim().toLowerCase();
|
||||
function loginFromBlockchainNameValue(value) {
|
||||
const name = String(value || '').trim();
|
||||
const match = name.match(/^(.*)-\d{3}$/u);
|
||||
return match?.[1] ? match[1] : '';
|
||||
}
|
||||
|
||||
if (!cleanBch) {
|
||||
throw new Error(`Missing message target blockchain for ${actionName}`);
|
||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
const cleanBch = String(target?.blockchainName || target?.authorBlockchainName || '').trim();
|
||||
const cleanLogin = String(target?.login || target?.authorLogin || target?.ownerLogin || loginFromBlockchainNameValue(cleanBch)).trim();
|
||||
const cleanBlockNumber = Number(target?.blockNumber ?? target?.messageRef?.blockNumber);
|
||||
const cleanBlockHash = String(target?.blockHash ?? target?.messageRef?.blockHash ?? '').trim().toLowerCase();
|
||||
|
||||
if (!cleanLogin) {
|
||||
throw new Error(`Missing message target login for ${actionName}`);
|
||||
}
|
||||
if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) {
|
||||
throw new Error(`Invalid message target block number for ${actionName}`);
|
||||
@@ -954,6 +974,7 @@ function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
}
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
blockchainName: cleanBch,
|
||||
blockNumber: cleanBlockNumber,
|
||||
blockHash: cleanBlockHash,
|
||||
@@ -1892,7 +1913,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeReactionLikeBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
});
|
||||
@@ -1915,7 +1936,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeReactionLikeBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
});
|
||||
@@ -1939,7 +1960,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeTextReplyBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -1964,7 +1985,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeTextRatingBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2004,7 +2025,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeStatusActionBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2077,7 +2098,7 @@ export class AuthService {
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2159,6 +2180,7 @@ export class AuthService {
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2176,6 +2198,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const bodyBytes = makeTextEditReplyBodyBytes({
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2206,6 +2229,7 @@ export class AuthService {
|
||||
return this.addBlockFollowChannel({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
targetLogin: cleanTargetLogin,
|
||||
targetBlockchainName: targetUser.blockchainName,
|
||||
targetBlockNumber: 0,
|
||||
targetBlockHashHex: targetHeaderHash,
|
||||
@@ -2217,6 +2241,7 @@ export class AuthService {
|
||||
async addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetLogin = '',
|
||||
targetBlockchainName,
|
||||
targetBlockNumber,
|
||||
targetBlockHashHex,
|
||||
@@ -2224,8 +2249,10 @@ export class AuthService {
|
||||
}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanTargetBch = String(targetBlockchainName || '').trim();
|
||||
const cleanTargetLogin = String(targetLogin || loginFromBlockchainNameValue(cleanTargetBch)).trim();
|
||||
const cleanTargetBlockNumber = Number(targetBlockNumber);
|
||||
if (!cleanTargetBch) throw new Error('Target blockchain is required');
|
||||
if (!cleanTargetLogin) throw new Error('Target login is required');
|
||||
if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number');
|
||||
}
|
||||
@@ -2245,7 +2272,7 @@ export class AuthService {
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO64,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName: cleanTargetBch,
|
||||
toLogin: cleanTargetLogin,
|
||||
toBlockNumber: cleanTargetBlockNumber,
|
||||
toBlockHashHex: targetHashHex,
|
||||
});
|
||||
@@ -3228,13 +3255,12 @@ export class AuthService {
|
||||
|
||||
const targetUser = await this.getUser(cleanToLogin);
|
||||
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
||||
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName,
|
||||
toLogin: cleanToLogin,
|
||||
toBlockNumber: 0,
|
||||
toBlockHashHex: ZERO_HASH_HEX,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createAns104DataItem, createAns104DataItemWithRawParts, parseAns104DataItem } from './ans104-data-item.js';
|
||||
import { base64ToBytes, bytesToBase64 } from './crypto-utils.js';
|
||||
import { updateShineUserPdaOnSolana } from './shine-user-pda-service.js';
|
||||
import { updateEncryptedUserSecrets } from './key-vault.js';
|
||||
|
||||
const TE = new TextEncoder();
|
||||
const ZERO_HASH = '0'.repeat(64);
|
||||
const TECH_FORK_SUBTYPE = 2;
|
||||
|
||||
function payload(resp) { return resp?.payload && typeof resp.payload === 'object' ? resp.payload : (resp || {}); }
|
||||
function assertOk(resp, op) {
|
||||
if (Number(resp?.status) >= 200 && Number(resp?.status) < 300) return payload(resp);
|
||||
const p = payload(resp);
|
||||
const message = p?.message || p?.error || p?.code || `${op} failed (${resp?.status ?? '?'})`;
|
||||
const error = new Error(String(message));
|
||||
error.code = p?.code || p?.errorCode || '';
|
||||
error.response = resp;
|
||||
throw error;
|
||||
}
|
||||
function concat(...chunks) {
|
||||
const len = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(len); let p = 0;
|
||||
for (const c of chunks) { out.set(c, p); p += c.length; }
|
||||
return out;
|
||||
}
|
||||
function be16(v) { const a=new Uint8Array(2); new DataView(a.buffer).setUint16(0, Number(v), false); return a; }
|
||||
function be32(v) { const a=new Uint8Array(4); new DataView(a.buffer).setUint32(0, Number(v), false); return a; }
|
||||
function be64(v) { const a=new Uint8Array(8); new DataView(a.buffer).setBigInt64(0, BigInt(v), false); return a; }
|
||||
function hexToBytes(hex) {
|
||||
const s=String(hex||'').trim().toLowerCase(); if(!/^[0-9a-f]{64}$/.test(s)) throw new Error('Ожидался SHA-256 hash');
|
||||
const out=new Uint8Array(32); for(let i=0;i<32;i++) out[i]=parseInt(s.slice(i*2,i*2+2),16); return out;
|
||||
}
|
||||
function bytesToHex(bytes) { return Array.from(bytes || [], (b)=>b.toString(16).padStart(2,'0')).join(''); }
|
||||
function normalizeComment(value) { return String(value || '').trim().replace(/\r\n/g,'\n').replace(/\r/g,'\n'); }
|
||||
function readFrameTimestampMs(frame) {
|
||||
if (!(frame instanceof Uint8Array) || frame.length < 50) throw new Error('Некорректный SHiNE Frame');
|
||||
const seconds = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getBigInt64(42, false);
|
||||
return Number(seconds * 1000n);
|
||||
}
|
||||
function makeForkBody({ oldBlockchainKeyB64, forkPointBlock, forkPointHash, forkPointTimestampMs, parentTipBlock, parentTipHash, parentTipTimestampMs, reasonCode, comment }) {
|
||||
const oldKey=base64ToBytes(oldBlockchainKeyB64); if(oldKey.length!==32) throw new Error('Некорректный старый blockchain key');
|
||||
const c=TE.encode(normalizeComment(comment)); if(c.length>1024) throw new Error('Комментарий больше 1024 UTF-8 байт');
|
||||
const discarded=Number(parentTipBlock)-Number(forkPointBlock);
|
||||
if(discarded<0) throw new Error('Некорректная точка fork');
|
||||
return concat(oldKey,be32(forkPointBlock),hexToBytes(forkPointHash),be64(forkPointTimestampMs),be32(parentTipBlock),hexToBytes(parentTipHash),be64(parentTipTimestampMs),be32(discarded),Uint8Array.of(Number(reasonCode)),be16(c.length),c);
|
||||
}
|
||||
function makeFrame({ prevHash, blockNumber, type, subType, version=1, body, timestampMs=Date.now() }) {
|
||||
const bodyBytes=body || new Uint8Array(0);
|
||||
const size=2+32+4+4+8+2+2+2+bodyBytes.length;
|
||||
return concat(be16(1),hexToBytes(prevHash),be32(size),be32(blockNumber),be64(Math.floor(Number(timestampMs)/1000)),be16(type),be16(subType),be16(version),bodyBytes);
|
||||
}
|
||||
|
||||
export const KEY_ROTATION_REASONS = Object.freeze([
|
||||
{ code:1, label:'Обычная смена пароля / ключей' },
|
||||
{ code:2, label:'Возможная компрометация ключей' },
|
||||
{ code:3, label:'Подтверждённая компрометация — откат истории' },
|
||||
{ code:4, label:'Восстановление доступа' },
|
||||
]);
|
||||
|
||||
export class KeyRotationClient {
|
||||
constructor(authService) { this.auth = authService; }
|
||||
async status() { return assertOk(await this.auth.ws.request('KeyRotationStatus', {}), 'KeyRotationStatus'); }
|
||||
async start(args) { return assertOk(await this.auth.ws.request('KeyRotationStart', args), 'KeyRotationStart'); }
|
||||
async abort() { return assertOk(await this.auth.ws.request('KeyRotationAbort', {}), 'KeyRotationAbort'); }
|
||||
async continuePlaceholder() { return assertOk(await this.auth.ws.request('KeyRotationContinue', {}), 'KeyRotationContinue'); }
|
||||
async finishChain() { return assertOk(await this.auth.ws.request('KeyRotationFinishChain', {}), 'KeyRotationFinishChain'); }
|
||||
async notifyPdaRotation(signature) { return assertOk(await this.auth.ws.request('KeyRotationRotatePda', { pdaRotationSignature: signature }), 'KeyRotationRotatePda'); }
|
||||
async getMyBlockchain({ beforeBlock=null, limit=50, includeBlockBytes=false }={}) {
|
||||
return assertOk(await this.auth.ws.request('GetMyBlockchain', { beforeBlock, limit, includeBlockBytes }), 'GetMyBlockchain');
|
||||
}
|
||||
async getBlock(blockchainName, blockNumber) {
|
||||
return assertOk(await this.auth.ws.request('GetBlockchainBlock', { blockchainName, blockNumber }), 'GetBlockchainBlock');
|
||||
}
|
||||
async addCandidate({ blockNumber, prevBlockHash, blockBytesB64 }) {
|
||||
return assertOk(await this.auth.ws.request('KeyRotationAddBlock', { blockNumber, prevBlockHash, blockBytesB64 }, 30000), 'KeyRotationAddBlock');
|
||||
}
|
||||
|
||||
async copyCandidateChain({ rotation, newBundle, onProgress=()=>{} }) {
|
||||
const cutoff=Number(rotation.forkFromBlock); const source=String(rotation.sourceBlockchainName||'');
|
||||
if(!source || !Number.isInteger(cutoff) || cutoff<0) throw new Error('Некорректное состояние ротации');
|
||||
if(String(newBundle?.blockchainPair?.publicKeyB64||'') !== String(rotation.newBlockchainKey||'')) throw new Error('Новый пароль выводит другой blockchain key');
|
||||
const owner32=base64ToBytes(newBundle.blockchainPair.publicKeyB64);
|
||||
const privateKey=newBundle.blockchainPair.privateKey;
|
||||
let start=Math.max(0, Math.min(cutoff+1, Number(rotation.progressCurrent)||0));
|
||||
// progressCurrent tracks published, while server may already have more pending blocks. Re-sends are idempotent.
|
||||
for(let n=start;n<=cutoff;n++) {
|
||||
const src=await this.getBlock(source,n);
|
||||
const parsed=parseAns104DataItem(base64ToBytes(src.blockBytesB64));
|
||||
const raw=await createAns104DataItemWithRawParts({ owner32, privateKey, data:parsed.data, rawTags:parsed.rawTags, tagsCount:parsed.tagsCount, target:parsed.target, anchor:parsed.anchor });
|
||||
await this.addCandidate({ blockNumber:n, prevBlockHash:n===0?ZERO_HASH:nullIfEmpty(src.prevBlockHash)||await this.#sourcePrevHash(source,n), blockBytesB64:bytesToBase64(raw) });
|
||||
onProgress({ phase:'copy', current:n+1, total:cutoff+2 });
|
||||
}
|
||||
const forkSource=await this.getBlock(source,cutoff);
|
||||
const tipBlock=Number(rotation.sourceTipBlock);
|
||||
const tipSource=tipBlock===cutoff ? forkSource : await this.getBlock(source,tipBlock);
|
||||
const forkParsed=parseAns104DataItem(base64ToBytes(forkSource.blockBytesB64));
|
||||
const tipParsed=parseAns104DataItem(base64ToBytes(tipSource.blockBytesB64));
|
||||
const forkBody=makeForkBody({
|
||||
oldBlockchainKeyB64:rotation.oldBlockchainKey,
|
||||
forkPointBlock:cutoff,
|
||||
forkPointHash:rotation.forkFromHash,
|
||||
forkPointTimestampMs:readFrameTimestampMs(forkParsed.data),
|
||||
parentTipBlock:tipBlock,
|
||||
parentTipHash:rotation.sourceTipHash,
|
||||
parentTipTimestampMs:readFrameTimestampMs(tipParsed.data),
|
||||
reasonCode:rotation.reasonCode,
|
||||
comment:rotation.comment,
|
||||
});
|
||||
const techFrame=makeFrame({ prevHash:rotation.forkFromHash, blockNumber:cutoff+1, type:0, subType:TECH_FORK_SUBTYPE, version:1, body:forkBody });
|
||||
const techRaw=await createAns104DataItem({ owner32, privateKey, data:techFrame, tags:[{name:'App',value:'test5590'}] });
|
||||
await this.addCandidate({ blockNumber:cutoff+1, prevBlockHash:rotation.forkFromHash, blockBytesB64:bytesToBase64(techRaw) });
|
||||
onProgress({ phase:'copy', current:cutoff+2, total:cutoff+2 });
|
||||
}
|
||||
|
||||
async #sourcePrevHash(source, blockNumber) {
|
||||
if(blockNumber<=0) return ZERO_HASH;
|
||||
const prev=await this.getBlock(source,blockNumber-1);
|
||||
return String(prev.blockHash||'');
|
||||
}
|
||||
|
||||
async waitUntilPublished({ timeoutMs=180000, onProgress=()=>{} }={}) {
|
||||
const started=Date.now();
|
||||
while(Date.now()-started<timeoutMs) {
|
||||
const s=await this.status();
|
||||
onProgress({ phase:'publish', current:Number(s.progressCurrent)||0, total:Number(s.progressTotal)||0, status:s.rotationStatus });
|
||||
if(s.rotationStatus!=='COPYING_CHAIN') return s;
|
||||
if(Number(s.progressTotal)>0 && Number(s.progressCurrent)>=Number(s.progressTotal)) return s;
|
||||
await new Promise(r=>setTimeout(r,1500));
|
||||
}
|
||||
throw new Error('Публикация candidate-цепочки ещё не завершилась. Можно закрыть окно и продолжить позже.');
|
||||
}
|
||||
|
||||
async rotatePda({ login, solanaEndpoint, oldBundle, newBundle, storagePwd }) {
|
||||
const rotation=await this.status();
|
||||
if(rotation.rotationStatus!=='CHAIN_READY' && rotation.rotationStatus!=='ROTATING_PDA') throw new Error('Новая цепочка ещё не готова к смене PDA');
|
||||
const checks=[['root',oldBundle?.rootPair?.publicKeyB64,rotation.oldRootKey],['blockchain',oldBundle?.blockchainPair?.publicKeyB64,rotation.oldBlockchainKey],['client',oldBundle?.clientPair?.publicKeyB64,rotation.oldClientKey],['new root',newBundle?.rootPair?.publicKeyB64,rotation.newRootKey],['new blockchain',newBundle?.blockchainPair?.publicKeyB64,rotation.newBlockchainKey],['new client',newBundle?.clientPair?.publicKeyB64,rotation.newClientKey]];
|
||||
for(const [name,actual,expected] of checks) if(String(actual||'')!==String(expected||'')) throw new Error(`Ключ «${name}» не соответствует начатой ротации`);
|
||||
let signature=String(rotation.pdaRotationSignature||'');
|
||||
if(!signature) {
|
||||
const tx=await updateShineUserPdaOnSolana({
|
||||
login, solanaEndpoint,
|
||||
rootPrivatePkcs8B64:oldBundle.rootPair.privatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64:oldBundle.blockchainPair.privatePkcs8B64,
|
||||
clientPrivatePkcs8B64:oldBundle.clientPair.privatePkcs8B64,
|
||||
payerPrivatePkcs8B64:oldBundle.blockchainPair.privatePkcs8B64,
|
||||
authorityMode:'root',
|
||||
newRootPublicKey32:base64ToBytes(newBundle.rootPair.publicKeyB64),
|
||||
nextClientPublicKey32:base64ToBytes(newBundle.clientPair.publicKeyB64),
|
||||
newBlockchainPublicKey32:base64ToBytes(newBundle.blockchainPair.publicKeyB64),
|
||||
newBlockchainPrivatePkcs8B64:newBundle.blockchainPair.privatePkcs8B64,
|
||||
});
|
||||
signature=String(tx.signature||'');
|
||||
if(!signature) throw new Error('Solana не вернула signature ротации');
|
||||
}
|
||||
const state=await this.notifyPdaRotation(signature);
|
||||
if(storagePwd) {
|
||||
await updateEncryptedUserSecrets(login, storagePwd, (current)=>({
|
||||
...(current||{}),
|
||||
rootKey:newBundle.rootPair.privatePkcs8B64,
|
||||
blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
|
||||
clientKey:newBundle.clientPair.privatePkcs8B64,
|
||||
}));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function nullIfEmpty(value) { const s=String(value||'').trim(); return s || null; }
|
||||
@@ -557,7 +557,12 @@ async function createShineUserPdaOnSolana({
|
||||
const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||
const rootPriv = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
|
||||
const bchPriv = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
|
||||
const ctx = await buildCommonContext({ login: cleanLogin, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, solanaEndpoint });
|
||||
const ctx = await buildCommonContext({
|
||||
login: cleanLogin,
|
||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||
payerPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
|
||||
solanaEndpoint,
|
||||
});
|
||||
const ecoInfo = await ctx.connection.getAccountInfo(ctx.economyConfigPda, 'confirmed');
|
||||
if (!ecoInfo?.data) throw new Error('Economy config не инициализирован.');
|
||||
const startBonusLimit = parseUsersEconomyConfig(new Uint8Array(ecoInfo.data)).startBonusLimit;
|
||||
@@ -603,7 +608,7 @@ async function createShineUserPdaOnSolana({
|
||||
const createIx = new ctx.solana.TransactionInstruction({
|
||||
programId: ctx.usersProgram,
|
||||
keys: [
|
||||
{ pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true },
|
||||
{ pubkey: ctx.payerKeypair.publicKey, isSigner: true, isWritable: true },
|
||||
{ pubkey: ctx.userPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
|
||||
@@ -618,7 +623,7 @@ async function createShineUserPdaOnSolana({
|
||||
const tx = new ctx.solana.Transaction();
|
||||
if (promoEdIx) tx.add(promoEdIx);
|
||||
tx.add(rootIx, recordIx, createIx);
|
||||
const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, tx, [ctx.clientKeypair], { commitment: 'confirmed' });
|
||||
const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, tx, [ctx.payerKeypair], { commitment: 'confirmed' });
|
||||
return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), blockchainName: `${cleanLogin}-001` };
|
||||
} catch (error) { throw await attachSolanaLogs(error, ctx.connection); }
|
||||
}
|
||||
@@ -678,15 +683,27 @@ export async function updateShineUserPdaOnSolana({
|
||||
accessServers,
|
||||
}) {
|
||||
const current = await readShineUserPda({ login, solanaEndpoint });
|
||||
const ctx = await buildCommonContext({ login: current.login, clientPrivatePkcs8B64, payerPrivatePkcs8B64, solanaEndpoint });
|
||||
const ctx = await buildCommonContext({
|
||||
login: current.login,
|
||||
clientPrivatePkcs8B64,
|
||||
payerPrivatePkcs8B64: String(payerPrivatePkcs8B64 || blockchainPrivatePkcs8B64 || ''),
|
||||
solanaEndpoint,
|
||||
});
|
||||
const addLimit = BigInt(additionalLimitBytes || 0);
|
||||
if (addLimit < 0n || addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
|
||||
const authMode = authorityMode === 'blockchain' || authorityMode === AUTH_MODE_BLOCKCHAIN ? AUTH_MODE_BLOCKCHAIN : AUTH_MODE_ROOT;
|
||||
const rootKey = newRootPublicKey32 ? toUint8Array(newRootPublicKey32, 32) : current.rootKey;
|
||||
const clientKey = nextClientPublicKey32 ? toUint8Array(nextClientPublicKey32, 32) : current.clientKey;
|
||||
const rootChanged = bytesToBase58(rootKey) !== bytesToBase58(current.rootKey);
|
||||
if (authMode === AUTH_MODE_BLOCKCHAIN && rootChanged) throw new Error('Blockchain authority не может менять root key');
|
||||
if (rootChanged && newBlockchainPublicKey32) throw new Error('Root rotation и blockchain fork нужно делать разными транзакциями');
|
||||
const clientChanged = bytesToBase58(clientKey) !== bytesToBase58(current.clientKey);
|
||||
const fullKeyRotation = rootChanged && clientChanged && Boolean(newBlockchainPublicKey32);
|
||||
if ((rootChanged || clientChanged) && !fullKeyRotation) {
|
||||
throw new Error('Root/client keys меняются только как полная ротация: новый root + новый client + новый blockchain fork');
|
||||
}
|
||||
const authMode = fullKeyRotation ? AUTH_MODE_ROOT : AUTH_MODE_BLOCKCHAIN;
|
||||
if (authorityMode != null) {
|
||||
const requestedMode = authorityMode === 'root' || authorityMode === AUTH_MODE_ROOT ? AUTH_MODE_ROOT : AUTH_MODE_BLOCKCHAIN;
|
||||
if (requestedMode !== authMode) throw new Error(fullKeyRotation ? 'Полная ротация требует root authority' : 'Обычное обновление PDA выполняется blockchain authority');
|
||||
}
|
||||
|
||||
const updatedAtMs = BigInt(Date.now());
|
||||
const forks = current.forks.map((fork) => ({ blockchainKey: fork.blockchainKey, createdAtMs: BigInt(fork.createdAtMs), paidLimitBytes: BigInt(fork.paidLimitBytes) }));
|
||||
@@ -695,7 +712,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
appendedKey = toUint8Array(newBlockchainPublicKey32, 32);
|
||||
if (forks.some((f) => bytesToBase58(f.blockchainKey) === bytesToBase58(appendedKey))) throw new Error('Этот blockchain key уже был в истории fork');
|
||||
const lastForkAt = BigInt(forks.at(-1).createdAtMs);
|
||||
if (authMode === AUTH_MODE_BLOCKCHAIN && updatedAtMs - lastForkAt < FORK_COOLDOWN_MS) throw new Error('Новый fork обычным blockchain authority можно создавать не чаще одного раза в 72 часа');
|
||||
if (!fullKeyRotation && updatedAtMs - lastForkAt < FORK_COOLDOWN_MS) throw new Error('Новый fork обычным blockchain authority можно создавать не чаще одного раза в 72 часа');
|
||||
const newLimit = forks.at(-1).paidLimitBytes + addLimit;
|
||||
if (newLimit > 0xffffffffn) throw new Error('paid_limit_bytes превышает u32');
|
||||
forks.push({ blockchainKey: appendedKey, createdAtMs: updatedAtMs, paidLimitBytes: newLimit });
|
||||
@@ -728,23 +745,17 @@ export async function updateShineUserPdaOnSolana({
|
||||
const unsigned = serializeUnsignedRecordFromState(next);
|
||||
const hash = await sha256Bytes(unsigned);
|
||||
|
||||
const oldAuthorityPub = authMode === AUTH_MODE_ROOT ? current.rootKey : current.forks.at(-1).blockchainKey;
|
||||
const oldAuthorityPrivB64 = authMode === AUTH_MODE_ROOT ? rootPrivatePkcs8B64 : blockchainPrivatePkcs8B64;
|
||||
if (!oldAuthorityPrivB64) throw new Error(authMode === AUTH_MODE_ROOT ? 'Нужен root private key' : 'Нужен blockchain private key');
|
||||
const oldAuthorityPub = fullKeyRotation ? current.rootKey : current.forks.at(-1).blockchainKey;
|
||||
const oldAuthorityPrivB64 = fullKeyRotation ? rootPrivatePkcs8B64 : blockchainPrivatePkcs8B64;
|
||||
if (!oldAuthorityPrivB64) throw new Error(fullKeyRotation ? 'Для полной ротации нужен старый root private key' : 'Для обновления PDA нужен blockchain private key');
|
||||
const oldAuthorityPriv = await importPkcs8Ed25519(oldAuthorityPrivB64);
|
||||
const authSig = await signBytes(oldAuthorityPriv, hash);
|
||||
|
||||
let recordSignerPub;
|
||||
let recordSignerPrivB64;
|
||||
if (rootChanged) {
|
||||
recordSignerPub = rootKey;
|
||||
recordSignerPrivB64 = newRootPrivatePkcs8B64;
|
||||
} else if (appendedKey) {
|
||||
if (appendedKey) {
|
||||
recordSignerPub = appendedKey;
|
||||
recordSignerPrivB64 = newBlockchainPrivatePkcs8B64;
|
||||
} else if (authMode === AUTH_MODE_ROOT) {
|
||||
recordSignerPub = rootKey;
|
||||
recordSignerPrivB64 = rootPrivatePkcs8B64;
|
||||
} else {
|
||||
recordSignerPub = current.forks.at(-1).blockchainKey;
|
||||
recordSignerPrivB64 = blockchainPrivatePkcs8B64;
|
||||
@@ -758,7 +769,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
const updateIx = new ctx.solana.TransactionInstruction({
|
||||
programId: ctx.usersProgram,
|
||||
keys: [
|
||||
{ pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true },
|
||||
{ pubkey: ctx.payerKeypair.publicKey, isSigner: true, isWritable: true },
|
||||
{ pubkey: ctx.userPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
|
||||
@@ -768,7 +779,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
data: serializeUpdateUserPdaArgs({ login: current.login, rootKey32: rootKey, updatedAtMs, additionalLimitBytes: addLimit, clientKey32: clientKey, authMode, newBlockchainKey32: appendedKey, serverAddresses: addresses, accessServers: nextAccess, recordSignature64: recordSig }),
|
||||
});
|
||||
try {
|
||||
const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, new ctx.solana.Transaction().add(authIx, recordIx, updateIx), [ctx.clientKeypair], { commitment: 'confirmed' });
|
||||
const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, new ctx.solana.Transaction().add(authIx, recordIx, updateIx), [ctx.payerKeypair], { commitment: 'confirmed' });
|
||||
return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), paidLimitBytes: forks.at(-1).paidLimitBytes, forkCount: forks.length };
|
||||
} catch (error) { throw await attachSolanaLogs(error, ctx.connection); }
|
||||
}
|
||||
@@ -778,8 +789,9 @@ export async function updateServerOnSolana({ login, keyBundle, serverAddress, se
|
||||
login,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
|
||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||
authorityMode: 'root',
|
||||
authorityMode: 'blockchain',
|
||||
serverAddresses: serverAddresses || [{ addressFormatType, addressFormatVersion, address: serverAddress }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,6 +180,26 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function getWalletFromStoredBlockchainKey({ login, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
if (!cleanLogin || !cleanPwd) {
|
||||
throw new Error('Нет активной сессии для доступа к blockchain.key');
|
||||
}
|
||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
|
||||
const blockchainPrivate = String(secrets?.blockchainKey || '').trim();
|
||||
if (!blockchainPrivate) {
|
||||
throw new Error('На устройстве не найден blockchain.key');
|
||||
}
|
||||
const keypair = await keypairFromStoredSecret(blockchainPrivate);
|
||||
return {
|
||||
address: keypair.publicKey.toBase58(),
|
||||
keypair,
|
||||
blockchainPrivatePkcs8B64: blockchainPrivate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWalletFromStoredRootKey({ login, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
@@ -199,39 +219,18 @@ export async function getWalletFromStoredRootKey({ login, storagePwd }) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStoredSolanaWalletChoices({ login, storagePwd, includeRoot = true } = {}) {
|
||||
export async function getStoredSolanaWalletChoices({ login, storagePwd } = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
if (!cleanLogin || !cleanPwd) {
|
||||
throw new Error('Нет активной сессии для доступа к ключам');
|
||||
}
|
||||
|
||||
const choices = [];
|
||||
const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
choices.push({
|
||||
id: 'client-key',
|
||||
keySource: 'client',
|
||||
label: 'client key',
|
||||
address: clientWallet.address,
|
||||
keypair: clientWallet.keypair,
|
||||
});
|
||||
|
||||
if (includeRoot) {
|
||||
try {
|
||||
const rootWallet = await getWalletFromStoredRootKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
choices.push({
|
||||
id: 'root-key',
|
||||
keySource: 'root',
|
||||
label: 'root key',
|
||||
address: rootWallet.address,
|
||||
keypair: rootWallet.keypair,
|
||||
});
|
||||
} catch {
|
||||
// root key на устройстве может отсутствовать, это допустимо
|
||||
}
|
||||
}
|
||||
|
||||
return choices;
|
||||
if (!cleanLogin || !cleanPwd) throw new Error('Нет активной сессии для доступа к ключам');
|
||||
const wallet = await getWalletFromStoredBlockchainKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
return [{
|
||||
id: 'blockchain-key',
|
||||
keySource: 'blockchain',
|
||||
label: 'blockchain key',
|
||||
address: wallet.address,
|
||||
keypair: wallet.keypair,
|
||||
}];
|
||||
}
|
||||
|
||||
export async function encodeSolanaSecretKeyBase58(secretKey) {
|
||||
|
||||
@@ -25,8 +25,8 @@ function parseSolToLamports(amountSol) {
|
||||
return lamports.toString();
|
||||
}
|
||||
|
||||
function normalizeKeySource(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'root' ? 'root' : 'client';
|
||||
function normalizeKeySource() {
|
||||
return 'blockchain';
|
||||
}
|
||||
|
||||
async function loadTurboLib() {
|
||||
@@ -36,14 +36,12 @@ async function loadTurboLib() {
|
||||
return turboLibPromise;
|
||||
}
|
||||
|
||||
async function getTurboWalletChoice({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
async function getTurboWalletChoice({ login, storagePwd, keySource = 'blockchain' } = {}) {
|
||||
const normalizedKeySource = normalizeKeySource(keySource);
|
||||
const choices = await getStoredSolanaWalletChoices({ login, storagePwd, includeRoot: true });
|
||||
const choices = await getStoredSolanaWalletChoices({ login, storagePwd });
|
||||
const selected = choices.find((item) => item.keySource === normalizedKeySource) || choices[0] || null;
|
||||
if (!selected?.keypair) {
|
||||
throw new Error(normalizedKeySource === 'root'
|
||||
? 'На устройстве не найден root key для Turbo.'
|
||||
: 'На устройстве не найден client key для Turbo.');
|
||||
throw new Error('На устройстве не найден blockchain key для Turbo.');
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
@@ -90,7 +88,7 @@ export function formatTurboCredits(value, digits = 6) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'blockchain' } = {}) {
|
||||
const choice = await getTurboWalletChoice({ login, storagePwd, keySource });
|
||||
const turbo = await createTurboClientFromWalletChoice(choice);
|
||||
return {
|
||||
@@ -103,7 +101,7 @@ export async function getTurboContextForStoredSolanaKey({ login, storagePwd, key
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'blockchain' } = {}) {
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const balance = await ctx.turbo.getBalance(ctx.address);
|
||||
const solanaBalance = await getBalanceSol({
|
||||
@@ -123,7 +121,7 @@ export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, key
|
||||
};
|
||||
}
|
||||
|
||||
export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'client', byteLength } = {}) {
|
||||
export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'blockchain', byteLength } = {}) {
|
||||
const bytes = Number(byteLength);
|
||||
if (!Number.isInteger(bytes) || bytes <= 0) {
|
||||
throw new Error('Некорректный размер файла для Turbo.');
|
||||
@@ -147,7 +145,7 @@ export async function estimateTurboUploadPrice({ login, storagePwd, keySource =
|
||||
export async function uploadFileWithTurbo({
|
||||
login,
|
||||
storagePwd,
|
||||
keySource = 'client',
|
||||
keySource = 'blockchain',
|
||||
file,
|
||||
tags = [],
|
||||
shineType = 'attachment',
|
||||
@@ -191,7 +189,7 @@ export async function uploadFileWithTurbo({
|
||||
export async function topUpTurboWithSolana({
|
||||
login,
|
||||
storagePwd,
|
||||
keySource = 'client',
|
||||
keySource = 'blockchain',
|
||||
amountSol,
|
||||
turboCreditDestinationAddress = '',
|
||||
} = {}) {
|
||||
|
||||
@@ -200,7 +200,7 @@ $('btnUpdate').addEventListener('click', async () => {
|
||||
if (!serverAddress) throw new Error('Укажите адрес сервера');
|
||||
|
||||
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
|
||||
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: false });
|
||||
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: true });
|
||||
$('rootPub').value = normalized.rootPubB58;
|
||||
$('rootPriv').value = normalized.rootPrivB58;
|
||||
$('bchPub').value = normalized.bchPubB58;
|
||||
|
||||
Reference in New Issue
Block a user