Миграция PDA на client.key

This commit is contained in:
AidarKC
2026-06-22 21:57:09 +04:00
parent ba348dafb3
commit 5c92b6a734
133 changed files with 941 additions and 30531 deletions
+4 -4
View File
@@ -57,12 +57,12 @@ import * as serverSettingsView from './pages/server-settings-view.js?v=202606161
import * as toolsSettingsView from './pages/tools-settings-view.js';
import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
import * as devicePairingView from './pages/device-pairing-view.js?v=202606180940';
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
import * as deviceQrView from './pages/device-qr-view.js';
import * as deviceCameraView from './pages/device-camera-view.js';
import * as showKeysView from './pages/show-keys-view.js';
import * as deviceSessionView from './pages/device-session-view.js?v=202606131435';
import * as clientSessionView from './pages/device-session-view.js?v=202606131435';
import * as languageView from './pages/language-view.js';
import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
@@ -103,12 +103,12 @@ const routes = {
'tools-settings-view': toolsSettingsView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
'device-pairing-view': devicePairingView,
'device-pairing-view': clientPairingView,
'trusted-device-login-settings-view': trustedDeviceLoginSettingsView,
'device-qr-view': deviceQrView,
'device-camera-view': deviceCameraView,
'show-keys-view': showKeysView,
'device-session-view': deviceSessionView,
'device-session-view': clientSessionView,
'language-view': languageView,
'app-log-view': appLogView,
'pwa-diagnostics-view': pwaDiagnosticsView,
+2 -2
View File
@@ -1,5 +1,5 @@
import { authService } from '../state.js';
import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import { getArweaveBalance, getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
import {
buildArweaveDataUrl,
getArweaveUploadPrice,
@@ -596,7 +596,7 @@ export function openAvatarWizard({
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
walletCtx = await getArweaveWalletFromStoredDeviceKey({
walletCtx = await getArweaveWalletFromStoredClientKey({
login: cleanLogin,
storagePwd: cleanStoragePwd,
onStatus: (message) => {
+1 -1
View File
@@ -15,7 +15,7 @@ export const wallet = {
updatedAt: 'сегодня, 14:42',
};
export const deviceSessions = [
export const clientSessions = [
{
sessionId: 'sess_7c5e5c4b',
clientInfoFromClient: 'Android 15; Pixel 9',
+3 -3
View File
@@ -21,7 +21,7 @@ export function render({ navigate }) {
<p>Выберите, какие ключи передать на подключаемое устройство</p>
<label class="checkbox-row"><input type="checkbox" id="connect-root" ${state.deviceConnect.root ? 'checked' : ''} /> root key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-blockchain" ${state.deviceConnect.blockchain ? 'checked' : ''} /> blockchain key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> device key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> client key</label>
<p class="meta-muted" id="connect-keys-status">Проверяем ключи на этом устройстве...</p>
<div class="row">
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
@@ -68,7 +68,7 @@ export function render({ navigate }) {
<p class="meta-muted">если ключа нет — он недоступен</p>
<p class="meta-muted">blockchain key — можно передать или нет</p>
<p class="meta-muted">root key — только если существует</p>
<p class="meta-muted">device key передаётся всегда</p>
<p class="meta-muted">client key передаётся всегда</p>
<p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p>
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
@@ -100,7 +100,7 @@ export function render({ navigate }) {
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const hasRoot = Boolean(savedKeys.rootKey);
const hasBlockchain = Boolean(savedKeys.blockchainKey);
const hasDevice = Boolean(savedKeys.deviceKey);
const hasDevice = Boolean(savedKeys.clientKey);
rootToggle.disabled = !hasRoot;
blockchainToggle.disabled = !hasBlockchain;
+2 -2
View File
@@ -11,7 +11,7 @@ import {
promptPwaInstall,
} from '../services/pwa-install-service.js';
import { initPwaPush } from '../services/pwa-push-service.js';
import { getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import { getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
import {
prepareAvatarImageFile,
uploadArweaveFile,
@@ -143,7 +143,7 @@ function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {
+ `(${optimized.width}x${optimized.height}, ${optimized.contentType})`,
);
walletCtx = await getArweaveWalletFromStoredDeviceKey({
walletCtx = await getArweaveWalletFromStoredClientKey({
login: walletLogin,
storagePwd,
onStatus: (message) => setMeta(message),
+7 -7
View File
@@ -38,12 +38,12 @@ function pairingSessionKindLabel(sessionType) {
function buildTransferKeys(savedKeys, { withExtras = false }) {
const keys = {
deviceKey: String(savedKeys?.deviceKey || '').trim(),
clientKey: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
blockchainKey: '',
rootKey: '',
};
if (!keys.deviceKey) {
throw new Error('На этом устройстве нет сохранённого device key для передачи.');
if (!keys.clientKey) {
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
}
if (withExtras) {
if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) {
@@ -432,12 +432,12 @@ export function render({ navigate }) {
const loadSavedKeys = async () => {
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const available = [];
if (savedKeys?.deviceKey) available.push('device');
if (savedKeys?.clientKey || savedKeys?.clientKey) available.push('client');
if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain');
if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root');
keySummaryEl.textContent = available.length
? `При расширенном подключении будут переданы: ${available.join(', ')}.`
: 'На этом устройстве доступен только device key.';
: 'На этом устройстве доступен только client key.';
};
const reloadRequests = async ({ silent = false } = {}) => {
@@ -464,9 +464,9 @@ export function render({ navigate }) {
const withExtras = mode === 'with-extras';
let payload;
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
const delegatedSession = await authService.createDelegatedSessionWithDeviceKey({
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
login: state.session.login,
devicePrivPkcs8: String(savedKeys?.deviceKey || '').trim(),
clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
sessionKey: String(request?.requesterSessionKey || '').trim(),
sessionType: Number(request?.requesterSessionType || SESSION_TYPE_WALLET) || SESSION_TYPE_WALLET,
clientPlatform: String(request?.requesterClientPlatform || '').trim() || 'Wallet plugin',
+2 -2
View File
@@ -42,11 +42,11 @@ export function render({ navigate }) {
}
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const keys = {
deviceKey: savedKeys.deviceKey || '',
clientKey: savedKeys.clientKey || '',
blockchainKey: state.deviceConnect.blockchain ? (savedKeys.blockchainKey || '') : '',
rootKey: state.deviceConnect.root ? (savedKeys.rootKey || '') : '',
};
if (!keys.deviceKey) throw new Error('На этом устройстве нет device key');
if (!keys.clientKey) throw new Error('На этом устройстве нет client key');
const qrText = makeKeyTransferText({ login: state.session.login, keys });
qrEl.innerHTML = renderQrSvg(qrText);
+2 -2
View File
@@ -43,7 +43,7 @@ export function render({ navigate }) {
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
<input class="input" type="text" value="${state.keyStorage.deviceKey}" />
<input class="input" type="text" value="${state.keyStorage.clientKey}" />
</div>
`;
@@ -63,7 +63,7 @@ export function render({ navigate }) {
const deviceInput = card.children[2].querySelector('.input');
deviceInput.addEventListener('input', () => {
state.keyStorage.deviceKey = deviceInput.value;
state.keyStorage.clientKey = deviceInput.value;
});
const actions = document.createElement('div');
+2 -2
View File
@@ -141,8 +141,8 @@ export function render({ navigate }) {
const parseTransferText = (text) => {
try {
const transfer = parseKeyTransferText(text);
if (!transfer.keys.deviceKey) {
throw new Error('В QR-коде нет device key для входа');
if (!transfer.keys.clientKey) {
throw new Error('В QR-коде нет client key для входа');
}
showTransfer(transfer);
} catch (error) {
+1 -1
View File
@@ -379,7 +379,7 @@ export function render({ navigate }) {
const details2 = document.createElement('p');
details2.className = 'meta-muted';
details2.textContent = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
details2.textContent = 'Из этого секрета строятся recovery key, root key, blockchain key и client key. Это может занять некоторое время.';
const details3 = document.createElement('p');
details3.className = 'meta-muted';
@@ -1,7 +1,7 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false };
@@ -109,6 +109,20 @@ export function render({ navigate }) {
}
card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 }));
// Recovery key
const recoverySep = document.createElement('p');
recoverySep.className = 'field-label';
recoverySep.textContent = 'Recovery key';
card.append(recoverySep);
card.append(makePublicField({
label: 'Recovery — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Recovery — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.recoveryPair.privatePkcs8B64)),
}));
// Root key
const rootSep = document.createElement('p');
rootSep.className = 'field-label';
@@ -137,18 +151,18 @@ export function render({ navigate }) {
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.blockchainPair.privatePkcs8B64)),
}));
// Device key
// Client key
const devSep = document.createElement('p');
devSep.className = 'field-label';
devSep.textContent = 'Device key (= Solana wallet)';
devSep.textContent = 'Client key (= Solana wallet)';
card.append(devSep);
card.append(makePublicField({
label: 'Device — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.devicePair.publicKeyB64)),
label: 'Client — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.clientPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Device — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64)),
label: 'Client — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.clientPair.privatePkcs8B64)),
}));
}
+2 -2
View File
@@ -30,7 +30,7 @@ export const REGISTRATION_FAQ_TOPICS = [
title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
'Уже из этого секрета детерминированно строятся три основных ключа: root key, blockchain key и device key.',
'Уже из этого секрета детерминированно строятся четыре основных ключа: recovery key, root key, blockchain key и client key.',
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
],
},
@@ -41,7 +41,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.',
'Device key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Client key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
],
},
@@ -113,7 +113,7 @@ export function render({ navigate }) {
const deriveUserWalletAddress = async () => {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.');
const { publicKeyB64 } = keyBundle.devicePair;
const { publicKeyB64 } = keyBundle.clientPair;
const raw = atob(publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
@@ -219,7 +219,7 @@ export function render({ navigate }) {
card.innerHTML = `
<p class="auth-copy">Для регистрации в тестовой Solana нужно минимум 0,01 SOL на вашем кошельке.</p>
<label class="stack"><span class="field-label">Номер кошелька (wallet.key = device.key)</span></label>
<label class="stack"><span class="field-label">Номер кошелька (client.key)</span></label>
<div class="stack">
<span class="field-label">Баланс (Solana)</span>
</div>
@@ -242,7 +242,7 @@ export function render({ navigate }) {
await refreshBalance({ addressOverride: walletAddress });
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось подготовить wallet.key: ${error?.message || 'unknown'}`;
status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`;
status.style.display = '';
}
})();
+4 -4
View File
@@ -1,7 +1,7 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import { bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
@@ -68,7 +68,7 @@ export function render({ navigate }) {
card.append(
renderField('root', 'root key (base58)'),
renderField('blockchain', 'blockchain.key (base58)'),
renderField('device', 'device key (base58)'),
renderField('device', 'client key (base58)'),
);
const setMissingState = (id) => {
@@ -133,11 +133,11 @@ export function render({ navigate }) {
const rootSeed32 = savedKeys.rootKey ? extractSeed32FromPkcs8B64(savedKeys.rootKey) : null;
const blockchainSeed32 = savedKeys.blockchainKey ? extractSeed32FromPkcs8B64(savedKeys.blockchainKey) : null;
const deviceSeed32 = savedKeys.deviceKey ? extractSeed32FromPkcs8B64(savedKeys.deviceKey) : null;
const clientSeed32 = savedKeys.clientKey ? extractSeed32FromPkcs8B64(savedKeys.clientKey) : null;
keys.root = rootSeed32 ? bytesToBase58(rootSeed32) : '';
keys.blockchain = blockchainSeed32 ? bytesToBase58(blockchainSeed32) : '';
keys.device = deviceSeed32 ? bytesToBase58(deviceSeed32) : '';
keys.device = clientSeed32 ? bytesToBase58(clientSeed32) : '';
if (keys.root || keys.blockchain || keys.device) {
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';
+5 -5
View File
@@ -12,12 +12,12 @@ export const pageMeta = { id: 'topup-view', title: 'Пополнение сче
// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей.
// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес
// напрямую из пароля, иначе он расходится с device-ключом регистрации.
async function deviceWalletAddressFromBundle() {
async function clientWalletAddressFromBundle() {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle || !keyBundle.devicePair) {
if (!keyBundle || !keyBundle.clientPair) {
throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.');
}
const raw = atob(keyBundle.devicePair.publicKeyB64);
const raw = atob(keyBundle.clientPair.publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4');
@@ -69,7 +69,7 @@ export function render({ navigate }) {
</div>
<a class="link-card" id="topup-site-link" href="${getTopupSiteUrl(state.registrationPayment.walletAddress || '')}" target="_blank" rel="noreferrer">Открыть сайт пополнения</a>
<div class="card stack" style="padding:12px; max-width:320px;">
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (device key = Solana wallet)</div>
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (client key = Solana wallet)</div>
</div>
`;
card.children[3].append(walletRow);
@@ -117,7 +117,7 @@ export function render({ navigate }) {
(async () => {
try {
if (!walletValue.value) {
const address = await deviceWalletAddressFromBundle();
const address = await clientWalletAddressFromBundle();
state.registrationPayment.walletAddress = address;
walletValue.value = address;
}
+17 -17
View File
@@ -6,14 +6,14 @@ import {
formatSol,
getBalanceSol,
getTopupSiteUrl,
getWalletFromStoredDeviceKey,
getWalletFromStoredClientKey,
transferSol,
} from '../services/solana-wallet-service.js';
import {
formatAr,
getArweaveBalance,
getArweaveTopupSiteUrl,
getArweaveWalletFromStoredDeviceKey,
getArweaveWalletFromStoredClientKey,
transferAr,
} from '../services/arweave-wallet-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
@@ -262,10 +262,10 @@ export function render({ navigate }) {
}
let rootKey = String(saved?.rootKey || '').trim();
let blockchainKey = String(saved?.blockchainKey || '').trim();
const deviceKey = String(saved?.deviceKey || '').trim();
if (!deviceKey) throw new Error('На устройстве нет device.key. Выполните вход заново.');
const clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey };
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
const password = window.prompt(
@@ -284,7 +284,7 @@ export function render({ navigate }) {
if (shouldSave) {
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
}
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey };
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
};
const setButtonsDisabled = (disabled) => {
@@ -341,7 +341,7 @@ export function render({ navigate }) {
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: 0n,
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
nextLastBlockNumber: serverState.lastNumber,
@@ -388,7 +388,7 @@ export function render({ navigate }) {
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: addBytes,
});
if (modeToken !== activeModeToken) return;
@@ -443,7 +443,7 @@ export function render({ navigate }) {
addressCard.className = 'card';
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (wallet.key = device.key)</p>
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (client.key)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value"></p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
@@ -705,7 +705,7 @@ export function render({ navigate }) {
sendBtn.addEventListener('click', async () => {
if (!walletCtx?.keypair) {
setStatus('Перевод недоступен: wallet.key не загружен.');
setStatus('Перевод недоступен: client.key не загружен.');
return;
}
const toAddress = window.prompt('Введите адрес получателя (Solana):', '');
@@ -741,17 +741,17 @@ export function render({ navigate }) {
});
content.append(backBtn, card, actions, generatedCard);
setStatus('Инициализация wallet.key...');
setStatus('Инициализация client.key...');
try {
walletCtx = await getWalletFromStoredDeviceKey(sessionArgsOrThrow());
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
if (modeToken !== activeModeToken) return;
walletAddress = walletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'wallet.key недоступен';
addressEl.textContent = 'client.key недоступен';
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
}
}
@@ -797,7 +797,7 @@ export function render({ navigate }) {
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
SHiNE берёт ваш локальный device.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;
@@ -899,14 +899,14 @@ export function render({ navigate }) {
try {
let wasFirstTimeGeneration = false;
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({
arweaveWalletCtx = await getArweaveWalletFromStoredClientKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
wasFirstTimeGeneration = true;
setStatus('Подождите — ваш Arweave-ключ вычисляется из device key. Это происходит только один раз, потом будет мгновенно.');
setStatus('Подождите — ваш Arweave-ключ вычисляется из client key. Это происходит только один раз, потом будет мгновенно.');
return;
}
setStatus(text);
@@ -919,7 +919,7 @@ export function render({ navigate }) {
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'wallet.key недоступен';
addressEl.textContent = 'client.key недоступен';
clearArweaveSecretsInMemory();
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
}
+11 -11
View File
@@ -1,6 +1,6 @@
import { loadEncryptedUserSecrets, updateEncryptedUserSecrets } from './key-vault.js';
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { deriveArweaveWalletFromDeviceKey32 } from './sawd-v1.js';
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
import { deriveArweaveWalletFromClientKey32 } from './sawd-v1.js';
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
const AR_TOPUP_URL = 'https://changenow.io/exchange?from=usd&to=ar&amount=10&fiatMode=true';
@@ -102,11 +102,11 @@ function safeStatus(onStatus, text) {
}
}
export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd, onStatus } = {}) {
export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к wallet.key');
throw new Error('Нет активной сессии для доступа к client.key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
@@ -116,19 +116,19 @@ export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd, o
return cached;
}
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего device key. Это может занять немного времени.');
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего client key. Это может занять немного времени.');
const storedDeviceKey = String(secrets?.deviceKey || '').trim();
if (!storedDeviceKey) {
throw new Error('На устройстве не найден device.key (wallet.key)');
const storedClientKey = String(secrets?.clientKey || '').trim();
if (!storedClientKey) {
throw new Error('На устройстве не найден client.key');
}
const deviceKey32 = extractDeviceKey32FromStoredValue(storedDeviceKey);
const clientKey32 = extractClientKey32FromStoredValue(storedClientKey);
let wallet;
try {
wallet = await deriveArweaveWalletFromDeviceKey32(deviceKey32);
wallet = await deriveArweaveWalletFromClientKey32(clientKey32);
} finally {
deviceKey32.fill(0);
clientKey32.fill(0);
}
const cachedWallet = {
+48 -39
View File
@@ -809,22 +809,27 @@ export class AuthService {
});
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' });
if (onProgress) onProgress({ percent: 93, stage: 'derive', message: 'Вычисление recovery key...' });
const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret, 'recovery.key');
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 95, stage: 'derive', message: 'Вычисление root key...' });
const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key');
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 96, stage: 'derive', message: 'Вычисление blockchain key...' });
const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'bch.key');
if (onProgress) onProgress({ percent: 97, stage: 'derive', message: 'Вычисление blockchain key...' });
const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'blockchain.key');
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key');
const result = {
masterSecretB64: bytesToBase64(masterSecret),
rootPair,
blockchainPair,
devicePair,
};
if (onProgress) onProgress({ percent: 99, stage: 'derive', message: 'Вычисление client key...' });
const clientPair = await deriveEd25519FromMasterSecret(masterSecret, 'client.key');
const result = {
masterSecretB64: bytesToBase64(masterSecret),
recoveryPair,
rootPair,
blockchainPair,
clientPair,
};
this.passwordKeyBundleCache.set(cacheKey, result);
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
return result;
@@ -839,6 +844,8 @@ export class AuthService {
async createAuthSession(login, keyBundle) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
const clientPair = keyBundle?.clientPair;
if (!clientPair) throw new Error('createAuthSession: не передан clientPair');
const sessionPair = await generateEd25519Pair();
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
@@ -853,7 +860,7 @@ export class AuthService {
const timeMs = Date.now();
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
const signatureB64 = await signBase64(keyBundle.devicePair.privateKey, preimage);
const signatureB64 = await signBase64(clientPair.privateKey, preimage);
const createResp = await this.ws.request('CreateAuthSession', {
login: cleanLogin,
@@ -861,7 +868,7 @@ export class AuthService {
sessionKey,
timeMs,
authNonce,
deviceKey: keyBundle.devicePair.publicKeyB64,
clientKey: clientPair.publicKeyB64,
signatureB64,
sessionType: SESSION_TYPE_CLIENT,
clientPlatform: makeClientPlatform(),
@@ -892,13 +899,14 @@ export class AuthService {
if (!isFree) throw new Error('Этот логин уже занят');
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
const clientPair = keyBundle?.clientPair;
const addResp = await this.ws.request('AddUser', {
login: cleanLogin,
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
solanaKey: keyBundle.devicePair.publicKeyB64,
solanaKey: clientPair.publicKeyB64,
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
deviceKey: keyBundle.devicePair.publicKeyB64,
clientKey: clientPair.publicKeyB64,
bchLimit: 1000000,
});
if (addResp.status !== 200) throw opError('AddUser', addResp);
@@ -910,13 +918,14 @@ export class AuthService {
async registerUserWithKeyBundle(login, keyBundle) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
const clientPair = keyBundle?.clientPair;
const addResp = await this.ws.request('AddUser', {
login: cleanLogin,
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
solanaKey: keyBundle.devicePair.publicKeyB64,
solanaKey: clientPair.publicKeyB64,
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
deviceKey: keyBundle.devicePair.publicKeyB64,
clientKey: clientPair.publicKeyB64,
bchLimit: 1000000,
});
if (addResp.status !== 200) throw opError('AddUser', addResp);
@@ -937,13 +946,13 @@ export class AuthService {
async createSessionFromImportedSecrets(login, secrets) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('В QR-коде нет логина');
const deviceKey = String(secrets?.deviceKey || '').trim();
if (!deviceKey) throw new Error('В QR-коде нет device key для входа');
const clientKey = String(secrets?.clientKey || secrets?.clientKey || '').trim();
if (!clientKey) throw new Error('В QR-коде нет client key для входа');
const privateKey = await importPkcs8Ed25519(deviceKey);
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(deviceKey);
const privateKey = await importPkcs8Ed25519(clientKey);
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey);
const session = await this.createAuthSession(cleanLogin, {
devicePair: {
clientPair: {
privateKey,
publicKeyB64,
},
@@ -951,9 +960,9 @@ export class AuthService {
return session;
}
async createDelegatedSessionWithDeviceKey({
async createDelegatedSessionWithClientKey({
login,
devicePrivPkcs8,
clientPrivPkcs8,
sessionKey,
sessionType = SESSION_TYPE_WALLET,
clientPlatform = 'Delegated session',
@@ -961,13 +970,13 @@ export class AuthService {
}) {
const cleanLogin = String(login || '').trim();
const cleanSessionKey = String(sessionKey || '').trim();
const cleanDevicePriv = String(devicePrivPkcs8 || '').trim();
if (!cleanLogin) throw new Error('createDelegatedSessionWithDeviceKey: пустой login');
if (!cleanSessionKey) throw new Error('createDelegatedSessionWithDeviceKey: пустой sessionKey');
if (!cleanDevicePriv) throw new Error('createDelegatedSessionWithDeviceKey: пустой device private key');
const cleanClientPriv = String(clientPrivPkcs8 || '').trim();
if (!cleanLogin) throw new Error('createDelegatedSessionWithClientKey: пустой login');
if (!cleanSessionKey) throw new Error('createDelegatedSessionWithClientKey: пустой sessionKey');
if (!cleanClientPriv) throw new Error('createDelegatedSessionWithClientKey: пустой client private key');
const devicePrivateKey = await importPkcs8Ed25519(cleanDevicePriv);
const devicePublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(cleanDevicePriv);
const clientPrivateKey = await importPkcs8Ed25519(cleanClientPriv);
const clientPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(cleanClientPriv);
const storagePwd = randomBase64(32);
const tempAuth = new AuthService(this.serverUrl);
@@ -980,7 +989,7 @@ export class AuthService {
const timeMs = Date.now();
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${cleanSessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
const signatureB64 = await signBase64(devicePrivateKey, preimage);
const signatureB64 = await signBase64(clientPrivateKey, preimage);
const createResp = await tempAuth.ws.request('CreateAuthSession', {
login: cleanLogin,
@@ -988,7 +997,7 @@ export class AuthService {
sessionKey: cleanSessionKey,
timeMs,
authNonce,
deviceKey: devicePublicKeyB64,
clientKey: clientPublicKeyB64,
signatureB64,
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
@@ -1025,7 +1034,7 @@ export class AuthService {
const secrets = {
...currentSecrets,
deviceKey: keyBundle.devicePair.privatePkcs8B64,
clientKey: keyBundle.clientPair.privatePkcs8B64,
};
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
@@ -1898,9 +1907,9 @@ export class AuthService {
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
const devicePriv = secrets?.deviceKey;
if (!devicePriv) throw new Error('Не найден приватный deviceKey');
const privateKey = await importPkcs8Ed25519(devicePriv);
const clientPriv = secrets?.clientKey || secrets?.clientKey;
if (!clientPriv) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPriv);
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
@@ -1941,9 +1950,9 @@ export class AuthService {
}
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
const devicePriv = secrets?.deviceKey;
if (!devicePriv) throw new Error('Не найден приватный deviceKey');
const privateKey = await importPkcs8Ed25519(devicePriv);
const clientPriv = secrets?.clientKey || secrets?.clientKey;
if (!clientPriv) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPriv);
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
@@ -18,14 +18,14 @@ function parseKeypairJson64(raw) {
if (!isByteArrayLike(parsed)) return null;
const asArray = Array.from(parsed);
if (asArray.length < 32) {
throw new Error('Некорректный JSON ключ device.key: ожидалось минимум 32 байта');
throw new Error('Некорректный JSON ключ client.key: ожидалось минимум 32 байта');
}
const out = new Uint8Array(asArray.length);
for (let i = 0; i < asArray.length; i += 1) {
const n = Number(asArray[i]);
if (!Number.isInteger(n) || n < 0 || n > 255) {
throw new Error('Некорректный JSON ключ device.key: найдены не-байтовые значения');
throw new Error('Некорректный JSON ключ client.key: найдены не-байтовые значения');
}
out[i] = n;
}
@@ -34,13 +34,13 @@ function parseKeypairJson64(raw) {
export function extractSeed32FromPkcs8B64(pkcs8B64) {
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ device.key');
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key');
return bytes.slice(bytes.length - 32);
}
export function extractDeviceKey32FromStoredValue(storedDeviceKey) {
const raw = String(storedDeviceKey || '').trim();
if (!raw) throw new Error('Пустой device.key');
export function extractClientKey32FromStoredValue(storedClientKey) {
const raw = String(storedClientKey || '').trim();
if (!raw) throw new Error('Пустой client.key');
const jsonBytes = parseKeypairJson64(raw);
if (jsonBytes) {
+14 -2
View File
@@ -1,6 +1,7 @@
const encoder = new TextEncoder();
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
function getCryptoApi() {
const api = globalThis.crypto;
@@ -205,8 +206,19 @@ export async function deriveEd25519FromMasterSecret(masterSecret32, suffix) {
if (secretBytes.length !== 32) {
throw new Error('Master secret должен быть длиной 32 байта');
}
const material = `${bytesToBase64(secretBytes)}|${String(suffix || '')}`;
const seed = await sha256Text(material);
const suffixBytes = utf8Bytes(String(suffix || ''));
const material = new Uint8Array(
SHINE_KEY_DERIVATION_PREFIX.length + 1 + secretBytes.length + 1 + suffixBytes.length,
);
let offset = 0;
material.set(utf8Bytes(SHINE_KEY_DERIVATION_PREFIX), offset);
offset += SHINE_KEY_DERIVATION_PREFIX.length;
material[offset++] = 0;
material.set(secretBytes, offset);
offset += secretBytes.length;
material[offset++] = 0;
material.set(suffixBytes, offset);
const seed = await sha256Bytes(material);
const pkcs8 = ed25519Pkcs8FromSeed(seed);
const subtle = getSubtleApi();
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
@@ -71,7 +71,7 @@ async function importAesKeyFromSharedSecret(sharedSecretBytes) {
function normalizeKeys(keys = {}) {
return {
deviceKey: String(keys?.deviceKey || '').trim(),
clientKey: String(keys?.clientKey || '').trim(),
blockchainKey: String(keys?.blockchainKey || '').trim(),
rootKey: String(keys?.rootKey || '').trim(),
};
@@ -30,7 +30,7 @@ export function keyLabel(id) {
export function describeTransferKeys(keys = {}) {
const out = [];
if (keys.deviceKey) out.push('device');
if (keys.clientKey) out.push('device');
if (keys.blockchainKey) out.push('blockchain');
if (keys.rootKey) out.push('root');
return out;
@@ -42,7 +42,7 @@ export function makeKeyTransferText({ login, keys }) {
type: 'shine-key-transfer',
login: String(login || '').trim(),
keys: {
deviceKey: String(keys?.deviceKey || ''),
clientKey: String(keys?.clientKey || ''),
blockchainKey: String(keys?.blockchainKey || ''),
rootKey: String(keys?.rootKey || ''),
},
@@ -65,13 +65,13 @@ export function parseKeyTransferText(text) {
const login = String(payload.login || '').trim();
if (!login) throw new Error('В QR-коде нет логина');
const keys = payload.keys && typeof payload.keys === 'object' ? payload.keys : {};
if (!keys.deviceKey && !keys.blockchainKey && !keys.rootKey) {
if (!keys.clientKey && !keys.blockchainKey && !keys.rootKey) {
throw new Error('В QR-коде нет ключей');
}
return {
login,
keys: {
deviceKey: String(keys.deviceKey || ''),
clientKey: String(keys.clientKey || ''),
blockchainKey: String(keys.blockchainKey || ''),
rootKey: String(keys.rootKey || ''),
},
+14 -14
View File
@@ -323,15 +323,15 @@ function toJwkB64(value) {
return base64UrlEncode(bigIntToUnsignedBytes(value));
}
async function deriveArweaveWalletParts(deviceKey32) {
if (!(deviceKey32 instanceof Uint8Array)) {
throw new Error('SAWD-v1: deviceKey32 должен быть Uint8Array');
async function deriveArweaveWalletParts(clientKey32) {
if (!(clientKey32 instanceof Uint8Array)) {
throw new Error('SAWD-v1: clientKey32 должен быть Uint8Array');
}
if (deviceKey32.length !== 32) {
throw new Error('SAWD-v1: deviceKey32 должен быть ровно 32 байта');
if (clientKey32.length !== 32) {
throw new Error('SAWD-v1: clientKey32 должен быть ровно 32 байта');
}
const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, deviceKey32);
const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, clientKey32);
const masterSeedKey = await importHmacKey(masterSeed32);
const pResult = await derivePrimeWithImportedKey(masterSeedKey, 'p');
@@ -381,8 +381,8 @@ async function deriveArweaveWalletParts(deviceKey32) {
};
}
export async function deriveArweaveWalletFromDeviceKey32(deviceKey32) {
const result = await deriveArweaveWalletParts(deviceKey32);
export async function deriveArweaveWalletFromClientKey32(clientKey32) {
const result = await deriveArweaveWalletParts(clientKey32);
return {
derivation: result.derivation,
jwk: result.jwk,
@@ -395,7 +395,7 @@ export async function selfTestSawdV1() {
const invalid = new Uint8Array(31);
let invalidFailed = false;
try {
await deriveArweaveWalletFromDeviceKey32(invalid);
await deriveArweaveWalletFromClientKey32(invalid);
} catch {
invalidFailed = true;
}
@@ -403,13 +403,13 @@ export async function selfTestSawdV1() {
throw new Error('SAWD-v1 self-test: длина != 32 должна приводить к ошибке');
}
const deviceKey = new Uint8Array(32);
for (let i = 0; i < deviceKey.length; i += 1) {
deviceKey[i] = i + 1;
const clientKey = new Uint8Array(32);
for (let i = 0; i < clientKey.length; i += 1) {
clientKey[i] = i + 1;
}
const first = await deriveArweaveWalletParts(deviceKey);
const second = await deriveArweaveWalletParts(deviceKey);
const first = await deriveArweaveWalletParts(clientKey);
const second = await deriveArweaveWalletParts(clientKey);
if (!first.address || !second.address) {
throw new Error('SAWD-v1 self-test: адрес пустой');
+54 -32
View File
@@ -1,5 +1,5 @@
import { base64ToBytes, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
import { extractSeed32FromPkcs8B64 } from './client-key-utils.js';
import {
SHINE_LOGIN_GUARD_PROGRAM_ID,
SHINE_PAYMENTS_PROGRAM_ID,
@@ -16,8 +16,9 @@ const BLOCKCHAIN_TYPE_MAIN_USER = 1;
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
const BLOCK_TYPE_RECOVERY_KEY = 0;
const BLOCK_TYPE_ROOT_KEY = 1;
const BLOCK_TYPE_DEVICE_KEY = 2;
const BLOCK_TYPE_CLIENT_KEY = 2;
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
const BLOCK_TYPE_SERVER_PROFILE = 30;
const BLOCK_TYPE_ACCESS_SERVERS = 40;
@@ -176,10 +177,11 @@ function serializeCreateUserPdaArgs(args) {
const buf = [];
buf.push(3);
pushStrU8(buf, args.login);
for (const x of args.recoveryKey32) buf.push(x);
for (const x of args.rootKey32) buf.push(x);
pushU64LE(buf, args.createdAtMs);
pushU64LE(buf, BigInt(args.additionalLimitBytes || 0n));
for (const x of args.deviceKey32) buf.push(x);
for (const x of args.clientKey32) buf.push(x);
for (const x of args.blockchainPublicKey32) buf.push(x);
pushStrU8(buf, args.blockchainName);
pushU64LE(buf, args.usedBytes);
@@ -218,13 +220,14 @@ function serializeUpdateUserPdaArgs(args) {
const buf = [];
buf.push(4);
pushStrU8(buf, args.login);
for (const x of args.recoveryKey32) buf.push(x);
for (const x of args.rootKey32) buf.push(x);
pushU64LE(buf, args.createdAtMs);
pushU64LE(buf, args.updatedAtMs);
pushU32LE(buf, args.version);
for (const x of args.prevHash32) buf.push(x);
pushU64LE(buf, args.additionalLimitBytes);
for (const x of args.deviceKey32) buf.push(x);
for (const x of args.clientKey32) buf.push(x);
for (const x of args.blockchainPublicKey32) buf.push(x);
pushStrU8(buf, args.blockchainName);
pushU64LE(buf, args.usedBytes);
@@ -288,8 +291,9 @@ function createPdaState({
updatedAtMs,
recordNumber,
prevRecordHash,
recoveryKey,
rootKey,
deviceKey,
clientKey,
blockchain,
isServer,
addressFormatType,
@@ -313,8 +317,10 @@ function createPdaState({
recordNumber,
prevRecordHash,
login,
recoveryKey,
rootKey,
deviceKey,
clientKey,
clientKey: clientKey,
blockchain,
isServer: Boolean(isServer),
serverProfile,
@@ -373,8 +379,9 @@ export function parseShineUserPda(dataBytes) {
const login = reader.readStrU8();
const blocksCount = reader.readU8();
let recoveryKey = null;
let rootKey = null;
let deviceKey = null;
let clientKey = null;
let blockchain = null;
let isServer = false;
let addressFormatType = 0;
@@ -390,12 +397,16 @@ export function parseShineUserPda(dataBytes) {
const blockType = reader.readU8();
reader.readU8();
if (blockType === BLOCK_TYPE_RECOVERY_KEY) {
recoveryKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_ROOT_KEY) {
rootKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_DEVICE_KEY) {
deviceKey = reader.readBytes(32);
if (blockType === BLOCK_TYPE_CLIENT_KEY) {
clientKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
@@ -466,7 +477,7 @@ export function parseShineUserPda(dataBytes) {
throw new Error(`Неизвестный блок PDA: ${blockType}`);
}
if (!rootKey || !deviceKey || !blockchain) {
if (!recoveryKey || !rootKey || !clientKey || !blockchain) {
throw new Error('В PDA отсутствуют обязательные блоки');
}
@@ -478,8 +489,9 @@ export function parseShineUserPda(dataBytes) {
updatedAtMs,
recordNumber,
prevRecordHash,
recoveryKey,
rootKey,
deviceKey,
clientKey,
blockchain,
isServer,
addressFormatType,
@@ -507,8 +519,9 @@ export function serializeUnsignedRecordFromState(stateLike) {
updatedAtMs: stateLike.updatedAtMs,
recordNumber: stateLike.recordNumber,
prevRecordHash: stateLike.prevRecordHash,
recoveryKey: stateLike.recoveryKey,
rootKey: stateLike.rootKey,
deviceKey: stateLike.deviceKey,
clientKey: stateLike.clientKey ?? stateLike.clientKey,
blockchain: stateLike.blockchain,
isServer: stateLike.isServer,
addressFormatType: stateLike.addressFormatType ?? stateLike.serverProfile?.addressFormatType,
@@ -527,13 +540,16 @@ export function serializeUnsignedRecordFromState(stateLike) {
pushU32LE(buf, state.recordNumber);
for (const x of state.prevRecordHash) buf.push(x);
pushStrU8(buf, state.login);
buf.push(state.isServer ? 7 : 6);
buf.push(state.isServer ? 8 : 7);
buf.push(BLOCK_TYPE_RECOVERY_KEY, 0);
for (const x of state.recoveryKey) buf.push(x);
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
for (const x of state.rootKey) buf.push(x);
buf.push(BLOCK_TYPE_DEVICE_KEY, 0);
for (const x of state.deviceKey) buf.push(x);
buf.push(BLOCK_TYPE_CLIENT_KEY, 0);
for (const x of state.clientKey) buf.push(x);
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1, state.blockchain.blockchainType);
pushStrU8(buf, state.blockchain.blockchainName);
@@ -656,14 +672,15 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const [economyConfigPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
const recoveryKey32 = base64ToBytes(keyBundle.recoveryPair.publicKeyB64);
const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64);
const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64);
const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
const clientSeed32 = extractSeed32FromPkcs8B64(keyBundle.clientPair.privatePkcs8B64);
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
return {
cleanLogin,
@@ -678,12 +695,13 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
userPda,
economyConfigPda,
inflowVault,
recoveryKey32,
rootKey32,
blockchainKey32,
deviceKey32,
clientKey32,
rootPrivKey,
bchPrivKey,
deviceKeypair,
clientKeypair,
};
}
@@ -720,8 +738,9 @@ async function createShineUserPdaOnSolana({
updatedAtMs: createdAtMs,
recordNumber: 0,
prevRecordHash: zeroHash32,
recoveryKey: ctx.recoveryKey32,
rootKey: ctx.rootKey32,
deviceKey: ctx.deviceKey32,
clientKey: ctx.clientKey32,
blockchain: createBlockchainState({
blockchainName,
blockchainPublicKey: ctx.blockchainKey32,
@@ -749,10 +768,11 @@ async function createShineUserPdaOnSolana({
const ixData = serializeCreateUserPdaArgs({
login: cleanLogin,
recoveryKey32: ctx.recoveryKey32,
rootKey32: ctx.rootKey32,
createdAtMs,
additionalLimitBytes: 0n,
deviceKey32: ctx.deviceKey32,
clientKey32: ctx.clientKey32,
blockchainPublicKey32: ctx.blockchainKey32,
blockchainName,
usedBytes: 0n,
@@ -785,7 +805,7 @@ async function createShineUserPdaOnSolana({
const createIx = new ctx.solana.TransactionInstruction({
programId: ctx.usersProgram,
keys: [
{ pubkey: ctx.deviceKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: ctx.userPda, isSigner: false, isWritable: true },
{ pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
@@ -799,7 +819,7 @@ async function createShineUserPdaOnSolana({
const signature = await ctx.solana.sendAndConfirmTransaction(
ctx.connection,
new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx),
[ctx.deviceKeypair],
[ctx.clientKeypair],
{ commitment: 'confirmed' },
);
@@ -848,7 +868,7 @@ export async function updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64,
devicePrivatePkcs8B64,
clientPrivatePkcs8B64,
blockchainPrivatePkcs8B64,
additionalLimitBytes = 0n,
nextUsedBytes,
@@ -882,8 +902,8 @@ export async function updateShineUserPdaOnSolana({
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram);
const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
const clientSeed32 = extractSeed32FromPkcs8B64(clientPrivatePkcs8B64);
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
const lastBlockStateBytes = buildLastBlockStateBytes(
cleanLogin,
@@ -928,8 +948,9 @@ export async function updateShineUserPdaOnSolana({
updatedAtMs,
recordNumber: newRecordNumber,
prevRecordHash: prevHash,
recoveryKey: current.recoveryKey,
rootKey: current.rootKey,
deviceKey: current.deviceKey,
clientKey: current.clientKey ?? current.clientKey,
blockchain: createBlockchainState({
blockchainName: currentBch.blockchainName,
blockchainPublicKey: currentBch.blockchainPublicKey,
@@ -957,13 +978,14 @@ export async function updateShineUserPdaOnSolana({
const ixData = serializeUpdateUserPdaArgs({
login: cleanLogin,
recoveryKey32: current.recoveryKey,
rootKey32: current.rootKey,
createdAtMs: current.createdAtMs,
updatedAtMs,
version: newRecordNumber,
prevHash32: prevHash,
additionalLimitBytes: addLimit,
deviceKey32: current.deviceKey,
clientKey32: current.clientKey,
blockchainPublicKey32: currentBch.blockchainPublicKey,
blockchainName: currentBch.blockchainName,
usedBytes: effectiveUsed,
@@ -996,7 +1018,7 @@ export async function updateShineUserPdaOnSolana({
const updateIx = new solana.TransactionInstruction({
programId: usersProgram,
keys: [
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: clientKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: userPda, isSigner: false, isWritable: true },
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: inflowVault, isSigner: false, isWritable: true },
@@ -1011,7 +1033,7 @@ export async function updateShineUserPdaOnSolana({
const signature = await solana.sendAndConfirmTransaction(
connection,
new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updateIx),
[deviceKeypair],
[clientKeypair],
{ commitment: 'confirmed' },
);
@@ -1040,7 +1062,7 @@ export async function updateServerOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
devicePrivatePkcs8B64: keyBundle.devicePair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
serverProfile: {
addressFormatType,
addressFormatVersion,
@@ -1,4 +1,4 @@
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
@@ -63,7 +63,7 @@ function decodeBase58(input) {
async function keypairFromPkcs8(pkcs8B64) {
const solana = await loadSolanaLib();
const seed32 = extractDeviceKey32FromStoredValue(pkcs8B64);
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
return solana.Keypair.fromSeed(seed32);
}
@@ -95,22 +95,22 @@ export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) {
};
}
export async function getWalletFromStoredDeviceKey({ login, storagePwd }) {
export async function getWalletFromStoredClientKey({ login, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к wallet.key');
throw new Error('Нет активной сессии для доступа к client.key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
const devicePrivate = String(secrets?.deviceKey || '').trim();
if (!devicePrivate) {
throw new Error('На устройстве не найден device.key (wallet.key)');
const clientPrivate = String(secrets?.clientKey || '').trim();
if (!clientPrivate) {
throw new Error('На устройстве не найден client.key');
}
const keypair = await keypairFromPkcs8(devicePrivate);
const keypair = await keypairFromPkcs8(clientPrivate);
return {
address: keypair.publicKey.toBase58(),
keypair,
devicePrivatePkcs8B64: devicePrivate,
clientPrivatePkcs8B64: clientPrivate,
};
}
+1 -1
View File
@@ -289,7 +289,7 @@ function createInitialState({ withStoredSession = true } = {}) {
keyStorage: {
rootKey: 'Ключ root хранится в зашифрованном виде',
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
deviceKey: 'Ключ device хранится в зашифрованном виде',
clientKey: 'Ключ device хранится в зашифрованном виде',
saveRoot: false,
saveBlockchain: true,
saveDevice: true,
+2 -2
View File
@@ -47,10 +47,10 @@
<div class="card">
<h2>Что потребуется</h2>
<p style="color:var(--text-muted);font-size:13px;line-height:1.7;">
<strong>Для создания:</strong> полный keyBundle сервера (rootPair + devicePair + blockchainPair),
<strong>Для создания:</strong> полный keyBundle сервера (rootPair + clientPair + blockchainPair),
логин сервера (без точки, не более 20 символов), URL-адрес сервера, Solana-эндпоинт,
достаточный баланс SOL на device-ключе для комиссии.<br/><br/>
<strong>Для обновления:</strong> только rootPair + devicePair (blockchain-ключ не нужен).
<strong>Для обновления:</strong> только rootPair + clientPair (blockchain-ключ не нужен).
</p>
</div>
</div>
+34 -7
View File
@@ -111,6 +111,18 @@ async function pairFromSeedBase58(seedB58, explicitPubB58) {
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();
@@ -122,13 +134,18 @@ export async function buildKeyBundleFromForm(fieldMap, options = {}) {
}
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,
devicePair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 },
clientPair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 },
},
normalized: {
recoveryPubB58: recovery?.publicKeyB58 || '',
recoveryPrivB58: recovery?.privateSeedB58 || '',
rootPubB58: root.publicKeyB58,
rootPrivB58: root.privateSeedB58,
bchPubB58: blockchain?.publicKeyB58 || '',
@@ -148,14 +165,20 @@ export async function deriveKeyBundleFromPassword({ login, password, onProgress
login: cleanLogin,
onProgress,
});
const [rootPair, blockchainPair, devicePair] = await Promise.all([
const [recoveryPair, rootPair, blockchainPair, clientPair] = await Promise.all([
deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'root.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'bch.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'dev.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'blockchain.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'client.key'),
]);
return {
masterSecret32,
keyBundle: { rootPair, blockchainPair, devicePair },
keyBundle: {
recoveryPair,
rootPair,
blockchainPair,
clientPair,
},
};
}
@@ -163,12 +186,16 @@ 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.devicePair.publicKeyB64));
$(fieldMap.devPriv).value = bytesToBase58(base64ToBytes(keyBundle.devicePair.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) {
@@ -50,7 +50,7 @@ function renderExpectedKeys(parsed) {
$('expectedKeysBox').style.display = 'block';
setText('expectedRootPub', publicKeyBytesToBase58(parsed.rootKey));
setText('expectedBchPub', publicKeyBytesToBase58(parsed.blockchain.blockchainPublicKey));
setText('expectedDevPub', publicKeyBytesToBase58(parsed.deviceKey));
setText('expectedDevPub', publicKeyBytesToBase58(parsed.clientKey));
setGenMessage($('expectedKeysStatus'), 'После генерации ключей этот блок покажет, совпадают ли они с уже записанной PDA.', 'warn');
}
@@ -63,7 +63,7 @@ function compareCurrentFormKeysWithPda() {
blockchain: blockchainActual
? compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), blockchainActual)
: { matches: true, expected: publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), actual: '' },
device: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.deviceKey), $('devPub').value),
device: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.clientKey), $('devPub').value),
},
};
}