SHA256
Wallet-session pairing и browser plugin wallet, оплаты пока не работают
This commit is contained in:
+1
-1
@@ -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>
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user