Wallet-session pairing и browser plugin wallet, оплаты пока не работают

This commit is contained in:
AidarKC 2026-06-16 16:23:08 +04:00
parent 5c155ef503
commit 3efa8bb7ee
41 changed files with 3260 additions and 37 deletions

View File

@ -183,7 +183,7 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
"requestId": "esp-set-001",
"payload": {
"enabled": true,
"passwordHash": "argon2id$...",
"passwordHash": "sha256$0123abcd...",
"ttlSeconds": 180
}
}
@ -191,6 +191,12 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
Если pairing должен работать **без доп. пароля**, клиент может включить его с пустым `passwordHash`.
Формат непустого `passwordHash`:
```text
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```
### Успешный ответ
```json
@ -222,7 +228,7 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
"requestId": "esp-start-001",
"payload": {
"login": "alice",
"passwordHash": "argon2id$...",
"passwordHash": "sha256$0123abcd...",
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
"requesterSessionType": 1,
"requesterClientPlatform": "Android",

View File

@ -0,0 +1,22 @@
# wallet-session pairing и SHA-256 пароль pairing
- краткое описание:
- добавлен сценарий `session-only` подключения wallet-plugin через доверенное устройство без передачи постоянных ключей;
- для pairing-пароля убран `argon2id`, вместо него используется только формат `sha256$<hex>`;
- новый plugin `SHiNE-browser-plugin-wallet` получает и хранит только `wallet-session`.
- что проверять:
- в `shine-UI` экран `Войти через другое устройство` создаёт заявку и получает `session-only` approve;
- на доверенном устройстве в `Подключить по коду` кнопка `Подключить wallet-session` действительно не передаёт `device/root/blockchain` ключи;
- новый plugin загружается как Chrome MV3 extension и получает wallet-session;
- pairing c доп. паролем работает только с форматом `sha256$<hex>`;
- pairing без доп. пароля продолжает работать.
- ожидаемый результат:
- requester получает только `sessionId/sessionKey/sessionPriv/storagePwd`;
- доверенное устройство не пересылает постоянные ключи в `session-only` режиме;
- сервер принимает только новый формат pairing-пароля;
- логин по сохранённой wallet-session восстанавливается успешно.
- статус:
- pending

View File

@ -58,7 +58,7 @@
## 3. Что именно делает сервер
- хранит включённость pairing и optional opaque `passwordHash`;
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
- рассчитывает короткий код `shortCode` из `7` цифр;
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
@ -104,3 +104,9 @@
- пароль на сервере, если он включён, только отсеивает лишних;
- онлайн доверенная сессия решает, добавлять ли новую сессию;
- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов.
Текущий формат pairing-пароля:
```text
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```

View File

@ -1 +0,0 @@
rootProject.name = 'ESP-wallet'

View File

@ -1,5 +1,6 @@
.gradle
build/
node_modules/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
@ -40,4 +41,4 @@ bin/
.vscode/
### Mac OS ###
.DS_Store
.DS_Store

10
SHiNE-browser-plugin-wallet/.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

1
SHiNE-browser-plugin-wallet/.idea/.name generated Normal file
View File

@ -0,0 +1 @@
ESP-wallet

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
<option name="myGradleHome" value="" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -0,0 +1,38 @@
# SHiNE Browser Plugin Wallet
Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
## Что уже умеет
- создать `wallet-session` через `StartEspPairing`;
- показать код подключения;
- дождаться подтверждения на доверенном устройстве;
- принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`;
- сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin;
- восстанавливать session через `SessionChallenge -> SessionLogin`;
- держать wallet-state в `background service worker`, а popup использовать как UI.
## Как загрузить локально
1. Открой `chrome://extensions/`
2. Включи `Developer mode`
3. Нажми `Load unpacked`
4. Выбери папку `SHiNE-browser-plugin-wallet/`
## Ограничения текущего этапа
- plugin пока не держит постоянный фоновый WS-канал после закрытия popup, но хранит wallet-state в `background`;
- на этом этапе реализован только `session-only login`;
- запросы на подпись будут следующим этапом.
- pairing-пароль, если он используется, должен генерироваться в формате `sha256$<hex>` от строки `shine-pairing|loginLower|password`.
## Сборка crypto bundle
Для обычной загрузки plugin это не нужно: bundled crypto-файл уже лежит в репозитории.
Если понадобится пересобрать локальный crypto bundle:
```bash
npm install
npx esbuild js/lib/vendor/noble-ed25519-entry.js --bundle --format=esm --platform=browser --outfile=js/lib/vendor/noble-ed25519-bundle.js
```

View File

