SHA256
Перенести server UI в shine-UI и объединить PDA-модуль
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { registerServerOnSolana } from '../../js/services/shine-user-pda-service.js';
|
||||
import {
|
||||
$,
|
||||
buildKeyBundleFromForm,
|
||||
clearGenMessage,
|
||||
clearStatus,
|
||||
deriveKeyBundleFromPassword,
|
||||
fillKeyFields,
|
||||
parseLoginList,
|
||||
setGenMessage,
|
||||
setStatus,
|
||||
setupPasswordEye,
|
||||
updateSolAddress,
|
||||
validateLoginOrThrow,
|
||||
wireDeviceAddressPreview,
|
||||
} from './server-ui-shared.js';
|
||||
|
||||
const fieldMap = {
|
||||
masterSecret: 'masterSecret',
|
||||
rootPub: 'rootPub',
|
||||
rootPriv: 'rootPriv',
|
||||
bchPub: 'bchPub',
|
||||
bchPriv: 'bchPriv',
|
||||
devPub: 'devPub',
|
||||
devPriv: 'devPriv',
|
||||
solBox: 'solBox',
|
||||
solAdr: 'solAdr',
|
||||
};
|
||||
|
||||
setupPasswordEye($('btnEye'), $('password'));
|
||||
wireDeviceAddressPreview(fieldMap);
|
||||
|
||||
$('btnGen').addEventListener('click', async () => {
|
||||
clearGenMessage($('genMsg'));
|
||||
clearStatus($('status'));
|
||||
$('btnGen').disabled = true;
|
||||
try {
|
||||
const login = validateLoginOrThrow($('login').value);
|
||||
const password = $('password').value;
|
||||
const { masterSecret32, keyBundle } = await deriveKeyBundleFromPassword({
|
||||
login,
|
||||
password,
|
||||
});
|
||||
fillKeyFields(fieldMap, keyBundle, masterSecret32);
|
||||
updateSolAddress(fieldMap);
|
||||
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля.', 'ok');
|
||||
} catch (error) {
|
||||
setGenMessage($('genMsg'), error?.message || String(error), 'err');
|
||||
} finally {
|
||||
$('btnGen').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btnCreate').addEventListener('click', async () => {
|
||||
clearStatus($('status'));
|
||||
clearGenMessage($('genMsg'));
|
||||
$('btnCreate').disabled = true;
|
||||
try {
|
||||
const login = validateLoginOrThrow($('login').value);
|
||||
const endpoint = String($('endpoint').value || '').trim();
|
||||
if (!endpoint) throw new Error('Укажите Solana endpoint');
|
||||
const serverAddress = String($('serverAddress').value || '').trim();
|
||||
if (!serverAddress) throw new Error('Укажите адрес сервера');
|
||||
|
||||
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
|
||||
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap);
|
||||
$('rootPub').value = normalized.rootPubB58;
|
||||
$('rootPriv').value = normalized.rootPrivB58;
|
||||
$('bchPub').value = normalized.bchPubB58;
|
||||
$('bchPriv').value = normalized.bchPrivB58;
|
||||
$('devPub').value = normalized.devPubB58;
|
||||
$('devPriv').value = normalized.devPrivB58;
|
||||
updateSolAddress(fieldMap);
|
||||
|
||||
setStatus($('status'), 'Отправка create_user_pda в Solana...', 'info');
|
||||
const result = await registerServerOnSolana({
|
||||
login,
|
||||
keyBundle,
|
||||
serverAddress,
|
||||
syncServers: parseLoginList($('syncServers').value),
|
||||
accessServers: parseLoginList($('accessServers').value),
|
||||
solanaEndpoint: endpoint,
|
||||
});
|
||||
|
||||
setStatus(
|
||||
$('status'),
|
||||
`✓ Сервер зарегистрирован!\n\nЛогин: ${login}\nPDA: ${result.pdaAddress}\nBlockchain: ${result.blockchainName}\nТранзакция: ${result.signature}`,
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus($('status'), error?.message || String(error), 'error');
|
||||
} finally {
|
||||
$('btnCreate').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.body.dataset.ready = '1';
|
||||
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
base58ToBytes,
|
||||
base64ToBytes,
|
||||
bytesToBase58,
|
||||
bytesToBase64,
|
||||
deriveEd25519FromMasterSecret,
|
||||
deriveMasterSecretFromPassword,
|
||||
publicKeyB64FromPkcs8Ed25519,
|
||||
} from '../../js/services/crypto-utils.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;
|
||||
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: {
|
||||
rootPair: { publicKeyB64: root.publicKeyB64, privatePkcs8B64: root.privatePkcs8B64 },
|
||||
blockchainPair: blockchain
|
||||
? { publicKeyB64: blockchain.publicKeyB64, privatePkcs8B64: blockchain.privatePkcs8B64 }
|
||||
: null,
|
||||
devicePair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 },
|
||||
},
|
||||
normalized: {
|
||||
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 [rootPair, blockchainPair, devicePair] = await Promise.all([
|
||||
deriveEd25519FromMasterSecret(masterSecret32, 'root.key'),
|
||||
deriveEd25519FromMasterSecret(masterSecret32, 'bch.key'),
|
||||
deriveEd25519FromMasterSecret(masterSecret32, 'dev.key'),
|
||||
]);
|
||||
return {
|
||||
masterSecret32,
|
||||
keyBundle: { rootPair, blockchainPair, devicePair },
|
||||
};
|
||||
}
|
||||
|
||||
export function fillKeyFields(fieldMap, keyBundle, masterSecret32) {
|
||||
if (masterSecret32) {
|
||||
$(fieldMap.masterSecret).value = bytesToBase58(masterSecret32);
|
||||
}
|
||||
$(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.devicePair.publicKeyB64));
|
||||
$(fieldMap.devPriv).value = bytesToBase58(base64ToBytes(keyBundle.devicePair.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 wireDeviceAddressPreview(fieldMap) {
|
||||
const update = () => updateSolAddress(fieldMap);
|
||||
$(fieldMap.devPub).addEventListener('input', update);
|
||||
update();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { readShineUserPda, updateServerOnSolana } from '../../js/services/shine-user-pda-service.js';
|
||||
import {
|
||||
$,
|
||||
buildKeyBundleFromForm,
|
||||
clearGenMessage,
|
||||
clearStatus,
|
||||
deriveKeyBundleFromPassword,
|
||||
fillKeyFields,
|
||||
formatBigInt,
|
||||
formatTimestamp,
|
||||
parseLoginList,
|
||||
setGenMessage,
|
||||
setStatus,
|
||||
setupPasswordEye,
|
||||
updateSolAddress,
|
||||
validateLoginOrThrow,
|
||||
wireDeviceAddressPreview,
|
||||
} from './server-ui-shared.js';
|
||||
|
||||
const fieldMap = {
|
||||
masterSecret: 'masterSecret',
|
||||
rootPub: 'rootPub',
|
||||
rootPriv: 'rootPriv',
|
||||
bchPub: 'bchPub',
|
||||
bchPriv: 'bchPriv',
|
||||
devPub: 'devPub',
|
||||
devPriv: 'devPriv',
|
||||
solBox: 'solBox',
|
||||
solAdr: 'solAdr',
|
||||
};
|
||||
|
||||
let currentPda = null;
|
||||
|
||||
setupPasswordEye($('btnEye'), $('password'));
|
||||
wireDeviceAddressPreview(fieldMap);
|
||||
|
||||
$('btnGen').addEventListener('click', async () => {
|
||||
clearGenMessage($('genMsg'));
|
||||
clearStatus($('status'));
|
||||
$('btnGen').disabled = true;
|
||||
try {
|
||||
const login = validateLoginOrThrow($('login').value);
|
||||
const password = $('password').value;
|
||||
const { masterSecret32, keyBundle } = await deriveKeyBundleFromPassword({ login, password });
|
||||
fillKeyFields(fieldMap, keyBundle, masterSecret32);
|
||||
updateSolAddress(fieldMap);
|
||||
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля.', 'ok');
|
||||
} catch (error) {
|
||||
setGenMessage($('genMsg'), error?.message || String(error), 'err');
|
||||
} finally {
|
||||
$('btnGen').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btnLoad').addEventListener('click', async () => {
|
||||
clearStatus($('status'));
|
||||
clearGenMessage($('genMsg'));
|
||||
$('btnLoad').disabled = true;
|
||||
currentPda = null;
|
||||
$('pdaInfo').style.display = 'none';
|
||||
$('updateForm').style.display = 'none';
|
||||
try {
|
||||
const login = validateLoginOrThrow($('login').value);
|
||||
const endpoint = String($('endpoint').value || '').trim();
|
||||
if (!endpoint) throw new Error('Укажите Solana endpoint');
|
||||
|
||||
setStatus($('status'), 'Загрузка PDA из Solana...', 'info');
|
||||
const parsed = await readShineUserPda({ login, solanaEndpoint: endpoint });
|
||||
if (!parsed.isServer) throw new Error('Эта PDA не является серверной');
|
||||
currentPda = parsed;
|
||||
|
||||
$('iAddr').textContent = parsed.pdaAddress;
|
||||
$('iVer').textContent = `#${parsed.recordNumber}`;
|
||||
$('iCreated').textContent = formatTimestamp(parsed.createdAtMs);
|
||||
$('iUpdated').textContent = formatTimestamp(parsed.updatedAtMs);
|
||||
$('iSrvAddr').textContent = parsed.serverAddress || '—';
|
||||
$('iSync').textContent = parsed.syncServers.length ? parsed.syncServers.join(', ') : '—';
|
||||
$('iBch').textContent = parsed.blockchain.blockchainName;
|
||||
$('iLimit').textContent = formatBigInt(parsed.blockchain.paidLimitBytes);
|
||||
|
||||
$('serverAddress').value = parsed.serverAddress || '';
|
||||
$('syncServers').value = parsed.syncServers.join('\n');
|
||||
$('pdaInfo').style.display = 'block';
|
||||
$('updateForm').style.display = 'block';
|
||||
setStatus($('status'), 'PDA загружена. Можно менять адрес или sync_servers.', 'success');
|
||||
} catch (error) {
|
||||
setStatus($('status'), error?.message || String(error), 'error');
|
||||
} finally {
|
||||
$('btnLoad').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('btnUpdate').addEventListener('click', async () => {
|
||||
clearStatus($('status'));
|
||||
clearGenMessage($('genMsg'));
|
||||
$('btnUpdate').disabled = true;
|
||||
try {
|
||||
if (!currentPda) throw new Error('Сначала загрузите PDA');
|
||||
const endpoint = String($('endpoint').value || '').trim();
|
||||
if (!endpoint) throw new Error('Укажите Solana endpoint');
|
||||
const serverAddress = String($('serverAddress').value || '').trim();
|
||||
if (!serverAddress) throw new Error('Укажите адрес сервера');
|
||||
|
||||
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
|
||||
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: false });
|
||||
$('rootPub').value = normalized.rootPubB58;
|
||||
$('rootPriv').value = normalized.rootPrivB58;
|
||||
$('bchPub').value = normalized.bchPubB58;
|
||||
$('bchPriv').value = normalized.bchPrivB58;
|
||||
$('devPub').value = normalized.devPubB58;
|
||||
$('devPriv').value = normalized.devPrivB58;
|
||||
updateSolAddress(fieldMap);
|
||||
|
||||
setStatus($('status'), 'Отправка update_user_pda в Solana...', 'info');
|
||||
const result = await updateServerOnSolana({
|
||||
login: currentPda.login,
|
||||
keyBundle,
|
||||
serverAddress,
|
||||
addressFormatType: currentPda.addressFormatType ?? 1,
|
||||
addressFormatVersion: currentPda.addressFormatVersion ?? 0,
|
||||
syncServers: parseLoginList($('syncServers').value),
|
||||
solanaEndpoint: endpoint,
|
||||
});
|
||||
|
||||
setStatus(
|
||||
$('status'),
|
||||
`✓ PDA обновлена!\n\nЛогин: ${currentPda.login}\nPDA: ${result.pdaAddress}\nТранзакция: ${result.signature}`,
|
||||
'success',
|
||||
);
|
||||
currentPda = null;
|
||||
$('pdaInfo').style.display = 'none';
|
||||
$('updateForm').style.display = 'none';
|
||||
} catch (error) {
|
||||
setStatus($('status'), error?.message || String(error), 'error');
|
||||
} finally {
|
||||
$('btnUpdate').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.body.dataset.ready = '1';
|
||||
Reference in New Issue
Block a user