SHA256
169 lines
11 KiB
JavaScript
169 lines
11 KiB
JavaScript
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; }
|