@ -0,0 +1,294 @@
import { createRequesterPairingMaterial, decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash } from './js/lib/device-pairing.js';
import { loadPluginSettings, loadSessionMaterial, savePluginSettings, saveSessionMaterial, clearSessionMaterial } from './js/lib/session-store.js';
import { ShineApiClient } from './js/lib/shine-api.js';
const state = {
api: null,
settings: {
serverUrl: 'wss://shineup.me/ws',
login: '',
},
requesterMaterial: null,
pairingId: '',
expiresAtMs: 0,
shortCode: '',
trustedSessionOnline: false,
pollTimer: 0,
activeSession: null,
connectionOnline: false,
statusText: '',
statusKind: 'info',
};
function setStatus(message = '', kind = 'info') {
state.statusText = String(message || '');
state.statusKind = kind === 'error' ? 'error' : 'info';
}
function stopPoll() {
if (state.pollTimer) {
clearTimeout(state.pollTimer);
state.pollTimer = 0;
}
}
function clearPairingState() {
stopPoll();
state.requesterMaterial = null;
state.pairingId = '';
state.expiresAtMs = 0;
state.shortCode = '';
state.trustedSessionOnline = false;
}
function ensureApi(serverUrl = state.settings.serverUrl) {
const normalized = String(serverUrl || '').trim() || 'wss://shineup.me/ws';
if (!state.api || state.api.serverUrl !== normalized) {
state.api?.close();
state.api = new ShineApiClient(normalized);
}
return state.api;
}
async function loadStateFromStorage() {
const settings = await loadPluginSettings();
state.settings = {
serverUrl: String(settings?.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
login: String(settings?.login || '').trim(),
};
state.activeSession = await loadSessionMaterial();
}
async function persistSettings(nextSettings = {}) {
state.settings = {
...state.settings,
...nextSettings,
};
await savePluginSettings(state.settings);
return state.settings;
}
async function resumeActiveSession() {
const sessionRecord = await loadSessionMaterial();
state.activeSession = sessionRecord;
if (!sessionRecord) {
state.connectionOnline = false;
setStatus('Wallet-session ещё не подключена.', 'info');
return { ok: true, connected: false };
}
try {
await persistSettings({
serverUrl: String(sessionRecord?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim(),
login: String(sessionRecord?.login || state.settings.login || '').trim(),
});
const resumed = await ensureApi().resumeSession(sessionRecord);
state.connectionOnline = true;
setStatus(`Wallet-session активна для @${resumed.login}.`, 'info');
return { ok: true, connected: true, login: resumed.login, sessionId: resumed.sessionId };
} catch (error) {
state.connectionOnline = false;
setStatus(error.message || 'Не удалось восстановить wallet-session.', 'error');
return { ok: false, connected: false, error: state.statusText };
}
}
async function attachApprovedSession(payload) {
if (String(payload?.type || '') !== 'shine-esp-session-attach') {
throw new Error('Доверенное устройство вернуло неподдерживаемый payload. Для plugin нужен session-only approve.');
}
const login = String(payload?.login || state.settings.login || '').trim();
const approvedSession = payload?.session || {};
const sessionRecord = {
login,
sessionId: String(approvedSession?.sessionId || '').trim(),
sessionKey: state.requesterMaterial?.sessionKey || '',
sessionPrivPkcs8: state.requesterMaterial?.sessionPrivPkcs8 || '',
sessionType: Number(approvedSession?.sessionType || 50) || 50,
serverUrl: state.settings.serverUrl,
};
if (!sessionRecord.login || !sessionRecord.sessionId || !sessionRecord.sessionKey || !sessionRecord.sessionPrivPkcs8) {
throw new Error('Получен неполный session-only payload');
}
await clearSessionMaterial();
await saveSessionMaterial(sessionRecord);
state.activeSession = sessionRecord;
await persistSettings({
login: sessionRecord.login,
serverUrl: sessionRecord.serverUrl,
});
await resumeActiveSession();
}
async function pollPairingStatus() {
if (!state.pairingId || !state.requesterMaterial) return;
try {
const payload = await ensureApi().getEspPairingStatus(state.pairingId);
const stateValue = String(payload?.state || '');
if (stateValue === 'created') {
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 2200);
return;
}
if (stateValue === 'approved') {
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, state.requesterMaterial);
await attachApprovedSession(decoded);
clearPairingState();
setStatus('Wallet-session создана и сохранена.', 'info');
return;
}
if (stateValue === 'rejected') {
clearPairingState();
setStatus('Заявка отклонена на доверенном устройстве.', 'error');
return;
}
if (stateValue === 'expired' || stateValue === 'canceled') {
clearPairingState();
setStatus('Ожидание подключения завершено.', 'error');
return;
}
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 2200);
} catch (error) {
clearPairingState();
setStatus(error.message || 'Не удалось проверить pairing-статус.', 'error');
}
}
async function startPairing({ login, usePassword, password, serverUrl }) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) {
throw new Error('Введите логин.');
}
await persistSettings({
serverUrl: String(serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
login: cleanLogin,
});
clearPairingState();
setStatus('Проверяем пользователя и создаём wallet-session заявку...', 'info');
const api = ensureApi();
const user = await api.getUser(cleanLogin);
if (user?.exists !== true) {
throw new Error('Пользователь не найден.');
}
state.requesterMaterial = await createRequesterPairingMaterial();
const passwordHash = usePassword
? await deriveEspPairingPasswordHash(cleanLogin, String(password || ''))
: '';
const payload = await api.startEspPairing({
login: cleanLogin,
passwordHash,
requesterSessionKey: state.requesterMaterial.sessionKey,
payloadType: 1,
});
state.pairingId = String(payload?.pairingId || '').trim();
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
state.shortCode = String(payload?.shortCode || '0000000');
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
if (!state.pairingId) {
throw new Error('Сервер не вернул pairingId.');
}
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 1800);
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
return {
pairingId: state.pairingId,
shortCode: String(payload?.shortCode || '0000000'),
expiresAtMs: state.expiresAtMs,
trustedSessionOnline: !!payload?.trustedSessionOnline,
};
}
async function cancelPairing() {
if (!state.pairingId || !state.requesterMaterial?.sessionKey) {
clearPairingState();
return { ok: true };
}
await ensureApi().cancelEspPairing(state.pairingId, state.requesterMaterial.sessionKey);
clearPairingState();
setStatus('Ожидание подключения отменено.', 'info');
return { ok: true };
}
async function disconnectSession() {
await clearSessionMaterial();
state.activeSession = null;
state.connectionOnline = false;
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
return { ok: true };
}
function snapshot() {
return {
settings: { ...state.settings },
pairing: {
active: !!state.pairingId,
pairingId: state.pairingId,
expiresAtMs: state.expiresAtMs,
shortCode: state.shortCode,
trustedSessionOnline: state.trustedSessionOnline,
},
session: state.activeSession ? { ...state.activeSession } : null,
connectionOnline: state.connectionOnline,
status: {
text: state.statusText,
kind: state.statusKind,
},
};
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
(async () => {
const type = String(message?.type || '');
if (type === 'wallet:getState') {
await loadStateFromStorage();
sendResponse({ ok: true, state: snapshot() });
return;
}
if (type === 'wallet:saveSettings') {
await persistSettings(message?.payload || {});
sendResponse({ ok: true, state: snapshot() });
return;
}
if (type === 'wallet:startPairing') {
const result = await startPairing(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:cancelPairing') {
const result = await cancelPairing();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:resumeSession') {
const result = await resumeActiveSession();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:disconnectSession') {
const result = await disconnectSession();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
})().catch((error) => {
setStatus(error?.message || 'Unknown error', 'error');
sendResponse({ ok: false, error: error?.message || 'Unknown error', state: snapshot() });
});
return true;
});
void loadStateFromStorage().then(() => resumeActiveSession()).catch((error) => {
setStatus(error?.message || 'Не удалось инициализировать wallet plugin.', 'error');
});

View File

@ -0,0 +1,78 @@
function getCryptoApi() {
const api = globalThis.crypto;
if (!api?.subtle || typeof api.getRandomValues !== 'function') {
throw new Error('WebCrypto недоступен в текущем браузере.');
}
return api;
}
function getSubtleApi() {
return getCryptoApi().subtle;
}
function base64UrlToBase64(value) {
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
return normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
}
export function utf8Bytes(value) {
return new TextEncoder().encode(String(value ?? ''));
}
export function bytesToBase64(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode(...slice);
}
return btoa(binary);
}
export function base64ToBytes(value) {
const binary = atob(base64UrlToBase64(value));
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
export async function generateEd25519Pair() {
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
}
export async function exportEd25519PublicKeyB64(publicKey) {
const raw = await getSubtleApi().exportKey('raw', publicKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function exportPkcs8B64(privateKey) {
const raw = await getSubtleApi().exportKey('pkcs8', privateKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function importPkcs8Ed25519(pkcs8B64) {
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
}
export async function signBase64(privateKey, text) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
return bytesToBase64(new Uint8Array(signature));
}
export async function sha256Bytes(bytes) {
const digest = await getSubtleApi().digest('SHA-256', bytes);
return new Uint8Array(digest);
}
export async function sha256Text(text) {
return sha256Bytes(utf8Bytes(text));
}
export function randomBase64(size) {
const bytes = getCryptoApi().getRandomValues(new Uint8Array(size));
return bytesToBase64(bytes);
}
export function bytesToHex(bytes) {
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}

View File

@ -0,0 +1,90 @@
import {
base64ToBytes,
bytesToBase64,
bytesToHex,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
sha256Bytes,
sha256Text,
utf8Bytes,
} from './crypto-utils.js';
import { edwardsToMontgomeryPriv, x25519 } from './vendor/noble-ed25519-bundle.js';
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
const PAIRING_HASH_PREFIX = 'sha256$';
const PAIRING_HASH_VERSION = 'shine-pairing';
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
function getCryptoApi() {
const api = globalThis.crypto;
if (!api?.subtle || typeof api.getRandomValues !== 'function') {
throw new Error('WebCrypto недоступен.');
}
return api;
}
async function importAesKeyFromSharedSecret(sharedSecretBytes) {
const digest = await sha256Bytes(sharedSecretBytes);
return getCryptoApi().subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['decrypt']);
}
function base64UrlToBytes(value) {
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
return base64ToBytes(padded);
}
function extractSeedFromPkcs8(pkcs8B64) {
const raw = base64ToBytes(pkcs8B64);
if (raw.length !== ED25519_PKCS8_PREFIX.length + 32) {
throw new Error('Некорректный приватный Ed25519 ключ');
}
for (let i = 0; i < ED25519_PKCS8_PREFIX.length; i += 1) {
if (raw[i] !== ED25519_PKCS8_PREFIX[i]) {
throw new Error('Неподдерживаемый формат приватного Ed25519 ключа');
}
}
return raw.slice(ED25519_PKCS8_PREFIX.length);
}
export async function createRequesterPairingMaterial() {
const sessionPair = await generateEd25519Pair();
const sessionPublicB64 = await exportEd25519PublicKeyB64(sessionPair.publicKey);
return {
sessionKey: `ed25519/${sessionPublicB64}`,
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
};
}
export async function deriveEspPairingPasswordHash(login, password) {
const loginLower = String(login || '').trim().toLowerCase();
const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`;
const digest = await sha256Text(preimage);
return `${PAIRING_HASH_PREFIX}${bytesToHex(digest)}`;
}
export async function decryptPairingPayloadFromEnvelope(encryptedPayload, requesterPairingMaterial) {
const raw = String(encryptedPayload || '').trim();
if (!raw.startsWith(PAIRING_ENVELOPE_PREFIX)) {
throw new Error('Неподдерживаемый формат pairing payload');
}
const jsonBytes = base64UrlToBytes(raw.slice(PAIRING_ENVELOPE_PREFIX.length));
const envelope = JSON.parse(new TextDecoder().decode(jsonBytes));
if (Number(envelope?.v) !== 1 || String(envelope?.alg || '') !== 'x25519-aes256-gcm') {
throw new Error('Неподдерживаемая версия pairing payload');
}
const requesterSeed = extractSeedFromPkcs8(String(requesterPairingMaterial?.sessionPrivPkcs8 || ''));
const requesterMontPriv = edwardsToMontgomeryPriv(requesterSeed);
const sharedSecret = x25519.getSharedSecret(requesterMontPriv, base64ToBytes(String(envelope?.ephPubB64 || '')));
const aesKey = await importAesKeyFromSharedSecret(sharedSecret);
const plain = await getCryptoApi().subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(String(envelope?.ivB64 || '')) },
aesKey,
base64ToBytes(String(envelope?.cipherB64 || '')),
);
return JSON.parse(new TextDecoder().decode(plain));
}

View File

@ -0,0 +1,152 @@
import { base64ToBytes, bytesToBase64 } from './crypto-utils.js';
const DB_NAME = 'shine-wallet-plugin';
const DB_VERSION = 1;
const STORE_META = 'meta';
const STORE_VAULT = 'vault';
const SESSION_ENTRY_ID = 'active-session';
const VAULT_KEY_ID = 'session-wrap-key';
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_META)) {
db.createObjectStore(STORE_META, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_VAULT)) {
db.createObjectStore(STORE_VAULT, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('IndexedDB недоступен'));
});
}
async function withStore(storeName, mode, run) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
let settled = false;
const done = (fn) => (value) => {
if (settled) return;
settled = true;
fn(value);
};
tx.oncomplete = () => done(resolve)(undefined);
tx.onerror = () => done(reject)(tx.error || new Error('IndexedDB transaction failed'));
Promise.resolve(run(store, tx, done)).catch((error) => done(reject)(error));
});
} finally {
db.close();
}
}
async function put(storeName, value) {
return withStore(storeName, 'readwrite', (store) => {
store.put(value);
});
}
async function get(storeName, key) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const req = tx.objectStore(storeName).get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error || new Error('Ошибка чтения из IndexedDB'));
});
} finally {
db.close();
}
}
async function deleteById(storeName, key) {
return withStore(storeName, 'readwrite', (store) => {
store.delete(key);
});
}
async function getOrCreateVaultKey() {
const current = await get(STORE_META, VAULT_KEY_ID);
if (current?.key) return current.key;
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
await put(STORE_META, { id: VAULT_KEY_ID, key, createdAtMs: Date.now() });
return key;
}
async function encryptJson(value) {
const key = await getOrCreateVaultKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const plainBytes = new TextEncoder().encode(JSON.stringify(value));
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
return {
ivB64: bytesToBase64(iv),
cipherB64: bytesToBase64(new Uint8Array(cipher)),
};
}
async function decryptJson(envelope) {
const key = await getOrCreateVaultKey();
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(envelope.ivB64) },
key,
base64ToBytes(envelope.cipherB64),
);
return JSON.parse(new TextDecoder().decode(plain));
}
function storageApi() {
if (globalThis.chrome?.storage?.local) return globalThis.chrome.storage.local;
return null;
}
export async function savePluginSettings(settings) {
const api = storageApi();
if (api) {
await api.set({ shineWalletSettings: settings });
return;
}
localStorage.setItem('shineWalletSettings', JSON.stringify(settings));
}
export async function loadPluginSettings() {
const api = storageApi();
if (api) {
const row = await api.get('shineWalletSettings');
return row?.shineWalletSettings || {};
}
try {
return JSON.parse(localStorage.getItem('shineWalletSettings') || '{}');
} catch {
return {};
}
}
export async function saveSessionMaterial(sessionRecord) {
const encrypted = await encryptJson(sessionRecord);
await put(STORE_VAULT, {
id: SESSION_ENTRY_ID,
encrypted,
updatedAtMs: Date.now(),
});
}
export async function loadSessionMaterial() {
const row = await get(STORE_VAULT, SESSION_ENTRY_ID);
if (!row?.encrypted) return null;
return decryptJson(row.encrypted);
}
export async function clearSessionMaterial() {
await deleteById(STORE_VAULT, SESSION_ENTRY_ID);
}

View File

@ -0,0 +1,111 @@
import { importPkcs8Ed25519, signBase64 } from './crypto-utils.js';
import { WsJsonClient } from './ws-client.js';
const SESSION_TYPE_WALLET = 50;
function normalizeServerUrl(url) {
const value = String(url || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('http://') || value.startsWith('https://')) {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
}
return value;
}
function opError(op, response) {
const payload = response?.payload || {};
const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
const error = new Error(`${op}: ${message} (${code})`);
error.op = op;
error.code = code;
error.status = response?.status || 0;
return error;
}
export class ShineApiClient {
constructor(serverUrl) {
this.serverUrl = normalizeServerUrl(serverUrl);
this.ws = new WsJsonClient(this.serverUrl);
}
async getUser(login) {
const response = await this.ws.request('GetUser', { login: String(login || '').trim() });
if (response.status !== 200) throw opError('GetUser', response);
return response.payload || {};
}
async startEspPairing({ login, passwordHash, requesterSessionKey, payloadType = 1 }) {
const response = await this.ws.request('StartEspPairing', {
login: String(login || '').trim(),
passwordHash: String(passwordHash || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
requesterSessionType: SESSION_TYPE_WALLET,
requesterClientPlatform: 'Chrome Extension Wallet',
payloadType: Number(payloadType) || 1,
});
if (response.status !== 200) throw opError('StartEspPairing', response);
return response.payload || {};
}
async getEspPairingStatus(pairingId) {
const response = await this.ws.request('GetEspPairingStatus', {
pairingId: String(pairingId || '').trim(),
});
if (response.status !== 200) throw opError('GetEspPairingStatus', response);
return response.payload || {};
}
async cancelEspPairing(pairingId, requesterSessionKey) {
const response = await this.ws.request('CancelEspPairing', {
pairingId: String(pairingId || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
});
if (response.status !== 200) throw opError('CancelEspPairing', response);
return response.payload || {};
}
async resumeSession(sessionRecord) {
const login = String(sessionRecord?.login || '').trim();
const sessionId = String(sessionRecord?.sessionId || '').trim();
const sessionKey = String(sessionRecord?.sessionKey || '').trim();
const sessionPrivPkcs8 = String(sessionRecord?.sessionPrivPkcs8 || '').trim();
if (!login || !sessionId || !sessionKey || !sessionPrivPkcs8) {
throw new Error('Сохранённая wallet-session неполная');
}
const privateKey = await importPkcs8Ed25519(sessionPrivPkcs8);
const challengeResp = await this.ws.request('SessionChallenge', { sessionId });
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
const nonce = challengeResp?.payload?.nonce;
if (!nonce) throw new Error('SessionChallenge: сервер не вернул nonce');
const timeMs = Date.now();
const preimage = `SESSION_LOGIN:${sessionId}:${timeMs}:${nonce}`;
const signatureB64 = await signBase64(privateKey, preimage);
const loginResp = await this.ws.request('SessionLogin', {
sessionId,
sessionKey,
timeMs,
signatureB64,
sessionType: Number(sessionRecord?.sessionType || SESSION_TYPE_WALLET) || SESSION_TYPE_WALLET,
clientPlatform: 'Chrome Extension Wallet',
clientInfo: 'SHiNE Browser Plugin Wallet',
});
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
return {
login,
sessionId,
storagePwd: String(loginResp?.payload?.storagePwd || '').trim(),
};
}
close() {
this.ws.close();
}
}

View File

@ -0,0 +1,995 @@
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_assert.js
function isBytes(a) {
return a instanceof Uint8Array || a != null && typeof a === "object" && a.constructor.name === "Uint8Array";
}
function bytes(b, ...lengths) {
if (!isBytes(b))
throw new Error("Uint8Array expected");
if (lengths.length > 0 && !lengths.includes(b.length))
throw new Error(`Uint8Array expected of length ${lengths}, not of length=${b.length}`);
}
function exists(instance, checkFinished = true) {
if (instance.destroyed)
throw new Error("Hash instance has been destroyed");
if (checkFinished && instance.finished)
throw new Error("Hash#digest() has already been called");
}
function output(out, instance) {
bytes(out);
const min = instance.outputLen;
if (out.length < min) {
throw new Error(`digestInto() expects output buffer of length at least ${min}`);
}
}
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/crypto.js
var crypto = typeof globalThis === "object" && "crypto" in globalThis ? globalThis.crypto : void 0;
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/utils.js
var createView = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
var isLE = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68;
function utf8ToBytes(str) {
if (typeof str !== "string")
throw new Error(`utf8ToBytes expected string, got ${typeof str}`);
return new Uint8Array(new TextEncoder().encode(str));
}
function toBytes(data) {
if (typeof data === "string")
data = utf8ToBytes(data);
bytes(data);
return data;
}
var Hash = class {
// Safe version that clones internal state
clone() {
return this._cloneInto();
}
};
var toStr = {}.toString;
function wrapConstructor(hashCons) {
const hashC = (msg) => hashCons().update(toBytes(msg)).digest();
const tmp = hashCons();
hashC.outputLen = tmp.outputLen;
hashC.blockLen = tmp.blockLen;
hashC.create = () => hashCons();
return hashC;
}
function randomBytes(bytesLength = 32) {
if (crypto && typeof crypto.getRandomValues === "function") {
return crypto.getRandomValues(new Uint8Array(bytesLength));
}
throw new Error("crypto.getRandomValues must be defined");
}
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_md.js
function setBigUint64(view, byteOffset, value, isLE2) {
if (typeof view.setBigUint64 === "function")
return view.setBigUint64(byteOffset, value, isLE2);
const _32n2 = BigInt(32);
const _u32_max = BigInt(4294967295);
const wh = Number(value >> _32n2 & _u32_max);
const wl = Number(value & _u32_max);
const h = isLE2 ? 4 : 0;
const l = isLE2 ? 0 : 4;
view.setUint32(byteOffset + h, wh, isLE2);
view.setUint32(byteOffset + l, wl, isLE2);
}
var HashMD = class extends Hash {
constructor(blockLen, outputLen, padOffset, isLE2) {
super();
this.blockLen = blockLen;
this.outputLen = outputLen;
this.padOffset = padOffset;
this.isLE = isLE2;
this.finished = false;
this.length = 0;
this.pos = 0;
this.destroyed = false;
this.buffer = new Uint8Array(blockLen);
this.view = createView(this.buffer);
}
update(data) {
exists(this);
const { view, buffer, blockLen } = this;
data = toBytes(data);
const len = data.length;
for (let pos = 0; pos < len; ) {
const take = Math.min(blockLen - this.pos, len - pos);
if (take === blockLen) {
const dataView = createView(data);
for (; blockLen <= len - pos; pos += blockLen)
this.process(dataView, pos);
continue;
}
buffer.set(data.subarray(pos, pos + take), this.pos);
this.pos += take;
pos += take;
if (this.pos === blockLen) {
this.process(view, 0);
this.pos = 0;
}
}
this.length += data.length;
this.roundClean();
return this;
}
digestInto(out) {
exists(this);
output(out, this);
this.finished = true;
const { buffer, view, blockLen, isLE: isLE2 } = this;
let { pos } = this;
buffer[pos++] = 128;
this.buffer.subarray(pos).fill(0);
if (this.padOffset > blockLen - pos) {
this.process(view, 0);
pos = 0;
}
for (let i = pos; i < blockLen; i++)
buffer[i] = 0;
setBigUint64(view, blockLen - 8, BigInt(this.length * 8), isLE2);
this.process(view, 0);
const oview = createView(out);
const len = this.outputLen;
if (len % 4)
throw new Error("_sha2: outputLen should be aligned to 32bit");
const outLen = len / 4;
const state = this.get();
if (outLen > state.length)
throw new Error("_sha2: outputLen bigger than state");
for (let i = 0; i < outLen; i++)
oview.setUint32(4 * i, state[i], isLE2);
}
digest() {
const { buffer, outputLen } = this;
this.digestInto(buffer);
const res = buffer.slice(0, outputLen);
this.destroy();
return res;
}
_cloneInto(to) {
to || (to = new this.constructor());
to.set(...this.get());
const { blockLen, buffer, length, finished, destroyed, pos } = this;
to.length = length;
to.pos = pos;
to.finished = finished;
to.destroyed = destroyed;
if (length % blockLen)
to.buffer.set(buffer);
return to;
}
};
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_u64.js
var U32_MASK64 = /* @__PURE__ */ BigInt(2 ** 32 - 1);
var _32n = /* @__PURE__ */ BigInt(32);
function fromBig(n, le = false) {
if (le)
return { h: Number(n & U32_MASK64), l: Number(n >> _32n & U32_MASK64) };
return { h: Number(n >> _32n & U32_MASK64) | 0, l: Number(n & U32_MASK64) | 0 };
}
function split(lst, le = false) {
let Ah = new Uint32Array(lst.length);
let Al = new Uint32Array(lst.length);
for (let i = 0; i < lst.length; i++) {
const { h, l } = fromBig(lst[i], le);
[Ah[i], Al[i]] = [h, l];
}
return [Ah, Al];
}
var toBig = (h, l) => BigInt(h >>> 0) << _32n | BigInt(l >>> 0);
var shrSH = (h, _l, s) => h >>> s;
var shrSL = (h, l, s) => h << 32 - s | l >>> s;
var rotrSH = (h, l, s) => h >>> s | l << 32 - s;
var rotrSL = (h, l, s) => h << 32 - s | l >>> s;
var rotrBH = (h, l, s) => h << 64 - s | l >>> s - 32;
var rotrBL = (h, l, s) => h >>> s - 32 | l << 64 - s;
var rotr32H = (_h, l) => l;
var rotr32L = (h, _l) => h;
var rotlSH = (h, l, s) => h << s | l >>> 32 - s;
var rotlSL = (h, l, s) => l << s | h >>> 32 - s;
var rotlBH = (h, l, s) => l << s - 32 | h >>> 64 - s;
var rotlBL = (h, l, s) => h << s - 32 | l >>> 64 - s;
function add(Ah, Al, Bh, Bl) {
const l = (Al >>> 0) + (Bl >>> 0);
return { h: Ah + Bh + (l / 2 ** 32 | 0) | 0, l: l | 0 };
}
var add3L = (Al, Bl, Cl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0);
var add3H = (low, Ah, Bh, Ch) => Ah + Bh + Ch + (low / 2 ** 32 | 0) | 0;
var add4L = (Al, Bl, Cl, Dl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0);
var add4H = (low, Ah, Bh, Ch, Dh) => Ah + Bh + Ch + Dh + (low / 2 ** 32 | 0) | 0;
var add5L = (Al, Bl, Cl, Dl, El) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) + (El >>> 0);
var add5H = (low, Ah, Bh, Ch, Dh, Eh) => Ah + Bh + Ch + Dh + Eh + (low / 2 ** 32 | 0) | 0;
var u64 = {
fromBig,
split,
toBig,
shrSH,
shrSL,
rotrSH,
rotrSL,
rotrBH,
rotrBL,
rotr32H,
rotr32L,
rotlSH,
rotlSL,
rotlBH,
rotlBL,
add,
add3L,
add3H,
add4L,
add4H,
add5H,
add5L
};
var u64_default = u64;
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/sha512.js
var [SHA512_Kh, SHA512_Kl] = /* @__PURE__ */ (() => u64_default.split([
"0x428a2f98d728ae22",
"0x7137449123ef65cd",
"0xb5c0fbcfec4d3b2f",
"0xe9b5dba58189dbbc",
"0x3956c25bf348b538",
"0x59f111f1b605d019",
"0x923f82a4af194f9b",
"0xab1c5ed5da6d8118",
"0xd807aa98a3030242",
"0x12835b0145706fbe",
"0x243185be4ee4b28c",
"0x550c7dc3d5ffb4e2",
"0x72be5d74f27b896f",
"0x80deb1fe3b1696b1",
"0x9bdc06a725c71235",
"0xc19bf174cf692694",
"0xe49b69c19ef14ad2",
"0xefbe4786384f25e3",
"0x0fc19dc68b8cd5b5",
"0x240ca1cc77ac9c65",
"0x2de92c6f592b0275",
"0x4a7484aa6ea6e483",
"0x5cb0a9dcbd41fbd4",
"0x76f988da831153b5",
"0x983e5152ee66dfab",
"0xa831c66d2db43210",
"0xb00327c898fb213f",
"0xbf597fc7beef0ee4",
"0xc6e00bf33da88fc2",
"0xd5a79147930aa725",
"0x06ca6351e003826f",
"0x142929670a0e6e70",
"0x27b70a8546d22ffc",
"0x2e1b21385c26c926",
"0x4d2c6dfc5ac42aed",
"0x53380d139d95b3df",
"0x650a73548baf63de",
"0x766a0abb3c77b2a8",
"0x81c2c92e47edaee6",
"0x92722c851482353b",
"0xa2bfe8a14cf10364",
"0xa81a664bbc423001",
"0xc24b8b70d0f89791",
"0xc76c51a30654be30",
"0xd192e819d6ef5218",
"0xd69906245565a910",
"0xf40e35855771202a",
"0x106aa07032bbd1b8",
"0x19a4c116b8d2d0c8",
"0x1e376c085141ab53",
"0x2748774cdf8eeb99",
"0x34b0bcb5e19b48a8",
"0x391c0cb3c5c95a63",
"0x4ed8aa4ae3418acb",
"0x5b9cca4f7763e373",
"0x682e6ff3d6b2b8a3",
"0x748f82ee5defb2fc",
"0x78a5636f43172f60",
"0x84c87814a1f0ab72",
"0x8cc702081a6439ec",
"0x90befffa23631e28",
"0xa4506cebde82bde9",
"0xbef9a3f7b2c67915",
"0xc67178f2e372532b",
"0xca273eceea26619c",
"0xd186b8c721c0c207",
"0xeada7dd6cde0eb1e",
"0xf57d4f7fee6ed178",
"0x06f067aa72176fba",
"0x0a637dc5a2c898a6",
"0x113f9804bef90dae",
"0x1b710b35131c471b",
"0x28db77f523047d84",
"0x32caab7b40c72493",
"0x3c9ebe0a15c9bebc",
"0x431d67c49c100d4c",
"0x4cc5d4becb3e42b6",
"0x597f299cfc657e2a",
"0x5fcb6fab3ad6faec",
"0x6c44198c4a475817"
].map((n) => BigInt(n))))();
var SHA512_W_H = /* @__PURE__ */ new Uint32Array(80);
var SHA512_W_L = /* @__PURE__ */ new Uint32Array(80);
var SHA512 = class extends HashMD {
constructor() {
super(128, 64, 16, false);
this.Ah = 1779033703 | 0;
this.Al = 4089235720 | 0;
this.Bh = 3144134277 | 0;
this.Bl = 2227873595 | 0;
this.Ch = 1013904242 | 0;
this.Cl = 4271175723 | 0;
this.Dh = 2773480762 | 0;
this.Dl = 1595750129 | 0;
this.Eh = 1359893119 | 0;
this.El = 2917565137 | 0;
this.Fh = 2600822924 | 0;
this.Fl = 725511199 | 0;
this.Gh = 528734635 | 0;
this.Gl = 4215389547 | 0;
this.Hh = 1541459225 | 0;
this.Hl = 327033209 | 0;
}
// prettier-ignore
get() {
const { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
return [Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl];
}
// prettier-ignore
set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl) {
this.Ah = Ah | 0;
this.Al = Al | 0;
this.Bh = Bh | 0;
this.Bl = Bl | 0;
this.Ch = Ch | 0;
this.Cl = Cl | 0;
this.Dh = Dh | 0;
this.Dl = Dl | 0;
this.Eh = Eh | 0;
this.El = El | 0;
this.Fh = Fh | 0;
this.Fl = Fl | 0;
this.Gh = Gh | 0;
this.Gl = Gl | 0;
this.Hh = Hh | 0;
this.Hl = Hl | 0;
}
process(view, offset) {
for (let i = 0; i < 16; i++, offset += 4) {
SHA512_W_H[i] = view.getUint32(offset);
SHA512_W_L[i] = view.getUint32(offset += 4);
}
for (let i = 16; i < 80; i++) {
const W15h = SHA512_W_H[i - 15] | 0;
const W15l = SHA512_W_L[i - 15] | 0;
const s0h = u64_default.rotrSH(W15h, W15l, 1) ^ u64_default.rotrSH(W15h, W15l, 8) ^ u64_default.shrSH(W15h, W15l, 7);
const s0l = u64_default.rotrSL(W15h, W15l, 1) ^ u64_default.rotrSL(W15h, W15l, 8) ^ u64_default.shrSL(W15h, W15l, 7);
const W2h = SHA512_W_H[i - 2] | 0;
const W2l = SHA512_W_L[i - 2] | 0;
const s1h = u64_default.rotrSH(W2h, W2l, 19) ^ u64_default.rotrBH(W2h, W2l, 61) ^ u64_default.shrSH(W2h, W2l, 6);
const s1l = u64_default.rotrSL(W2h, W2l, 19) ^ u64_default.rotrBL(W2h, W2l, 61) ^ u64_default.shrSL(W2h, W2l, 6);
const SUMl = u64_default.add4L(s0l, s1l, SHA512_W_L[i - 7], SHA512_W_L[i - 16]);
const SUMh = u64_default.add4H(SUMl, s0h, s1h, SHA512_W_H[i - 7], SHA512_W_H[i - 16]);
SHA512_W_H[i] = SUMh | 0;
SHA512_W_L[i] = SUMl | 0;
}
let { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
for (let i = 0; i < 80; i++) {
const sigma1h = u64_default.rotrSH(Eh, El, 14) ^ u64_default.rotrSH(Eh, El, 18) ^ u64_default.rotrBH(Eh, El, 41);
const sigma1l = u64_default.rotrSL(Eh, El, 14) ^ u64_default.rotrSL(Eh, El, 18) ^ u64_default.rotrBL(Eh, El, 41);
const CHIh = Eh & Fh ^ ~Eh & Gh;
const CHIl = El & Fl ^ ~El & Gl;
const T1ll = u64_default.add5L(Hl, sigma1l, CHIl, SHA512_Kl[i], SHA512_W_L[i]);
const T1h = u64_default.add5H(T1ll, Hh, sigma1h, CHIh, SHA512_Kh[i], SHA512_W_H[i]);
const T1l = T1ll | 0;
const sigma0h = u64_default.rotrSH(Ah, Al, 28) ^ u64_default.rotrBH(Ah, Al, 34) ^ u64_default.rotrBH(Ah, Al, 39);
const sigma0l = u64_default.rotrSL(Ah, Al, 28) ^ u64_default.rotrBL(Ah, Al, 34) ^ u64_default.rotrBL(Ah, Al, 39);
const MAJh = Ah & Bh ^ Ah & Ch ^ Bh & Ch;
const MAJl = Al & Bl ^ Al & Cl ^ Bl & Cl;
Hh = Gh | 0;
Hl = Gl | 0;
Gh = Fh | 0;
Gl = Fl | 0;
Fh = Eh | 0;
Fl = El | 0;
({ h: Eh, l: El } = u64_default.add(Dh | 0, Dl | 0, T1h | 0, T1l | 0));
Dh = Ch | 0;
Dl = Cl | 0;
Ch = Bh | 0;
Cl = Bl | 0;
Bh = Ah | 0;
Bl = Al | 0;
const All = u64_default.add3L(T1l, sigma0l, MAJl);
Ah = u64_default.add3H(All, T1h, sigma0h, MAJh);
Al = All | 0;
}
({ h: Ah, l: Al } = u64_default.add(this.Ah | 0, this.Al | 0, Ah | 0, Al | 0));
({ h: Bh, l: Bl } = u64_default.add(this.Bh | 0, this.Bl | 0, Bh | 0, Bl | 0));
({ h: Ch, l: Cl } = u64_default.add(this.Ch | 0, this.Cl | 0, Ch | 0, Cl | 0));
({ h: Dh, l: Dl } = u64_default.add(this.Dh | 0, this.Dl | 0, Dh | 0, Dl | 0));
({ h: Eh, l: El } = u64_default.add(this.Eh | 0, this.El | 0, Eh | 0, El | 0));
({ h: Fh, l: Fl } = u64_default.add(this.Fh | 0, this.Fl | 0, Fh | 0, Fl | 0));
({ h: Gh, l: Gl } = u64_default.add(this.Gh | 0, this.Gl | 0, Gh | 0, Gl | 0));
({ h: Hh, l: Hl } = u64_default.add(this.Hh | 0, this.Hl | 0, Hh | 0, Hl | 0));
this.set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl);
}
roundClean() {
SHA512_W_H.fill(0);
SHA512_W_L.fill(0);
}
destroy() {
this.buffer.fill(0);
this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
}
};
var sha512 = /* @__PURE__ */ wrapConstructor(() => new SHA512());
// node_modules/@noble/curves/esm/abstract/utils.js
var _0n = /* @__PURE__ */ BigInt(0);
var _1n = /* @__PURE__ */ BigInt(1);
var _2n = /* @__PURE__ */ BigInt(2);
function isBytes2(a) {
return a instanceof Uint8Array || a != null && typeof a === "object" && a.constructor.name === "Uint8Array";
}
function abytes(item) {
if (!isBytes2(item))
throw new Error("Uint8Array expected");
}
var hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
function bytesToHex(bytes2) {
abytes(bytes2);
let hex = "";
for (let i = 0; i < bytes2.length; i++) {
hex += hexes[bytes2[i]];
}
return hex;
}
function hexToNumber(hex) {
if (typeof hex !== "string")
throw new Error("hex string expected, got " + typeof hex);
return BigInt(hex === "" ? "0" : `0x${hex}`);
}
var asciis = { _0: 48, _9: 57, _A: 65, _F: 70, _a: 97, _f: 102 };
function asciiToBase16(char) {
if (char >= asciis._0 && char <= asciis._9)
return char - asciis._0;
if (char >= asciis._A && char <= asciis._F)
return char - (asciis._A - 10);
if (char >= asciis._a && char <= asciis._f)
return char - (asciis._a - 10);
return;
}
function hexToBytes(hex) {
if (typeof hex !== "string")
throw new Error("hex string expected, got " + typeof hex);
const hl = hex.length;
const al = hl / 2;
if (hl % 2)
throw new Error("padded hex string expected, got unpadded hex of length " + hl);
const array = new Uint8Array(al);
for (let ai = 0, hi = 0; ai < al; ai++, hi += 2) {
const n1 = asciiToBase16(hex.charCodeAt(hi));
const n2 = asciiToBase16(hex.charCodeAt(hi + 1));
if (n1 === void 0 || n2 === void 0) {
const char = hex[hi] + hex[hi + 1];
throw new Error('hex string expected, got non-hex character "' + char + '" at index ' + hi);
}
array[ai] = n1 * 16 + n2;
}
return array;
}
function bytesToNumberBE(bytes2) {
return hexToNumber(bytesToHex(bytes2));
}
function bytesToNumberLE(bytes2) {
abytes(bytes2);
return hexToNumber(bytesToHex(Uint8Array.from(bytes2).reverse()));
}
function numberToBytesBE(n, len) {
return hexToBytes(n.toString(16).padStart(len * 2, "0"));
}
function numberToBytesLE(n, len) {
return numberToBytesBE(n, len).reverse();
}
function ensureBytes(title, hex, expectedLength) {
let res;
if (typeof hex === "string") {
try {
res = hexToBytes(hex);
} catch (e) {
throw new Error(`${title} must be valid hex string, got "${hex}". Cause: ${e}`);
}
} else if (isBytes2(hex)) {
res = Uint8Array.from(hex);
} else {
throw new Error(`${title} must be hex string or Uint8Array`);
}
const len = res.length;
if (typeof expectedLength === "number" && len !== expectedLength)
throw new Error(`${title} expected ${expectedLength} bytes, got ${len}`);
return res;
}
var isPosBig = (n) => typeof n === "bigint" && _0n <= n;
function inRange(n, min, max) {
return isPosBig(n) && isPosBig(min) && isPosBig(max) && min <= n && n < max;
}
function aInRange(title, n, min, max) {
if (!inRange(n, min, max))
throw new Error(`expected valid ${title}: ${min} <= n < ${max}, got ${typeof n} ${n}`);
}
var bitMask = (n) => (_2n << BigInt(n - 1)) - _1n;
var validatorFns = {
bigint: (val) => typeof val === "bigint",
function: (val) => typeof val === "function",
boolean: (val) => typeof val === "boolean",
string: (val) => typeof val === "string",
stringOrUint8Array: (val) => typeof val === "string" || isBytes2(val),
isSafeInteger: (val) => Number.isSafeInteger(val),
array: (val) => Array.isArray(val),
field: (val, object) => object.Fp.isValid(val),
hash: (val) => typeof val === "function" && Number.isSafeInteger(val.outputLen)
};
function validateObject(object, validators, optValidators = {}) {
const checkField = (fieldName, type, isOptional) => {
const checkVal = validatorFns[type];
if (typeof checkVal !== "function")
throw new Error(`Invalid validator "${type}", expected function`);
const val = object[fieldName];
if (isOptional && val === void 0)
return;
if (!checkVal(val, object)) {
throw new Error(`Invalid param ${String(fieldName)}=${val} (${typeof val}), expected ${type}`);
}
};
for (const [fieldName, type] of Object.entries(validators))
checkField(fieldName, type, false);
for (const [fieldName, type] of Object.entries(optValidators))
checkField(fieldName, type, true);
return object;
}
// node_modules/@noble/curves/esm/abstract/modular.js
var _0n2 = BigInt(0);
var _1n2 = BigInt(1);
var _2n2 = BigInt(2);
var _3n = BigInt(3);
var _4n = BigInt(4);
var _5n = BigInt(5);
var _8n = BigInt(8);
var _9n = BigInt(9);
var _16n = BigInt(16);
function mod(a, b) {
const result = a % b;
return result >= _0n2 ? result : b + result;
}
function pow(num, power, modulo) {
if (modulo <= _0n2 || power < _0n2)
throw new Error("Expected power/modulo > 0");
if (modulo === _1n2)
return _0n2;
let res = _1n2;
while (power > _0n2) {
if (power & _1n2)
res = res * num % modulo;
num = num * num % modulo;
power >>= _1n2;
}
return res;
}
function pow2(x, power, modulo) {
let res = x;
while (power-- > _0n2) {
res *= res;
res %= modulo;
}
return res;
}
function invert(number, modulo) {
if (number === _0n2 || modulo <= _0n2) {
throw new Error(`invert: expected positive integers, got n=${number} mod=${modulo}`);
}
let a = mod(number, modulo);
let b = modulo;
let x = _0n2, y = _1n2, u = _1n2, v = _0n2;
while (a !== _0n2) {
const q = b / a;
const r = b % a;
const m = x - u * q;
const n = y - v * q;
b = a, a = r, x = u, y = v, u = m, v = n;
}
const gcd = b;
if (gcd !== _1n2)
throw new Error("invert: does not exist");
return mod(x, modulo);
}
function tonelliShanks(P) {
const legendreC = (P - _1n2) / _2n2;
let Q, S, Z;
for (Q = P - _1n2, S = 0; Q % _2n2 === _0n2; Q /= _2n2, S++)
;
for (Z = _2n2; Z < P && pow(Z, legendreC, P) !== P - _1n2; Z++)
;
if (S === 1) {
const p1div4 = (P + _1n2) / _4n;
return function tonelliFast(Fp2, n) {
const root = Fp2.pow(n, p1div4);
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
const Q1div2 = (Q + _1n2) / _2n2;
return function tonelliSlow(Fp2, n) {
if (Fp2.pow(n, legendreC) === Fp2.neg(Fp2.ONE))
throw new Error("Cannot find square root");
let r = S;
let g = Fp2.pow(Fp2.mul(Fp2.ONE, Z), Q);
let x = Fp2.pow(n, Q1div2);
let b = Fp2.pow(n, Q);
while (!Fp2.eql(b, Fp2.ONE)) {
if (Fp2.eql(b, Fp2.ZERO))
return Fp2.ZERO;
let m = 1;
for (let t2 = Fp2.sqr(b); m < r; m++) {
if (Fp2.eql(t2, Fp2.ONE))
break;
t2 = Fp2.sqr(t2);
}
const ge = Fp2.pow(g, _1n2 << BigInt(r - m - 1));
g = Fp2.sqr(ge);
x = Fp2.mul(x, ge);
b = Fp2.mul(b, g);
r = m;
}
return x;
};
}
function FpSqrt(P) {
if (P % _4n === _3n) {
const p1div4 = (P + _1n2) / _4n;
return function sqrt3mod4(Fp2, n) {
const root = Fp2.pow(n, p1div4);
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
if (P % _8n === _5n) {
const c1 = (P - _5n) / _8n;
return function sqrt5mod8(Fp2, n) {
const n2 = Fp2.mul(n, _2n2);
const v = Fp2.pow(n2, c1);
const nv = Fp2.mul(n, v);
const i = Fp2.mul(Fp2.mul(nv, _2n2), v);
const root = Fp2.mul(nv, Fp2.sub(i, Fp2.ONE));
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
if (P % _16n === _9n) {
}
return tonelliShanks(P);
}
var isNegativeLE = (num, modulo) => (mod(num, modulo) & _1n2) === _1n2;
function FpPow(f, num, power) {
if (power < _0n2)
throw new Error("Expected power > 0");
if (power === _0n2)
return f.ONE;
if (power === _1n2)
return num;
let p = f.ONE;
let d = num;
while (power > _0n2) {
if (power & _1n2)
p = f.mul(p, d);
d = f.sqr(d);
power >>= _1n2;
}
return p;
}
function FpInvertBatch(f, nums) {
const tmp = new Array(nums.length);
const lastMultiplied = nums.reduce((acc, num, i) => {
if (f.is0(num))
return acc;
tmp[i] = acc;
return f.mul(acc, num);
}, f.ONE);
const inverted = f.inv(lastMultiplied);
nums.reduceRight((acc, num, i) => {
if (f.is0(num))
return acc;
tmp[i] = f.mul(acc, tmp[i]);
return f.mul(acc, num);
}, inverted);
return tmp;
}
function nLength(n, nBitLength) {
const _nBitLength = nBitLength !== void 0 ? nBitLength : n.toString(2).length;
const nByteLength = Math.ceil(_nBitLength / 8);
return { nBitLength: _nBitLength, nByteLength };
}
function Field(ORDER, bitLen, isLE2 = false, redef = {}) {
if (ORDER <= _0n2)
throw new Error(`Expected Field ORDER > 0, got ${ORDER}`);
const { nBitLength: BITS, nByteLength: BYTES } = nLength(ORDER, bitLen);
if (BYTES > 2048)
throw new Error("Field lengths over 2048 bytes are not supported");
const sqrtP = FpSqrt(ORDER);
const f = Object.freeze({
ORDER,
BITS,
BYTES,
MASK: bitMask(BITS),
ZERO: _0n2,
ONE: _1n2,
create: (num) => mod(num, ORDER),
isValid: (num) => {
if (typeof num !== "bigint")
throw new Error(`Invalid field element: expected bigint, got ${typeof num}`);
return _0n2 <= num && num < ORDER;
},
is0: (num) => num === _0n2,
isOdd: (num) => (num & _1n2) === _1n2,
neg: (num) => mod(-num, ORDER),
eql: (lhs, rhs) => lhs === rhs,
sqr: (num) => mod(num * num, ORDER),
add: (lhs, rhs) => mod(lhs + rhs, ORDER),
sub: (lhs, rhs) => mod(lhs - rhs, ORDER),
mul: (lhs, rhs) => mod(lhs * rhs, ORDER),
pow: (num, power) => FpPow(f, num, power),
div: (lhs, rhs) => mod(lhs * invert(rhs, ORDER), ORDER),
// Same as above, but doesn't normalize
sqrN: (num) => num * num,
addN: (lhs, rhs) => lhs + rhs,
subN: (lhs, rhs) => lhs - rhs,
mulN: (lhs, rhs) => lhs * rhs,
inv: (num) => invert(num, ORDER),
sqrt: redef.sqrt || ((n) => sqrtP(f, n)),
invertBatch: (lst) => FpInvertBatch(f, lst),
// TODO: do we really need constant cmov?
// We don't have const-time bigints anyway, so probably will be not very useful
cmov: (a, b, c) => c ? b : a,
toBytes: (num) => isLE2 ? numberToBytesLE(num, BYTES) : numberToBytesBE(num, BYTES),
fromBytes: (bytes2) => {
if (bytes2.length !== BYTES)
throw new Error(`Fp.fromBytes: expected ${BYTES}, got ${bytes2.length}`);
return isLE2 ? bytesToNumberLE(bytes2) : bytesToNumberBE(bytes2);
}
});
return Object.freeze(f);
}
// node_modules/@noble/curves/esm/abstract/montgomery.js
var _0n3 = BigInt(0);
var _1n3 = BigInt(1);
function validateOpts(curve) {
validateObject(curve, {
a: "bigint"
}, {
montgomeryBits: "isSafeInteger",
nByteLength: "isSafeInteger",
adjustScalarBytes: "function",
domain: "function",
powPminus2: "function",
Gu: "bigint"
});
return Object.freeze({ ...curve });
}
function montgomery(curveDef) {
const CURVE = validateOpts(curveDef);
const { P } = CURVE;
const modP = (n) => mod(n, P);
const montgomeryBits = CURVE.montgomeryBits;
const montgomeryBytes = Math.ceil(montgomeryBits / 8);
const fieldLen = CURVE.nByteLength;
const adjustScalarBytes2 = CURVE.adjustScalarBytes || ((bytes2) => bytes2);
const powPminus2 = CURVE.powPminus2 || ((x) => pow(x, P - BigInt(2), P));
function cswap(swap, x_2, x_3) {
const dummy = modP(swap * (x_2 - x_3));
x_2 = modP(x_2 - dummy);
x_3 = modP(x_3 + dummy);
return [x_2, x_3];
}
const a24 = (CURVE.a - BigInt(2)) / BigInt(4);
function montgomeryLadder(u, scalar) {
aInRange("u", u, _0n3, P);
aInRange("scalar", scalar, _0n3, P);
const k = scalar;
const x_1 = u;
let x_2 = _1n3;
let z_2 = _0n3;
let x_3 = u;
let z_3 = _1n3;
let swap = _0n3;
let sw;
for (let t = BigInt(montgomeryBits - 1); t >= _0n3; t--) {
const k_t = k >> t & _1n3;
swap ^= k_t;
sw = cswap(swap, x_2, x_3);
x_2 = sw[0];
x_3 = sw[1];
sw = cswap(swap, z_2, z_3);
z_2 = sw[0];
z_3 = sw[1];
swap = k_t;
const A = x_2 + z_2;
const AA = modP(A * A);
const B = x_2 - z_2;
const BB = modP(B * B);
const E = AA - BB;
const C = x_3 + z_3;
const D = x_3 - z_3;
const DA = modP(D * A);
const CB = modP(C * B);
const dacb = DA + CB;
const da_cb = DA - CB;
x_3 = modP(dacb * dacb);
z_3 = modP(x_1 * modP(da_cb * da_cb));
x_2 = modP(AA * BB);
z_2 = modP(E * (AA + modP(a24 * E)));
}
sw = cswap(swap, x_2, x_3);
x_2 = sw[0];
x_3 = sw[1];
sw = cswap(swap, z_2, z_3);
z_2 = sw[0];
z_3 = sw[1];
const z2 = powPminus2(z_2);
return modP(x_2 * z2);
}
function encodeUCoordinate(u) {
return numberToBytesLE(modP(u), montgomeryBytes);
}
function decodeUCoordinate(uEnc) {
const u = ensureBytes("u coordinate", uEnc, montgomeryBytes);
if (fieldLen === 32)
u[31] &= 127;
return bytesToNumberLE(u);
}
function decodeScalar(n) {
const bytes2 = ensureBytes("scalar", n);
const len = bytes2.length;
if (len !== montgomeryBytes && len !== fieldLen)
throw new Error(`Expected ${montgomeryBytes} or ${fieldLen} bytes, got ${len}`);
return bytesToNumberLE(adjustScalarBytes2(bytes2));
}
function scalarMult(scalar, u) {
const pointU = decodeUCoordinate(u);
const _scalar = decodeScalar(scalar);
const pu = montgomeryLadder(pointU, _scalar);
if (pu === _0n3)
throw new Error("Invalid private or public key received");
return encodeUCoordinate(pu);
}
const GuBytes = encodeUCoordinate(CURVE.Gu);
function scalarMultBase(scalar) {
return scalarMult(scalar, GuBytes);
}
return {
scalarMult,
scalarMultBase,
getSharedSecret: (privateKey, publicKey) => scalarMult(privateKey, publicKey),
getPublicKey: (privateKey) => scalarMultBase(privateKey),
utils: { randomPrivateKey: () => CURVE.randomBytes(CURVE.nByteLength) },
GuBytes
};
}
// node_modules/@noble/curves/esm/ed25519.js
var ED25519_P = BigInt("57896044618658097711785492504343953926634992332820282019728792003956564819949");
var ED25519_SQRT_M1 = /* @__PURE__ */ BigInt("19681161376707505956807079304988542015446066515923890162744021073123829784752");
var _0n4 = BigInt(0);
var _1n4 = BigInt(1);
var _2n3 = BigInt(2);
var _3n2 = BigInt(3);
var _5n2 = BigInt(5);
var _8n2 = BigInt(8);
function ed25519_pow_2_252_3(x) {
const _10n = BigInt(10), _20n = BigInt(20), _40n = BigInt(40), _80n = BigInt(80);
const P = ED25519_P;
const x2 = x * x % P;
const b2 = x2 * x % P;
const b4 = pow2(b2, _2n3, P) * b2 % P;
const b5 = pow2(b4, _1n4, P) * x % P;
const b10 = pow2(b5, _5n2, P) * b5 % P;
const b20 = pow2(b10, _10n, P) * b10 % P;
const b40 = pow2(b20, _20n, P) * b20 % P;
const b80 = pow2(b40, _40n, P) * b40 % P;
const b160 = pow2(b80, _80n, P) * b80 % P;
const b240 = pow2(b160, _80n, P) * b80 % P;
const b250 = pow2(b240, _10n, P) * b10 % P;
const pow_p_5_8 = pow2(b250, _2n3, P) * x % P;
return { pow_p_5_8, b2 };
}
function adjustScalarBytes(bytes2) {
bytes2[0] &= 248;
bytes2[31] &= 127;
bytes2[31] |= 64;
return bytes2;
}
function uvRatio(u, v) {
const P = ED25519_P;
const v3 = mod(v * v * v, P);
const v7 = mod(v3 * v3 * v, P);
const pow3 = ed25519_pow_2_252_3(u * v7).pow_p_5_8;
let x = mod(u * v3 * pow3, P);
const vx2 = mod(v * x * x, P);
const root1 = x;
const root2 = mod(x * ED25519_SQRT_M1, P);
const useRoot1 = vx2 === u;
const useRoot2 = vx2 === mod(-u, P);
const noRoot = vx2 === mod(-u * ED25519_SQRT_M1, P);
if (useRoot1)
x = root1;
if (useRoot2 || noRoot)
x = root2;
if (isNegativeLE(x, P))
x = mod(-x, P);
return { isValid: useRoot1 || useRoot2, value: x };
}
var Fp = /* @__PURE__ */ (() => Field(ED25519_P, void 0, true))();
var ed25519Defaults = /* @__PURE__ */ (() => ({
// Param: a
a: BigInt(-1),
// Fp.create(-1) is proper; our way still works and is faster
// d is equal to -121665/121666 over finite field.
// Negative number is P - number, and division is invert(number, P)
d: BigInt("37095705934669439343138083508754565189542113879843219016388785533085940283555"),
// Finite field 𝔽p over which we'll do calculations; 2n**255n - 19n
Fp,
// Subgroup order: how many points curve has
// 2n**252n + 27742317777372353535851937790883648493n;
n: BigInt("7237005577332262213973186563042994240857116359379907606001950938285454250989"),
// Cofactor
h: _8n2,
// Base point (x, y) aka generator point
Gx: BigInt("15112221349535400772501151409588531511454012693041857206046113283949847762202"),
Gy: BigInt("46316835694926478169428394003475163141307993866256225615783033603165251855960"),
hash: sha512,
randomBytes,
adjustScalarBytes,
// dom2
// Ratio of u to v. Allows us to combine inversion and square root. Uses algo from RFC8032 5.1.3.
// Constant-time, u/√v
uvRatio
}))();
var x25519 = /* @__PURE__ */ (() => montgomery({
P: ED25519_P,
a: BigInt(486662),
montgomeryBits: 255,
// n is 253 bits
nByteLength: 32,
Gu: BigInt(9),
powPminus2: (x) => {
const P = ED25519_P;
const { pow_p_5_8, b2 } = ed25519_pow_2_252_3(x);
return mod(pow2(pow_p_5_8, _3n2, P) * b2, P);
},
adjustScalarBytes,
randomBytes
}))();
function edwardsToMontgomeryPriv(edwardsPriv) {
const hashed = ed25519Defaults.hash(edwardsPriv.subarray(0, 32));
return ed25519Defaults.adjustScalarBytes(hashed).subarray(0, 32);
}
export {
edwardsToMontgomeryPriv,
x25519
};
/*! Bundled license information:
@noble/hashes/esm/utils.js:
(*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
@noble/curves/esm/abstract/utils.js:
@noble/curves/esm/abstract/modular.js:
@noble/curves/esm/abstract/montgomery.js:
@noble/curves/esm/ed25519.js:
(*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
*/

View File

@ -0,0 +1,3 @@
import { edwardsToMontgomeryPriv, x25519 } from '../../../node_modules/@noble/curves/esm/ed25519.js';
export { edwardsToMontgomeryPriv, x25519 };

View File

@ -0,0 +1,101 @@
const DEFAULT_TIMEOUT_MS = 12000;
const runtimeTimers = globalThis;
function buildWsUrl(raw) {
const value = String(raw || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('http://') || value.startsWith('https://')) {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
}
return value;
}
function createRequestId(op) {
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export class WsJsonClient {
constructor(url) {
this.url = buildWsUrl(url);
this.ws = null;
this.openPromise = null;
this.pending = new Map();
}
async open() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
if (this.openPromise) return this.openPromise;
this.openPromise = new Promise((resolve, reject) => {
const ws = new WebSocket(this.url);
this.ws = ws;
ws.addEventListener('open', () => resolve(), { once: true });
ws.addEventListener('error', () => reject(new Error(`Не удалось подключиться к ${this.url}`)), { once: true });
ws.addEventListener('close', () => this.failPending('WebSocket соединение закрыто'));
ws.addEventListener('message', (event) => this.handleMessage(event.data));
}).finally(() => {
this.openPromise = null;
});
return this.openPromise;
}
async request(op, payload = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
await this.open();
const requestId = createRequestId(op);
const body = { op, requestId, payload };
const response = new Promise((resolve, reject) => {
const timer = runtimeTimers.setTimeout(() => {
this.pending.delete(requestId);
reject(new Error(`Таймаут ответа для операции ${op}`));
}, timeoutMs);
this.pending.set(requestId, {
resolve: (value) => {
runtimeTimers.clearTimeout(timer);
resolve(value);
},
reject: (error) => {
runtimeTimers.clearTimeout(timer);
reject(error);
},
});
});
this.ws.send(JSON.stringify(body));
return response;
}
handleMessage(raw) {
let data;
try {
data = JSON.parse(raw);
} catch {
return;
}
const requestId = data?.requestId;
if (!requestId) return;
const slot = this.pending.get(requestId);
if (!slot) return;
this.pending.delete(requestId);
slot.resolve(data);
}
failPending(message) {
const error = new Error(message);
for (const slot of this.pending.values()) slot.reject(error);
this.pending.clear();
}
close() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}

View File

@ -0,0 +1,20 @@
{
"manifest_version": 3,
"name": "SHiNE Browser Plugin Wallet",
"version": "0.1.0",
"description": "Wallet-session plugin for SHiNE with session-only login via trusted device.",
"permissions": [
"storage"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "SHiNE Wallet",
"default_popup": "popup.html"
}
}

View File

@ -0,0 +1,527 @@
{
"name": "shine-browser-plugin-wallet",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shine-browser-plugin-wallet",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@noble/curves": "^1.5.0"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@noble/curves": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.5.0.tgz",
"integrity": "sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.4.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
}

View File

@ -0,0 +1,18 @@
{
"name": "shine-browser-plugin-wallet",
"version": "1.0.0",
"description": "Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.",
"main": "popup.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@noble/curves": "^1.5.0"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}

View File

@ -0,0 +1,180 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 360px;
background: #0f1720;
color: #e8eef6;
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.layout {
padding: 12px;
}
.panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-header h1 {
margin: 0;
font-size: 18px;
}
.muted {
margin: 2px 0 0;
color: #9aabbd;
}
.small {
font-size: 12px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.pill-offline {
background: #4b1f28;
color: #ffc4cf;
}
.pill-online {
background: #153926;
color: #b7f5ce;
}
.card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid #253446;
border-radius: 8px;
background: #131d29;
}
.card-title {
font-size: 13px;
font-weight: 700;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field span {
font-size: 12px;
color: #b8c4d1;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #314459;
border-radius: 8px;
background: #0d141d;
color: #edf3fb;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn.primary {
background: #2f7df4;
color: #fff;
}
.btn.secondary {
background: #243446;
color: #e8eef6;
}
.btn.danger {
background: #6a2430;
color: #ffd6de;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.code {
font-size: 34px;
font-weight: 700;
letter-spacing: 0.18em;
}
.summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.summary-row code {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #bed5f5;
}
.status {
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
}
.status.info {
background: #172838;
color: #d8ebff;
}
.status.error {
background: #4d1e26;
color: #ffd0d8;
}
.hidden {
display: none;
}

View File

@ -0,0 +1,75 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SHiNE Wallet</title>
<link rel="stylesheet" href="./popup.css" />
</head>
<body>
<main class="layout">
<section class="panel">
<div class="panel-header">
<div>
<h1>SHiNE Wallet</h1>
<p class="muted">Session-only wallet plugin</p>
</div>
<span id="connection-pill" class="pill pill-offline">offline</span>
</div>
<label class="field">
<span>Shine server</span>
<input id="server-url" type="text" placeholder="wss://shineup.me/ws" />
</label>
<div id="session-card" class="card hidden">
<div class="card-title">Подключённая wallet-session</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="summary-row"><span>Session ID</span><code id="session-id"></code></div>
<div class="summary-row"><span>Тип</span><strong id="session-type">wallet</strong></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Переподключить</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div class="card">
<div class="card-title">Войти через другое устройство</div>
<label class="field">
<span>Логин</span>
<input id="login-input" type="text" autocomplete="username" />
</label>
<label class="checkbox-row">
<input id="use-password" type="checkbox" />
<span>Использовать доп. пароль</span>
</label>
<label id="password-field" class="field hidden">
<span>Пароль подключения</span>
<input id="password-input" type="password" autocomplete="current-password" />
</label>
<button id="start-btn" class="btn primary" type="button">Получить код</button>
<p class="muted small">
Wallet plugin создаёт временный requester keypair, ждёт подтверждение на доверенном устройстве
и получает только wallet-session без передачи постоянных ключей.
</p>
</div>
<div id="pairing-card" class="card hidden">
<div class="card-title">Код подключения</div>
<div id="short-code" class="code">0000000</div>
<p id="pairing-hint" class="muted small">
Покажите код на доверенном устройстве в разделе «Подключить по коду».
</p>
<p id="pairing-expire" class="muted small"></p>
<div class="actions">
<button id="cancel-btn" class="btn secondary" type="button">Отменить</button>
</div>
</div>
<div id="status" class="status hidden"></div>
</section>
</main>
<script type="module" src="./popup.js"></script>
</body>
</html>

View File

@ -0,0 +1,244 @@
const els = {
serverUrl: document.querySelector('#server-url'),
loginInput: document.querySelector('#login-input'),
usePassword: document.querySelector('#use-password'),
passwordField: document.querySelector('#password-field'),
passwordInput: document.querySelector('#password-input'),
startBtn: document.querySelector('#start-btn'),
pairingCard: document.querySelector('#pairing-card'),
shortCode: document.querySelector('#short-code'),
pairingHint: document.querySelector('#pairing-hint'),
pairingExpire: document.querySelector('#pairing-expire'),
cancelBtn: document.querySelector('#cancel-btn'),
status: document.querySelector('#status'),
sessionCard: document.querySelector('#session-card'),
sessionLogin: document.querySelector('#session-login'),
sessionId: document.querySelector('#session-id'),
sessionType: document.querySelector('#session-type'),
resumeBtn: document.querySelector('#resume-btn'),
disconnectBtn: document.querySelector('#disconnect-btn'),
connectionPill: document.querySelector('#connection-pill'),
};
let state = {
settings: {
serverUrl: 'wss://shineup.me/ws',
login: '',
},
pairing: {
active: false,
pairingId: '',
expiresAtMs: 0,
},
session: null,
connectionOnline: false,
status: {
text: '',
kind: 'info',
},
};
let refreshTimer = 0;
let saveSettingsTimer = 0;
function setStatus(message, kind = 'info') {
els.status.textContent = String(message || '');
els.status.className = `status ${kind === 'error' ? 'error' : 'info'}`;
els.status.classList.toggle('hidden', !message);
}
function setConnectedPill(connected) {
els.connectionPill.textContent = connected ? 'online' : 'offline';
els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline';
}
function formatRemaining(ms) {
const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000));
const minutes = Math.floor(safe / 60);
const seconds = safe % 60;
return `${minutes} мин ${seconds} сек`;
}
function applyState(nextState) {
state = nextState || state;
const serverValue = String(state?.settings?.serverUrl || 'wss://shineup.me/ws');
const loginValue = String(state?.settings?.login || '');
if (document.activeElement !== els.serverUrl) {
els.serverUrl.value = serverValue;
}
if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue;
}
setConnectedPill(!!state?.connectionOnline);
setStatus(state?.status?.text || '', state?.status?.kind || 'info');
const session = state?.session;
if (session) {
els.sessionCard.classList.remove('hidden');
els.sessionLogin.textContent = session.login || '—';
els.sessionId.textContent = session.sessionId || '—';
els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—');
} else {
els.sessionCard.classList.add('hidden');
els.sessionLogin.textContent = '—';
els.sessionId.textContent = '—';
els.sessionType.textContent = 'wallet';
}
const pairing = state?.pairing || {};
if (pairing.active) {
els.pairingCard.classList.remove('hidden');
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '0000000');
els.shortCode.dataset.shortCode = shortCode;
els.shortCode.textContent = shortCode;
els.pairingHint.textContent = pairing.trustedSessionOnline
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
const leftMs = Number(pairing.expiresAtMs || 0) - Date.now();
els.pairingExpire.textContent = leftMs > 0 ? `Код действителен ещё ${formatRemaining(leftMs)}.` : 'Время ожидания истекло.';
els.startBtn.disabled = true;
} else {
els.pairingCard.classList.add('hidden');
els.shortCode.textContent = '0000000';
delete els.shortCode.dataset.shortCode;
els.pairingExpire.textContent = '';
els.startBtn.disabled = false;
}
}
function normalizeError(response, fallback) {
return response?.error || fallback || 'Unknown error';
}
function sendMessage(type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message || 'Runtime message failed'));
return;
}
if (!response?.ok) {
reject(new Error(normalizeError(response, 'Wallet operation failed')));
return;
}
if (response?.state) applyState(response.state);
resolve(response);
});
});
}
async function refreshState() {
const response = await sendMessage('wallet:getState');
applyState(response.state);
}
async function saveSettings() {
await sendMessage('wallet:saveSettings', {
serverUrl: String(els.serverUrl.value || '').trim(),
login: String(els.loginInput.value || '').trim(),
});
}
function scheduleSaveSettings() {
if (saveSettingsTimer) {
window.clearTimeout(saveSettingsTimer);
}
saveSettingsTimer = window.setTimeout(() => {
saveSettingsTimer = 0;
void saveSettings();
}, 250);
}
async function startPairing() {
const login = String(els.loginInput.value || '').trim();
if (!login) {
setStatus('Введите логин.', 'error');
return;
}
setStatus('Создаём wallet-session заявку...', 'info');
els.startBtn.disabled = true;
try {
const response = await sendMessage('wallet:startPairing', {
login,
usePassword: !!els.usePassword.checked,
password: String(els.passwordInput.value || ''),
serverUrl: String(els.serverUrl.value || '').trim(),
});
applyState(response.state);
} catch (error) {
els.startBtn.disabled = false;
setStatus(error.message || 'Не удалось начать pairing.', 'error');
}
}
async function cancelPairing() {
try {
await sendMessage('wallet:cancelPairing');
} catch (error) {
setStatus(error.message || 'Не удалось отменить pairing.', 'error');
}
}
async function resumeSession() {
setStatus('Проверяем сохранённую wallet-session...', 'info');
try {
await sendMessage('wallet:resumeSession');
} catch (error) {
setStatus(error.message || 'Не удалось восстановить session.', 'error');
}
}
async function disconnectSession() {
try {
await sendMessage('wallet:disconnectSession');
} catch (error) {
setStatus(error.message || 'Не удалось удалить session.', 'error');
}
}
function startUiRefreshLoop() {
stopUiRefreshLoop();
refreshTimer = window.setInterval(() => {
void refreshState();
}, 1000);
}
function stopUiRefreshLoop() {
if (refreshTimer) {
window.clearInterval(refreshTimer);
refreshTimer = 0;
}
}
function bindUi() {
els.usePassword.addEventListener('change', () => {
els.passwordField.classList.toggle('hidden', !els.usePassword.checked);
if (!els.usePassword.checked) {
els.passwordInput.value = '';
}
});
els.serverUrl.addEventListener('input', () => { scheduleSaveSettings(); });
els.serverUrl.addEventListener('change', () => { void saveSettings(); });
els.loginInput.addEventListener('input', () => { scheduleSaveSettings(); });
els.loginInput.addEventListener('change', () => { void saveSettings(); });
els.startBtn.addEventListener('click', () => { void startPairing(); });
els.cancelBtn.addEventListener('click', () => { void cancelPairing(); });
els.resumeBtn.addEventListener('click', () => { void resumeSession(); });
els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); });
}
async function init() {
bindUi();
await refreshState();
startUiRefreshLoop();
}
window.addEventListener('beforeunload', () => {
stopUiRefreshLoop();
if (saveSettingsTimer) {
window.clearTimeout(saveSettingsTimer);
saveSettingsTimer = 0;
}
});
void init();

View File

@ -0,0 +1 @@
rootProject.name = 'SHiNE-browser-plugin-wallet'

View File

@ -4,6 +4,7 @@ import org.eclipse.jetty.websocket.api.Session;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import shine.db.entities.ActiveSessionEntry;
import utils.crypto.HashSHA256Util;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@ -29,6 +30,8 @@ final class EspPairingSupport {
static final String STATE_REJECTED = "rejected";
static final String STATE_CANCELED = "canceled";
static final String STATE_EXPIRED = "expired";
static final String PASSWORD_HASH_PREFIX = "sha256$";
static final String PASSWORD_HASH_VERSION = "shine-pairing";
private static final SecureRandom RANDOM = new SecureRandom();
private static final char[] BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
@ -77,6 +80,30 @@ final class EspPairingSupport {
return value;
}
static String normalizePasswordHash(String raw) {
String value = normalizeOpaqueHash(raw);
if (value == null) return null;
if (!value.regionMatches(true, 0, PASSWORD_HASH_PREFIX, 0, PASSWORD_HASH_PREFIX.length())) {
return null;
}
String hex = value.substring(PASSWORD_HASH_PREFIX.length()).trim().toLowerCase(Locale.ROOT);
if (hex.length() != 64) return null;
for (int i = 0; i < hex.length(); i++) {
char ch = hex.charAt(i);
boolean ok = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
if (!ok) return null;
}
return PASSWORD_HASH_PREFIX + hex;
}
static String derivePasswordHash(String loginRaw, String passwordRaw) {
String login = loginRaw == null ? "" : loginRaw.trim().toLowerCase(Locale.ROOT);
String password = passwordRaw == null ? "" : passwordRaw;
String preimage = PASSWORD_HASH_VERSION + "|" + login + "|" + password;
byte[] digest = HashSHA256Util.sha256(preimage.getBytes(StandardCharsets.UTF_8));
return PASSWORD_HASH_PREFIX + toHexLower(digest);
}
static String normalizeEncryptedPayload(String raw) {
if (raw == null) return null;
String value = raw.trim();
@ -149,5 +176,14 @@ final class EspPairingSupport {
return remainder;
}
private static String toHexLower(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >>> 4) & 0x0F, 16));
sb.append(Character.forDigit(b & 0x0F, 16));
}
return sb.toString();
}
record PairingFingerprint(String shortCode, String fingerprintB58) {}
}

View File

@ -54,7 +54,11 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
if (!EspPairingSupport.isSupportedPayloadType(payloadType)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3");
}
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash);
if (rawPasswordHash != null && passwordHash == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PASSWORD_HASH_FORMAT", "passwordHash должен быть пустым или иметь формат sha256$<64 hex>");
}
SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login);
if (user == null) {

View File

@ -27,7 +27,16 @@ public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler
}
boolean enabled = req.getEnabled() != null && req.getEnabled();
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash);
if (rawPasswordHash != null && passwordHash == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_PASSWORD_HASH_FORMAT",
"passwordHash должен быть пустым или иметь формат sha256$<64 hex>"
);
}
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds());
long now = System.currentTimeMillis();

View File

@ -9,9 +9,11 @@ import test.it.utils.ws.WsSession;
import utils.crypto.Ed25519Util;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.SolanaUserEntry;
import utils.crypto.HashSHA256Util;
import java.time.Duration;
import java.util.Base64;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
@ -38,7 +40,7 @@ public class IT_07_EspPairing {
sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r);
String passwordHash = "argon2id$v=19$m=65536,t=2,p=1$test$esp_pairing_hash";
String passwordHash = derivePairingHash(LOGIN, "test-pairing-password");
String upsertResp = clientWs.call(
"UpsertEspPairingSettings",
JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180),
@ -218,6 +220,17 @@ public class IT_07_EspPairing {
SolanaUsersDAO.getInstance().insert(entry);
}
private static String derivePairingHash(String login, String password) {
String preimage = "shine-pairing|" + login.trim().toLowerCase() + "|" + password;
byte[] digest = HashSHA256Util.sha256(preimage.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : digest) {
sb.append(Character.forDigit((b >>> 4) & 0x0F, 16));
sb.append(Character.forDigit(b & 0x0F, 16));
}
return "sha256$" + sb;
}
private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {}
private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {}
}

View File

@ -1,2 +1,2 @@
client.version=1.2.204
server.version=1.2.193
client.version=1.2.205
server.version=1.2.194

View File

@ -7,7 +7,7 @@
<link rel="manifest" href="./manifest.webmanifest" />
<title>Shine UI Demo</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260530000700';
window.__SHINE_BUILD_HASH__ = '20260616091500';
window.__SHINE_CLIENT_VERSION__ = '1.2.8';
</script>
<script>

View File

@ -42,7 +42,7 @@ import * as topupView from './pages/topup-view.js';
import * as devnetTopupView from './pages/devnet-topup-view.js';
import * as loginView from './pages/login-view.js?v=202606150110';
import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150215';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606160915';
import * as loginPasswordView from './pages/login-password-view.js';
import * as keyStorageView from './pages/key-storage-view.js';
@ -55,7 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js';
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=202606151050';
import * as devicePairingView from './pages/device-pairing-view.js?v=202606160915';
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';

View File

@ -1,14 +1,17 @@
import { renderHeader } from '../components/header.js';
import {
authService,
authorizeSession,
refreshSessions,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js';
import { formatRelativeTime, showToast } from '../services/channels-ux.js';
import {
buildSecretsPayload,
buildSessionAttachPayload,
deriveEspPairingPasswordHash,
encryptPairingPayloadForRequester,
} from '../services/device-pairing-service.js';
@ -53,26 +56,73 @@ function buildTransferKeys(savedKeys, { withExtras = false }) {
function requestCardHtml(request) {
const shortCode = String(request?.shortCode || '').trim() || '0000000';
const client = String(request?.requesterClientPlatform || 'unknown');
const requesterSessionType = Number(request?.requesterSessionType || 0);
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
const sessionOnly = requesterSessionType === 50;
return `
<div class="card stack" data-pairing-id="${String(request?.pairingId || '')}">
<div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px;">
<div style="font-size:28px; font-weight:700; letter-spacing:0.14em;">${shortCode}</div>
<span class="meta-muted">Платформа: ${client}</span>
<span class="meta-muted">Тип сессии: ${requesterSessionType || '—'}</span>
<span class="meta-muted">Тип payload: ${Number(request?.payloadType || 0)}</span>
<span class="meta-muted">Истекает: ${expiresText}</span>
</div>
</div>
<div class="row" style="flex-wrap:wrap;">
<button class="ghost-btn" type="button" data-action="approve-device">Подключить без доп. ключей</button>
<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>
<button class="ghost-btn" type="button" data-action="approve-device">${sessionOnly ? 'Подключить wallet-session' : 'Подключить без доп. ключей'}</button>
${sessionOnly ? '' : '<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>'}
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
</div>
</div>
`;
}
async function restoreAuthorizedSessionForPairing() {
const login = String(state.session.login || '').trim();
const sessionId = String(state.session.sessionId || '').trim();
if (!login || !sessionId) {
throw new Error('Нет активной сохранённой сессии для восстановления pairing-доступа.');
}
const resumed = await authService.resumeSession(login, sessionId);
authorizeSession({
login: resumed.login || login,
sessionId: resumed.sessionId || sessionId,
storagePwd: resumed.storagePwd || state.session.storagePwdInMemory,
});
await refreshSessions().catch(() => {});
return resumed;
}
async function runPairingOpWithSessionRestore(runAction) {
try {
return await runAction();
} catch (error) {
const code = String(error?.code || '').trim().toUpperCase();
if (code !== 'PAIRING_REQUIRES_AUTH_SESSION' && code !== 'NOT_AUTHENTICATED') {
throw error;
}
try {
await restoreAuthorizedSessionForPairing();
} catch (restoreError) {
const restoreCode = String(restoreError?.code || '').trim().toUpperCase();
if (restoreCode === 'SESSION_NOT_FOUND'
|| restoreCode === 'SESSION_KEY_NOT_ACTUAL'
|| restoreCode === 'SESSION_OF_ANOTHER_USER') {
await terminateCurrentSession({
infoMessage: 'Сохранённая сессия устарела. Выполните вход заново.',
});
}
throw restoreError;
}
return runAction();
}
}
function makePasswordToggleIcons() {
return {
eye: `
@ -154,7 +204,7 @@ export function render({ navigate }) {
</label>
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
</div>
<p class="meta-muted">Если код не введён, показываются все активные заявки. Для подключения без доп. ключей передаётся только device key.</p>
<p class="meta-muted">Если код не введён, показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
<div class="stack" id="pairing-requests-list"></div>
`;
@ -301,11 +351,11 @@ export function render({ navigate }) {
};
const removeAdditionalPassword = async () => {
const payload = await authService.upsertEspPairingSettings({
const payload = await runPairingOpWithSessionRestore(() => authService.upsertEspPairingSettings({
enabled: true,
passwordHash: '',
ttlSeconds: 180,
});
}));
pairingPasswordConfigured = false;
saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer, false);
setAuthInfo(`Подключение по коду без дополнительного пароля включено. TTL: ${payload?.ttlSeconds || 180} сек.`);
@ -417,7 +467,7 @@ export function render({ navigate }) {
const reloadRequests = async ({ silent = false } = {}) => {
try {
requests = await authService.listEspPairingRequests();
requests = await runPairingOpWithSessionRestore(() => authService.listEspPairingRequests());
renderRequests();
if (!silent) {
setStatus(status, 'Список pairing-заявок обновлён.', 'info');
@ -435,16 +485,45 @@ export function render({ navigate }) {
const approveRequest = async (request, mode) => {
const withExtras = mode === 'with-extras';
const keys = buildTransferKeys(savedKeys, { withExtras });
const payload = buildSecretsPayload({
login: state.session.login,
keys,
mode: withExtras ? 'with-extras' : 'device-only',
});
let payload;
if (!withExtras && Number(request?.requesterSessionType || 0) === 50) {
const delegatedSession = await authService.createDelegatedSessionWithDeviceKey({
login: state.session.login,
devicePrivPkcs8: String(savedKeys?.deviceKey || '').trim(),
sessionKey: String(request?.requesterSessionKey || '').trim(),
sessionType: Number(request?.requesterSessionType || 50) || 50,
clientPlatform: String(request?.requesterClientPlatform || '').trim() || 'Wallet plugin',
clientInfo: 'Wallet session approved via device pairing',
});
payload = buildSessionAttachPayload({
login: state.session.login,
session: delegatedSession,
});
} else {
const keys = buildTransferKeys(savedKeys, { withExtras });
payload = buildSecretsPayload({
login: state.session.login,
keys,
mode: withExtras ? 'with-extras' : 'device-only',
});
}
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
await authService.approveEspPairing(request?.pairingId, encryptedPayload);
showToast(withExtras ? 'Ключи переданы на новое устройство' : 'Новое устройство подключено');
setAuthInfo(withExtras ? 'Заявка подтверждена, ключи переданы.' : 'Заявка подтверждена без передачи доп. ключей.');
await runPairingOpWithSessionRestore(() => authService.approveEspPairing(request?.pairingId, encryptedPayload));
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === 50;
showToast(
withExtras
? 'Ключи переданы на новое устройство'
: sessionOnly
? 'Wallet-session выпущена для нового устройства'
: 'Новое устройство подключено',
);
setAuthInfo(
withExtras
? 'Заявка подтверждена, ключи переданы.'
: sessionOnly
? 'Заявка подтверждена, для нового устройства создана отдельная wallet-session без передачи постоянных ключей.'
: 'Заявка подтверждена без передачи доп. ключей.',
);
await refreshSessions().catch(() => {});
await reloadRequests({ silent: true });
};
@ -513,11 +592,11 @@ export function render({ navigate }) {
dialogSaveBtn.disabled = true;
try {
const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password);
const payload = await authService.upsertEspPairingSettings({
const payload = await runPairingOpWithSessionRestore(() => authService.upsertEspPairingSettings({
enabled: true,
passwordHash,
ttlSeconds: 180,
});
}));
pairingPasswordConfigured = true;
saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer, true);
closePasswordDialog();
@ -562,7 +641,7 @@ export function render({ navigate }) {
} else if (action === 'approve-full') {
await approveRequest(request, 'with-extras');
} else if (action === 'reject') {
await authService.rejectEspPairing(pairingId, 'rejected_by_user');
await runPairingOpWithSessionRestore(() => authService.rejectEspPairing(pairingId, 'rejected_by_user'));
showToast('Заявка отклонена', { kind: 'error' });
await reloadRequests({ silent: true });
}

View File

@ -54,6 +54,7 @@ const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const SESSION_TYPE_CLIENT = 1;
const SESSION_TYPE_WALLET = 50;
const CONNECTION_SUBTYPES = Object.freeze({
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
@ -887,6 +888,67 @@ export class AuthService {
return session;
}
async createDelegatedSessionWithDeviceKey({
login,
devicePrivPkcs8,
sessionKey,
sessionType = SESSION_TYPE_WALLET,
clientPlatform = 'Delegated session',
clientInfo = 'Delegated session via pairing',
}) {
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 devicePrivateKey = await importPkcs8Ed25519(cleanDevicePriv);
const devicePublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(cleanDevicePriv);
const storagePwd = randomBase64(32);
const tempAuth = new AuthService(this.serverUrl);
try {
const challengeResp = await tempAuth.ws.request('AuthChallenge', { login: cleanLogin });
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
const authNonce = challengeResp?.payload?.authNonce;
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
const timeMs = Date.now();
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${cleanSessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
const signatureB64 = await signBase64(devicePrivateKey, preimage);
const createResp = await tempAuth.ws.request('CreateAuthSession', {
login: cleanLogin,
storagePwd,
sessionKey: cleanSessionKey,
timeMs,
authNonce,
deviceKey: devicePublicKeyB64,
signatureB64,
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
clientInfo: String(clientInfo || '').trim() || 'Delegated session via pairing',
});
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
return {
login: cleanLogin,
sessionId,
storagePwd,
sessionKey: cleanSessionKey,
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
};
} finally {
tempAuth.ws.close();
}
}
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
let currentSecrets = {};
try {

View File

@ -1,11 +1,11 @@
import {
base64ToBytes,
bytesToBase64,
deriveOpaqueArgon2Hash,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
sha256Bytes,
sha256Text,
utf8Bytes,
} from './crypto-utils.js';
import {
@ -14,8 +14,9 @@ import {
x25519,
} from 'https://esm.sh/@noble/curves@1.5.0/ed25519';
const PAIRING_HASH_SUFFIX = 'esp.pairing.password';
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
const PAIRING_HASH_PREFIX = 'sha256$';
const PAIRING_HASH_VERSION = 'shine-pairing';
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
@ -84,10 +85,11 @@ export function detectPairingPayloadType(keys = {}) {
}
export async function deriveEspPairingPasswordHash(login, password) {
return deriveOpaqueArgon2Hash(password, {
login,
suffix: PAIRING_HASH_SUFFIX,
});
const loginLower = String(login || '').trim().toLowerCase();
const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`;
const digest = await sha256Text(preimage);
const hex = [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('');
return `${PAIRING_HASH_PREFIX}${hex}`;
}
export async function createRequesterPairingMaterial() {
@ -158,3 +160,19 @@ export function buildSecretsPayload({ login, keys, mode }) {
createdAtMs: Date.now(),
};
}
export function buildSessionAttachPayload({ login, session }) {
return {
v: 1,
type: 'shine-esp-session-attach',
login: String(login || '').trim(),
session: {
sessionId: String(session?.sessionId || '').trim(),
sessionKey: String(session?.sessionKey || '').trim(),
storagePwd: String(session?.storagePwd || '').trim(),
sessionType: Number(session?.sessionType || 50) || 50,
clientPlatform: String(session?.clientPlatform || '').trim(),
},
createdAtMs: Date.now(),
};
}