SHiNE-server/shine-UI/js/services/crypto-utils.js
2026-06-22 21:57:09 +04:00

343 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const encoder = new TextEncoder();
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
function getCryptoApi() {
const api = globalThis.crypto;
if (!api || typeof api.getRandomValues !== 'function') {
throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE);
}
return api;
}
function getSubtleApi() {
const api = getCryptoApi();
if (!api.subtle) {
throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE);
}
return api.subtle;
}
function base64UrlToBase64(value) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padLen = (4 - (normalized.length % 4)) % 4;
return normalized + '='.repeat(padLen);
}
function base64ToBase64Url(value) {
return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
export function bytesToBase58(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (input.length === 0) return '';
const digits = [];
for (let i = 0; i < input.length; i += 1) {
let carry = input[i];
for (let j = 0; j < digits.length; j += 1) {
const value = (digits[j] * 256) + carry;
digits[j] = value % 58;
carry = Math.floor(value / 58);
}
while (carry > 0) {
digits.push(carry % 58);
carry = Math.floor(carry / 58);
}
}
for (let i = 0; i < input.length && input[i] === 0; i += 1) {
digits.push(0);
}
return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join('');
}
export function base58ToBytes(value) {
const text = String(value || '').trim();
if (!text) return new Uint8Array();
const digits = [];
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const index = BASE58_ALPHABET.indexOf(char);
if (index < 0) throw new Error(`Недопустимый символ base58: ${char}`);
let carry = index;
for (let j = 0; j < digits.length; j += 1) {
const acc = (digits[j] * 58) + carry;
digits[j] = acc & 0xff;
carry = acc >> 8;
}
while (carry > 0) {
digits.push(carry & 0xff);
carry >>= 8;
}
}
for (let i = 0; i < text.length && text[i] === '1'; i += 1) {
digits.push(0);
}
return new Uint8Array(digits.reverse());
}
export function randomBase64(byteLen = 32) {
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
return bytesToBase64(bytes);
}
export function bytesToBase64(bytes) {
let binary = '';
bytes.forEach((b) => {
binary += String.fromCharCode(b);
});
return btoa(binary);
}
export function bytesToBase64Url(bytes) {
return base64ToBase64Url(bytesToBase64(bytes));
}
export function base64ToBytes(base64) {
const normalized = (base64 || '').trim();
const binary = atob(normalized);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export function base64UrlToBytes(value) {
return base64ToBytes(base64UrlToBase64(String(value || '').trim()));
}
export function utf8Bytes(value) {
return encoder.encode(value);
}
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));
}
function normalizeLoginForKdf(login) {
return String(login || '').trim().toLowerCase();
}
async function makeArgon2Salt(login, suffix) {
const normalizedLogin = normalizeLoginForKdf(login);
const normalizedSuffix = String(suffix || '').trim();
const saltSource = `shine-auth-v2|login=${normalizedLogin}|suffix=${normalizedSuffix}`;
const digest = await sha256Text(saltSource);
return digest.slice(0, 16);
}
async function deriveMasterSecretArgon2id({ login, password, onProgress }) {
const normalizedLogin = normalizeLoginForKdf(login);
const normalizedPassword = String(password ?? '');
const salt = await makeArgon2Salt(normalizedLogin, 'master.secret');
const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`);
const out = await argon2idAsync(passBytes, salt, {
t: 2,
m: 65536,
p: 1,
dkLen: 32,
onProgress,
});
return new Uint8Array(out);
}
function ed25519Pkcs8FromSeed(seed32) {
if (seed32.length !== 32) {
throw new Error('Для Ed25519 нужен seed длиной 32 байта');
}
const prefix = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
const out = new Uint8Array(prefix.length + seed32.length);
out.set(prefix, 0);
out.set(seed32, prefix.length);
return out;
}
export async function deriveMasterSecretFromPassword(password, options = {}) {
const normalizedPassword = String(password ?? '');
const normalizedLogin = String(options?.login ?? '');
const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : undefined;
if (normalizedPassword.length === 0) {
// Пустой пароль запрещён: упрощённый легаси-путь убран, регистрация/вход требуют непустой пароль.
throw new Error('Пустой пароль запрещён: регистрация и вход требуют непустой пароль.');
}
return deriveMasterSecretArgon2id({
login: normalizedLogin,
password: normalizedPassword,
onProgress,
});
}
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
: new Uint8Array(masterSecret32 || []);
if (secretBytes.length !== 32) {
throw new Error('Master secret должен быть длиной 32 байта');
}
const suffixBytes = utf8Bytes(String(suffix || ''));
const material = new Uint8Array(
SHINE_KEY_DERIVATION_PREFIX.length + 1 + secretBytes.length + 1 + suffixBytes.length,
);
let offset = 0;
material.set(utf8Bytes(SHINE_KEY_DERIVATION_PREFIX), offset);
offset += SHINE_KEY_DERIVATION_PREFIX.length;
material[offset++] = 0;
material.set(secretBytes, offset);
offset += secretBytes.length;
material[offset++] = 0;
material.set(suffixBytes, offset);
const seed = await sha256Bytes(material);
const pkcs8 = ed25519Pkcs8FromSeed(seed);
const subtle = getSubtleApi();
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
const jwk = await subtle.exportKey('jwk', privateKey);
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
return {
privateKey,
publicKeyB64: bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))),
privatePkcs8B64: bytesToBase64(pkcs8),
};
}
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
const subtle = getSubtleApi();
const baseKey = await subtle.importKey(
'raw',
utf8Bytes(storagePwd),
{ name: 'PBKDF2' },
false,
['deriveKey'],
);
return subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: 210000,
hash: 'SHA-256',
},
baseKey,
{
name: 'AES-GCM',
length: 256,
},
false,
['encrypt', 'decrypt'],
);
}
export async function encryptJsonWithStoragePwd(value, storagePwd) {
const cryptoApi = getCryptoApi();
const subtle = getSubtleApi();
const salt = cryptoApi.getRandomValues(new Uint8Array(16));
const iv = cryptoApi.getRandomValues(new Uint8Array(12));
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
const plainBytes = utf8Bytes(JSON.stringify(value));
const cipher = await subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
return {
saltB64: bytesToBase64(salt),
ivB64: bytesToBase64(iv),
cipherB64: bytesToBase64(new Uint8Array(cipher)),
};
}
export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
const salt = base64ToBytes(envelope.saltB64);
const iv = base64ToBytes(envelope.ivB64);
const cipher = base64ToBytes(envelope.cipherB64);
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv }, key, cipher);
const text = new TextDecoder().decode(plain);
return JSON.parse(text);
}
export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt']) {
return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages);
}
export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes) {
const key = await importAesKeyRaw(keyBytes, ['encrypt']);
const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes);
return new Uint8Array(cipher);
}
export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) {
const key = await importAesKeyRaw(keyBytes, ['decrypt']);
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes);
return new Uint8Array(plain);
}
export function randomBytes(byteLen = 32) {
const out = new Uint8Array(byteLen);
getCryptoApi().getRandomValues(out);
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 publicKeyB64FromPkcs8Ed25519(pkcs8B64) {
const privateKey = await getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, true, ['sign']);
const jwk = await getSubtleApi().exportKey('jwk', privateKey);
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
return bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x)));
}
export async function signBase64(privateKey, text) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
return bytesToBase64(new Uint8Array(signature));
}
export async function signBytes(privateKey, bytes) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
return new Uint8Array(signature);
}