Добавить SAWD-v1 и Arweave-кошелек в UI

This commit is contained in:
AidarKC 2026-04-26 01:19:46 +03:00
parent c8fa4a01a1
commit 126cf2f5c3
8 changed files with 1174 additions and 175 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.3
server.version=1.2.3
client.version=1.2.4
server.version=1.2.4

View File

@ -0,0 +1,104 @@
# SHiNE Arweave Wallet Derivation v1
Сокращение: **SAWD-v1**.
## Назначение
Из 32-байтного `deviceKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address.
## Вход
- `deviceKey32`: ровно 32 байта.
- Если исходный `device.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8.
- Если используется Solana keypair JSON на 64 байта, используются только `bytes[0..31]`.
## Выход
```json
{
"derivation": "SAWD-v1",
"jwk": {
"kty": "RSA",
"e": "AQAB",
"n": "...",
"d": "...",
"p": "...",
"q": "...",
"dp": "...",
"dq": "...",
"qi": "..."
},
"owner": "...",
"address": "..."
}
```
Где:
- `owner = jwk.n`
- `address = base64url_no_padding(SHA-256(unsigned_big_endian_bytes(n)))`
## Константы
- `DERIVATION_NAME = "SAWD-v1"`
- `MASTER_LABEL = "SHINE/ARWEAVE/RSA4096/SAWD-v1/MASTER"`
- `STREAM_LABEL = "SHINE/ARWEAVE/RSA4096/SAWD-v1/STREAM"`
- `MR_LABEL = "SHINE/ARWEAVE/RSA4096/SAWD-v1/MILLER-RABIN"`
- `RSA_BITS = 4096`
- `PRIME_BITS = 2048`
- `PUBLIC_EXPONENT = 65537`
- `MILLER_RABIN_ROUNDS = 64`
- `SMALL_PRIME_LIMIT = 10000`
## Алгоритм
1. Проверить `deviceKey32.length == 32`.
2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = deviceKey32)`.
3. Реализовать `deriveBytes(label, length)`:
- `output = empty`
- `counter = 0`
- while `output.length < length`:
- `block = HMAC-SHA256(key = masterSeed32, message = UTF8(STREAM_LABEL) || UTF8("/") || UTF8(label) || UTF8("/") || uint64_be(counter))`
- `output = output || block`
- `counter++`
- вернуть первые `length` байт.
4. Для `p` и `q`:
- `raw = deriveBytes(label + "/" + index, 256)`
- `candidate = unsigned_big_endian_integer(raw)`
- `candidate = candidate OR 2^2047`
- `candidate = candidate OR 1`
- Проверить:
- `bitLength(candidate) == 2048`
- `candidate odd`
- не делится на малые простые `<= 10000`
- `gcd(candidate - 1, 65537) == 1`
- проходит Miller-Rabin `64 rounds`
5. Базы Miller-Rabin детерминированные:
- `baseBytes = HMAC-SHA256(key = masterSeed32, message = UTF8(MR_LABEL) || UTF8("/") || UTF8(label) || UTF8("/") || uint64_be(index) || UTF8("/") || uint32_be(round))`
- `a = 2 + (unsigned_big_endian_integer(baseBytes) mod (candidate - 3))`
6. `p = derivePrime("p")`, `q = derivePrime("q")`.
7. Если `p == q`, продолжить поиск `q`.
8. Если `p > q`, поменять местами. В SAWD-v1 всегда `p < q`.
9. `n = p * q`
10. `e = 65537`
11. `lambda = lcm(p - 1, q - 1)`
12. `d = modular_inverse(e, lambda)`
13. `dp = d mod (p - 1)`
14. `dq = d mod (q - 1)`
15. `qi = modular_inverse(q, p)`
16. Сформировать JWK:
- `kty = "RSA"`
- `e = "AQAB"`
- `n,d,p,q,dp,dq,qi = base64url unsigned big-endian integer without padding`
17. `owner = jwk.n`
18. `address = base64url_no_padding(SHA-256(unsigned_big_endian_bytes(n)))`
## Запрещено
- `crypto.generateKeyPair`
- `WebCrypto generateKey`
- `KeyPairGenerator`
- `SecureRandom(seed)`
- `Math.random`
- системный `random`
- ArDrive CLI
- Turbo
- внешний API для генерации ключа
- сохранение приватного JWK
## Версионирование стандарта
Если меняется любая константа или шаг алгоритма — это уже **SAWD-v2**.
Пользователи, созданные на SAWD-v1, должны продолжать восстанавливаться через SAWD-v1.

View File

@ -6,8 +6,8 @@
<link rel="manifest" href="./manifest.webmanifest" />
<title>Shine UI Demo</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260413151200';
window.__SHINE_CLIENT_VERSION__ = '1.2.2';
window.__SHINE_BUILD_HASH__ = '20260426011500';
window.__SHINE_CLIENT_VERSION__ = '1.2.4';
</script>
<script>
(function attachStylesWithBuildHash() {

View File

@ -8,6 +8,13 @@ import {
requestAirdropSol,
transferSol,
} from '../services/solana-wallet-service.js';
import {
formatAr,
getArweaveBalance,
getArweaveTopupSiteUrl,
getArweaveWalletFromStoredDeviceKey,
transferAr,
} from '../services/arweave-wallet-service.js';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
@ -15,24 +22,101 @@ function nowRu() {
return new Date().toLocaleString('ru-RU');
}
function createModeBackButton(renderWalletChoice) {
const backBtn = document.createElement('button');
backBtn.className = 'text-btn';
backBtn.textContent = '← К выбору кошелька';
backBtn.addEventListener('click', () => {
renderWalletChoice();
});
return backBtn;
}
function sessionArgsOrThrow() {
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) {
throw new Error('Нет активной сессии. Выполните вход заново.');
}
return { login, storagePwd };
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const status = document.createElement('p');
status.className = 'meta-muted';
const setStatus = (text) => {
status.textContent = String(text || '');
};
const content = document.createElement('div');
content.className = 'stack';
screen.append(
renderHeader({
title: 'Кошелёк',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
content,
status,
);
let activeModeToken = 0;
let arweaveWalletCtx = null;
function clearArweaveSecretsInMemory() {
if (!arweaveWalletCtx?.jwk) return;
Object.keys(arweaveWalletCtx.jwk).forEach((key) => {
arweaveWalletCtx.jwk[key] = '';
});
arweaveWalletCtx.jwk = null;
arweaveWalletCtx = null;
}
function renderWalletChoice() {
activeModeToken += 1;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<h2 style="margin:0 0 6px;">Кошелёк</h2>
<p class="meta-muted">Выберите режим кошелька.</p>
`;
const solanaBtn = document.createElement('button');
solanaBtn.className = 'primary-btn';
solanaBtn.style.width = '100%';
solanaBtn.textContent = 'Solana кошелёк';
solanaBtn.addEventListener('click', () => {
void renderSolanaWallet();
});
const arweaveBtn = document.createElement('button');
arweaveBtn.className = 'primary-btn';
arweaveBtn.style.width = '100%';
arweaveBtn.textContent = 'Arweave кошелёк';
arweaveBtn.addEventListener('click', () => {
void renderArweaveWallet();
});
card.append(solanaBtn, arweaveBtn);
content.append(card);
setStatus('Выберите тип кошелька.');
}
async function renderSolanaWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
let walletCtx = null;
let walletAddress = '';
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Инициализация wallet.key...';
const screenTitle = 'Кошелёк';
screen.append(
renderHeader({
title: screenTitle,
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
);
const backBtn = createModeBackButton(renderWalletChoice);
const card = document.createElement('div');
card.className = 'card stack';
@ -59,6 +143,7 @@ export function render({ navigate }) {
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (wallet.key = device.key)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value"></p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
card.append(balanceWrap, addressCard);
@ -79,11 +164,6 @@ export function render({ navigate }) {
const refreshBtn = actions.querySelector('#refresh-balance');
const sendBtn = actions.querySelector('#send-sol');
const topupBtn = actions.querySelector('#topup-sol');
const addressEl = addressCard.querySelector('#wallet-address-value');
const setStatus = (text) => {
status.textContent = String(text || '');
};
const refreshBalance = async () => {
if (!walletAddress) {
@ -96,11 +176,13 @@ export function render({ navigate }) {
endpoint: state.entrySettings.solanaServer,
address: walletAddress,
});
if (modeToken !== activeModeToken) return;
balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
endpointLabel.textContent = `RPC: ${balance.endpoint}`;
setStatus('Баланс обновлён.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
@ -139,9 +221,11 @@ export function render({ navigate }) {
toAddress,
amountSol: Number(String(amountRaw || '').replace(',', '.')),
});
if (modeToken !== activeModeToken) return;
setStatus(`Перевод отправлен. Signature: ${tx.signature}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`);
} finally {
sendBtn.disabled = false;
@ -155,7 +239,7 @@ export function render({ navigate }) {
}
const openSite = window.confirm(
'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).'
'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).',
);
if (openSite) {
window.open(getTopupSiteUrl(), '_blank', 'noopener,noreferrer');
@ -170,31 +254,177 @@ export function render({ navigate }) {
address: walletAddress,
amountSol: 1,
});
if (modeToken !== activeModeToken) return;
setStatus(`Тестовое пополнение выполнено (airdrop 1 SOL). Signature: ${drop.signature}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка тестового пополнения: ${error?.message || 'unknown'}`);
} finally {
topupBtn.disabled = false;
}
});
(async () => {
content.append(backBtn, card, actions);
setStatus('Инициализация wallet.key...');
try {
walletCtx = await getWalletFromStoredDeviceKey({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
walletCtx = await getWalletFromStoredDeviceKey(sessionArgsOrThrow());
if (modeToken !== activeModeToken) return;
walletAddress = walletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'wallet.key недоступен';
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
}
})();
screen.append(card, actions, status);
return screen;
}
async function renderArweaveWallet() {
const modeToken = ++activeModeToken;
content.innerHTML = '';
let walletAddress = '';
const backBtn = createModeBackButton(renderWalletChoice);
const card = document.createElement('div');
card.className = 'card stack';
const balanceWrap = document.createElement('div');
const balanceLabel = document.createElement('p');
balanceLabel.className = 'meta-muted';
balanceLabel.textContent = 'Баланс (Arweave)';
const balanceValue = document.createElement('h2');
balanceValue.style.fontSize = '30px';
balanceValue.textContent = '— AR';
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
const gatewayLabel = document.createElement('p');
gatewayLabel.className = 'meta-muted';
gatewayLabel.textContent = `Gateway: ${state.entrySettings.arweaveServer}`;
balanceWrap.append(balanceLabel, balanceValue, updatedLabel, gatewayLabel);
const addressCard = document.createElement('div');
addressCard.className = 'card';
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес Arweave (SAWD-v1)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value"></p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
card.append(balanceWrap, addressCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<div class="row">
<button class="text-btn" id="copy-address">Копировать адрес</button>
<button class="ghost-btn" id="refresh-balance">Обновить баланс</button>
</div>
<div class="row">
<button class="primary-btn" id="send-ar" style="width:100%;">Перевести</button>
<button class="primary-btn" id="topup-ar" style="width:100%;">Пополнить</button>
</div>
`;
const copyBtn = actions.querySelector('#copy-address');
const refreshBtn = actions.querySelector('#refresh-balance');
const sendBtn = actions.querySelector('#send-ar');
const topupBtn = actions.querySelector('#topup-ar');
const refreshBalance = async () => {
if (!walletAddress) {
setStatus('Arweave-кошелёк не инициализирован.');
return;
}
refreshBtn.disabled = true;
try {
const balance = await getArweaveBalance({
gateway: state.entrySettings.arweaveServer,
address: walletAddress,
});
if (modeToken !== activeModeToken) return;
balanceValue.textContent = `${formatAr(balance.ar, 6)} AR`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
gatewayLabel.textContent = `Gateway: ${balance.gateway}`;
setStatus('Баланс обновлён.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
}
};
copyBtn.addEventListener('click', async () => {
if (!walletAddress) return;
try {
await navigator.clipboard.writeText(walletAddress);
setStatus('Адрес скопирован');
} catch {
setStatus('Не удалось скопировать адрес в этом браузере');
}
});
refreshBtn.addEventListener('click', () => {
void refreshBalance();
});
sendBtn.addEventListener('click', async () => {
if (!arweaveWalletCtx?.jwk) {
setStatus('Перевод недоступен: Arweave-кошелёк не инициализирован.');
return;
}
const toAddress = window.prompt('Введите адрес получателя (Arweave):', '');
if (!toAddress) return;
const amountRaw = window.prompt('Введите сумму AR для перевода:', '0.01');
if (!amountRaw) return;
sendBtn.disabled = true;
try {
const tx = await transferAr({
gateway: state.entrySettings.arweaveServer,
jwk: arweaveWalletCtx.jwk,
toAddress,
amountAr: amountRaw,
});
if (modeToken !== activeModeToken) return;
setStatus(`Перевод отправлен. Transaction ID: ${tx.id}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`);
} finally {
sendBtn.disabled = false;
}
});
topupBtn.addEventListener('click', () => {
window.open(getArweaveTopupSiteUrl(), '_blank', 'noopener,noreferrer');
setStatus('Открыта страница пополнения.');
});
content.append(backBtn, card, actions);
setStatus('Генерация Arweave-кошелька...');
try {
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey(sessionArgsOrThrow());
if (modeToken !== activeModeToken) return;
walletAddress = arweaveWalletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'wallet.key недоступен';
clearArweaveSecretsInMemory();
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
}
}
renderWalletChoice();
return screen;
}

View File

@ -0,0 +1,173 @@
import { loadEncryptedUserSecrets } from './key-vault.js';
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { deriveArweaveWalletFromDeviceKey32 } from './sawd-v1.js';
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
const AR_TOPUP_URL = 'https://changenow.io/exchange?from=usd&to=ar&amount=10&fiatMode=true';
const WINSTON_PER_AR = 1_000_000_000_000n;
let arweaveLibPromise = null;
function normalizeGateway(rawGateway) {
const raw = String(rawGateway || '').trim();
if (!raw) return DEFAULT_ARWEAVE_GATEWAY;
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new Error('Некорректный Arweave gateway URL');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Arweave gateway должен использовать http или https');
}
const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/+$/g, '');
return normalized || DEFAULT_ARWEAVE_GATEWAY;
}
function parseGatewayForArweaveInit(gateway) {
let parsed;
try {
parsed = new URL(gateway);
} catch {
throw new Error('Некорректный Arweave gateway URL');
}
const protocol = parsed.protocol.replace(':', '');
if (protocol !== 'http' && protocol !== 'https') {
throw new Error('Arweave gateway должен использовать http или https');
}
const port = parsed.port
? Number(parsed.port)
: (protocol === 'https' ? 443 : 80);
return {
protocol,
host: parsed.hostname,
port,
};
}
function parseArToWinston(amountAr) {
const raw = String(amountAr ?? '').trim().replace(',', '.');
const match = raw.match(/^(\d+)(?:\.(\d+))?$/);
if (!match) {
throw new Error('Сумма перевода должна быть числом');
}
const intPart = BigInt(match[1] || '0');
const frac = String(match[2] || '');
if (frac.length > 12) {
throw new Error('Слишком много знаков после запятой (максимум 12)');
}
const fracPadded = `${frac}${'0'.repeat(12 - frac.length)}`;
const winston = (intPart * WINSTON_PER_AR) + BigInt(fracPadded || '0');
if (winston <= 0n) {
throw new Error('Сумма перевода должна быть больше 0');
}
return winston.toString();
}
async function loadArweaveLib() {
if (!arweaveLibPromise) {
arweaveLibPromise = import('https://esm.sh/arweave@1.15.7?bundle');
}
return arweaveLibPromise;
}
export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к wallet.key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
const storedDeviceKey = String(secrets?.deviceKey || '').trim();
if (!storedDeviceKey) {
throw new Error('На устройстве не найден device.key (wallet.key)');
}
const deviceKey32 = extractDeviceKey32FromStoredValue(storedDeviceKey);
let wallet;
try {
wallet = await deriveArweaveWalletFromDeviceKey32(deviceKey32);
} finally {
deviceKey32.fill(0);
}
return {
derivation: wallet.derivation,
address: wallet.address,
owner: wallet.owner,
jwk: wallet.jwk,
};
}
export async function getArweaveBalance({ gateway, address }) {
const normalizedGateway = normalizeGateway(gateway);
const cleanAddress = String(address || '').trim();
if (!cleanAddress) {
throw new Error('Не указан адрес Arweave');
}
const url = `${normalizedGateway}/wallet/${encodeURIComponent(cleanAddress)}/balance`;
const response = await fetch(url, { method: 'GET' });
const rawText = (await response.text()).trim();
if (!response.ok) {
throw new Error(`Не удалось получить баланс Arweave (${response.status} ${response.statusText})`);
}
if (!/^\d+$/.test(rawText)) {
throw new Error('Arweave gateway вернул некорректный баланс');
}
const winstonBig = BigInt(rawText);
const ar = Number(winstonBig) / Number(WINSTON_PER_AR);
return {
gateway: normalizedGateway,
winston: rawText,
ar,
};
}
export function formatAr(value, digits = 6) {
const n = Number(value);
if (!Number.isFinite(n)) return '0';
return n.toLocaleString('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: digits,
});
}
export function getArweaveTopupSiteUrl() {
return AR_TOPUP_URL;
}
export async function transferAr({ gateway, jwk, toAddress, amountAr }) {
const cleanTo = String(toAddress || '').trim();
if (!cleanTo) throw new Error('Не указан адрес получателя Arweave');
if (!jwk || typeof jwk !== 'object') throw new Error('Кошелёк Arweave не инициализирован');
const normalizedGateway = normalizeGateway(gateway);
const { host, port, protocol } = parseGatewayForArweaveInit(normalizedGateway);
const winston = parseArToWinston(amountAr);
const moduleRef = await loadArweaveLib();
const Arweave = moduleRef?.default || moduleRef;
const arweave = Arweave.init({ host, port, protocol });
const tx = await arweave.createTransaction(
{
target: cleanTo,
quantity: winston,
},
jwk,
);
await arweave.transactions.sign(tx, jwk);
const postResult = await arweave.transactions.post(tx);
return {
gateway: normalizedGateway,
id: tx.id,
status: Number(postResult?.status || 0),
statusText: String(postResult?.statusText || ''),
};
}

View File

@ -0,0 +1,51 @@
import { base64ToBytes } from './crypto-utils.js';
function isByteArrayLike(value) {
return Array.isArray(value) || ArrayBuffer.isView(value);
}
function parseKeypairJson64(raw) {
const text = String(raw || '').trim();
if (!text.startsWith('[')) return null;
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (!isByteArrayLike(parsed)) return null;
const asArray = Array.from(parsed);
if (asArray.length < 32) {
throw new Error('Некорректный JSON ключ device.key: ожидалось минимум 32 байта');
}
const out = new Uint8Array(asArray.length);
for (let i = 0; i < asArray.length; i += 1) {
const n = Number(asArray[i]);
if (!Number.isInteger(n) || n < 0 || n > 255) {
throw new Error('Некорректный JSON ключ device.key: найдены не-байтовые значения');
}
out[i] = n;
}
return out;
}
export function extractSeed32FromPkcs8B64(pkcs8B64) {
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ device.key');
return bytes.slice(bytes.length - 32);
}
export function extractDeviceKey32FromStoredValue(storedDeviceKey) {
const raw = String(storedDeviceKey || '').trim();
if (!raw) throw new Error('Пустой device.key');
const jsonBytes = parseKeypairJson64(raw);
if (jsonBytes) {
return jsonBytes.slice(0, 32);
}
return extractSeed32FromPkcs8B64(raw);
}

View File

@ -0,0 +1,447 @@
const encoder = new TextEncoder();
const RSA_BITS = 4096;
const PRIME_BITS = 2048;
const PUBLIC_EXPONENT = 65537n;
export const DERIVATION_NAME = 'SAWD-v1';
export const MASTER_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MASTER';
export const STREAM_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/STREAM';
export const MR_LABEL = 'SHINE/ARWEAVE/RSA4096/SAWD-v1/MILLER-RABIN';
export const MILLER_RABIN_ROUNDS = 64;
export const SMALL_PRIME_LIMIT = 10000;
function getSubtle() {
const subtle = globalThis.crypto?.subtle;
if (!subtle) {
throw new Error('SAWD-v1 требует WebCrypto (crypto.subtle)');
}
return subtle;
}
function utf8(text) {
return encoder.encode(String(text));
}
function concatBytes(...chunks) {
const total = chunks.reduce((sum, part) => sum + (part?.length || 0), 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of chunks) {
if (!part?.length) continue;
out.set(part, offset);
offset += part.length;
}
return out;
}
function bytesToBase64(bytes) {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
function base64UrlToBytes(value) {
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
const padLen = (4 - (normalized.length % 4)) % 4;
const binary = atob(normalized + '='.repeat(padLen));
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
export function base64UrlEncode(bytes) {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function unsignedBytesToBigInt(bytes) {
let value = 0n;
for (const byte of bytes) {
value = (value << 8n) | BigInt(byte);
}
return value;
}
export function bigIntToUnsignedBytes(value) {
let v = BigInt(value);
if (v < 0n) throw new Error('Ожидалось неотрицательное BigInt значение');
if (v === 0n) return new Uint8Array([0]);
let hex = v.toString(16);
if (hex.length % 2 !== 0) hex = `0${hex}`;
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i += 1) {
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
function bitLength(v) {
return BigInt(v).toString(2).length;
}
function uint64be(counter) {
const out = new Uint8Array(8);
let v = BigInt(counter);
for (let i = 7; i >= 0; i -= 1) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
function uint32be(counter) {
const out = new Uint8Array(4);
let v = BigInt(counter);
for (let i = 3; i >= 0; i -= 1) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
async function importHmacKey(keyBytes) {
return getSubtle().importKey(
'raw',
keyBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
}
async function hmacSha256WithImportedKey(importedKey, messageBytes) {
const signed = await getSubtle().sign('HMAC', importedKey, messageBytes);
return new Uint8Array(signed);
}
export async function hmacSha256(keyBytes, messageBytes) {
const imported = await importHmacKey(keyBytes);
return hmacSha256WithImportedKey(imported, messageBytes);
}
export async function sha256(bytes) {
const digest = await getSubtle().digest('SHA-256', bytes);
return new Uint8Array(digest);
}
const STREAM_PREFIX = utf8(`${STREAM_LABEL}/`);
const MR_PREFIX = utf8(`${MR_LABEL}/`);
const SLASH = utf8('/');
const MASTER_LABEL_UTF8 = utf8(MASTER_LABEL);
const TWO = 2n;
const THREE = 3n;
const PRIME_HIGH_BIT_MASK = 1n << 2047n;
function makeSmallPrimes(limit) {
const sieve = new Uint8Array(limit + 1);
const primes = [];
for (let i = 2; i <= limit; i += 1) {
if (sieve[i]) continue;
primes.push(i);
for (let j = i * 2; j <= limit; j += i) {
sieve[j] = 1;
}
}
return primes;
}
const SMALL_PRIMES = makeSmallPrimes(SMALL_PRIME_LIMIT);
export async function deriveBytes(masterSeed32, label, length) {
const imported = await importHmacKey(masterSeed32);
return deriveBytesWithImportedKey(imported, label, length);
}
async function deriveBytesWithImportedKey(masterSeedKey, label, length) {
const target = Number(length);
if (!Number.isInteger(target) || target <= 0) {
throw new Error('deriveBytes: length должен быть положительным целым');
}
let counter = 0n;
const chunks = [];
let written = 0;
const labelBytes = utf8(String(label || ''));
while (written < target) {
const msg = concatBytes(STREAM_PREFIX, labelBytes, SLASH, uint64be(counter));
const block = await hmacSha256WithImportedKey(masterSeedKey, msg);
chunks.push(block);
written += block.length;
counter += 1n;
}
return concatBytes(...chunks).slice(0, target);
}
export function gcd(a, b) {
let x = BigInt(a);
let y = BigInt(b);
while (y !== 0n) {
const t = x % y;
x = y;
y = t;
}
return x < 0n ? -x : x;
}
export function lcm(a, b) {
const x = BigInt(a);
const y = BigInt(b);
return (x / gcd(x, y)) * y;
}
export function modPow(base, exponent, modulus) {
let b = BigInt(base) % BigInt(modulus);
let e = BigInt(exponent);
const m = BigInt(modulus);
let result = 1n;
while (e > 0n) {
if (e & 1n) result = (result * b) % m;
b = (b * b) % m;
e >>= 1n;
}
return result;
}
export function modInverse(a, modulus) {
let t = 0n;
let newT = 1n;
let r = BigInt(modulus);
let newR = BigInt(a) % BigInt(modulus);
while (newR !== 0n) {
const q = r / newR;
[t, newT] = [newT, t - q * newT];
[r, newR] = [newR, r - q * newR];
}
if (r !== 1n) throw new Error('Обратный элемент не существует');
if (t < 0n) t += BigInt(modulus);
return t;
}
function passesSmallPrimeFilter(candidate) {
for (const p of SMALL_PRIMES) {
const pBig = BigInt(p);
if (candidate === pBig) return true;
if (candidate % pBig === 0n) return false;
}
return true;
}
async function millerRabinWithImportedKey(masterSeedKey, candidate, label, index, rounds = MILLER_RABIN_ROUNDS) {
const n = BigInt(candidate);
if (n < 2n) return false;
if (n === 2n || n === 3n) return true;
if ((n & 1n) === 0n) return false;
let d = n - 1n;
let s = 0;
while ((d & 1n) === 0n) {
d >>= 1n;
s += 1;
}
const labelBytes = utf8(String(label || ''));
const indexBytes = uint64be(index);
const nMinusThree = n - THREE;
if (nMinusThree <= 0n) return false;
for (let round = 0; round < rounds; round += 1) {
const msg = concatBytes(MR_PREFIX, labelBytes, SLASH, indexBytes, SLASH, uint32be(round));
const baseBytes = await hmacSha256WithImportedKey(masterSeedKey, msg);
const a = TWO + (unsignedBytesToBigInt(baseBytes) % nMinusThree);
let x = modPow(a, d, n);
if (x === 1n || x === n - 1n) continue;
let witnessComposite = true;
for (let i = 1; i < s; i += 1) {
x = modPow(x, 2n, n);
if (x === n - 1n) {
witnessComposite = false;
break;
}
}
if (witnessComposite) return false;
}
return true;
}
export async function millerRabin(masterSeed32, candidate, label, index, rounds = MILLER_RABIN_ROUNDS) {
const imported = await importHmacKey(masterSeed32);
return millerRabinWithImportedKey(imported, candidate, label, index, rounds);
}
export async function isProbablePrime(masterSeed32, candidate, label, index) {
const imported = await importHmacKey(masterSeed32);
return isProbablePrimeWithImportedKey(imported, candidate, label, index);
}
async function isProbablePrimeWithImportedKey(masterSeedKey, candidate, label, index) {
const n = BigInt(candidate);
if ((n & 1n) === 0n) return false;
if (bitLength(n) !== PRIME_BITS) return false;
if (!passesSmallPrimeFilter(n)) return false;
if (gcd(n - 1n, PUBLIC_EXPONENT) !== 1n) return false;
return millerRabinWithImportedKey(masterSeedKey, n, label, index, MILLER_RABIN_ROUNDS);
}
async function tickEvery(iteration, step = 8n) {
if (iteration % step === 0n) {
await Promise.resolve();
}
}
export async function derivePrime(masterSeed32, label) {
const imported = await importHmacKey(masterSeed32);
return derivePrimeWithImportedKey(imported, label);
}
async function derivePrimeWithImportedKey(masterSeedKey, label, startIndex = 0n) {
let index = BigInt(startIndex);
while (true) {
const raw = await deriveBytesWithImportedKey(masterSeedKey, `${label}/${index}`, PRIME_BITS / 8);
let candidate = unsignedBytesToBigInt(raw);
candidate |= PRIME_HIGH_BIT_MASK;
candidate |= 1n;
if (bitLength(candidate) === PRIME_BITS && await isProbablePrimeWithImportedKey(masterSeedKey, candidate, label, index)) {
return { prime: candidate, index };
}
index += 1n;
await tickEvery(index);
}
}
function toJwkB64(value) {
return base64UrlEncode(bigIntToUnsignedBytes(value));
}
async function deriveArweaveWalletParts(deviceKey32) {
if (!(deviceKey32 instanceof Uint8Array)) {
throw new Error('SAWD-v1: deviceKey32 должен быть Uint8Array');
}
if (deviceKey32.length !== 32) {
throw new Error('SAWD-v1: deviceKey32 должен быть ровно 32 байта');
}
const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, deviceKey32);
const masterSeedKey = await importHmacKey(masterSeed32);
const pResult = await derivePrimeWithImportedKey(masterSeedKey, 'p');
let qResult = await derivePrimeWithImportedKey(masterSeedKey, 'q');
while (qResult.prime === pResult.prime) {
qResult = await derivePrimeWithImportedKey(masterSeedKey, 'q', qResult.index + 1n);
}
let p = pResult.prime;
let q = qResult.prime;
if (p > q) {
[p, q] = [q, p];
}
const n = p * q;
const lambda = lcm(p - 1n, q - 1n);
const d = modInverse(PUBLIC_EXPONENT, lambda);
const dp = d % (p - 1n);
const dq = d % (q - 1n);
const qi = modInverse(q, p);
const jwk = {
kty: 'RSA',
e: 'AQAB',
n: toJwkB64(n),
d: toJwkB64(d),
p: toJwkB64(p),
q: toJwkB64(q),
dp: toJwkB64(dp),
dq: toJwkB64(dq),
qi: toJwkB64(qi),
};
const owner = jwk.n;
const address = base64UrlEncode(await sha256(bigIntToUnsignedBytes(n)));
return {
derivation: DERIVATION_NAME,
jwk,
owner,
address,
_private: {
p,
q,
n,
},
};
}
export async function deriveArweaveWalletFromDeviceKey32(deviceKey32) {
const result = await deriveArweaveWalletParts(deviceKey32);
return {
derivation: result.derivation,
jwk: result.jwk,
owner: result.owner,
address: result.address,
};
}
export async function selfTestSawdV1() {
const invalid = new Uint8Array(31);
let invalidFailed = false;
try {
await deriveArweaveWalletFromDeviceKey32(invalid);
} catch {
invalidFailed = true;
}
if (!invalidFailed) {
throw new Error('SAWD-v1 self-test: длина != 32 должна приводить к ошибке');
}
const deviceKey = new Uint8Array(32);
for (let i = 0; i < deviceKey.length; i += 1) {
deviceKey[i] = i + 1;
}
const first = await deriveArweaveWalletParts(deviceKey);
const second = await deriveArweaveWalletParts(deviceKey);
if (!first.address || !second.address) {
throw new Error('SAWD-v1 self-test: адрес пустой');
}
if (first.address !== second.address) {
throw new Error('SAWD-v1 self-test: адрес должен быть детерминированным');
}
if (first.jwk.n !== second.jwk.n) {
throw new Error('SAWD-v1 self-test: jwk.n должен быть детерминированным');
}
if (first.jwk.e !== 'AQAB') {
throw new Error('SAWD-v1 self-test: jwk.e должен быть AQAB');
}
if (first.owner !== first.jwk.n) {
throw new Error('SAWD-v1 self-test: owner должен совпадать с jwk.n');
}
if (!(first._private.p < first._private.q)) {
throw new Error('SAWD-v1 self-test: ожидается p < q');
}
if (bitLength(first._private.n) !== RSA_BITS) {
throw new Error('SAWD-v1 self-test: ожидается n с длиной 4096 бит');
}
const pFromJwk = unsignedBytesToBigInt(base64UrlToBytes(first.jwk.p));
const qFromJwk = unsignedBytesToBigInt(base64UrlToBytes(first.jwk.q));
if (!(pFromJwk < qFromJwk)) {
throw new Error('SAWD-v1 self-test: p и q в JWK должны удовлетворять p < q');
}
return {
ok: true,
derivation: DERIVATION_NAME,
address: first.address,
};
}

View File

@ -1,4 +1,5 @@
import { base64ToBytes, deriveEd25519FromPassword } from './crypto-utils.js';
import { deriveEd25519FromPassword } from './crypto-utils.js';
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
@ -19,15 +20,9 @@ async function loadSolanaLib() {
return solanaLibPromise;
}
function extractSeed32FromPkcs8B64(pkcs8B64) {
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ device.key');
return bytes.slice(bytes.length - 32);
}
async function keypairFromPkcs8(pkcs8B64) {
const solana = await loadSolanaLib();
const seed32 = extractSeed32FromPkcs8B64(pkcs8B64);
const seed32 = extractDeviceKey32FromStoredValue(pkcs8B64);
return solana.Keypair.fromSeed(seed32);
}
@ -123,4 +118,3 @@ export function formatSol(value, digits = 6) {
export function getTopupSiteUrl() {
return TOPUP_SITE_URL;
}