SHA256
Добавить UI pairing по коду и обновить документацию агента
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user