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