286 lines
10 KiB
JavaScript
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');
|
||
}
|