SHA256
Промежуточный коммит: состояние до нормальной Solana-first регистрации
This commit is contained in:
@@ -2,7 +2,8 @@ import { WsJsonClient } from './ws-client.js';
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
deriveEd25519FromPassword,
|
||||
deriveEd25519FromMasterSecret,
|
||||
deriveMasterSecretFromPassword,
|
||||
exportEd25519PublicKeyB64,
|
||||
exportPkcs8B64,
|
||||
generateEd25519Pair,
|
||||
@@ -671,6 +672,8 @@ export class AuthService {
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
this.headerHashCache = new Map();
|
||||
this.writeLocks = new Map();
|
||||
this.passwordKeyBundleCache = new Map();
|
||||
this.passwordKeyBundleInFlight = new Map();
|
||||
}
|
||||
|
||||
async reconnect(serverUrl) {
|
||||
@@ -706,13 +709,56 @@ export class AuthService {
|
||||
return payload.exists !== true;
|
||||
}
|
||||
|
||||
async derivePasswordKeyBundle(login, password) {
|
||||
async derivePasswordKeyBundle(login, password, options = {}) {
|
||||
const normalizedLogin = String(login ?? '');
|
||||
const normalizedPassword = String(password ?? '');
|
||||
const rootPair = await deriveEd25519FromPassword(normalizedPassword, 'root.key', { login: normalizedLogin });
|
||||
const blockchainPair = await deriveEd25519FromPassword(normalizedPassword, 'bch.key', { login: normalizedLogin });
|
||||
const devicePair = await deriveEd25519FromPassword(normalizedPassword, 'dev.key', { login: normalizedLogin });
|
||||
return { rootPair, blockchainPair, devicePair };
|
||||
const cacheKey = `${normalizedLogin}\n${normalizedPassword}`;
|
||||
const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : null;
|
||||
const isCancelled = typeof options?.isCancelled === 'function' ? options.isCancelled : null;
|
||||
|
||||
if (this.passwordKeyBundleCache.has(cacheKey)) {
|
||||
if (onProgress) onProgress({ percent: 100, stage: 'cached', message: 'Ключи уже сгенерированы в памяти.' });
|
||||
return this.passwordKeyBundleCache.get(cacheKey);
|
||||
}
|
||||
if (this.passwordKeyBundleInFlight.has(cacheKey)) {
|
||||
return this.passwordKeyBundleInFlight.get(cacheKey);
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
if (onProgress) onProgress({ percent: 3, stage: 'prepare', message: 'Подготовка параметров генерации...' });
|
||||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||||
|
||||
const masterSecret = await deriveMasterSecretFromPassword(normalizedPassword, {
|
||||
login: normalizedLogin,
|
||||
onProgress: (value01) => {
|
||||
if (!onProgress) return;
|
||||
const v = Math.max(0, Math.min(1, Number(value01) || 0));
|
||||
const percent = Math.round(5 + (v * 88));
|
||||
onProgress({ percent, stage: 'secret', message: 'Генерация секрета из пароля...' });
|
||||
},
|
||||
});
|
||||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||||
|
||||
if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' });
|
||||
const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key');
|
||||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||||
|
||||
if (onProgress) onProgress({ percent: 96, stage: 'derive', message: 'Вычисление blockchain key...' });
|
||||
const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'bch.key');
|
||||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||||
|
||||
if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
|
||||
const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key');
|
||||
const result = { rootPair, blockchainPair, devicePair };
|
||||
this.passwordKeyBundleCache.set(cacheKey, result);
|
||||
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
|
||||
return result;
|
||||
})().finally(() => {
|
||||
this.passwordKeyBundleInFlight.delete(cacheKey);
|
||||
});
|
||||
|
||||
this.passwordKeyBundleInFlight.set(cacheKey, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async createAuthSession(login, keyBundle) {
|
||||
@@ -784,6 +830,24 @@ export class AuthService {
|
||||
return { ...session, keyBundle };
|
||||
}
|
||||
|
||||
async registerUserWithKeyBundle(login, keyBundle) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
|
||||
const addResp = await this.ws.request('AddUser', {
|
||||
login: cleanLogin,
|
||||
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
|
||||
solanaKey: keyBundle.devicePair.publicKeyB64,
|
||||
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
|
||||
deviceKey: keyBundle.devicePair.publicKeyB64,
|
||||
bchLimit: 1000000,
|
||||
});
|
||||
if (addResp.status !== 200) throw opError('AddUser', addResp);
|
||||
|
||||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||||
return { ...session, keyBundle };
|
||||
}
|
||||
|
||||
async createSessionForExistingUser(login, password) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
|
||||
@@ -86,14 +86,29 @@ async function derivePasswordSeedArgon2id({ login, password, suffix }) {
|
||||
const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix);
|
||||
const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`);
|
||||
const out = await argon2idAsync(passBytes, salt, {
|
||||
t: 3,
|
||||
m: 262144,
|
||||
t: 2,
|
||||
m: 65536,
|
||||
p: 1,
|
||||
dkLen: 32,
|
||||
});
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
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 байта');
|
||||
@@ -131,6 +146,43 @@ export async function deriveEd25519FromPassword(password, suffix, options = {})
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const legacy = await derivePasswordSeed(normalizedPassword, 'master.secret');
|
||||
if (onProgress) onProgress(1);
|
||||
return legacy;
|
||||
}
|
||||
return deriveMasterSecretArgon2id({
|
||||
login: normalizedLogin,
|
||||
password: normalizedPassword,
|
||||
onProgress,
|
||||
});
|
||||
}
|
||||
|
||||
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 material = `${bytesToBase64(secretBytes)}|${String(suffix || '')}`;
|
||||
const seed = await sha256Text(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(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../solana-programs.js';
|
||||
|
||||
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
|
||||
const CLASSIFY_LOGIN_DISCRIMINATOR = new Uint8Array([118, 253, 204, 124, 22, 232, 235, 32]);
|
||||
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
|
||||
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
|
||||
|
||||
@@ -183,6 +184,49 @@ function serializeCreateUserPdaArgs(
|
||||
return b.result();
|
||||
}
|
||||
|
||||
function serializeClassifyLoginArgs(login) {
|
||||
const b = new BorshBuf();
|
||||
b.raw(CLASSIFY_LOGIN_DISCRIMINATOR);
|
||||
b.str(String(login || ''));
|
||||
return b.result();
|
||||
}
|
||||
|
||||
function decodeU32FromB64(rawB64) {
|
||||
const bytes = Uint8Array.from(atob(rawB64), (ch) => ch.charCodeAt(0));
|
||||
if (bytes.length < 4) throw new Error('LOGIN_GUARD_BAD_RETURN_DATA');
|
||||
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, true);
|
||||
}
|
||||
|
||||
export async function precheckLoginClassOnSolana({ login, solanaEndpoint }) {
|
||||
const solana = await loadSolanaLib();
|
||||
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');
|
||||
const loginGuardProgram = new solana.PublicKey(SHINE_LOGIN_GUARD_PROGRAM_ID);
|
||||
const signer = solana.Keypair.generate();
|
||||
const ix = new solana.TransactionInstruction({
|
||||
programId: loginGuardProgram,
|
||||
keys: [{ pubkey: signer.publicKey, isSigner: true, isWritable: false }],
|
||||
data: serializeClassifyLoginArgs(String(login || '').toLowerCase()),
|
||||
});
|
||||
const tx = new solana.Transaction().add(ix);
|
||||
tx.feePayer = signer.publicKey;
|
||||
tx.recentBlockhash = (await connection.getLatestBlockhash('confirmed')).blockhash;
|
||||
tx.sign(signer);
|
||||
|
||||
const sim = await connection.simulateTransaction(tx, { commitment: 'confirmed', sigVerify: true });
|
||||
if (sim?.value?.err) {
|
||||
throw new Error(`LOGIN_GUARD_SIMULATION_FAILED: ${JSON.stringify(sim.value.err)}`);
|
||||
}
|
||||
const returnData = sim?.value?.returnData;
|
||||
if (!returnData || returnData.programId !== SHINE_LOGIN_GUARD_PROGRAM_ID) {
|
||||
throw new Error('LOGIN_GUARD_BAD_RETURN_DATA');
|
||||
}
|
||||
const classValue = decodeU32FromB64(returnData.data?.[0] || '');
|
||||
if (classValue === 0) return { classCode: 0, className: 'free' };
|
||||
if (classValue === 1) return { classCode: 1, className: 'premium' };
|
||||
if (classValue === 2) return { classCode: 2, className: 'company' };
|
||||
return { classCode: classValue, className: 'unknown' };
|
||||
}
|
||||
|
||||
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
|
||||
const solana = await loadSolanaLib();
|
||||
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');
|
||||
@@ -249,12 +293,12 @@ export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint })
|
||||
const ed25519RootIx = new solana.TransactionInstruction({
|
||||
programId: ed25519Program,
|
||||
keys: [],
|
||||
data: Buffer.from(buildEd25519IxData(rootSig64, rootKey32, unsignedHash)),
|
||||
data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash),
|
||||
});
|
||||
const ed25519BchIx = new solana.TransactionInstruction({
|
||||
programId: ed25519Program,
|
||||
keys: [],
|
||||
data: Buffer.from(buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash)),
|
||||
data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash),
|
||||
});
|
||||
const createUserIx = new solana.TransactionInstruction({
|
||||
programId: usersProgram,
|
||||
@@ -267,7 +311,7 @@ export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint })
|
||||
{ pubkey: economyConfigPda, isSigner: false, isWritable: false },
|
||||
{ pubkey: loginGuardProgram, isSigner: false, isWritable: false },
|
||||
],
|
||||
data: Buffer.from(ixData),
|
||||
data: ixData,
|
||||
});
|
||||
|
||||
const sig = await solana.sendAndConfirmTransaction(
|
||||
|
||||
@@ -4,10 +4,18 @@ import { loadEncryptedUserSecrets } from './key-vault.js';
|
||||
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
|
||||
|
||||
const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT;
|
||||
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
|
||||
const TOPUP_SITE_URL = '/devnet-topup-view';
|
||||
|
||||
let solanaLibPromise = null;
|
||||
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const BASE58_MAP = (() => {
|
||||
const out = Object.create(null);
|
||||
for (let i = 0; i < BASE58_ALPHABET.length; i += 1) {
|
||||
out[BASE58_ALPHABET[i]] = i;
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
function normalizeEndpoint(url) {
|
||||
const raw = String(url || '').trim();
|
||||
@@ -22,6 +30,38 @@ async function loadSolanaLib() {
|
||||
return solanaLibPromise;
|
||||
}
|
||||
|
||||
function decodeBase58(input) {
|
||||
const text = String(input || '').trim();
|
||||
if (!text) return new Uint8Array(0);
|
||||
|
||||
const bytes = [0];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
const value = BASE58_MAP[ch];
|
||||
if (value == null) {
|
||||
throw new Error('Недопустимый символ Base58');
|
||||
}
|
||||
|
||||
let carry = value;
|
||||
for (let j = 0; j < bytes.length; j += 1) {
|
||||
const x = (bytes[j] * 58) + carry;
|
||||
bytes[j] = x & 0xff;
|
||||
carry = x >> 8;
|
||||
}
|
||||
while (carry > 0) {
|
||||
bytes.push(carry & 0xff);
|
||||
carry >>= 8;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < text.length && text[i] === '1'; i += 1) {
|
||||
bytes.push(0);
|
||||
}
|
||||
|
||||
bytes.reverse();
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
async function keypairFromPkcs8(pkcs8B64) {
|
||||
const solana = await loadSolanaLib();
|
||||
const seed32 = extractDeviceKey32FromStoredValue(pkcs8B64);
|
||||
@@ -55,7 +95,7 @@ export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) {
|
||||
const clean = String(privateKey32Base58 || '').trim();
|
||||
if (!clean) throw new Error('Введите приватный ключ');
|
||||
if (!BASE58_RE.test(clean)) throw new Error('Разрешены только символы Base58');
|
||||
const privateBytes = solana.bs58.decode(clean);
|
||||
const privateBytes = decodeBase58(clean);
|
||||
if (privateBytes.length !== 32) {
|
||||
throw new Error('Приватный ключ должен быть ровно 32 байта в Base58');
|
||||
}
|
||||
@@ -149,7 +189,10 @@ export function getTopupSiteUrl(walletAddress = '') {
|
||||
const cleanWallet = String(walletAddress || '').trim();
|
||||
if (!cleanWallet) return TOPUP_SITE_URL;
|
||||
try {
|
||||
const url = new URL(TOPUP_SITE_URL);
|
||||
const base = typeof window !== 'undefined' && window.location?.origin
|
||||
? window.location.origin
|
||||
: 'http://localhost:8088';
|
||||
const url = new URL(TOPUP_SITE_URL, base);
|
||||
url.searchParams.set('wallet', cleanWallet);
|
||||
return url.toString();
|
||||
} catch {
|
||||
|
||||
@@ -50,6 +50,26 @@ export function toUserMessage(error, fallback = 'Действие не выпо
|
||||
return 'Пользователь не найден. Проверьте логин.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'PREMIUMLOGIN' ||
|
||||
text.includes('premiumlogin') ||
|
||||
text.includes('error code: premiumlogin') ||
|
||||
text.includes('логин относится к платным')
|
||||
) {
|
||||
return 'Этот логин относится к премиум-категории. Для него нужна отдельная покупка через DAO.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'TRADEMARKLOGINREQUIRESREVIEW' ||
|
||||
text.includes('trademarkloginrequiresreview') ||
|
||||
text.includes('companyloginrequiresreview') ||
|
||||
text.includes('логин компании') ||
|
||||
text.includes('логин относится к компаниям') ||
|
||||
text.includes('требует отдельного согласования')
|
||||
) {
|
||||
return 'Этот логин относится к компании/бренду. Для него нужен отдельный процесс согласования.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'BAD_CHANNEL_NAME' ||
|
||||
text.includes('channel name must match') ||
|
||||
|
||||
Reference in New Issue
Block a user