diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyHasTarget.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyHasTarget.java index 3a12340c..d22c5122 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyHasTarget.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyHasTarget.java @@ -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. */ diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyTargetSupport.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyTargetSupport.java index 01e6d7b5..c2755af7 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyTargetSupport.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyTargetSupport.java @@ -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"); } } diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java index b05f7470..4ebfc5fe 100644 --- a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java @@ -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 diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java index 5d256b02..4b4e4fd1 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java @@ -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; } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index d12df5d4..39db74be 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -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, diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 7835f4e5..7cbb3fb2 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -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 { diff --git a/TODO/Близкое/Переработать_authority_Solana_PDA_через_Recovery_key.md b/TODO/Близкое/Переработать_authority_Solana_PDA_через_Recovery_key.md new file mode 100644 index 00000000..20c34cbb --- /dev/null +++ b/TODO/Близкое/Переработать_authority_Solana_PDA_через_Recovery_key.md @@ -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 в промежуточном состоянии. diff --git a/VERSION.properties b/VERSION.properties index 770ee4f4..5fc079e5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.14.3 -server.version=1.12.1 +client.version=1.14.4 +server.version=1.12.2 diff --git a/docs/API/19_Key_Rotation_API.md b/docs/API/19_Key_Rotation_API.md index 2704c862..1b385b9a 100644 --- a/docs/API/19_Key_Rotation_API.md +++ b/docs/API/19_Key_Rotation_API.md @@ -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` diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index fc86cbd3..56bf7de8 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -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: diff --git a/docs/Blockchain/12_REACTION_Blocks.md b/docs/Blockchain/12_REACTION_Blocks.md index 523d07cd..b94a489d 100644 --- a/docs/Blockchain/12_REACTION_Blocks.md +++ b/docs/Blockchain/12_REACTION_Blocks.md @@ -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 продолжает указывать на тот же логический блок. diff --git a/docs/Blockchain/13_CONNECTION_Blocks.md b/docs/Blockchain/13_CONNECTION_Blocks.md index 1821d419..faa1690c 100644 --- a/docs/Blockchain/13_CONNECTION_Blocks.md +++ b/docs/Blockchain/13_CONNECTION_Blocks.md @@ -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 diff --git a/docs/Blockchain/15_STATUS_ACTION_Blocks.md b/docs/Blockchain/15_STATUS_ACTION_Blocks.md index 39ae9ecc..9a364298 100644 --- a/docs/Blockchain/15_STATUS_ACTION_Blocks.md +++ b/docs/Blockchain/15_STATUS_ACTION_Blocks.md @@ -48,7 +48,7 @@ Где: - `toLogin` — login владельца целевого блока; -- `toForkNumber` — историческая метка fork (`1..999`, `0` reserved unknown/legacy), не часть identity; +- `toForkNumber` — историческая метка fork (`1..999`), не часть identity; - `toBlockGlobalNumber` — номер целевого блока; - `toBlockHash32` — хэш целевого блока; - `text` — опциональное пояснение пользователя к статусу. diff --git a/docs/Blockchain/18_KEY_ROTATION_AND_FORK_TARGETS.md b/docs/Blockchain/18_KEY_ROTATION_AND_FORK_TARGETS.md index f27de579..6c2bfb41 100644 --- a/docs/Blockchain/18_KEY_ROTATION_AND_FORK_TARGETS.md +++ b/docs/Blockchain/18_KEY_ROTATION_AND_FORK_TARGETS.md @@ -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 цели: diff --git a/docs/Keys/DERIVATION.md b/docs/Keys/DERIVATION.md index 364a707a..f6d26ec2 100644 --- a/docs/Keys/DERIVATION.md +++ b/docs/Keys/DERIVATION.md @@ -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, этим правилом не затрагивается. + +Ограниченный режим устройства архитектурно возможен благодаря разделению ключей, но в текущем релизе не включён: обычное авторизованное устройство всегда получает и хранит оба рабочих ключа. --- diff --git a/docs/Keys/README.md b/docs/Keys/README.md index f09dc1c0..757a36fd 100644 --- a/docs/Keys/README.md +++ b/docs/Keys/README.md @@ -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`. ## Что нужно уточнить перед реализацией diff --git a/docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md b/docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md index 25e48b47..95972dd9 100644 --- a/docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md +++ b/docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md @@ -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 считаются частью стабильного формата: изменение источника ключа или любой константы потребует новой версии стандарта. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index cf1c7d37..f94e3fbe 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -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, diff --git a/shine-UI/js/components/arweave-attachment-manager.js b/shine-UI/js/components/arweave-attachment-manager.js index 2110a2a3..89a86fa8 100644 --- a/shine-UI/js/components/arweave-attachment-manager.js +++ b/shine-UI/js/components/arweave-attachment-manager.js @@ -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({ ? `

