Промежуточный коммит: состояние до нормальной Solana-first регистрации

This commit is contained in:
AidarKC
2026-05-27 18:33:26 +04:00
parent b345900459
commit 6f0bb01b61
17 changed files with 661 additions and 72 deletions
+70 -6
View File
@@ -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('Введите логин');
+54 -2
View File
@@ -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(
+46 -3
View File
@@ -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 {
+20
View File
@@ -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') ||