Добавить SAWD-v1 и Arweave-кошелек в UI
This commit is contained in:
parent
c8fa4a01a1
commit
126cf2f5c3
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.3
|
client.version=1.2.4
|
||||||
server.version=1.2.3
|
server.version=1.2.4
|
||||||
|
|||||||
104
docs/SHINE_ARWEAVE_DERIVATION_V1.md
Normal file
104
docs/SHINE_ARWEAVE_DERIVATION_V1.md
Normal 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.
|
||||||
@ -6,8 +6,8 @@
|
|||||||
<link rel="manifest" href="./manifest.webmanifest" />
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
<title>Shine UI Demo</title>
|
<title>Shine UI Demo</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260413151200';
|
window.__SHINE_BUILD_HASH__ = '20260426011500';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.2';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.4';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachStylesWithBuildHash() {
|
(function attachStylesWithBuildHash() {
|
||||||
|
|||||||
@ -8,6 +8,13 @@ import {
|
|||||||
requestAirdropSol,
|
requestAirdropSol,
|
||||||
transferSol,
|
transferSol,
|
||||||
} from '../services/solana-wallet-service.js';
|
} 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: 'Кошелёк' };
|
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
||||||
|
|
||||||
@ -15,24 +22,101 @@ function nowRu() {
|
|||||||
return new Date().toLocaleString('ru-RU');
|
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
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 walletCtx = null;
|
||||||
let walletAddress = '';
|
let walletAddress = '';
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const backBtn = createModeBackButton(renderWalletChoice);
|
||||||
status.className = 'meta-muted';
|
|
||||||
status.textContent = 'Инициализация wallet.key...';
|
|
||||||
|
|
||||||
const screenTitle = 'Кошелёк';
|
|
||||||
screen.append(
|
|
||||||
renderHeader({
|
|
||||||
title: screenTitle,
|
|
||||||
leftAction: { label: '←', onClick: () => navigate('profile-view') },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack';
|
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 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>
|
<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);
|
card.append(balanceWrap, addressCard);
|
||||||
|
|
||||||
@ -79,11 +164,6 @@ export function render({ navigate }) {
|
|||||||
const refreshBtn = actions.querySelector('#refresh-balance');
|
const refreshBtn = actions.querySelector('#refresh-balance');
|
||||||
const sendBtn = actions.querySelector('#send-sol');
|
const sendBtn = actions.querySelector('#send-sol');
|
||||||
const topupBtn = actions.querySelector('#topup-sol');
|
const topupBtn = actions.querySelector('#topup-sol');
|
||||||
const addressEl = addressCard.querySelector('#wallet-address-value');
|
|
||||||
|
|
||||||
const setStatus = (text) => {
|
|
||||||
status.textContent = String(text || '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshBalance = async () => {
|
const refreshBalance = async () => {
|
||||||
if (!walletAddress) {
|
if (!walletAddress) {
|
||||||
@ -96,11 +176,13 @@ export function render({ navigate }) {
|
|||||||
endpoint: state.entrySettings.solanaServer,
|
endpoint: state.entrySettings.solanaServer,
|
||||||
address: walletAddress,
|
address: walletAddress,
|
||||||
});
|
});
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`;
|
balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`;
|
||||||
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
|
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
|
||||||
endpointLabel.textContent = `RPC: ${balance.endpoint}`;
|
endpointLabel.textContent = `RPC: ${balance.endpoint}`;
|
||||||
setStatus('Баланс обновлён.');
|
setStatus('Баланс обновлён.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`);
|
setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`);
|
||||||
} finally {
|
} finally {
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
@ -139,9 +221,11 @@ export function render({ navigate }) {
|
|||||||
toAddress,
|
toAddress,
|
||||||
amountSol: Number(String(amountRaw || '').replace(',', '.')),
|
amountSol: Number(String(amountRaw || '').replace(',', '.')),
|
||||||
});
|
});
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
setStatus(`Перевод отправлен. Signature: ${tx.signature}`);
|
setStatus(`Перевод отправлен. Signature: ${tx.signature}`);
|
||||||
await refreshBalance();
|
await refreshBalance();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`);
|
setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`);
|
||||||
} finally {
|
} finally {
|
||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
@ -155,7 +239,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openSite = window.confirm(
|
const openSite = window.confirm(
|
||||||
'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).'
|
'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).',
|
||||||
);
|
);
|
||||||
if (openSite) {
|
if (openSite) {
|
||||||
window.open(getTopupSiteUrl(), '_blank', 'noopener,noreferrer');
|
window.open(getTopupSiteUrl(), '_blank', 'noopener,noreferrer');
|
||||||
@ -170,31 +254,177 @@ export function render({ navigate }) {
|
|||||||
address: walletAddress,
|
address: walletAddress,
|
||||||
amountSol: 1,
|
amountSol: 1,
|
||||||
});
|
});
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
setStatus(`Тестовое пополнение выполнено (airdrop 1 SOL). Signature: ${drop.signature}`);
|
setStatus(`Тестовое пополнение выполнено (airdrop 1 SOL). Signature: ${drop.signature}`);
|
||||||
await refreshBalance();
|
await refreshBalance();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
setStatus(`Ошибка тестового пополнения: ${error?.message || 'unknown'}`);
|
setStatus(`Ошибка тестового пополнения: ${error?.message || 'unknown'}`);
|
||||||
} finally {
|
} finally {
|
||||||
topupBtn.disabled = false;
|
topupBtn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(async () => {
|
content.append(backBtn, card, actions);
|
||||||
|
setStatus('Инициализация wallet.key...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
walletCtx = await getWalletFromStoredDeviceKey({
|
walletCtx = await getWalletFromStoredDeviceKey(sessionArgsOrThrow());
|
||||||
login: state.session.login,
|
if (modeToken !== activeModeToken) return;
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
});
|
|
||||||
walletAddress = walletCtx.address;
|
walletAddress = walletCtx.address;
|
||||||
addressEl.textContent = walletAddress;
|
addressEl.textContent = walletAddress;
|
||||||
await refreshBalance();
|
await refreshBalance();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
addressEl.textContent = 'wallet.key недоступен';
|
addressEl.textContent = 'wallet.key недоступен';
|
||||||
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
173
shine-UI/js/services/arweave-wallet-service.js
Normal file
173
shine-UI/js/services/arweave-wallet-service.js
Normal 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 || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
51
shine-UI/js/services/device-key-utils.js
Normal file
51
shine-UI/js/services/device-key-utils.js
Normal 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);
|
||||||
|
}
|
||||||
447
shine-UI/js/services/sawd-v1.js
Normal file
447
shine-UI/js/services/sawd-v1.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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';
|
import { loadEncryptedUserSecrets } from './key-vault.js';
|
||||||
|
|
||||||
const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
|
const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
|
||||||
@ -19,15 +20,9 @@ async function loadSolanaLib() {
|
|||||||
return solanaLibPromise;
|
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) {
|
async function keypairFromPkcs8(pkcs8B64) {
|
||||||
const solana = await loadSolanaLib();
|
const solana = await loadSolanaLib();
|
||||||
const seed32 = extractSeed32FromPkcs8B64(pkcs8B64);
|
const seed32 = extractDeviceKey32FromStoredValue(pkcs8B64);
|
||||||
return solana.Keypair.fromSeed(seed32);
|
return solana.Keypair.fromSeed(seed32);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,4 +118,3 @@ export function formatSol(value, digits = 6) {
|
|||||||
export function getTopupSiteUrl() {
|
export function getTopupSiteUrl() {
|
||||||
return TOPUP_SITE_URL;
|
return TOPUP_SITE_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user