Добавить UI pairing по коду и обновить документацию агента

This commit is contained in:
AidarKC
2026-06-14 20:39:05 +04:00
parent b166013707
commit c681b4d684
13 changed files with 846 additions and 5 deletions
+62
View File
@@ -966,6 +966,68 @@ export class AuthService {
if (response.status !== 200) throw opError('CloseActiveSession', response);
}
async upsertEspPairingSettings({ enabled, passwordHash = '', ttlSeconds = 180 }) {
const response = await this.ws.request('UpsertEspPairingSettings', {
enabled: !!enabled,
passwordHash: String(passwordHash || '').trim(),
ttlSeconds: Number(ttlSeconds) || 180,
});
if (response.status !== 200) throw opError('UpsertEspPairingSettings', response);
return response.payload || {};
}
async startEspPairing({
login,
passwordHash,
requesterSessionKey,
requesterSessionType = SESSION_TYPE_CLIENT,
requesterClientPlatform = makeClientPlatform(),
payloadType = 3,
}) {
const response = await this.ws.request('StartEspPairing', {
login: String(login || '').trim(),
passwordHash: String(passwordHash || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
requesterSessionType: Number(requesterSessionType) || SESSION_TYPE_CLIENT,
requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(),
payloadType: Number(payloadType) || 3,
});
if (response.status !== 200) throw opError('StartEspPairing', response);
return response.payload || {};
}
async listEspPairingRequests() {
const response = await this.ws.request('ListEspPairingRequests', {});
if (response.status !== 200) throw opError('ListEspPairingRequests', response);
return Array.isArray(response?.payload?.requests) ? response.payload.requests : [];
}
async approveEspPairing(pairingId, encryptedPayload) {
const response = await this.ws.request('ApproveEspPairing', {
pairingId: String(pairingId || '').trim(),
encryptedPayload: String(encryptedPayload || '').trim(),
});
if (response.status !== 200) throw opError('ApproveEspPairing', response);
return response.payload || {};
}
async rejectEspPairing(pairingId, reason = '') {
const response = await this.ws.request('RejectEspPairing', {
pairingId: String(pairingId || '').trim(),
reason: String(reason || '').trim(),
});
if (response.status !== 200) throw opError('RejectEspPairing', 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 listSubscriptionsFeed(login, limit = 200) {
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
+15
View File
@@ -171,6 +171,21 @@ export async function deriveMasterSecretFromPassword(password, options = {}) {
});
}
export async function deriveOpaqueArgon2Hash(password, options = {}) {
const normalizedPassword = String(password ?? '');
const normalizedLogin = String(options?.login ?? '');
const normalizedSuffix = String(options?.suffix || 'opaque.hash');
const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix);
const passBytes = utf8Bytes(`${normalizeLoginForKdf(normalizedLogin)}\n${normalizedPassword}`);
const out = await argon2idAsync(passBytes, salt, {
t: 2,
m: 65536,
p: 1,
dkLen: 32,
});
return `argon2id$v=19$m=65536,t=2,p=1$${bytesToBase64(salt)}$${bytesToBase64(new Uint8Array(out))}`;
}
export async function deriveEd25519FromMasterSecret(masterSecret32, suffix) {
const secretBytes = masterSecret32 instanceof Uint8Array
? masterSecret32
@@ -0,0 +1,160 @@
import {
base64ToBytes,
bytesToBase64,
deriveOpaqueArgon2Hash,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
sha256Bytes,
utf8Bytes,
} from './crypto-utils.js';
import {
edwardsToMontgomeryPriv,
edwardsToMontgomeryPub,
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 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('Криптография браузера недоступна. Откройте приложение через HTTPS или localhost.');
}
return api;
}
function bytesToBase64Url(bytes) {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
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);
}
function extractSessionPublicKeyB64(sessionKey) {
const raw = String(sessionKey || '').trim();
if (!raw.startsWith('ed25519/')) {
throw new Error('Неподдерживаемый requesterSessionKey');
}
const publicKeyB64 = raw.slice('ed25519/'.length).trim();
if (!publicKeyB64) {
throw new Error('Пустой requesterSessionKey');
}
return publicKeyB64;
}
async function importAesKeyFromSharedSecret(sharedSecretBytes) {
const digest = await sha256Bytes(sharedSecretBytes);
return getCryptoApi().subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
function normalizeKeys(keys = {}) {
return {
deviceKey: String(keys?.deviceKey || '').trim(),
blockchainKey: String(keys?.blockchainKey || '').trim(),
rootKey: String(keys?.rootKey || '').trim(),
};
}
export function detectPairingPayloadType(keys = {}) {
const normalized = normalizeKeys(keys);
if (normalized.rootKey) return 3;
if (normalized.blockchainKey) return 2;
return 1;
}
export async function deriveEspPairingPasswordHash(login, password) {
return deriveOpaqueArgon2Hash(password, {
login,
suffix: PAIRING_HASH_SUFFIX,
});
}
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 encryptPairingPayloadForRequester(requesterSessionKey, payload) {
const requesterPublicB64 = extractSessionPublicKeyB64(requesterSessionKey);
const requesterMontPub = edwardsToMontgomeryPub(base64ToBytes(requesterPublicB64));
const ephemeralPriv = x25519.utils.randomPrivateKey();
const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
const sharedSecret = x25519.getSharedSecret(ephemeralPriv, requesterMontPub);
const aesKey = await importAesKeyFromSharedSecret(sharedSecret);
const iv = getCryptoApi().getRandomValues(new Uint8Array(12));
const plainBytes = utf8Bytes(JSON.stringify(payload));
const cipher = await getCryptoApi().subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, plainBytes);
const envelope = {
v: 1,
alg: 'x25519-aes256-gcm',
ephPubB64: bytesToBase64(ephemeralPub),
ivB64: bytesToBase64(iv),
cipherB64: bytesToBase64(new Uint8Array(cipher)),
createdAtMs: Date.now(),
};
return `${PAIRING_ENVELOPE_PREFIX}${bytesToBase64Url(utf8Bytes(JSON.stringify(envelope)))}`;
}
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 || '')),
);
const payload = JSON.parse(new TextDecoder().decode(plain));
return {
...payload,
keys: normalizeKeys(payload?.keys),
};
}
export function buildSecretsPayload({ login, keys, mode }) {
return {
v: 1,
type: 'shine-esp-pairing-transfer',
login: String(login || '').trim(),
mode: String(mode || 'device-only').trim() || 'device-only',
keys: normalizeKeys(keys),
payloadType: detectPairingPayloadType(keys),
createdAtMs: Date.now(),
};
}