Обновление архитектуры ключей и кошельков

This commit is contained in:
AidarKC
2026-10-01 17:11:38 +03:00
parent f452ce1f1e
commit 8d22602761
51 changed files with 565 additions and 898 deletions
@@ -7,7 +7,7 @@ package blockchain.body;
* - external target: login + forkNumber + blockNumber + blockHash;
* - own EDIT target: blockNumber + blockHash only;
* - logical identity NEVER includes forkNumber: login + blockNumber + blockHash;
* - forkNumber is informational/historical only (0 = unknown/reserved).
* - forkNumber is informational/historical only and must be in 1..999.
*/
public interface BodyHasTarget {
/** Target login; null for own-chain compact EDIT targets. */
@@ -24,6 +24,7 @@ final class BodyTargetSupport {
if (login.isBlank()) throw new IllegalArgumentException(ctx + " toLogin is blank");
int forkNumber = Short.toUnsignedInt(bb.getShort());
validateForkNumber(forkNumber, ctx);
int blockNumber = bb.getInt();
if (blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber < 0");
@@ -81,8 +82,8 @@ final class BodyTargetSupport {
}
static void validateForkNumber(int forkNumber, String ctx) {
if (forkNumber < 0 || forkNumber > 999) {
throw new IllegalArgumentException(ctx + " toForkNumber must be 0..999");
if (forkNumber < 1 || forkNumber > 999) {
throw new IllegalArgumentException(ctx + " toForkNumber must be 1..999");
}
}
@@ -84,10 +84,16 @@ final class TargetLoginFormatTest {
}
@Test
void forkZeroIsReservedButStillDecodableAsHistoricalUnknown() {
ReactionBody source = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 0, 42, HASH);
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
assertEquals(0, parsed.toForkNumber());
void forkZeroIsRejectedForNewProtocol() {
assertThrows(IllegalArgumentException.class,
() -> new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 0, 42, HASH));
byte[] encoded = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 1, 42, HASH).toBytes();
int forkOffset = 1 + "alice".getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
encoded[forkOffset] = 0;
encoded[forkOffset + 1] = 0;
assertThrows(IllegalArgumentException.class,
() -> new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, encoded));
}
@Test
@@ -86,7 +86,7 @@ public class BlockEntry {
}
public Integer getToForkNumber() { return toForkNumber; }
public void setToForkNumber(Integer toForkNumber) { if (toForkNumber != null && (toForkNumber < 0 || toForkNumber > 999)) throw new IllegalArgumentException("toForkNumber must be 0..999"); this.toForkNumber = toForkNumber; }
public void setToForkNumber(Integer toForkNumber) { if (toForkNumber != null && (toForkNumber < 1 || toForkNumber > 999)) throw new IllegalArgumentException("toForkNumber must be 1..999"); this.toForkNumber = toForkNumber; }
public Integer getToBlockNumber() { return toBlockNumber; }
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
@@ -523,7 +523,7 @@ CREATE TABLE IF NOT EXISTS blocks (
block_bytes BYTEA NOT NULL,
to_login TEXT,
to_bch_name TEXT,
to_fork_number INTEGER CHECK (to_fork_number IS NULL OR (to_fork_number >= 0 AND to_fork_number <= 999)),
to_fork_number INTEGER CHECK (to_fork_number IS NULL OR (to_fork_number >= 1 AND to_fork_number <= 999)),
to_block_number INTEGER CHECK (to_block_number IS NULL OR to_block_number >= 0),
to_block_hash BYTEA,
block_hash BYTEA NOT NULL,
@@ -540,11 +540,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
}
if (serverLastNum == -1 && block.body instanceof HeaderBody headerBody
&& !Arrays.equals(headerBody.initialBlockchainKey32, pubKey32)) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_initial_key_mismatch", serverLastNum, serverLastHashHex);
}
// 6) ANS-104 Ed25519 подпись и owner
boolean sigOk;
try {
@@ -0,0 +1,48 @@
# Переработать authority Solana PDA через Recovery key
## Статус
**Не делать в текущем patch.** Solana smart contract и существующие правила `shine_users` пока остаются без изменений.
Причина: текущий контракт рабочий, а окончательная модель прав ещё требует отдельного решения и тестов. Не смешивать эту переработку с UI/кошельками/хранением ключей.
## Текущая модель
- обычные `update_user_pda` авторизуются активным `blockchain key`;
- полная ротация root + client + blockchain использует старый `root/recovery key` как дополнительное recovery-разрешение;
- новый blockchain key подписывает новое состояние по текущему протоколу;
- увеличение платного лимита также проходит через текущую схему обычного update.
## Желаемое направление
Recovery key логически должен быть главным ключом для критического управления аккаунтом, например:
- смена recovery/root key;
- смена blockchain key / создание нового fork;
- смена client key;
- смена access server;
- изменение критических настроек серверов/доступа.
При этом простое **увеличение оплаченного лимита** не должно заставлять пользователя вводить пароль и временно восстанавливать Recovery private key.
## Предпочтительный вариант для исследования
Не исключать отдельные поля PDA из цифровой подписи. Подпись должна покрывать всё авторизуемое состояние целиком.
Вместо этого разделить Solana-инструкции по полномочиям:
1. `top_up_limit(...)` — узкая экономическая инструкция, которая умеет только увеличить `paid_limit`/`additional_limit` и физически не может менять identity, ключи или серверы. Recovery не требуется.
2. `rotate_keys(...)` / критический `update_account(...)` — изменение root/client/blockchain identity, требует Recovery-authority; отдельно решить, нужна ли совместная подпись текущего blockchain key.
3. `update_servers(...)` — критические server/access-server изменения, вероятно требуют Recovery-authority; окончательное правило зафиксировать после threat-model.
Возможная усиленная схема для критических изменений: текущий blockchain key + текущий Recovery key подписывают один и тот же hash полного нового состояния, а при ротации новый blockchain key дополнительно доказывает владение новым ключом.
## Обязательные тесты перед изменением контракта
- compromised blockchain key не может захватить identity без Recovery;
- Recovery может безопасно восстановить аккаунт после компрометации blockchain key;
- top-up лимита не может изменить ни один identity/server field;
- replay старой подписи невозможен;
- смена любого ключа атомарна;
- fee payer не обязан совпадать с Recovery key;
- crash/retry не оставляет PDA в промежуточном состоянии.
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.14.3
server.version=1.12.1
client.version=1.14.4
server.version=1.12.2
+3 -3
View File
@@ -285,9 +285,9 @@
## `KeyRotationContinue`
Продолжает интерактивные post-PDA этапы. В текущей версии два будущих этапа являются честными заглушками:
Продолжает интерактивные post-PDA этапы. Сервер по-прежнему не хранит приватные ключи и сам не переводит средства. В текущем UI перед вызовом `KeyRotationContinue` на этапе `WALLET_MIGRATION` пользователь может перевести весь доступный SOL со старого blockchain-wallet на новый (или явно пропустить перенос). После этого сервер фиксирует этап как `NOT_IMPLEMENTED`/завершённый placeholder и переводит процесс дальше.
- `WALLET_MIGRATION`: `walletMigrationStatus = NOT_IMPLEMENTED`, затем переход в `MESSAGE_MIGRATION`;
- `WALLET_MIGRATION`: клиентский UI выполняет перенос SOL локально, используя временно доступный старый blockchain private key; сервер только продолжает state machine. Перенос AR/Turbo пока TODO;
- `MESSAGE_MIGRATION`: `messageMigrationStatus = NOT_IMPLEMENTED`, затем `FINALIZING -> COMPLETE`.
Request:
@@ -300,7 +300,7 @@ Request:
}
```
`KeyRotationContinue` не переводит деньги и не перешифровывает DM. Это специально оставленные точки расширения для будущей реализации.
`KeyRotationContinue` сам не переводит деньги и не перешифровывает DM. SOL-перевод реализован на стороне UI до этого запроса; серверная часть WALLET_MIGRATION остаётся точкой координации. AR/Turbo и DM остаются отдельными точками расширения.
## `KeyRotationAbort`
+1 -1
View File
@@ -70,7 +70,7 @@ TEXT-тип хранит сообщения, материалы и редакт
[32] toBlockHash32
```
`toForkNumber`: `1..999` — fork, который видел автор действия; `0` зарезервирован как unknown/legacy. Поле информационное и **не входит в логическую идентичность цели**. Цель определяется только `toLogin + toBlockGlobalNumber + toBlockHash32`; несовпадение текущего fork само по себе не инвалидирует ссылку.
`toForkNumber`: строго `1..999` — fork, который видел автор действия. Поле информационное и **не входит в логическую идентичность цели**. Цель определяется только `toLogin + toBlockGlobalNumber + toBlockHash32`; несовпадение текущего fork само по себе не инвалидирует ссылку.
Собственные edit (`TEXT_EDIT_POST`, `TEXT_EDIT_REPLY`) используют компактный target:
+1 -1
View File
@@ -24,4 +24,4 @@
[32] toBlockHash32
```
`toForkNumber` — только историческая метка (`1..999`, `0` reserved unknown/legacy). Логическая идентичность реакции: `toLogin + toBlockGlobalNumber + toBlockHash32`. При fork номер fork может измениться; если number+hash сохранены в новой ветке, LIKE/UNLIKE продолжает указывать на тот же логический блок.
`toForkNumber` — только историческая метка и всегда находится в диапазоне `1..999`. Логическая идентичность реакции: `toLogin + toBlockGlobalNumber + toBlockHash32`. При fork номер fork может измениться; если number+hash сохранены в новой ветке, LIKE/UNLIKE продолжает указывать на тот же логический блок.
+1 -1
View File
@@ -30,7 +30,7 @@ CONNECTION-тип описывает социальные связи и подп
[32] toBlockHash32
```
`toForkNumber` хранит fork, который видел автор связи (`1..999`), но не участвует в identity. `0` зарезервирован как unknown/legacy; `1000+` недопустим. Для связи на пользователя используется **реальный HEADER hash**: `toBlockGlobalNumber=0`, `toBlockHash32=SHA-256(Frame HEADER)`. Нулевой hash запрещён.
`toForkNumber` хранит fork, который видел автор связи (`1..999`), но не участвует в identity. Значения `0` и `1000+` недопустимы. Для связи на пользователя используется **реальный HEADER hash**: `toBlockGlobalNumber=0`, `toBlockHash32=SHA-256(Frame HEADER)`. Нулевой hash запрещён.
## Правила target
+1 -1
View File
@@ -48,7 +48,7 @@
Где:
- `toLogin` — login владельца целевого блока;
- `toForkNumber` — историческая метка fork (`1..999`, `0` reserved unknown/legacy), не часть identity;
- `toForkNumber` — историческая метка fork (`1..999`), не часть identity;
- `toBlockGlobalNumber` — номер целевого блока;
- `toBlockHash32` — хэш целевого блока;
- `text` — опциональное пояснение пользователя к статусу.
@@ -17,7 +17,7 @@ Block 0 новой пользовательской истории — `HEADER`:
SHiNE + login + initialBlockchainKey32
```
`initialBlockchainKey32` — public blockchain-signing key fork №1. При смене ключа сохранённый префикс, включая HEADER, перепубликуется с тем же Frame; поэтому это поле остаётся исходным ключом, а новый ключ конкретного fork определяется подписью/owner и PDA.
`initialBlockchainKey32` — public blockchain-signing key fork №1. Это только историческая запись происхождения цепочки. При смене ключа сохранённый префикс, включая HEADER, перепубликуется с тем же Frame; поле остаётся исходным ключом и не сравнивается с текущим ключом fork. Текущий ключ конкретного fork определяется подписью/owner и PDA.
## 3. Внешний target
@@ -31,7 +31,7 @@ SHiNE + login + initialBlockchainKey32
[32] toBlockHash32
```
`toForkNumber` — информационная историческая метка: fork, на который смотрел автор действия. `0` зарезервирован как unknown/legacy и не является реальным fork. Поле не участвует в identity и не должно сравниваться с текущим active fork как условие валидности.
`toForkNumber` — информационная историческая метка: fork, на который смотрел автор действия. Допустимы только реальные fork `1..999`; `0` запрещён. Поле не участвует в identity и не должно сравниваться с текущим active fork как условие валидности.
Логическая identity цели:
+16 -4
View File
@@ -55,8 +55,8 @@ seed(32) = SHA-256(material)
| Ключ | Суффикс | Назначение (кратко) |
|------|---------|---------------------|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
| blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. |
| client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. |
| blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является каноническим источником пользовательских кошельков: Solana и Arweave SAWD-v1. |
| client | `client.key` | Общий клиентский ключ для входа на сервер, устройств и DM. Не используется как источник штатных кошельков. |
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
Полные роли каждого ключа — в `docs/Keys/README.md`.
@@ -75,9 +75,21 @@ seed(32) = SHA-256(material)
`root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork.
`client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька.
`client.key` не используется как wallet/fee payer. Он остаётся клиентским криптографическим ключом для серверной авторизации, устройств и DM. Нативный Arweave SAWD-v1 wallet теперь детерминированно выводится из активного `blockchain.key`, как и Solana-wallet.
При fork Solana-адрес меняется вместе с blockchain key. Перенос SOL со старого blockchain-wallet на новый является отдельным необязательным этапом ротации; в текущей первой реализации этот этап оставлен явной заглушкой.
При fork адреса кошельков меняются вместе с blockchain key. В UI ротации реализован отдельный этап переноса доступного SOL со старого blockchain-wallet на новый за вычетом комиссии сети. Перенос AR/Turbo при ротации пока отмечен отдельным TODO.
## 3.1. Хранение приватных ключей в текущем UI
После обычного входа или подключения нового доверенного устройства UI автоматически сохраняет в зашифрованном локальном контейнере два рабочих приватных ключа:
- `blockchain.key`;
- `client.key`.
Приватный `root.key` (Recovery key) **никогда не сохраняется** в IndexedDB/localStorage и **никогда не передаётся** при pairing/QR. Для операций, которым он нужен по текущему Solana-протоколу, root временно выводится из введённого пользователем пароля и существует только в оперативной памяти процесса. Публичный root key, записанный в PDA, этим правилом не затрагивается.
Ограниченный режим устройства архитектурно возможен благодаря разделению ключей, но в текущем релизе не включён: обычное авторизованное устройство всегда получает и хранит оба рабочих ключа.
---
+20 -30
View File
@@ -8,16 +8,16 @@
В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк.
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя.
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является.
- `root key` / **ключ восстановления** - recovery-authority. Его приватная часть не хранится на устройствах и временно выводится из пароля только для операций, где нужна по текущему протоколу. Это не кошелёк.
- `blockchain key` / **ключ блокчейна** - постоянно хранимый рабочий ключ записи в персональный SHiNE-блокчейн и канонический источник пользовательских кошельков (Solana, Arweave SAWD-v1, Turbo через Solana).
- `client key` / **ключ доступа** - постоянно хранимый рабочий ключ для входа на сервер, устройства, звонков и DM. Кошельки из него не выводятся.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
В текущем релизе обычное авторизованное устройство автоматически хранит два рабочих ключа: `blockchain key` и `client key`. Ограниченного режима пока нет. Приватный `root key` не сохраняется и не передаётся между устройствами.
## `root key`
`root key` - главный ключ пользователя.
`root key` - ключ восстановления пользователя. Его приватная часть не является постоянно сохранённым ключом устройства.
Назначение:
@@ -27,7 +27,7 @@
Обычные PDA-update **не требуют root key**: их выполняет активный blockchain key. При полной ротации старый root подписывает тот же hash нового unsigned PDA state, который подписывает новый blockchain key. Так root разрешает переход, не становясь повседневным ключом.
Важно не путать recovery-authority и кошелёк: `root key` не является fee payer. Текущий Solana-wallet/fee payer — активный `blockchain key`. Подробнее — `docs/Keys/DERIVATION.md`, §3.
Важно не путать recovery-authority и кошелёк: `root key` не является fee payer. Его публичная часть хранится в PDA, а приватная часть в текущем UI выводится из пароля только на время защищённой операции и затем не сохраняется. Текущий Solana-wallet/fee payer — активный `blockchain key`. Подробнее — `docs/Keys/DERIVATION.md`, §3.
## `blockchain key`
@@ -38,7 +38,9 @@
- подпись записей в персональном блокчейне пользователя;
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
- обычные обновления пользовательской PDA;
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`).
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`);
- источник штатного Arweave SAWD-v1 wallet;
- ключ Solana-кошелька, которым пополняется Turbo.
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
@@ -58,15 +60,9 @@
- повседневные входящие и исходящие личные сообщения;
- звонки и связанные с ними сообщения;
- self-messages, то есть внутренние сообщения пользователя самому себе;
- derivation Arweave-кошелька;
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
- self-messages, то есть внутренние сообщения пользователя самому себе.
Arweave-кошелёк должен выводиться из `client key` по протоколу:
- `docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md`
Если пользователь теряет только `client key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными.
Штатные кошельки из `client key` не выводятся. Arweave SAWD-v1 использует `blockchain key`: `docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md`.
## `session key`
@@ -82,7 +78,7 @@ Arweave-кошелёк должен выводиться из `client key` по
- авторизация сессии на сервере;
- привязка устройства к пользователю;
- подтверждение запросов от конкретной сессии;
- доступ к зашифрованному `client key` после успешной авторизации.
- доступ к локально зашифрованным рабочим ключам (`client key` и `blockchain key`) после успешной авторизации.
Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает.
@@ -102,22 +98,16 @@ Arweave-кошелёк должен выводиться из `client key` по
- обычная пользовательская сессия;
- серверная сессия;
- аппаратная или доверенная сессия с доступом к расширенным ключам.
- аппаратная или доверенная сессия для будущих специализированных сценариев.
Обычное устройство обычно имеет:
Обычное авторизованное устройство в текущем релизе имеет:
- собственный `session key`;
- зашифрованный `client key`, который открывается после авторизации;
- доступ к DM, звонкам и обычным пользовательским операциям.
- зашифрованный `client key`;
- зашифрованный `blockchain key`;
- доступ к DM, блокчейн-действиям и пользовательским кошелькам.
Доверенное серверное или аппаратное устройство может иметь:
- `root key`;
- `blockchain key`;
- `client key`;
- собственный `session key`.
Такая сессия может подписывать операции повышенной важности по запросам пользователя.
Приватный `root key` не хранится даже на обычном доверенном устройстве и не передаётся при подключении другого устройства. Будущий аппаратный режим может расширить эту модель отдельно, но текущий UI такого режима не включает.
## Внутренние self-messages
@@ -126,7 +116,7 @@ Self-message - это сообщение пользователя самому
Такие сообщения нужны, чтобы обычное устройство могло попросить доверенное устройство выполнить действие:
- подписать запись `blockchain key` и передать её в SHiNE-блокчейн;
- подписать изменение настройки через `root key`;
- инициировать защищённую настройку, после чего Recovery key при необходимости должен быть временно выведен из пароля на устройстве пользователя;
- обновить ключи;
- сохранить внутреннюю команду или настройку;
- отправить сообщение другому пользователю с сохранением копии себе;
@@ -163,7 +153,7 @@ Self-message - это сообщение пользователя самому
- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
- `docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств.
- `docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации.
- `docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `client key`.
- `docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `blockchain key`.
## Что нужно уточнить перед реализацией
@@ -3,11 +3,11 @@
Сокращение: **SAWD-v1**.
## Назначение
Из 32-байтного `clientKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address.
Из 32-байтного приватного seed активного `blockchainKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address.
## Вход
- `clientKey32`: ровно 32 байта.
- Если исходный `client.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8.
- `blockchainKey32`: ровно 32 байта.
- Если исходный `blockchain.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8.
- Если используется Solana keypair JSON на 64 байта, используются только `bytes[0..31]`.
## Выход
@@ -46,8 +46,8 @@
- `SMALL_PRIME_LIMIT = 10000`
## Алгоритм
1. Проверить `clientKey32.length == 32`.
2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = clientKey32)`.
1. Проверить `blockchainKey32.length == 32`.
2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = blockchainKey32)`.
3. Реализовать `deriveBytes(label, length)`:
- `output = empty`
- `counter = 0`
@@ -71,9 +71,9 @@
- `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`.
7. Продолжать детерминированный поиск `q` (`q/index+1`, `q/index+2`, ...), если `p == q` **или** `bitLength(p * q) != 4096`. Это гарантирует настоящий 4096-битный RSA modulus, а не допустимый математически, но неподходящий для стандарта 4095-битный product двух 2048-битных простых.
8. Если `p > q`, поменять местами. В SAWD-v1 всегда `p < q`.
9. `n = p * q`
9. `n = p * q`; обязательно `bitLength(n) == 4096`
10. `e = 65537`
11. `lambda = lcm(p - 1, q - 1)`
12. `d = modular_inverse(e, lambda)`
@@ -101,4 +101,4 @@
## Версионирование стандарта
Если меняется любая константа или шаг алгоритма — это уже **SAWD-v2**.
Пользователи, созданные на SAWD-v1, должны продолжать восстанавливаться через SAWD-v1.
До публичного запуска legacy-совместимость не требуется. После запуска источник и алгоритм SAWD-v1 считаются частью стабильного формата: изменение источника ключа или любой константы потребует новой версии стандарта.
-4
View File
@@ -58,7 +58,6 @@ import * as entrySettingsView from './pages/entry-settings-view.js?v=20260926090
import * as registerView from './pages/register-view.js?v=202609260900';
import * as registrationFaqView from './pages/registration-faq-view.js?v=202609260900';
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
import * as registrationKeysView from './pages/registration-keys-view.js';
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
import * as topupView from './pages/topup-view.js';
import * as devnetTopupView from './pages/devnet-topup-view.js';
@@ -66,7 +65,6 @@ import * as loginView from './pages/login-view.js?v=202609260900';
import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
import * as keyStorageView from './pages/key-storage-view.js';
import * as publicSupportQueueView from './pages/public-support-queue-view.js';
import * as profileView from './pages/profile-view.js?v=202609260900';
@@ -130,7 +128,6 @@ const routes = {
'register-view': registerView,
'registration-faq-view': registrationFaqView,
'registration-payment-view': registrationPaymentView,
'registration-keys-view': registrationKeysView,
'registration-draft-keys-view': registrationDraftKeysView,
'topup-view': topupView,
'devnet-topup-view': devnetTopupView,
@@ -138,7 +135,6 @@ const routes = {
'login-camera-view': loginCameraView,
'login-other-device-view': loginOtherDeviceView,
'login-password-view': loginPasswordView,
'key-storage-view': keyStorageView,
queue: publicSupportQueueView,
'profile-view': profileView,
'profile-edit-view': profileEditView,
@@ -21,7 +21,6 @@ import {
const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1';
const LAST_WALLET_KEY_PREFIX = 'shine-ui-arweave-last-wallet-v1:';
const LAST_TURBO_KEY_SOURCE_PREFIX = 'shine-ui-turbo-last-key-source-v1:';
const MAX_HISTORY_ITEMS = 100;
const RECENT_UPLOAD_MS = 20 * 60 * 1000;
@@ -171,10 +170,6 @@ function lastWalletStorageKey(login) {
return `${LAST_WALLET_KEY_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`;
}
function lastTurboKeySourceStorageKey(login) {
return `${LAST_TURBO_KEY_SOURCE_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`;
}
function readLastWalletId(login) {
try {
return sessionStorage.getItem(lastWalletStorageKey(login)) || '';
@@ -192,23 +187,6 @@ function writeLastWalletId(login, walletId) {
}
}
function readLastTurboKeySource(login, fallback = 'client') {
try {
return sessionStorage.getItem(lastTurboKeySourceStorageKey(login)) || String(fallback || 'client');
} catch {
return String(fallback || 'client');
}
}
function writeLastTurboKeySource(login, keySource) {
try {
const clean = String(keySource || '').trim() || 'client';
sessionStorage.setItem(lastTurboKeySourceStorageKey(login), clean);
} catch {
// ignore sessionStorage errors
}
}
async function resizeImageFileToAvatar(file) {
const source = file instanceof File ? file : null;
if (!source) throw new Error('Выберите изображение.');
@@ -315,8 +293,8 @@ export function openArweaveAttachmentManager({
let closed = false;
let wallets = [];
let turboKeyChoices = [];
let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key';
let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource);
let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-blockchain-key';
const selectedTurboKeySource = 'blockchain';
let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'arweave' ? 'arweave' : 'turbo';
let selectedFile = null;
let selectedSha256 = '';
@@ -394,7 +372,7 @@ export function openArweaveAttachmentManager({
try {
wallets = await getArweaveWalletChoices({ login: cleanLogin, storagePwd: cleanStoragePwd });
if (!wallets.some((item) => String(item.id) === String(selectedWalletId))) {
selectedWalletId = String(wallets[0]?.id || 'derived-client-key');
selectedWalletId = String(wallets[0]?.id || 'derived-blockchain-key');
}
writeLastWalletId(cleanLogin, selectedWalletId);
} catch (error) {
@@ -408,12 +386,11 @@ export function openArweaveAttachmentManager({
login: cleanLogin,
storagePwd: cleanStoragePwd,
});
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain');
if (!turboKeyChoices.some((item) => String(item.keySource) === 'blockchain')) {
throw new Error('На устройстве нет ключа блокчейна.');
}
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
} catch (error) {
setText(errorEl, error?.message || 'Не удалось загрузить Solana-ключи для Turbo.');
setText(errorEl, error?.message || 'Не удалось загрузить ключ блокчейна для Turbo.');
}
};
@@ -633,8 +610,7 @@ export function openArweaveAttachmentManager({
? `
<p class="ar-attachment-note">Маленькие файлы и аватары через Turbo пока загружаются бесплатно. Для больших файлов пополните Turbo со своего SOL-кошелька.</p>
<div class="form-field">
<label for="turbo-key-source">Подписывать и пополнять через</label>
<select class="select ar-attachment-wallet-select" id="turbo-key-source"></select>
<span class="meta-muted">Подпись и пополнение: ключ блокчейна пользователя.</span>
</div>
`
: `
@@ -671,7 +647,6 @@ export function openArweaveAttachmentManager({
`;
bindBackdrop();
const walletEl = root.querySelector('#ar-attach-wallet');
const turboKeyEl = root.querySelector('#turbo-key-source');
const fileEl = root.querySelector('#ar-attach-file');
const mainMetaEl = root.querySelector('[data-meta-main="true"]');
const previewMetaEl = root.querySelector('[data-meta-preview="true"]');
@@ -705,17 +680,6 @@ export function openArweaveAttachmentManager({
if (turboMode) {
await loadTurboKeyChoices(errorEl);
if (turboKeyEl) {
turboKeyEl.innerHTML = turboKeyChoices.map((item) => (
`<option value="${escapeHtml(item.keySource)}">${escapeHtml(item.label)} · ${escapeHtml(shortAddress(item.address))}</option>`
)).join('');
turboKeyEl.value = selectedTurboKeySource;
turboKeyEl.addEventListener('change', async () => {
selectedTurboKeySource = String(turboKeyEl.value || 'client');
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
});
}
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
} else {
await loadWallets(errorEl);
+6 -25
View File
@@ -174,17 +174,7 @@ function createPasswordModal() {
<span class="field-label">Пароль аккаунта</span>
<input class="input" id="access-servers-password-input" type="password" autocomplete="current-password" placeholder="Введите пароль" />
</label>
<div class="stack" style="gap:0.45rem;">
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать blockchain key только сейчас</span>
</label>
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить blockchain key на этом устройстве</span>
</label>
</div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
<p class="meta-muted">После проверки пароля рабочие ключи будут восстановлены и сохранены на этом устройстве. Приватный ключ восстановления не сохраняется.</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
@@ -244,11 +234,7 @@ function createPasswordModal() {
inputEl.focus();
return;
}
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
close({
password,
saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
});
close({ password });
});
window.setTimeout(() => inputEl.focus(), 0);
});
@@ -494,10 +480,10 @@ export function render({navigate, chrome}) {
const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Рабочие ключи не найдены в локальном зашифрованном контейнере устройства.',
note: savedClient
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
: 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
? 'Ключ доступа уже сохранён. Из пароля будет восстановлен ключ блокчейна.'
: 'На устройстве не хватает ключа блокчейна и/или ключа доступа. Они будут восстановлены из пароля аккаунта.',
});
if (!passwordResult) {
throw new Error('Операция отменена пользователем.');
@@ -514,12 +500,7 @@ export function render({navigate, chrome}) {
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
}
if (passwordResult.saveBlockchain) {
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
saveRoot: false,
saveBlockchain: true,
});
}
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle);
return {
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
-1
View File
@@ -15,7 +15,6 @@ import {
getBalanceSol,
getSolanaWalletFromStoredSecret,
getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64,
transferSol,
} from '../services/solana-wallet-service.js';
-1
View File
@@ -7,7 +7,6 @@ import {
getBalanceSol,
getSolanaWalletFromStoredSecret,
getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64,
transferSol,
} from '../services/solana-wallet-service.js';
+24 -75
View File
@@ -9,17 +9,18 @@ export function render({navigate, chrome}) {
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
title: 'Подключить устройство',
back: { label: '←', onClick: () => navigate('device-view') },
}));
title: 'Подключить устройство',
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<p>Выберите, какие ключи передать на подключаемое устройство</p>
<label class="checkbox-row"><input type="checkbox" id="connect-root" ${state.deviceConnect.root ? 'checked' : ''} /> root key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-blockchain" ${state.deviceConnect.blockchain ? 'checked' : ''} /> blockchain key</label>
<label class="checkbox-row"><input type="checkbox" id="connect-device" checked disabled /> client key</label>
<div class="stack" style="gap:6px;">
<p class="field-label">Что будет передано</p>
<p class="meta-muted">Новое устройство автоматически получит ключ доступа и ключ блокчейна.</p>
<p class="meta-muted">Ключ восстановления никогда не передаётся и не сохраняется на устройствах.</p>
</div>
<p class="meta-muted" id="connect-keys-status">Проверяем ключи на этом устройстве...</p>
<div class="row">
<button class="icon-btn small-btn" type="button" id="tech-help">Техсправка</button>
@@ -31,24 +32,9 @@ export function render({navigate, chrome}) {
</div>
`;
const rootToggle = card.querySelector('#connect-root');
const blockchainToggle = card.querySelector('#connect-blockchain');
const deviceToggle = card.querySelector('#connect-device');
const statusEl = card.querySelector('#connect-keys-status');
const openQrBtn = card.querySelector('#open-qr');
const openPairBtn = card.querySelector('#open-pairing');
deviceToggle.checked = true;
rootToggle.addEventListener('change', () => {
state.deviceConnect.root = rootToggle.checked;
});
blockchainToggle.addEventListener('change', () => {
state.deviceConnect.blockchain = blockchainToggle.checked;
});
deviceToggle.addEventListener('change', () => {
state.deviceConnect.client = true;
deviceToggle.checked = true;
});
const helpModal = document.createElement('div');
helpModal.className = 'modal-shell';
@@ -61,15 +47,10 @@ export function render({navigate, chrome}) {
<button class="icon-btn" type="button" data-close="true" aria-label="Закрыть">✕</button>
</div>
<div class="stack" style="gap:6px;">
<p class="meta-muted">пользователь выбирает ключи для передачи</p>
<p class="meta-muted">передать можно только существующие ключи</p>
<p class="meta-muted">если ключа нет – он недоступен</p>
<p class="meta-muted">blockchain key – можно передать или нет</p>
<p class="meta-muted">root key – только если существует</p>
<p class="meta-muted">client key передаётся всегда</p>
<p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p>
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
<p class="meta-muted">client key (ключ доступа) передаётся всегда</p>
<p class="meta-muted">blockchain key (ключ блокчейна) передаётся всегда</p>
<p class="meta-muted">root/recovery private key никогда не передаётся</p>
<p class="meta-muted">QR-сценарий передаёт рабочие ключи напрямую между устройствами</p>
<p class="meta-muted">для сценария через сервер используйте кнопку «Подключить по коду»</p>
</div>
<button class="primary-btn" type="button" data-close="true">OK</button>
@@ -78,12 +59,9 @@ export function render({navigate, chrome}) {
const openHelp = () => {
helpModal.hidden = false;
helpModal.querySelector('.modal-dialog').focus();
};
const closeHelp = () => {
helpModal.hidden = true;
helpModal.querySelector('.modal-dialog')?.focus();
};
const closeHelp = () => { helpModal.hidden = true; };
card.querySelector('#tech-help').addEventListener('click', openHelp);
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
@@ -96,61 +74,32 @@ export function render({navigate, chrome}) {
throw new Error('Нет активной сессии');
}
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const hasRoot = Boolean(savedKeys.rootKey);
const hasBlockchain = Boolean(savedKeys.blockchainKey);
const hasClient = Boolean(savedKeys.clientKey);
rootToggle.disabled = !hasRoot;
blockchainToggle.disabled = !hasBlockchain;
deviceToggle.disabled = true;
state.deviceConnect.root = hasRoot && rootToggle.checked;
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
state.deviceConnect.client = hasClient;
rootToggle.checked = state.deviceConnect.root;
blockchainToggle.checked = state.deviceConnect.blockchain;
deviceToggle.checked = hasClient;
openQrBtn.disabled = !hasClient;
openPairBtn.disabled = !hasClient;
const available = [
hasClient ? 'client' : '',
hasBlockchain ? 'blockchain' : '',
hasRoot ? 'root' : '',
].filter(Boolean);
statusEl.textContent = available.length
? `На этом устройстве доступны: ${available.join(', ')}.`
: 'На этом устройстве нет сохранённых ключей для передачи.';
const ready = hasBlockchain && hasClient;
openQrBtn.disabled = !ready;
openPairBtn.disabled = !ready;
statusEl.textContent = ready
? 'Ключ доступа и ключ блокчейна готовы к безопасной передаче.'
: 'На этом устройстве не хватает рабочих ключей. Выполните вход по паролю заново.';
} catch {
rootToggle.disabled = true;
blockchainToggle.disabled = true;
deviceToggle.checked = false;
state.deviceConnect.root = false;
state.deviceConnect.blockchain = false;
state.deviceConnect.client = false;
openQrBtn.disabled = true;
openPairBtn.disabled = true;
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
statusEl.textContent = 'Не удалось прочитать рабочие ключи на этом устройстве.';
}
})();
helpModal.addEventListener('click', (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.dataset.close === 'true') {
closeHelp();
}
if (target instanceof HTMLElement && target.dataset.close === 'true') closeHelp();
});
helpModal.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeHelp();
}
if (event.key === 'Escape') closeHelp();
});
screen.append(card);
const modalRoot = document.getElementById('modal-root');
modalRoot?.append(helpModal);
screen.cleanup = () => {
helpModal.remove();
};
screen.cleanup = () => { helpModal.remove(); };
return screen;
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
promptPwaInstall,
} from '../services/pwa-install-service.js';
import { initPwaPush } from '../services/pwa-push-service.js';
import { getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
import { getArweaveWalletFromStoredBlockchainKey } from '../services/arweave-wallet-service.js';
import {
prepareAvatarImageFile,
uploadArweaveFile,
@@ -144,7 +144,7 @@ function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {
+ `(${optimized.width}x${optimized.height}, ${optimized.contentType})`,
);
walletCtx = await getArweaveWalletFromStoredClientKey({
walletCtx = await getArweaveWalletFromStoredBlockchainKey({
login: walletLogin,
storagePwd,
onStatus: (message) => setMeta(message),
+22 -125
View File
@@ -37,24 +37,18 @@ function pairingSessionKindLabel(sessionType) {
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
}
function buildTransferKeys(savedKeys, selection = {}) {
function buildTransferKeys(savedKeys) {
const keys = {
clientKey: String(savedKeys?.clientKey || '').trim(),
blockchainKey: '',
rootKey: '',
blockchainKey: String(savedKeys?.blockchainKey || '').trim(),
};
if (!keys.clientKey) {
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
}
if (selection.blockchain && savedKeys?.blockchainKey) {
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
}
if (selection.root && savedKeys?.rootKey) {
keys.rootKey = String(savedKeys.rootKey || '').trim();
if (!keys.clientKey || !keys.blockchainKey) {
throw new Error('На этом устройстве нет полного набора рабочих ключей. Выполните вход по паролю заново.');
}
return keys;
}
function requestCardHtml(request) {
const shortCode = formatPairingShortCode(request?.shortCode || '');
const client = String(request?.requesterClientPlatform || 'unknown');
@@ -166,45 +160,12 @@ export function render({navigate, chrome}) {
let settingsBusy = false;
let pairingPasswordConfigured = false;
let dialogMode = '';
let pendingTransferRequest = null;
chrome?.setTopbar(createTopBar({
title: 'Подключить по коду',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const transferDialog = document.createElement('div');
transferDialog.className = 'pairing-transfer-dialog';
transferDialog.hidden = true;
transferDialog.innerHTML = `
<div class="pairing-transfer-dialog__backdrop" data-transfer-action="cancel"></div>
<section class="card stack pairing-transfer-dialog__card" role="dialog" aria-modal="true" aria-labelledby="pairing-transfer-title">
<div class="stack" style="gap:5px;">
<h2 class="login-panel-title" id="pairing-transfer-title">Подключить и передать ключи</h2>
<p class="meta-muted">Client key передаётся всегда. Дополнительные ключи можно передать только если они есть на этом устройстве.</p>
</div>
<div class="pairing-transfer-key-list">
<label class="pairing-transfer-key is-required">
<input type="checkbox" checked disabled />
<span><strong>Client key</strong><small>Обязателен для клиентского устройства</small></span>
</label>
<label class="pairing-transfer-key" data-transfer-key="root">
<input type="checkbox" id="pairing-transfer-root" checked />
<span><strong>Root key</strong><small>Передать на новое устройство</small></span>
</label>
<label class="pairing-transfer-key" data-transfer-key="blockchain">
<input type="checkbox" id="pairing-transfer-blockchain" checked />
<span><strong>Blockchain key</strong><small>Передать на новое устройство</small></span>
</label>
</div>
<div class="row pairing-transfer-dialog__actions">
<button class="ghost-btn" type="button" data-transfer-action="cancel">Отмена</button>
<button class="primary-btn pairing-approve-btn" type="button" data-transfer-action="confirm">Подключить</button>
</div>
</section>
`;
screen.append(transferDialog);
const settingsCard = document.createElement('div');
settingsCard.className = 'card stack';
const passwordIcons = makePasswordToggleIcons();
@@ -212,8 +173,9 @@ export function render({navigate, chrome}) {
const keySummaryCard = document.createElement('div');
keySummaryCard.className = 'card stack';
keySummaryCard.innerHTML = `
<p class="field-label">Что передаётся при расширенном подключении</p>
<p class="field-label">Что передаётся новому устройству</p>
<p class="meta-muted" id="pairing-key-summary">Проверяем локальные ключи...</p>
<p class="meta-muted">Ключ восстановления никогда не передаётся.</p>
`;
const requestsCard = document.createElement('div');
@@ -449,13 +411,11 @@ export function render({navigate, chrome}) {
const loadSavedKeys = async () => {
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const available = [];
if (savedKeys?.clientKey) available.push('client');
if (savedKeys?.blockchainKey) available.push('blockchain');
if (savedKeys?.rootKey) available.push('root');
keySummaryEl.textContent = available.length > 1
? `Доступны для подключения: ${available.join(', ')}.`
: 'На этом устройстве доступен только client key.';
const hasClient = !!String(savedKeys?.clientKey || '').trim();
const hasBlockchain = !!String(savedKeys?.blockchainKey || '').trim();
keySummaryEl.textContent = hasClient && hasBlockchain
? 'Будут переданы: ключ доступа и ключ блокчейна.'
: 'На устройстве не хватает рабочего набора ключей. Выполните вход по паролю заново.';
};
const reloadRequests = async ({ silent = false } = {}) => {
@@ -478,8 +438,7 @@ export function render({navigate, chrome}) {
refreshBtn.disabled = flag;
};
const approveRequest = async (request, selection = {}) => {
const withExtras = !!selection.root || !!selection.blockchain;
const approveRequest = async (request) => {
let payload;
if (Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
@@ -495,81 +454,26 @@ export function render({navigate, chrome}) {
session: delegatedSession,
});
} else {
const keys = buildTransferKeys(savedKeys, selection);
const keys = buildTransferKeys(savedKeys);
payload = buildSecretsPayload({
login: state.session.login,
keys,
mode: withExtras ? 'with-extras' : 'device-only',
mode: 'full-device',
});
}
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
const sessionOnly = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
showToast(
withExtras
? 'Ключи переданы на новое устройство'
: sessionOnly
? 'Wallet-session выпущена для нового устройства'
: 'Новое устройство подключено',
);
setAuthInfo(
withExtras
? 'Заявка подтверждена, ключи переданы.'
: sessionOnly
? 'Заявка подтверждена, для нового устройства создана отдельная wallet-session без передачи постоянных ключей.'
: 'Заявка подтверждена без передачи доп. ключей.',
);
showToast(sessionOnly
? 'Wallet-session выпущена для нового устройства'
: 'Ключ доступа и ключ блокчейна переданы на новое устройство');
setAuthInfo(sessionOnly
? 'Заявка подтверждена, для нового устройства создана отдельная wallet-session без передачи постоянных ключей.'
: 'Заявка подтверждена. Рабочие ключи переданы, ключ восстановления не передавался.');
await refreshSessions().catch(() => {});
await reloadRequests({ silent: true });
};
const closeTransferDialog = () => {
pendingTransferRequest = null;
transferDialog.hidden = true;
};
const openTransferDialog = (request) => {
pendingTransferRequest = request;
const rootRow = transferDialog.querySelector('[data-transfer-key="root"]');
const blockchainRow = transferDialog.querySelector('[data-transfer-key="blockchain"]');
const rootInput = transferDialog.querySelector('#pairing-transfer-root');
const blockchainInput = transferDialog.querySelector('#pairing-transfer-blockchain');
const hasRoot = !!String(savedKeys?.rootKey || '').trim();
const hasBlockchain = !!String(savedKeys?.blockchainKey || '').trim();
rootRow.hidden = !hasRoot;
blockchainRow.hidden = !hasBlockchain;
rootInput.checked = hasRoot;
blockchainInput.checked = hasBlockchain;
transferDialog.hidden = false;
};
transferDialog.addEventListener('click', async (event) => {
const target = event.target instanceof Element ? event.target.closest('[data-transfer-action]') : null;
const action = String(target?.dataset?.transferAction || '');
if (!action) return;
if (action === 'cancel') {
closeTransferDialog();
return;
}
if (action !== 'confirm' || !pendingTransferRequest) return;
const request = pendingTransferRequest;
const confirmBtn = transferDialog.querySelector('[data-transfer-action="confirm"]');
confirmBtn.disabled = true;
try {
await approveRequest(request, {
root: !!transferDialog.querySelector('#pairing-transfer-root')?.checked,
blockchain: !!transferDialog.querySelector('#pairing-transfer-blockchain')?.checked,
});
closeTransferDialog();
} catch (error) {
const message = toUserMessage(error, 'Не удалось подключить устройство.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
confirmBtn.disabled = false;
}
});
passwordDialog.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
@@ -674,14 +578,7 @@ export function render({navigate, chrome}) {
buttons.forEach((btn) => { btn.disabled = true; });
try {
if (action === 'approve-device') {
const isWallet = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
const hasExtras = !!String(savedKeys?.rootKey || '').trim() || !!String(savedKeys?.blockchainKey || '').trim();
if (!isWallet && hasExtras) {
openTransferDialog(request);
buttons.forEach((btn) => { btn.disabled = false; });
return;
}
await approveRequest(request, {});
await approveRequest(request);
} else if (action === 'reject') {
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
showToast('Заявка отклонена', { kind: 'error' });
+9 -8
View File
@@ -14,9 +14,9 @@ export function render({navigate, chrome}) {
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
title: 'Показать QR-код',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
title: 'Показать QR-код',
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack qr-card';
@@ -24,6 +24,7 @@ export function render({navigate, chrome}) {
<div class="qr-image" id="device-transfer-qr" aria-label="QR-код для переноса ключей"></div>
<p class="meta-muted" id="device-transfer-login">Логин: ...</p>
<p class="meta-muted" id="device-transfer-keys">Ключи: ...</p>
<p class="meta-muted">Ключ восстановления в QR-код не включается.</p>
<p class="status-line is-unavailable" id="device-transfer-status" style="display:none;"></p>
<button class="primary-btn" type="button" id="qr-ok">OK</button>
`;
@@ -40,11 +41,12 @@ export function render({navigate, chrome}) {
}
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const keys = {
clientKey: savedKeys.clientKey || '',
blockchainKey: state.deviceConnect.blockchain ? (savedKeys.blockchainKey || '') : '',
rootKey: state.deviceConnect.root ? (savedKeys.rootKey || '') : '',
clientKey: String(savedKeys.clientKey || '').trim(),
blockchainKey: String(savedKeys.blockchainKey || '').trim(),
};
if (!keys.clientKey) throw new Error('На этом устройстве нет client key');
if (!keys.clientKey || !keys.blockchainKey) {
throw new Error('На этом устройстве нет полного набора рабочих ключей');
}
const qrText = makeKeyTransferText({ login: state.session.login, keys });
qrEl.innerHTML = renderQrSvg(qrText);
@@ -60,7 +62,6 @@ export function render({navigate, chrome}) {
})();
card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
screen.append(card);
return screen;
}
+58 -3
View File
@@ -3,6 +3,7 @@ import { authService, state } from '../state.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { readShineUserPda } from '../services/shine-user-pda-service.js';
import { KeyRotationClient, KEY_ROTATION_REASONS } from '../services/key-rotation-service.js';
import { formatSol, getBalanceSol, getSolanaWalletFromStoredSecret, transferAllSol } from '../services/solana-wallet-service.js';
export const pageMeta = { id:'key-rotation-view', title:'Смена ключей', hideToolbar:true };
@@ -56,12 +57,66 @@ export function render({navigate,chrome}){
async function renderChainReady(s){
const oldP=input('Текущий пароль');const newP=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Изменить ключи в Solana';const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';const err=document.createElement('div');err.className='meta-muted';
go.addEventListener('click',async()=>{go.disabled=true;try{cachedOldBundle=await derive(oldP.el.value);cachedNewBundle=await derive(newP.el.value);await client.rotatePda({login,solanaEndpoint,oldBundle:cachedOldBundle,newBundle:cachedNewBundle,storagePwd});await refresh();}catch(e){err.textContent=e?.message||String(e);go.disabled=false;}});abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(statusCard(s),note('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый root разрешит атомарную смену root + client + blockchain fork, а новое PDA подпишет новый blockchain key.'),oldP.wrap,newP.wrap,go,abort,err);
go.addEventListener('click',async()=>{go.disabled=true;try{cachedOldBundle=await derive(oldP.el.value);cachedNewBundle=await derive(newP.el.value);await client.rotatePda({login,solanaEndpoint,oldBundle:cachedOldBundle,newBundle:cachedNewBundle,storagePwd});await refresh();}catch(e){err.textContent=e?.message||String(e);go.disabled=false;}});abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(statusCard(s),note('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый ключ восстановления временно используется для атомарной смены recovery + client + blockchain fork, а новое PDA подпишет новый blockchain key. Приватный ключ восстановления на устройстве не сохраняется.'),oldP.wrap,newP.wrap,go,abort,err);
}
function pollView(s){setRoot(statusCard(s),note(s.rotationStatus==='REBUILDING_SERVER'?'Сервер перестраивает текущую рабочую историю по новому fork.':'Ожидаем подтверждение нового состояния PDA в Solana.'));timer=setTimeout(()=>void refresh(),POLL_MS);}
function placeholderView(s,kind){const wallet=kind==='wallet';const text=wallet?'Перевод SOL со старого blockchain-wallet на новый пока не реализован. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.':'Перешифрование старых личных сообщений новым client key пока не реализовано. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.';const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить';go.addEventListener('click',async()=>{go.disabled=true;try{const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();}catch(e){go.disabled=false;root.append(note(e?.message||String(e)));}});setRoot(statusCard(s),note(text),go);}
async function renderWalletMigration(s){
const oldP=cachedOldBundle?null:input('Старый пароль');
const newP=cachedNewBundle?null:input('Новый пароль');
const info=note('После смены blockchain key меняется и Solana-кошелёк. На этом шаге можно перевести весь доступный SOL со старого кошелька на новый за вычетом сетевой комиссии.');
const warning=note('Сейчас автоматически переносится только SOL. Миграцию AR/Turbo при смене blockchain key нужно доработать отдельно.');
const details=document.createElement('div');details.className='card stack';details.innerHTML='<span class="meta-muted">Проверьте старый и новый кошельки.</span>';
const prepare=document.createElement('button');prepare.className='secondary-btn';prepare.type='button';prepare.textContent='Проверить кошельки';
const transfer=document.createElement('button');transfer.className='primary-btn';transfer.type='button';transfer.textContent='Перевести SOL и продолжить';transfer.disabled=true;
const skip=document.createElement('button');skip.className='secondary-btn';skip.type='button';skip.textContent='Пропустить перенос';
const err=document.createElement('div');err.className='meta-muted';
let oldWallet=null,newWallet=null,oldBalance=null;
async function refresh(){if(disposed)return;if(timer){clearTimeout(timer);timer=null;}try{const s=await client.status();const st=String(s.rotationStatus||'NONE');if(st==='NONE'){await renderNone();return;}if(st==='COPYING_CHAIN'){if(cachedNewBundle)await runCopy(s);else await askNewPasswordAndCopy(s);return;}if(st==='CHAIN_READY'){await renderChainReady(s);return;}if(['ROTATING_PDA','PDA_ROTATED','REBUILDING_SERVER'].includes(st)){pollView(s);return;}if(st==='WALLET_MIGRATION'){placeholderView(s,'wallet');return;}if(st==='MESSAGE_MIGRATION'){placeholderView(s,'messages');return;}if(st==='FINALIZING'){const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();return;}if(st==='COMPLETE'){setRoot(note('Смена ключей завершена.'));return;}setRoot(note(`Неизвестное состояние ротации: ${st}`));}catch(e){setRoot(note(`Не удалось прочитать состояние смены ключей: ${e?.message||e}`));}}
const resolveBundles=async()=>{
if(!cachedOldBundle){if(!oldP?.el.value)throw new Error('Введите старый пароль');cachedOldBundle=await derive(oldP.el.value);}
if(!cachedNewBundle){if(!newP?.el.value)throw new Error('Введите новый пароль');cachedNewBundle=await derive(newP.el.value);}
if(String(cachedOldBundle?.blockchainPair?.publicKeyB64||'')!==String(s.oldBlockchainKey||''))throw new Error('Старый пароль выводит другой blockchain key');
if(String(cachedNewBundle?.blockchainPair?.publicKeyB64||'')!==String(s.newBlockchainKey||''))throw new Error('Новый пароль выводит другой blockchain key');
};
const prepareWallets=async()=>{
await resolveBundles();
oldWallet=await getSolanaWalletFromStoredSecret(cachedOldBundle.blockchainPair.privatePkcs8B64);
newWallet=await getSolanaWalletFromStoredSecret(cachedNewBundle.blockchainPair.privatePkcs8B64);
const [oldB,newB]=await Promise.all([
getBalanceSol({endpoint:solanaEndpoint,address:oldWallet.address}),
getBalanceSol({endpoint:solanaEndpoint,address:newWallet.address}),
]);
oldBalance=oldB;
details.innerHTML=`<strong>Старый кошелёк</strong><span class="meta-muted">${h(oldWallet.address)}</span><span>${h(formatSol(oldB.sol,9))} SOL</span><strong>Новый кошелёк</strong><span class="meta-muted">${h(newWallet.address)}</span><span>${h(formatSol(newB.sol,9))} SOL</span>`;
transfer.textContent=Number(oldB.lamports||0)>0?'Перевести SOL и продолжить':'Средств нет — продолжить';
transfer.disabled=false;
};
prepare.addEventListener('click',async()=>{prepare.disabled=true;transfer.disabled=true;err.textContent='';try{await prepareWallets();}catch(e){err.textContent=e?.message||String(e);}finally{prepare.disabled=false;}});
transfer.addEventListener('click',async()=>{transfer.disabled=true;prepare.disabled=true;skip.disabled=true;err.textContent='';try{
if(!oldWallet||!newWallet||!oldBalance)await prepareWallets();
if(Number(oldBalance?.lamports||0)>0){
const result=await transferAllSol({endpoint:solanaEndpoint,fromKeypair:oldWallet.keypair,toAddress:newWallet.address});
details.insertAdjacentHTML('beforeend',`<span class="meta-muted">${result.skipped?'Перевод не потребовался.':`Переведено ${h(formatSol(Number(result.lamports)/1_000_000_000,9))} SOL. Tx: ${h(result.signature)}`}</span>`);
}
const next=await client.continuePlaceholder();
if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}
await refresh();
}catch(e){err.textContent=e?.message||String(e);transfer.disabled=false;prepare.disabled=false;skip.disabled=false;}});
skip.addEventListener('click',async()=>{skip.disabled=true;transfer.disabled=true;prepare.disabled=true;err.textContent='';try{
const next=await client.continuePlaceholder();
if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}
await refresh();
}catch(e){err.textContent=e?.message||String(e);skip.disabled=false;transfer.disabled=!oldWallet;prepare.disabled=false;}});
const nodes=[statusCard(s),info,warning];if(oldP)nodes.push(oldP.wrap);if(newP)nodes.push(newP.wrap);nodes.push(details,prepare,transfer,skip,err);setRoot(...nodes);
if(cachedOldBundle&&cachedNewBundle)void prepareWallets().catch(e=>{err.textContent=e?.message||String(e);});
}
function placeholderView(s){const text='Перешифрование старых личных сообщений новым client key пока не реализовано. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.';const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить';go.addEventListener('click',async()=>{go.disabled=true;try{const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();}catch(e){go.disabled=false;root.append(note(e?.message||String(e)));}});setRoot(statusCard(s),note(text),go);}
async function refresh(){if(disposed)return;if(timer){clearTimeout(timer);timer=null;}try{const s=await client.status();const st=String(s.rotationStatus||'NONE');if(st==='NONE'){await renderNone();return;}if(st==='COPYING_CHAIN'){if(cachedNewBundle)await runCopy(s);else await askNewPasswordAndCopy(s);return;}if(st==='CHAIN_READY'){await renderChainReady(s);return;}if(['ROTATING_PDA','PDA_ROTATED','REBUILDING_SERVER'].includes(st)){pollView(s);return;}if(st==='WALLET_MIGRATION'){await renderWalletMigration(s);return;}if(st==='MESSAGE_MIGRATION'){placeholderView(s);return;}if(st==='FINALIZING'){const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();return;}if(st==='COMPLETE'){setRoot(note('Смена ключей завершена.'));return;}setRoot(note(`Неизвестное состояние ротации: ${st}`));}catch(e){setRoot(note(`Не удалось прочитать состояние смены ключей: ${e?.message||e}`));}}
void refresh();screen.cleanup=()=>{disposed=true;if(timer)clearTimeout(timer);};return screen;
}
-103
View File
@@ -1,103 +0,0 @@
import { createTopBar } from '../components/topbar.js';
import { authorizeSession, state } from '../state.js';
export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack';
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
rootToggle.addEventListener('change', () => {
state.keyStorage.saveRoot = rootToggle.checked;
if (rootToggle.checked) {
window.alert('Мы советуем не сохранять главный ключ на устройстве, он используется только для смены паролей и основных настроек.');
}
});
const blockchainToggle = document.createElement('input');
blockchainToggle.type = 'checkbox';
blockchainToggle.checked = state.keyStorage.saveBlockchain;
blockchainToggle.addEventListener('change', () => {
state.keyStorage.saveBlockchain = blockchainToggle.checked;
});
const deviceToggle = document.createElement('input');
deviceToggle.type = 'checkbox';
deviceToggle.checked = true;
deviceToggle.disabled = true;
card.innerHTML = `
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Root Key</span></label>
<p class="meta-muted key-storage-option__description">Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.</p>
<input class="input" type="text" value="${state.keyStorage.rootKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне Сияния.</p>
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
<p class="meta-muted key-storage-option__description">Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.</p>
<input class="input" type="text" value="${state.keyStorage.clientKey}" />
</div>
<p class="key-storage-note key-storage-note--strong">Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.</p>
`;
card.children[0].querySelector('label').prepend(rootToggle);
card.children[1].querySelector('label').prepend(blockchainToggle);
card.children[2].querySelector('label').prepend(deviceToggle);
const rootInput = card.children[0].querySelector('.input');
rootInput.addEventListener('input', () => {
state.keyStorage.rootKey = rootInput.value;
});
const blockchainInput = card.children[1].querySelector('.input');
blockchainInput.addEventListener('input', () => {
state.keyStorage.blockchainKey = blockchainInput.value;
});
const deviceInput = card.children[2].querySelector('.input');
deviceInput.addEventListener('input', () => {
state.keyStorage.clientKey = deviceInput.value;
});
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('login-password-view'));
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
okButton.type = 'button';
okButton.textContent = 'OK';
okButton.addEventListener('click', () => {
authorizeSession();
navigate('profile-view');
});
actions.append(cancelButton, okButton);
screen.append(
createTopBar({
title: 'Какие ключи сохранить',
back: { label: '←', onClick: () => navigate('login-password-view') },
}),
card,
actions,
);
return screen;
}
+2 -2
View File
@@ -143,8 +143,8 @@ export function render({ navigate }) {
const parseTransferText = (text) => {
try {
const transfer = parseKeyTransferText(text);
if (!transfer.keys.clientKey) {
throw new Error('В QR-коде нет client key для входа');
if (!transfer.keys.clientKey || !transfer.keys.blockchainKey) {
throw new Error('В QR-коде нет полного набора рабочих ключей');
}
showTransfer(transfer);
} catch (error) {
+4 -1
View File
@@ -157,6 +157,9 @@ export function render({ navigate }) {
};
const finalizeAuthorizedLogin = async (keys, login) => {
if (!String(keys?.clientKey || '').trim() || !String(keys?.blockchainKey || '').trim()) {
throw new Error('Новое устройство должно получить ключ доступа и ключ блокчейна.');
}
const session = await authService.createSessionFromImportedSecrets(login, keys);
const addingProfile = isAddingProfileLogin();
if (!addingProfile) {
@@ -309,7 +312,7 @@ export function render({ navigate }) {
login,
passwordHash,
requesterSessionKey: requesterMaterial.sessionKey,
payloadType: 3,
payloadType: 2,
});
activePairingId = String(payload?.pairingId || '');
+30 -9
View File
@@ -1,12 +1,17 @@
import { createTopBar } from '../components/topbar.js';
import {
authService,
authorizeSession,
clearAuthMessages,
consumeAuthReturnPage,
refreshSessions,
setAuthBusy,
setAuthError,
setAuthInfo,
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { clearStoredMessages } from '../services/message-store.js';
import { emptyPasswordWords, PASSWORD_MAX_LENGTH } from '../services/password-words.js';
export const pageMeta = { id: 'login-password-view', title: 'Введите пароль', showAppChrome: false };
@@ -135,17 +140,33 @@ export function render({ navigate }) {
enterButton.textContent = 'Проверяем пароль…';
const result = await authService.createAuthSession(currentLogin, keyBundle);
// Существующую дальнейшую логику после успешного пароля сохраняем без изменений.
state.registrationDraft.flowType = 'login';
state.registrationDraft.login = result.login;
state.registrationDraft.password = password;
// В текущей модели оба рабочих ключа всегда сохраняются автоматически.
// Recovery/root private key остаётся только в памяти этого шага и в key-vault не попадает.
await authService.persistSelectedKeys(result.login, result.storagePwd, keyBundle);
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
await clearStoredMessages(result.login).catch(() => {});
authorizeSession({
login: result.login,
sessionId: result.sessionId,
storagePwd: result.storagePwd,
});
state.loginDraft.login = result.login;
state.loginDraft.password = '';
state.loginDraft.passwordMode = 'single';
state.loginDraft.passwordWords = emptyPasswordWords();
state.registrationDraft.flowType = '';
state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = emptyPasswordWords();
state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
navigate('registration-keys-view');
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
state.registrationDraft.pendingSessionMaterial = null;
await refreshSessions();
setAuthInfo(`Вы вошли как @${result.login}.`);
navigate(consumeAuthReturnPage('profile-view'));
} catch (error) {
overlay.style.display = 'none';
const message = toUserMessage(error, 'Не удалось выполнить вход.');
+1 -1
View File
@@ -318,7 +318,7 @@ export function render({ navigate }) {
const subtitle = document.createElement('p');
subtitle.className = 'meta-muted';
subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены root/recovery key, blockchain key и client key.';
subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены ключ восстановления, ключ блокчейна и ключ доступа.';
const details = document.createElement('p');
details.className = 'meta-muted';
+9 -9
View File
@@ -9,9 +9,9 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Где ключи',
title: 'У кого хранятся ключи?',
paragraphs: [
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
'Сияние не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
'Рабочие приватные ключи хранятся только у вас: на вашем устройстве или на доверенных устройствах, которые вы контролируете сами.',
'Ключ доступа и ключ блокчейна сохраняются на авторизованном устройстве в зашифрованном контейнере. Приватный ключ восстановления на устройстве не сохраняется: он вычисляется из пароля только на время операций, где действительно нужен.',
'Сияние не хранит ваши приватные ключи на сервере. В будущем для повышенной защиты рабочие ключи можно будет вынести на отдельное программируемое устройство с открытым кодом, например ESP32-контроллер.',
],
},
{
@@ -30,7 +30,7 @@ export const REGISTRATION_FAQ_TOPICS = [
title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
'Уже из этого секрета детерминированно строятся три основных ключа: root/recovery key, blockchain key и client key.',
'Из этого секрета детерминированно строятся три ключа: ключ восстановления (Recovery), ключ блокчейна и ключ доступа.',
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
],
},
@@ -39,10 +39,10 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Три ключа',
title: 'Зачем нужны три ключа?',
paragraphs: [
'Главный ключ нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Ключ публикаций нужен для подписания действий и записей в блокчейне Сияния.',
'Ключ устройства нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
'Ключ восстановления (Recovery key) нужен для восстановления и самых критичных операций с учётной записью. Его приватная часть никогда не сохраняется на устройстве и при необходимости временно вычисляется из пароля.',
'Ключ блокчейна постоянно хранится на авторизованном устройстве. Он подписывает записи и действия в блокчейне Сияния и является исходным ключом для штатных пользовательских кошельков.',
'Ключ доступа (client key) также постоянно хранится на устройстве. Он используется для входа на сервер, работы устройства и защищённых личных сообщений.',
'Сейчас после обычного входа оба рабочих ключа — ключ блокчейна и ключ доступа — сохраняются автоматически. Отдельного ограниченного режима в текущей версии нет.',
],
},
{
@@ -93,7 +93,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
'Ключ устройства передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны главный ключ и/или ключ публикаций, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
'При подтверждении нового устройства автоматически передаются два рабочих ключа: ключ доступа и ключ блокчейна. Приватный ключ восстановления не передаётся никогда.',
],
},
{
-204
View File
@@ -1,204 +0,0 @@
import { createTopBar } from '../components/topbar.js';
import {
authService,
cancelAddProfileLogin,
consumeAuthReturnPage,
authorizeSession,
refreshSessions,
resetRegistrationFlow,
setAuthError,
setAuthInfo,
state,
} from '../state.js';
import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack keys-screen';
const isLoginFlow = state.registrationDraft.flowType === 'login';
const normalizedLogin = (state.registrationDraft.login || '').trim();
const displayLogin = normalizedLogin ? `@${normalizedLogin.replace(/^@/, '')}` : '@new.user';
const lead = document.createElement('div');
lead.className = 'keys-lead';
const title = document.createElement('h1');
title.className = 'keys-lead__title';
title.textContent = isLoginFlow ? `Вы вошли как ${displayLogin}` : `${displayLogin} зарегистрирован`;
const question = document.createElement('p');
question.className = 'keys-lead__text';
question.textContent = 'Какие ключи хранить на этом устройстве?';
const questionHint = document.createElement('p');
questionHint.className = 'keys-lead__text';
questionHint.textContent = 'Они хранятся в зашифрованном виде.';
lead.append(title, question, questionHint);
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const createKeyRow = (toggle, titleText, descriptionText) => {
const row = document.createElement('label');
row.className = 'keys-row';
const text = document.createElement('span');
text.className = 'keys-row__text';
const name = document.createElement('span');
name.className = 'keys-row__title';
name.textContent = titleText;
const description = document.createElement('span');
description.className = 'keys-row__hint';
description.textContent = descriptionText;
text.append(name, description);
toggle.className = 'switch';
row.append(text, toggle);
return row;
};
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
const blockchainToggle = document.createElement('input');
blockchainToggle.type = 'checkbox';
blockchainToggle.checked = state.keyStorage.saveBlockchain;
const deviceToggle = document.createElement('input');
deviceToggle.type = 'checkbox';
deviceToggle.checked = true;
deviceToggle.disabled = true;
const list = document.createElement('div');
list.className = 'keys-list';
list.append(
createKeyRow(rootToggle, 'Главный ключ', 'Смена пароля, восстановление доступа, важные настройки'),
createKeyRow(blockchainToggle, 'Ключ публикаций', 'Подписывает ваши записи и действия'),
createKeyRow(deviceToggle, 'Ключ устройства', 'Вход с этого устройства – нужен всегда'),
);
const simpleNote = document.createElement('p');
simpleNote.className = 'keys-note';
simpleNote.textContent = 'Не уверены – оставьте все включёнными.';
const actions = document.createElement('div');
actions.className = 'keys-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'secondary-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', async () => {
if (state.authReturnHash === '/profiles') {
await cancelAddProfileLogin();
navigate('profiles-view');
return;
}
resetRegistrationFlow();
navigate('start-view');
});
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
okButton.type = 'button';
okButton.textContent = 'Готово';
okButton.addEventListener('click', async () => {
status.style.display = 'none';
try {
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
}
state.keyStorage.saveRoot = rootToggle.checked;
state.keyStorage.saveBlockchain = blockchainToggle.checked;
await authService.persistSelectedKeys(
state.registrationDraft.login,
state.registrationDraft.storagePwd,
state.registrationDraft.pendingKeyBundle,
{
saveRoot: state.keyStorage.saveRoot,
saveBlockchain: state.keyStorage.saveBlockchain,
},
);
await authService.persistSessionMaterial(
state.registrationDraft.login,
state.registrationDraft.pendingSessionMaterial,
);
if (!state.keyStorage.saveRoot && state.registrationDraft.pendingKeyBundle) {
state.registrationDraft.pendingKeyBundle.rootPair = null;
}
if (!state.keyStorage.saveBlockchain && state.registrationDraft.pendingKeyBundle) {
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
}
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
authorizeSession({
login: state.registrationDraft.login,
sessionId: state.registrationDraft.sessionId,
storagePwd: state.registrationDraft.storagePwd,
});
state.loginDraft.login = state.registrationDraft.login;
state.loginDraft.password = '';
state.loginDraft.passwordMode = 'single';
state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.flowType = '';
state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.passwordWordsLinked = false;
state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
state.registrationDraft.pendingSessionMaterial = null;
state.registrationDraft.preGeneratedKeyBundle = null;
state.registrationPayment.walletAddress = '';
state.registrationPayment.balanceSOL = '0.0000';
await refreshSessions();
setAuthInfo(isLoginFlow
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
navigate(consumeAuthReturnPage('profile-view'));
} catch (error) {
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
setAuthError(message);
status.textContent = message;
status.style.display = '';
}
});
actions.append(okButton, cancelButton);
screen.append(
createTopBar({
title: 'Сохранение ключей',
back: {
label: '←',
onClick: async () => {
if (state.authReturnHash === '/profiles') {
await cancelAddProfileLogin();
navigate('profiles-view');
return;
}
resetRegistrationFlow();
navigate('start-view');
},
},
}),
lead,
list,
simpleNote,
status,
actions,
);
return screen;
}
+3 -12
View File
@@ -95,18 +95,9 @@ async function completeRegistrationLogin({ navigate, keyBundle }) {
state.registrationDraft.password,
);
state.keyStorage.saveRoot = Boolean(state.keyStorage.saveRoot);
state.keyStorage.saveBlockchain = Boolean(state.keyStorage.saveBlockchain);
await authService.persistSelectedKeys(
result.login,
result.storagePwd,
keyBundle,
{
saveRoot: state.keyStorage.saveRoot,
saveBlockchain: state.keyStorage.saveBlockchain,
},
);
// После регистрации оба рабочих ключа сохраняются автоматически.
// Приватный Recovery/root key не записывается на устройство.
await authService.persistSelectedKeys(result.login, result.storagePwd, keyBundle);
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
await clearStoredMessages(state.registrationDraft.login).catch(() => {});
+41 -50
View File
@@ -11,38 +11,41 @@ export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
const visible = {
root: false,
blockchain: false,
device: false,
};
const keys = {
root: '',
blockchain: '',
device: '',
};
const visible = { blockchain: false, access: false };
const keys = { blockchain: '', access: '' };
chrome?.setTopbar(createTopBar({
title: 'Ключи этого устройства',
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'nav-list key-list';
title: 'Ключи этого устройства',
back: { label: '←', onClick: () => navigate('device-view') },
}));
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Загружаем сохранённые ключи...';
const renderField = (id, label, hint = '') => {
const card = document.createElement('div');
card.className = 'nav-list key-list';
const recovery = document.createElement('div');
recovery.className = 'key-card stack';
recovery.innerHTML = `
<div class="row">
<span class="field-label">Ключ восстановления
<small class="key-card__hint">Управление учётной записью, восстановление и критическая смена ключей</small>
</span>
</div>
<div class="key-value key-value--compact">Не хранится на устройстве</div>
<p class="meta-muted">Приватный Recovery key создаётся из пароля только на время защищённых операций и не записывается в хранилище устройства.</p>
`;
const renderStoredField = (id, label, hint) => {
const row = document.createElement('div');
row.className = 'key-card stack';
const eyeIcon = iconHtml('eye', false, 'key-toggle-icon');
const eyeOffIcon = iconHtml('eyeOff', false, 'key-toggle-icon');
row.innerHTML = `
<div class="row">
<span class="field-label">${label}${hint ? `<small class="key-card__hint">${hint}</small>` : ''}</span>
<span class="field-label">${label}<small class="key-card__hint">${hint}</small></span>
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeIcon}</button>
</div>
<div class="key-value key-value--compact" data-value="${id}">*****</div>
@@ -53,15 +56,16 @@ export function render({navigate, chrome}) {
};
card.append(
renderField('root', 'Главный ключ', 'Восстановление аккаунта и важные настройки'),
renderField('blockchain', 'Ключ публикаций', 'Подпись ваших записей'),
renderField('device', 'Ключ устройства', 'Вход с этого устройства'),
recovery,
renderStoredField('blockchain', 'Ключ блокчейна', 'Подпись записей в Сиянии и управление вашими кошельками'),
renderStoredField('access', 'Ключ доступа', 'Вход на сервер, работа устройства и защищённые личные сообщения'),
);
const setMissingState = (id) => {
const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
const field = btnEl?.closest('.key-card');
if (!valueEl || !btnEl) return;
valueEl.textContent = 'нет данных';
btnEl.disabled = true;
btnEl.innerHTML = field?._eyeOffIcon || '';
@@ -73,59 +77,46 @@ export function render({navigate, chrome}) {
const valueEl = card.querySelector(`[data-value="${id}"]`);
const btnEl = card.querySelector(`[data-toggle="${id}"]`);
const field = btnEl?.closest('.key-card');
if (!valueEl || !btnEl) return;
if (!keys[id]) {
setMissingState(id);
return;
}
valueEl.textContent = visible[id] ? keys[id] : '*****';
btnEl.disabled = false;
btnEl.innerHTML = visible[id]
? field?._eyeOffIcon || ''
: field?._eyeIcon || '';
btnEl.innerHTML = visible[id] ? field?._eyeOffIcon || '' : field?._eyeIcon || '';
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
};
card.querySelectorAll('[data-toggle]').forEach((button) => {
button.addEventListener('click', () => {
const { toggle } = button.dataset;
if (!keys[toggle]) return;
visible[toggle] = !visible[toggle];
updateField(toggle);
const id = button.dataset.toggle;
if (!keys[id]) return;
visible[id] = !visible[id];
updateField(id);
});
});
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
['blockchain', 'access'].forEach(updateField);
(async () => {
try {
if (!state.session.login || !state.session.storagePwdInMemory) {
throw new Error('Нет активной сессии для чтения ключей');
}
const savedKeys = await loadEncryptedUserSecrets(
state.session.login,
state.session.storagePwdInMemory,
);
const rootSeed32 = savedKeys.rootKey ? extractSeed32FromPkcs8B64(savedKeys.rootKey) : null;
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const blockchainSeed32 = savedKeys.blockchainKey ? extractSeed32FromPkcs8B64(savedKeys.blockchainKey) : null;
const clientSeed32 = savedKeys.clientKey ? extractSeed32FromPkcs8B64(savedKeys.clientKey) : null;
keys.root = rootSeed32 ? bytesToBase58(rootSeed32) : '';
keys.blockchain = blockchainSeed32 ? bytesToBase58(blockchainSeed32) : '';
keys.device = clientSeed32 ? bytesToBase58(clientSeed32) : '';
if (keys.root || keys.blockchain || keys.device) {
status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.';
} else {
status.textContent = 'На этом устройстве нет сохранённых ключей.';
}
} catch (error) {
status.textContent = 'На этом устройстве нет сохранённых ключей.';
keys.access = clientSeed32 ? bytesToBase58(clientSeed32) : '';
status.textContent = keys.blockchain && keys.access
? 'На устройстве сохранены оба рабочих ключа. Ключ восстановления хранится только как публичная часть в учётной записи и приватно на устройстве не сохраняется.'
: 'На устройстве отсутствует часть рабочих ключей. Выполните вход по паролю заново.';
} catch {
status.textContent = 'Не удалось прочитать рабочие ключи этого устройства.';
}
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
['blockchain', 'access'].forEach(updateField);
})();
screen.append(status, card);
+21 -22
View File
@@ -12,7 +12,7 @@ import {
formatAr,
getArweaveBalance,
getArweaveTopupSiteUrl,
getArweaveWalletFromStoredClientKey,
getArweaveWalletFromStoredBlockchainKey,
transferAr,
} from '../services/arweave-wallet-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
@@ -496,39 +496,38 @@ export function render({navigate, chrome}) {
async function resolveWalletSigningMaterial() {
const { login, storagePwd } = sessionArgsOrThrow();
let saved;
let saved = null;
try {
saved = await loadEncryptedUserSecrets(login, storagePwd);
} catch {
saved = null;
}
let rootKey = String(saved?.rootKey || '').trim();
let blockchainKey = String(saved?.blockchainKey || '').trim();
const blockchainKey = String(saved?.blockchainKey || '').trim();
const clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
if (blockchainKey && clientKey) {
return {
rootPrivatePkcs8B64: '',
blockchainPrivatePkcs8B64: blockchainKey,
clientPrivatePkcs8B64: clientKey,
};
}
const password = window.prompt(
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
'На этом устройстве не найден полный набор рабочих ключей. Введите пароль аккаунта, чтобы восстановить ключ доступа и ключ блокчейна:',
'',
);
if (password == null) throw new Error('Операция отменена пользователем');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
const shouldSave = window.confirm(
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
);
if (shouldSave) {
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
}
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
await authService.persistSelectedKeys(login, storagePwd, keyBundle);
return {
rootPrivatePkcs8B64: '',
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
};
}
function styleSupportInputField(field) {
if (!field) return;
field.style.color = 'var(--text-primary)';
@@ -2024,7 +2023,7 @@ export function render({navigate, chrome}) {
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
Сияние берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Сияние берёт ваш локальный ключ блокчейна и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;
@@ -2126,14 +2125,14 @@ export function render({navigate, chrome}) {
try {
let wasFirstTimeGeneration = false;
arweaveWalletCtx = await getArweaveWalletFromStoredClientKey({
arweaveWalletCtx = await getArweaveWalletFromStoredBlockchainKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
wasFirstTimeGeneration = true;
setStatus('Подождите – ваш Arweave-ключ вычисляется из client key. Это происходит только один раз, потом будет мгновенно.');
setStatus('Подождите – ваш Arweave-ключ вычисляется из ключа блокчейна. Это происходит только один раз, потом будет мгновенно.');
return;
}
setStatus(text);
+2 -6
View File
@@ -9,14 +9,12 @@ const PRETTY_PATHS = new Map([
['registration-faq-view', 'register/faq'],
['registration-payment-view', 'register/payment'],
['registration-draft-keys-view', 'register/draft-keys'],
['registration-keys-view', 'register/keys'],
['topup-view', 'topup'],
['devnet-topup-view', 'devnet-topup'],
['login-view', 'login'],
['login-camera-view', 'login/camera'],
['login-other-device-view', 'login/device'],
['login-password-view', 'login/password'],
['key-storage-view', 'key-storage'],
['profile-view', 'profile'],
['profile-edit-view', 'profile/edit'],
['profiles-view', 'profiles'],
@@ -69,14 +67,12 @@ export const PRE_AUTH_PAGES = [
'registration-faq-view',
'registration-payment-view',
'registration-draft-keys-view',
'registration-keys-view',
'topup-view',
'devnet-topup-view',
'login-view',
'login-camera-view',
'login-other-device-view',
'login-password-view',
'key-storage-view',
];
export function parseRouteFromPath(pathname = '') {
@@ -223,7 +219,7 @@ export function parseRouteFromPath(pathname = '') {
if (sub === 'faq') return { pageId: 'registration-faq-view', params: {} };
if (sub === 'payment') return { pageId: 'registration-payment-view', params: {} };
if (sub === 'draft-keys') return { pageId: 'registration-draft-keys-view', params: {} };
if (sub === 'keys') return { pageId: 'registration-keys-view', params: {} };
if (sub === 'keys') return { pageId: 'register-view', params: {} };
return { pageId: 'register-view', params: {} };
}
@@ -244,7 +240,7 @@ export function parseRouteFromPath(pathname = '') {
}
if (pageId === 'key-storage') {
return { pageId: 'key-storage-view', params: {} };
return { pageId: 'login-view', params: {} };
}
if (pageId === 'profile') {
+18 -15
View File
@@ -1,6 +1,6 @@
import { loadEncryptedUserSecrets, updateEncryptedUserSecrets } from './key-vault.js';
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
import { deriveArweaveWalletFromClientKey32 } from './sawd-v1.js';
import { extractEd25519Seed32FromStoredValue } from './client-key-utils.js';
import { deriveArweaveWalletFromBlockchainKey32 } 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';
@@ -76,10 +76,11 @@ function pickCachedWallet(secrets) {
const cached = secrets?.arweaveWallet;
if (!cached || typeof cached !== 'object') return null;
const derivation = String(cached.derivation || '').trim();
const sourceKey = String(cached.sourceKey || '').trim();
const address = String(cached.address || '').trim();
const owner = String(cached.owner || '').trim();
const jwk = cached.jwk;
if (derivation !== 'SAWD-v1' || !address || !owner || !jwk || typeof jwk !== 'object') {
if (derivation !== 'SAWD-v1' || sourceKey !== 'blockchain' || !address || !owner || !jwk || typeof jwk !== 'object') {
return null;
}
if (!String(jwk.kty || '').trim() || !String(jwk.e || '').trim() || !String(jwk.n || '').trim() || !String(jwk.d || '').trim()) {
@@ -87,6 +88,7 @@ function pickCachedWallet(secrets) {
}
return {
derivation,
sourceKey,
address,
owner,
jwk,
@@ -150,13 +152,13 @@ function safeStatus(onStatus, text) {
}
export async function getArweaveWalletChoices({ login, storagePwd, onStatus } = {}) {
const derived = await getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus });
const derived = await getArweaveWalletFromStoredBlockchainKey({ login, storagePwd, onStatus });
const secrets = await loadEncryptedUserSecrets(String(login || '').trim(), String(storagePwd || '').trim());
const extras = normalizeExtraWallets(secrets);
return [
{
id: 'derived-client-key',
label: 'Стандартный из client key',
id: 'derived-blockchain-key',
label: 'Стандартный из ключа блокчейна',
address: derived.address,
owner: derived.owner,
jwk: derived.jwk,
@@ -206,11 +208,11 @@ export async function addArweaveWalletSecret({ login, storagePwd, secret, label
return wallet;
}
export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
export async function getArweaveWalletFromStoredBlockchainKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к client.key');
throw new Error('Нет активной сессии для доступа к blockchain key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
@@ -220,23 +222,24 @@ export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, o
return cached;
}
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего client key. Это может занять немного времени.');
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего ключа блокчейна. Это может занять немного времени.');
const storedClientKey = String(secrets?.clientKey || '').trim();
if (!storedClientKey) {
throw new Error('На устройстве не найден client.key');
const storedBlockchainKey = String(secrets?.blockchainKey || '').trim();
if (!storedBlockchainKey) {
throw new Error('На устройстве не найден blockchain key');
}
const clientKey32 = extractClientKey32FromStoredValue(storedClientKey);
const blockchainKey32 = extractEd25519Seed32FromStoredValue(storedBlockchainKey);
let wallet;
try {
wallet = await deriveArweaveWalletFromClientKey32(clientKey32);
wallet = await deriveArweaveWalletFromBlockchainKey32(blockchainKey32);
} finally {
clientKey32.fill(0);
blockchainKey32.fill(0);
}
const cachedWallet = {
derivation: wallet.derivation,
sourceKey: 'blockchain',
address: wallet.address,
owner: wallet.owner,
jwk: wallet.jwk,
+20 -7
View File
@@ -674,7 +674,7 @@ function targetLoginBytes(value) {
function normalizeTargetForkNumber(value, actionName = 'target') {
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
if (!Number.isInteger(n) || n < 1 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
return n;
}
@@ -1142,8 +1142,10 @@ export class AuthService {
async createSessionFromImportedSecrets(login, secrets) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('В QR-коде нет логина');
const clientKey = String(secrets?.clientKey || secrets?.clientKey || '').trim();
if (!clientKey) throw new Error('В QR-коде нет client key для входа');
const clientKey = String(secrets?.clientKey || '').trim();
const blockchainKey = String(secrets?.blockchainKey || '').trim();
if (!clientKey) throw new Error('В QR-коде нет ключа доступа для входа');
if (!blockchainKey) throw new Error('В QR-коде нет ключа блокчейна');
const privateKey = await importPkcs8Ed25519(clientKey);
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey);
@@ -1219,7 +1221,7 @@ export class AuthService {
}
}
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
async persistSelectedKeys(login, storagePwd, keyBundle) {
let currentSecrets = {};
try {
const loaded = await loadEncryptedUserSecrets(login, storagePwd);
@@ -1230,12 +1232,23 @@ export class AuthService {
// Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
}
const clientKey = String(keyBundle?.clientPair?.privatePkcs8B64 || '').trim();
const blockchainKey = String(keyBundle?.blockchainPair?.privatePkcs8B64 || '').trim();
if (!clientKey || !blockchainKey) {
throw new Error('Для входа нужно сохранить client key и blockchain key');
}
const secrets = {
...currentSecrets,
clientKey: keyBundle.clientPair.privatePkcs8B64,
clientKey,
blockchainKey,
};
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
// SAWD-v1 wallet зависит от blockchain key. При смене ключа старый кэш
// нельзя переносить в новую identity: он будет лениво выведен заново.
if (String(currentSecrets?.blockchainKey || '').trim() !== blockchainKey) {
delete secrets.arweaveWallet;
}
delete secrets.rootKey;
await saveEncryptedUserSecrets(login, storagePwd, secrets);
}
+9 -6
View File
@@ -18,14 +18,14 @@ function parseKeypairJson64(raw) {
if (!isByteArrayLike(parsed)) return null;
const asArray = Array.from(parsed);
if (asArray.length < 32) {
throw new Error('Некорректный JSON ключ client.key: ожидалось минимум 32 байта');
throw new Error('Некорректный JSON Ed25519-ключ: ожидалось минимум 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 ключ client.key: найдены не-байтовые значения');
throw new Error('Некорректный JSON Ed25519-ключ: найдены не-байтовые значения');
}
out[i] = n;
}
@@ -34,13 +34,13 @@ function parseKeypairJson64(raw) {
export function extractSeed32FromPkcs8B64(pkcs8B64) {
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key');
if (bytes.length < 32) throw new Error('Некорректный PKCS8 Ed25519-ключ');
return bytes.slice(bytes.length - 32);
}
export function extractClientKey32FromStoredValue(storedClientKey) {
const raw = String(storedClientKey || '').trim();
if (!raw) throw new Error('Пустой client.key');
export function extractEd25519Seed32FromStoredValue(storedKey) {
const raw = String(storedKey || '').trim();
if (!raw) throw new Error('Пустой приватный Ed25519-ключ');
const jsonBytes = parseKeypairJson64(raw);
if (jsonBytes) {
@@ -49,3 +49,6 @@ export function extractClientKey32FromStoredValue(storedClientKey) {
return extractSeed32FromPkcs8B64(raw);
}
// Legacy API name retained for callers outside the wallet subsystem.
export const extractClientKey32FromStoredValue = extractEd25519Seed32FromStoredValue;
@@ -73,15 +73,15 @@ function normalizeKeys(keys = {}) {
return {
clientKey: String(keys?.clientKey || '').trim(),
blockchainKey: String(keys?.blockchainKey || '').trim(),
rootKey: String(keys?.rootKey || '').trim(),
};
}
export function detectPairingPayloadType(keys = {}) {
const normalized = normalizeKeys(keys);
if (normalized.rootKey) return 3;
if (normalized.blockchainKey) return 2;
return 1;
if (!normalized.clientKey || !normalized.blockchainKey) {
throw new Error('Для подключения устройства нужны ключ доступа и ключ блокчейна');
}
return 2;
}
export async function deriveEspPairingPasswordHash(login, password) {
@@ -159,13 +159,14 @@ export async function decryptPairingPayloadFromEnvelope(encryptedPayload, reques
}
export function buildSecretsPayload({ login, keys, mode }) {
const normalizedKeys = normalizeKeys(keys);
return {
v: 1,
type: 'shine-esp-pairing-transfer',
login: String(login || '').trim(),
mode: String(mode || 'device-only').trim() || 'device-only',
keys: normalizeKeys(keys),
payloadType: detectPairingPayloadType(keys),
mode: String(mode || 'full-device').trim() || 'full-device',
keys: normalizedKeys,
payloadType: detectPairingPayloadType(normalizedKeys),
createdAtMs: Date.now(),
};
}
+11 -6
View File
@@ -160,12 +160,17 @@ export class KeyRotationClient {
}
const state=await this.notifyPdaRotation(signature);
if(storagePwd) {
await updateEncryptedUserSecrets(login, storagePwd, (current)=>({
...(current||{}),
rootKey:newBundle.rootPair.privatePkcs8B64,
blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
clientKey:newBundle.clientPair.privatePkcs8B64,
}));
await updateEncryptedUserSecrets(login, storagePwd, (current)=>{
const next={
...(current||{}),
blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
clientKey:newBundle.clientPair.privatePkcs8B64,
};
// Arweave SAWD-v1 привязан к blockchain key: после ротации старый JWK-cache недействителен.
delete next.arweaveWallet;
delete next.rootKey;
return next;
});
}
return state;
}
+30 -4
View File
@@ -12,6 +12,15 @@ function normalizeLoginStorageKey(login) {
return String(login || '').trim().toLowerCase();
}
function sanitizePersistentSecrets(keys) {
const source = keys && typeof keys === 'object' ? keys : {};
const safe = { ...source };
// Recovery/root private key is intentionally ephemeral: it may exist in RAM
// during a protected operation, but must never be persisted on the device.
delete safe.rootKey;
return safe;
}
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
@@ -53,7 +62,8 @@ async function get(storeName, key) {
}
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
const safeKeys = sanitizePersistentSecrets(keys);
const encrypted = await encryptJsonWithStoragePwd(safeKeys, storagePwd);
const normalizedLogin = normalizeLoginStorageKey(login);
await put(STORE_SECRETS, {
login: normalizedLogin,
@@ -71,7 +81,22 @@ export async function loadEncryptedUserSecrets(login, storagePwd) {
if (!row?.encrypted) {
throw new Error('На устройстве нет сохранённых ключей для этого логина');
}
return decryptJsonWithStoragePwd(row.encrypted, storagePwd);
const decrypted = await decryptJsonWithStoragePwd(row.encrypted, storagePwd);
const safe = sanitizePersistentSecrets(decrypted);
// Одноразовая очистка контейнеров, созданных старыми версиями UI, где rootKey
// мог быть сохранён. Перезаписываем тем же storagePwd уже без Recovery private key.
if (decrypted && typeof decrypted === 'object' && Object.prototype.hasOwnProperty.call(decrypted, 'rootKey')) {
const encrypted = await encryptJsonWithStoragePwd(safe, storagePwd);
await put(STORE_SECRETS, {
login: normalizedLogin,
encrypted,
updatedAtMs: Date.now(),
});
}
return safe;
}
export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
@@ -84,8 +109,9 @@ export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
if (!next || typeof next !== 'object') {
throw new Error('updateEncryptedUserSecrets: updater должен вернуть объект secrets');
}
await saveEncryptedUserSecrets(login, storagePwd, next);
return next;
const safe = sanitizePersistentSecrets(next);
await saveEncryptedUserSecrets(login, storagePwd, safe);
return safe;
}
export async function saveSessionMaterial(login, material) {
+16 -19
View File
@@ -22,30 +22,29 @@ function base64UrlToBytes(value) {
}
export function keyLabel(id) {
if (id === 'root') return 'root';
if (id === 'blockchain') return 'blockchain';
if (id === 'device') return 'device';
if (id === 'blockchain') return 'ключ блокчейна';
if (id === 'device' || id === 'client') return 'ключ доступа';
return id;
}
export function describeTransferKeys(keys = {}) {
const out = [];
if (keys.clientKey) out.push('device');
if (keys.blockchainKey) out.push('blockchain');
if (keys.rootKey) out.push('root');
if (keys.clientKey) out.push('ключ доступа');
if (keys.blockchainKey) out.push('ключ блокчейна');
return out;
}
export function makeKeyTransferText({ login, keys }) {
const clientKey = String(keys?.clientKey || '').trim();
const blockchainKey = String(keys?.blockchainKey || '').trim();
if (!clientKey || !blockchainKey) {
throw new Error('Для подключения нужны ключ доступа и ключ блокчейна');
}
const payload = {
v: 1,
type: 'shine-key-transfer',
login: String(login || '').trim(),
keys: {
clientKey: String(keys?.clientKey || ''),
blockchainKey: String(keys?.blockchainKey || ''),
rootKey: String(keys?.rootKey || ''),
},
keys: { clientKey, blockchainKey },
createdAtMs: Date.now(),
};
const json = JSON.stringify(payload);
@@ -65,17 +64,15 @@ export function parseKeyTransferText(text) {
const login = String(payload.login || '').trim();
if (!login) throw new Error('В QR-коде нет логина');
const keys = payload.keys && typeof payload.keys === 'object' ? payload.keys : {};
if (!keys.clientKey && !keys.blockchainKey && !keys.rootKey) {
throw new Error('В QR-коде нет ключей');
const clientKey = String(keys.clientKey || '').trim();
const blockchainKey = String(keys.blockchainKey || '').trim();
if (!clientKey || !blockchainKey) {
throw new Error('В QR-коде нет полного набора рабочих ключей');
}
return {
login,
keys: {
clientKey: String(keys.clientKey || ''),
blockchainKey: String(keys.blockchainKey || ''),
rootKey: String(keys.rootKey || ''),
},
keyTypes: describeTransferKeys(keys),
keys: { clientKey, blockchainKey },
keyTypes: describeTransferKeys({ clientKey, blockchainKey }),
};
}
+21 -15
View File
@@ -323,20 +323,26 @@ function toJwkB64(value) {
return base64UrlEncode(bigIntToUnsignedBytes(value));
}
async function deriveArweaveWalletParts(clientKey32) {
if (!(clientKey32 instanceof Uint8Array)) {
throw new Error('SAWD-v1: clientKey32 должен быть Uint8Array');
async function deriveArweaveWalletParts(blockchainKey32) {
if (!(blockchainKey32 instanceof Uint8Array)) {
throw new Error('SAWD-v1: blockchainKey32 должен быть Uint8Array');
}
if (clientKey32.length !== 32) {
throw new Error('SAWD-v1: clientKey32 должен быть ровно 32 байта');
if (blockchainKey32.length !== 32) {
throw new Error('SAWD-v1: blockchainKey32 должен быть ровно 32 байта');
}
const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, clientKey32);
const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, blockchainKey32);
const masterSeedKey = await importHmacKey(masterSeed32);
const pResult = await derivePrimeWithImportedKey(masterSeedKey, 'p');
let qResult = await derivePrimeWithImportedKey(masterSeedKey, 'q');
while (qResult.prime === pResult.prime) {
// Две 2048-битные простые не гарантируют 4096-битный product: n может иметь
// 4095 бит. До публичного запуска legacy-output не фиксирован, поэтому SAWD-v1
// детерминированно продолжает q-sequence, пока modulus не станет ровно RSA_BITS.
while (
qResult.prime === pResult.prime
|| bitLength(pResult.prime * qResult.prime) !== RSA_BITS
) {
qResult = await derivePrimeWithImportedKey(masterSeedKey, 'q', qResult.index + 1n);
}
@@ -381,8 +387,8 @@ async function deriveArweaveWalletParts(clientKey32) {
};
}
export async function deriveArweaveWalletFromClientKey32(clientKey32) {
const result = await deriveArweaveWalletParts(clientKey32);
export async function deriveArweaveWalletFromBlockchainKey32(blockchainKey32) {
const result = await deriveArweaveWalletParts(blockchainKey32);
return {
derivation: result.derivation,
jwk: result.jwk,
@@ -395,7 +401,7 @@ export async function selfTestSawdV1() {
const invalid = new Uint8Array(31);
let invalidFailed = false;
try {
await deriveArweaveWalletFromClientKey32(invalid);
await deriveArweaveWalletFromBlockchainKey32(invalid);
} catch {
invalidFailed = true;
}
@@ -403,13 +409,13 @@ export async function selfTestSawdV1() {
throw new Error('SAWD-v1 self-test: длина != 32 должна приводить к ошибке');
}
const clientKey = new Uint8Array(32);
for (let i = 0; i < clientKey.length; i += 1) {
clientKey[i] = i + 1;
const blockchainKey = new Uint8Array(32);
for (let i = 0; i < blockchainKey.length; i += 1) {
blockchainKey[i] = i + 1;
}
const first = await deriveArweaveWalletParts(clientKey);
const second = await deriveArweaveWalletParts(clientKey);
const first = await deriveArweaveWalletParts(blockchainKey);
const second = await deriveArweaveWalletParts(blockchainKey);
if (!first.address || !second.address) {
throw new Error('SAWD-v1 self-test: адрес пустой');
+70 -6
View File
@@ -1,4 +1,4 @@
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
import { extractEd25519Seed32FromStoredValue } from './client-key-utils.js';
import { base64ToBytes } from './crypto-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
@@ -104,7 +104,7 @@ function decodeBase58(input) {
async function keypairFromPkcs8(pkcs8B64) {
const solana = await loadSolanaLib();
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
const seed32 = extractEd25519Seed32FromStoredValue(pkcs8B64);
try {
return solana.Keypair.fromSeed(seed32);
} finally {
@@ -114,7 +114,7 @@ async function keypairFromPkcs8(pkcs8B64) {
async function keypairFromStoredSecret(storedSecret) {
const solana = await loadSolanaLib();
const seed32 = extractClientKey32FromStoredValue(storedSecret);
const seed32 = extractEd25519Seed32FromStoredValue(storedSecret);
try {
return solana.Keypair.fromSeed(seed32);
} finally {
@@ -161,7 +161,7 @@ export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) {
};
}
export async function getWalletFromStoredClientKey({ login, storagePwd }) {
) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
@@ -200,7 +200,7 @@ export async function getWalletFromStoredBlockchainKey({ login, storagePwd }) {
};
}
export async function getWalletFromStoredRootKey({ login, storagePwd }) {
) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
@@ -227,7 +227,7 @@ export async function getStoredSolanaWalletChoices({ login, storagePwd } = {}) {
return [{
id: 'blockchain-key',
keySource: 'blockchain',
label: 'blockchain key',
label: 'Ключ блокчейна',
address: wallet.address,
keypair: wallet.keypair,
}];
@@ -289,6 +289,70 @@ export async function transferSol({ endpoint, fromKeypair, toAddress, amountSol
return { endpoint: rpc, signature, lamports };
}
export async function transferAllSol({ endpoint, fromKeypair, toAddress }) {
const solana = await loadSolanaLib();
const rpc = normalizeEndpoint(endpoint);
const cleanTo = String(toAddress || '').trim();
if (!fromKeypair?.publicKey) throw new Error('Не передан старый Solana-кошелёк');
if (!cleanTo) throw new Error('Не указан новый адрес кошелька');
if (fromKeypair.publicKey.toBase58() === cleanTo) {
throw new Error('Старый и новый кошельки совпадают');
}
const conn = new solana.Connection(rpc, 'confirmed');
const balanceLamports = await conn.getBalance(fromKeypair.publicKey, 'confirmed');
if (balanceLamports <= 0) {
return { endpoint: rpc, signature: '', lamports: 0, feeLamports: 0, balanceLamports, skipped: true };
}
const latest = await conn.getLatestBlockhash('confirmed');
const probeTx = new solana.Transaction({
feePayer: fromKeypair.publicKey,
recentBlockhash: latest.blockhash,
}).add(solana.SystemProgram.transfer({
fromPubkey: fromKeypair.publicKey,
toPubkey: new solana.PublicKey(cleanTo),
lamports: 1,
}));
const feeResult = await conn.getFeeForMessage(probeTx.compileMessage(), 'confirmed');
const feeLamports = Number(feeResult?.value ?? 5000);
const transferLamports = balanceLamports - feeLamports;
if (transferLamports <= 0) {
return {
endpoint: rpc,
signature: '',
lamports: 0,
feeLamports,
balanceLamports,
skipped: true,
};
}
const tx = new solana.Transaction({
feePayer: fromKeypair.publicKey,
recentBlockhash: latest.blockhash,
}).add(solana.SystemProgram.transfer({
fromPubkey: fromKeypair.publicKey,
toPubkey: new solana.PublicKey(cleanTo),
lamports: transferLamports,
}));
tx.sign(fromKeypair);
const signature = await conn.sendRawTransaction(tx.serialize(), { skipPreflight: false });
await conn.confirmTransaction({
signature,
blockhash: latest.blockhash,
lastValidBlockHeight: latest.lastValidBlockHeight,
}, 'confirmed');
return {
endpoint: rpc,
signature,
lamports: transferLamports,
feeLamports,
balanceLamports,
skipped: false,
};
}
export function formatSol(value, digits = 6) {
const n = Number(value);
if (!Number.isFinite(n)) return '0';
+1 -1
View File
@@ -146,7 +146,7 @@ export function toUserMessage(error, fallback = 'Действие не выпо
}
if (code === 'UNSUPPORTED_KEY_ALGORITHM' || text.includes('unsupported key algorithm')) {
return 'Ключ устройства не поддерживается сервером. Очистите локальные ключи и войдите заново.';
return 'Ключ доступа не поддерживается сервером. Очистите локальные ключи и войдите заново.';
}
if (!raw) return fallback;
-16
View File
@@ -437,19 +437,6 @@ function createInitialState({ withStoredSession = true } = {}) {
walletAddress: '',
balanceSOL: '0.0000',
},
keyStorage: {
rootKey: 'Ключ root хранится в зашифрованном виде',
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
clientKey: 'Client key хранится в зашифрованном виде',
saveRoot: true,
saveBlockchain: true,
saveClient: true,
},
deviceConnect: {
root: true,
blockchain: true,
client: true,
},
authUi: {
busy: false,
error: '',
@@ -1113,7 +1100,6 @@ export function resetRegistrationFlow() {
state.registrationDraft = next.registrationDraft;
state.registrationHelp = next.registrationHelp;
state.registrationPayment = next.registrationPayment;
state.keyStorage = next.keyStorage;
}
function resetStateForSignedOut() {
@@ -1134,8 +1120,6 @@ function resetStateForSignedOut() {
state.registrationDraft = next.registrationDraft;
state.loginDraft = next.loginDraft;
state.registrationPayment = next.registrationPayment;
state.keyStorage = next.keyStorage;
state.deviceConnect = next.deviceConnect;
state.authUi = next.authUi;
state.sessions = next.sessions;
state.channelsFeed = next.channelsFeed;
+1 -1
View File
@@ -245,7 +245,7 @@
transform: translate(-50%, -50%);
border: 0;
border-radius: 0;
background: url("../assets/glass_overlay_faithful.png") center / contain no-repeat;
background: url("../../assets/glass_overlay_faithful.png") center / contain no-repeat;
box-shadow: none;
opacity: 1;
pointer-events: none;
@@ -1,27 +1,5 @@
/* Diagnostics/admin/developer UI styles. */
.key-storage-option {
gap: 6px;
}
.key-storage-option__description {
margin: 0;
}
.key-storage-note {
margin: 0;
line-height: 1.45;
}
.key-storage-note--strong {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
}
.pwa-diag-list {
gap: 8px;