Маленькие файлы и аватары через Turbo пока загружаются бесплатно. Для больших файлов пополните Turbo со своего SOL-кошелька.

- - + Подпись и пополнение: ключ блокчейна пользователя.
` : ` @@ -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) => ( - `` - )).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); diff --git a/shine-UI/js/pages/access-servers-view.js b/shine-UI/js/pages/access-servers-view.js index 5e2f577c..b4bf41ab 100644 --- a/shine-UI/js/pages/access-servers-view.js +++ b/shine-UI/js/pages/access-servers-view.js @@ -174,17 +174,7 @@ function createPasswordModal() { Пароль аккаунта -
- - -
-

client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.

+

После проверки пароля рабочие ключи будут восстановлены и сохранены на этом устройстве. Приватный ключ восстановления не сохраняется.

@@ -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, diff --git a/shine-UI/js/pages/channel-about-view.js b/shine-UI/js/pages/channel-about-view.js index 36188be0..d21fa094 100644 --- a/shine-UI/js/pages/channel-about-view.js +++ b/shine-UI/js/pages/channel-about-view.js @@ -15,7 +15,6 @@ import { getBalanceSol, getSolanaWalletFromStoredSecret, getWalletFromStoredBlockchainKey, - getWalletFromStoredRootKey, solanaAddressFromPublicKeyBase64, transferSol, } from '../services/solana-wallet-service.js'; diff --git a/shine-UI/js/pages/channel-donate-view.js b/shine-UI/js/pages/channel-donate-view.js index 2fad625f..cdc9ec0c 100644 --- a/shine-UI/js/pages/channel-donate-view.js +++ b/shine-UI/js/pages/channel-donate-view.js @@ -7,7 +7,6 @@ import { getBalanceSol, getSolanaWalletFromStoredSecret, getWalletFromStoredBlockchainKey, - getWalletFromStoredRootKey, solanaAddressFromPublicKeyBase64, transferSol, } from '../services/solana-wallet-service.js'; diff --git a/shine-UI/js/pages/connect-device-view.js b/shine-UI/js/pages/connect-device-view.js index d65145d4..8ad42af5 100644 --- a/shine-UI/js/pages/connect-device-view.js +++ b/shine-UI/js/pages/connect-device-view.js @@ -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 = ` -

Выберите, какие ключи передать на подключаемое устройство

- - - +
+

Что будет передано

+

Новое устройство автоматически получит ключ доступа и ключ блокчейна.

+

Ключ восстановления никогда не передаётся и не сохраняется на устройствах.

+

Проверяем ключи на этом устройстве...

@@ -31,24 +32,9 @@ export function render({navigate, chrome}) {
`; - 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}) {
-

пользователь выбирает ключи для передачи

-

передать можно только существующие ключи

-

если ключа нет – он недоступен

-

blockchain key – можно передать или нет

-

root key – только если существует

-

client key передаётся всегда

-

подключение происходит напрямую через QR

-

сервер не используется

-

текущая логика: устройство 1 показывает QR, устройство 2 сканирует

+

client key (ключ доступа) передаётся всегда

+

blockchain key (ключ блокчейна) передаётся всегда

+

root/recovery private key никогда не передаётся

+

QR-сценарий передаёт рабочие ключи напрямую между устройствами

для сценария через сервер используйте кнопку «Подключить по коду»

@@ -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; } diff --git a/shine-UI/js/pages/developer-settings-view.js b/shine-UI/js/pages/developer-settings-view.js index 43b16eca..1dc11550 100644 --- a/shine-UI/js/pages/developer-settings-view.js +++ b/shine-UI/js/pages/developer-settings-view.js @@ -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), diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 0b48d93c..0948ceac 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -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 = ` -
- - `; - 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 = ` -

Что передаётся при расширенном подключении

+

Что передаётся новому устройству

Проверяем локальные ключи...

+

Ключ восстановления никогда не передаётся.

`; 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' }); diff --git a/shine-UI/js/pages/device-qr-view.js b/shine-UI/js/pages/device-qr-view.js index 570bf0d7..8f6ddb47 100644 --- a/shine-UI/js/pages/device-qr-view.js +++ b/shine-UI/js/pages/device-qr-view.js @@ -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}) {

