Files
SHiNE-server/shine-UI/server-ui/js/server-ui-shared.js
T

286 lines
10 KiB
JavaScript

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');
}