SHiNE-server/shine-UI/server-ui/js/server-ui-shared.js
2026-06-22 21:57:09 +04:00

286 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
base58ToBytes,
base64ToBytes,
bytesToBase58,
bytesToBase64,
deriveEd25519FromMasterSecret,
deriveMasterSecretFromPassword,
publicKeyB64FromPkcs8Ed25519,
} from '../../js/services/crypto-utils.js';
import { formatSol, getBalanceSol } from '../../js/services/solana-wallet-service.js';
const LOGIN_RE = /^[a-z0-9_]{1,20}$/;
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
export function $(id) {
return document.getElementById(id);
}
export function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
export function validateLoginOrThrow(login) {
const clean = normalizeLogin(login);
if (!LOGIN_RE.test(clean)) {
throw new Error('Логин должен содержать только a-z, 0-9, _ и быть длиной 1..20 символов');
}
return clean;
}
export function parseLoginList(text) {
return String(text || '')
.split(/\r?\n/)
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
}
export function formatBigInt(value) {
return BigInt(value || 0n).toString(10);
}
export function formatTimestamp(value) {
const ts = Number(BigInt(value || 0n));
if (!Number.isFinite(ts) || ts <= 0) return '—';
return new Date(ts).toLocaleString('ru-RU');
}
export function setStatus(node, text, kind = 'info') {
node.className = `status ${kind}`;
node.textContent = String(text || '');
}
export function clearStatus(node) {
node.className = 'status';
node.textContent = '';
}
export function setGenMessage(node, text, kind) {
node.className = `gen-msg ${kind}`;
node.textContent = String(text || '');
}
export function clearGenMessage(node) {
node.className = 'gen-msg';
node.textContent = '';
}
export function setupPasswordEye(button, input) {
button.addEventListener('click', () => {
const nextType = input.type === 'password' ? 'text' : 'password';
input.type = nextType;
button.textContent = nextType === 'password' ? 'Показать' : 'Скрыть';
});
}
function ensure32Bytes(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (input.length > 32) throw new Error(`Ожидалось максимум 32 байта, получено ${input.length}`);
if (input.length === 32) return input;
const out = new Uint8Array(32);
out.set(input, 32 - input.length);
return out;
}
function pkcs8FromSeed32(seed32) {
const seed = ensure32Bytes(seed32);
const out = new Uint8Array(ED25519_PKCS8_PREFIX.length + seed.length);
out.set(ED25519_PKCS8_PREFIX, 0);
out.set(seed, ED25519_PKCS8_PREFIX.length);
return out;
}
async function pairFromSeedBase58(seedB58, explicitPubB58) {
const seed32 = ensure32Bytes(base58ToBytes(seedB58));
const privatePkcs8B64 = bytesToBase64(pkcs8FromSeed32(seed32));
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64);
const actualPubB58 = bytesToBase58(base64ToBytes(publicKeyB64));
const expectedPubB58 = String(explicitPubB58 || '').trim();
if (expectedPubB58 && actualPubB58 !== expectedPubB58) {
throw new Error(`Публичный ключ не совпадает с приватным seed: ${expectedPubB58}`);
}
return {
publicKeyB64,
privatePkcs8B64,
publicKeyB58: actualPubB58,
privateSeedB58: bytesToBase58(seed32),
};
}
export async function buildKeyBundleFromForm(fieldMap, options = {}) {
const requireBlockchain = options.requireBlockchain !== false;
let recovery = null;
const masterSecretValue = fieldMap.masterSecret ? String($(fieldMap.masterSecret).value || '').trim() : '';
if (masterSecretValue) {
const masterSecret32 = ensure32Bytes(base58ToBytes(masterSecretValue));
const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key');
recovery = {
publicKeyB64: recoveryPair.publicKeyB64,
privatePkcs8B64: recoveryPair.privatePkcs8B64,
publicKeyB58: bytesToBase58(base64ToBytes(recoveryPair.publicKeyB64)),
privateSeedB58: bytesToBase58(base64ToBytes(recoveryPair.privatePkcs8B64).slice(-32)),
};
}
const root = await pairFromSeedBase58($(fieldMap.rootPriv).value, $(fieldMap.rootPub).value);
const device = await pairFromSeedBase58($(fieldMap.devPriv).value, $(fieldMap.devPub).value);
const blockchainPriv = String($(fieldMap.bchPriv).value || '').trim();
const blockchainPub = String($(fieldMap.bchPub).value || '').trim();
const hasBlockchainInput = Boolean(blockchainPriv || blockchainPub);
let blockchain = null;
if (requireBlockchain || hasBlockchainInput) {
blockchain = await pairFromSeedBase58(blockchainPriv, blockchainPub);
}
return {
keyBundle: {
recoveryPair: recovery
? { publicKeyB64: recovery.publicKeyB64, privatePkcs8B64: recovery.privatePkcs8B64 }
: null,
rootPair: { publicKeyB64: root.publicKeyB64, privatePkcs8B64: root.privatePkcs8B64 },
blockchainPair: blockchain
? { publicKeyB64: blockchain.publicKeyB64, privatePkcs8B64: blockchain.privatePkcs8B64 }
: null,
clientPair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 },
},
normalized: {
recoveryPubB58: recovery?.publicKeyB58 || '',
recoveryPrivB58: recovery?.privateSeedB58 || '',
rootPubB58: root.publicKeyB58,
rootPrivB58: root.privateSeedB58,
bchPubB58: blockchain?.publicKeyB58 || '',
bchPrivB58: blockchain?.privateSeedB58 || '',
devPubB58: device.publicKeyB58,
devPrivB58: device.privateSeedB58,
},
};
}
export async function deriveKeyBundleFromPassword({ login, password, onProgress }) {
const cleanLogin = validateLoginOrThrow(login);
const cleanPassword = String(password ?? '');
if (!cleanPassword) throw new Error('Введите пароль');
const masterSecret32 = await deriveMasterSecretFromPassword(cleanPassword, {
login: cleanLogin,
onProgress,
});
const [recoveryPair, rootPair, blockchainPair, clientPair] = await Promise.all([
deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'root.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'blockchain.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'client.key'),
]);
return {
masterSecret32,
keyBundle: {
recoveryPair,
rootPair,
blockchainPair,
clientPair,
},
};
}
export function fillKeyFields(fieldMap, keyBundle, masterSecret32) {
if (masterSecret32) {
$(fieldMap.masterSecret).value = bytesToBase58(masterSecret32);
}
if (fieldMap.recoveryPub && fieldMap.recoveryPriv && keyBundle.recoveryPair) {
$(fieldMap.recoveryPub).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64));
$(fieldMap.recoveryPriv).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.privatePkcs8B64).slice(-32));
}
$(fieldMap.rootPub).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64));
$(fieldMap.rootPriv).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.privatePkcs8B64).slice(-32));
$(fieldMap.bchPub).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64));
$(fieldMap.bchPriv).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.privatePkcs8B64).slice(-32));
$(fieldMap.devPub).value = bytesToBase58(base64ToBytes(keyBundle.clientPair.publicKeyB64));
$(fieldMap.devPriv).value = bytesToBase58(base64ToBytes(keyBundle.clientPair.privatePkcs8B64).slice(-32));
}
export function updateSolAddress(fieldMap) {
const box = $(fieldMap.solBox);
const label = $(fieldMap.solAdr);
const pubB58 = String($(fieldMap.devPub).value || '').trim();
if (!pubB58) {
box.classList.remove('show');
label.textContent = '';
return;
}
try {
ensure32Bytes(base58ToBytes(pubB58));
label.textContent = pubB58;
box.classList.add('show');
} catch {
box.classList.remove('show');
label.textContent = '';
}
}
export function setText(idOrNode, value) {
const node = typeof idOrNode === 'string' ? $(idOrNode) : idOrNode;
if (node) node.textContent = String(value || '');
}
export function wireDeviceAddressPreview(fieldMap) {
const update = () => updateSolAddress(fieldMap);
$(fieldMap.devPub).addEventListener('input', update);
update();
}
export function publicKeyBytesToBase58(value) {
return bytesToBase58(value instanceof Uint8Array ? value : new Uint8Array(value || []));
}
export function compareExpectedPublicKeys(expected, actual) {
const exp = String(expected || '').trim();
const act = String(actual || '').trim();
return {
matches: Boolean(exp) && Boolean(act) && exp === act,
expected: exp,
actual: act,
};
}
export function summarizeKeyComparison(resultMap) {
const labels = {
root: 'root',
blockchain: 'blockchain',
device: 'device',
};
const mismatches = Object.entries(resultMap)
.filter(([, result]) => !result.matches)
.map(([key]) => labels[key] || key);
return {
allMatch: mismatches.length === 0,
mismatches,
};
}
export async function refreshDeviceBalance({ endpoint, deviceAddress, targetNode }) {
const cleanEndpoint = String(endpoint || '').trim();
const cleanAddress = String(deviceAddress || '').trim();
if (!cleanEndpoint) throw new Error('Укажите Solana endpoint');
if (!cleanAddress) throw new Error('Сначала укажите device-адрес');
const balance = await getBalanceSol({ endpoint: cleanEndpoint, address: cleanAddress });
setText(targetNode, `Баланс device: ${formatSol(balance.sol, 6)} SOL (${balance.lamports} lamports)`);
return balance;
}
export function buildDevnetTopupUrl(walletAddress) {
const cleanWallet = String(walletAddress || '').trim();
const url = new URL('../devnet-topup-view', window.location.href);
if (cleanWallet) {
url.searchParams.set('wallet', cleanWallet);
}
return url.toString();
}
export function openDevnetTopup(walletAddress) {
const cleanWallet = String(walletAddress || '').trim();
if (!cleanWallet) {
throw new Error('Сначала укажите device-адрес');
}
window.open(buildDevnetTopupUrl(cleanWallet), '_blank', 'noopener');
}