Логин: ...

Ключи: ...

+

Ключ восстановления в QR-код не включается.

`; @@ -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; } diff --git a/shine-UI/js/pages/key-rotation-view.js b/shine-UI/js/pages/key-rotation-view.js index 3ad9a615..98d76442 100644 --- a/shine-UI/js/pages/key-rotation-view.js +++ b/shine-UI/js/pages/key-rotation-view.js @@ -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='Проверьте старый и новый кошельки.'; + 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=`Старый кошелёк${h(oldWallet.address)}${h(formatSol(oldB.sol,9))} SOLНовый кошелёк${h(newWallet.address)}${h(formatSol(newB.sol,9))} SOL`; + 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',`${result.skipped?'Перевод не потребовался.':`Переведено ${h(formatSol(Number(result.lamports)/1_000_000_000,9))} SOL. Tx: ${h(result.signature)}`}`); + } + 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; } diff --git a/shine-UI/js/pages/key-storage-view.js b/shine-UI/js/pages/key-storage-view.js deleted file mode 100644 index 25e9416f..00000000 --- a/shine-UI/js/pages/key-storage-view.js +++ /dev/null @@ -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 = ` -
- -

Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.

- -
-
- -

Используется для подписи ваших действий и записей в блокчейне Сияния.

- -
-
- -

Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.

- -
-

Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.

- `; - - 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; -} diff --git a/shine-UI/js/pages/login-camera-view.js b/shine-UI/js/pages/login-camera-view.js index 3b7f1b69..3421943b 100644 --- a/shine-UI/js/pages/login-camera-view.js +++ b/shine-UI/js/pages/login-camera-view.js @@ -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) { diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index 1f8a6e22..17c111d1 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -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 || ''); diff --git a/shine-UI/js/pages/login-password-view.js b/shine-UI/js/pages/login-password-view.js index 2f869e8a..461cefe9 100644 --- a/shine-UI/js/pages/login-password-view.js +++ b/shine-UI/js/pages/login-password-view.js @@ -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, 'Не удалось выполнить вход.'); diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index 66805333..47acd135 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -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'; diff --git a/shine-UI/js/pages/registration-faq-view.js b/shine-UI/js/pages/registration-faq-view.js index 12d626fb..19b98e3b 100644 --- a/shine-UI/js/pages/registration-faq-view.js +++ b/shine-UI/js/pages/registration-faq-view.js @@ -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: [ 'На новом устройстве выберите вход через другое устройство и получите код подключения.', 'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.', - 'Ключ устройства передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны главный ключ и/или ключ публикаций, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.', + 'При подтверждении нового устройства автоматически передаются два рабочих ключа: ключ доступа и ключ блокчейна. Приватный ключ восстановления не передаётся никогда.', ], }, { diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js deleted file mode 100644 index a40fe0c9..00000000 --- a/shine-UI/js/pages/registration-keys-view.js +++ /dev/null @@ -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; -} diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index e052b7a2..dfcfd09b 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -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(() => {}); diff --git a/shine-UI/js/pages/show-keys-view.js b/shine-UI/js/pages/show-keys-view.js index 91c72fab..56108e7d 100644 --- a/shine-UI/js/pages/show-keys-view.js +++ b/shine-UI/js/pages/show-keys-view.js @@ -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 = ` +
+ Ключ восстановления + Управление учётной записью, восстановление и критическая смена ключей + +
+
Не хранится на устройстве
+

