SHA256
397 lines
14 KiB
JavaScript
397 lines
14 KiB
JavaScript
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';
|
|
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519';
|
|
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);
|
|
}
|
|
|
|
export function extractEd25519SeedFromPkcs8B64(pkcs8B64) {
|
|
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
|
|
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key');
|
|
return bytes.slice(bytes.length - 32);
|
|
}
|
|
|
|
export function ed25519SeedToX25519Private(seed32) {
|
|
const seed = seed32 instanceof Uint8Array ? seed32 : new Uint8Array(seed32 || []);
|
|
if (seed.length !== 32) throw new Error('Seed Ed25519 должен быть 32 байта');
|
|
return new Uint8Array(edwardsToMontgomeryPriv(seed));
|
|
}
|
|
|
|
export function ed25519PublicToX25519Public(ed25519Pub32) {
|
|
const pub = ed25519Pub32 instanceof Uint8Array ? ed25519Pub32 : new Uint8Array(ed25519Pub32 || []);
|
|
if (pub.length !== 32) throw new Error('Публичный Ed25519 ключ должен быть 32 байта');
|
|
return new Uint8Array(edwardsToMontgomeryPub(pub));
|
|
}
|
|
|
|
export function x25519RandomPrivateKey() {
|
|
return new Uint8Array(x25519.utils.randomPrivateKey());
|
|
}
|
|
|
|
export function x25519PublicFromPrivate(privateKey32) {
|
|
const key = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []);
|
|
if (key.length !== 32) throw new Error('Приватный X25519 ключ должен быть 32 байта');
|
|
return new Uint8Array(x25519.getPublicKey(key));
|
|
}
|
|
|
|
export function x25519SharedSecret(privateKey32, publicKey32) {
|
|
const priv = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []);
|
|
const pub = publicKey32 instanceof Uint8Array ? publicKey32 : new Uint8Array(publicKey32 || []);
|
|
if (priv.length !== 32 || pub.length !== 32) {
|
|
throw new Error('X25519 ключи должны быть по 32 байта');
|
|
}
|
|
return new Uint8Array(x25519.getSharedSecret(priv, pub));
|
|
}
|
|
|
|
export async function hkdfSha256(ikmBytes, saltBytes, infoBytes, outLen) {
|
|
const subtle = getSubtleApi();
|
|
const baseKey = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveBits']);
|
|
const bits = await subtle.deriveBits(
|
|
{
|
|
name: 'HKDF',
|
|
hash: 'SHA-256',
|
|
salt: saltBytes instanceof Uint8Array ? saltBytes : new Uint8Array(saltBytes || []),
|
|
info: infoBytes instanceof Uint8Array ? infoBytes : new Uint8Array(infoBytes || []),
|
|
},
|
|
baseKey,
|
|
Number(outLen) * 8,
|
|
);
|
|
return new Uint8Array(bits);
|
|
}
|