Приватный Recovery key создаётся из пароля только на время защищённых операций и не записывается в хранилище устройства.

+ `; + + 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 = `
- ${label}${hint ? `${hint}` : ''} + ${label}${hint}
*****
@@ -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); diff --git a/shine-UI/js/pages/wallet-view.js b/shine-UI/js/pages/wallet-view.js index 8630ee47..3c40d19b 100644 --- a/shine-UI/js/pages/wallet-view.js +++ b/shine-UI/js/pages/wallet-view.js @@ -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 = ` Как получен этот адрес?

- Сияние берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк. + Сияние берёт ваш локальный ключ блокчейна и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк. Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.

`; @@ -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); diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 88bd78f3..3b63b2b4 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -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') { diff --git a/shine-UI/js/services/arweave-wallet-service.js b/shine-UI/js/services/arweave-wallet-service.js index 580bd6b9..71ce5aa3 100644 --- a/shine-UI/js/services/arweave-wallet-service.js +++ b/shine-UI/js/services/arweave-wallet-service.js @@ -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, diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 95cb650b..5e7b6501 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -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); } diff --git a/shine-UI/js/services/client-key-utils.js b/shine-UI/js/services/client-key-utils.js index d0a3409f..8780c24f 100644 --- a/shine-UI/js/services/client-key-utils.js +++ b/shine-UI/js/services/client-key-utils.js @@ -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; diff --git a/shine-UI/js/services/device-pairing-service.js b/shine-UI/js/services/device-pairing-service.js index c167d96f..7051c7ff 100644 --- a/shine-UI/js/services/device-pairing-service.js +++ b/shine-UI/js/services/device-pairing-service.js @@ -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(), }; } diff --git a/shine-UI/js/services/key-rotation-service.js b/shine-UI/js/services/key-rotation-service.js index 4d19f5ab..0041c896 100644 --- a/shine-UI/js/services/key-rotation-service.js +++ b/shine-UI/js/services/key-rotation-service.js @@ -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; } diff --git a/shine-UI/js/services/key-vault.js b/shine-UI/js/services/key-vault.js index 6dbdc288..f9a7f183 100644 --- a/shine-UI/js/services/key-vault.js +++ b/shine-UI/js/services/key-vault.js @@ -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) { diff --git a/shine-UI/js/services/qr-key-transfer-service.js b/shine-UI/js/services/qr-key-transfer-service.js index 903465db..aa11201f 100644 --- a/shine-UI/js/services/qr-key-transfer-service.js +++ b/shine-UI/js/services/qr-key-transfer-service.js @@ -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 }), }; } diff --git a/shine-UI/js/services/sawd-v1.js b/shine-UI/js/services/sawd-v1.js index 551279a8..3e11f747 100644 --- a/shine-UI/js/services/sawd-v1.js +++ b/shine-UI/js/services/sawd-v1.js @@ -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: адрес пустой'); diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js index 239434b2..a195675b 100644 --- a/shine-UI/js/services/solana-wallet-service.js +++ b/shine-UI/js/services/solana-wallet-service.js @@ -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'; diff --git a/shine-UI/js/services/ui-error-texts.js b/shine-UI/js/services/ui-error-texts.js index a8d28c90..e2811fa1 100644 --- a/shine-UI/js/services/ui-error-texts.js +++ b/shine-UI/js/services/ui-error-texts.js @@ -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; diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 1e406789..6b28aa3d 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -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; diff --git a/shine-UI/styles/components/avatar.css b/shine-UI/styles/components/avatar.css index d2d04930..053bd698 100644 --- a/shine-UI/styles/components/avatar.css +++ b/shine-UI/styles/components/avatar.css @@ -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; diff --git a/shine-UI/styles/features/developer-tools.css b/shine-UI/styles/features/developer-tools.css index d9fab676..88160a48 100644 --- a/shine-UI/styles/features/developer-tools.css +++ b/shine-UI/styles/features/developer-tools.css @@ -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;