From 8d226027616263a56f1360e4ac172218acdb639d1dd3ab5c7cc3ba9d9fc50e00 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 1 Oct 2026 17:11:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D1=8B=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9?= =?UTF-8?q?=20=D0=B8=20=D0=BA=D0=BE=D1=88=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/blockchain/body/BodyHasTarget.java | 2 +- .../blockchain/body/BodyTargetSupport.java | 5 +- .../blockchain/TargetLoginFormatTest.java | 14 +- .../java/shine/db/entities/BlockEntry.java | 2 +- .../src/main/resources/postgres/schema_v1.sql | 2 +- .../blockchain/Net_AddBlock_Handler.java | 5 - ...authority_Solana_PDA_через_Recovery_key.md | 48 +++++ VERSION.properties | 4 +- docs/API/19_Key_Rotation_API.md | 6 +- docs/Blockchain/11_TEXT_Blocks.md | 2 +- docs/Blockchain/12_REACTION_Blocks.md | 2 +- docs/Blockchain/13_CONNECTION_Blocks.md | 2 +- docs/Blockchain/15_STATUS_ACTION_Blocks.md | 2 +- .../18_KEY_ROTATION_AND_FORK_TARGETS.md | 4 +- docs/Keys/DERIVATION.md | 20 +- docs/Keys/README.md | 50 ++--- docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md | 16 +- shine-UI/js/app.js | 4 - .../components/arweave-attachment-manager.js | 50 +---- shine-UI/js/pages/access-servers-view.js | 31 +-- shine-UI/js/pages/channel-about-view.js | 1 - shine-UI/js/pages/channel-donate-view.js | 1 - shine-UI/js/pages/connect-device-view.js | 99 +++------ shine-UI/js/pages/developer-settings-view.js | 4 +- shine-UI/js/pages/device-pairing-view.js | 147 ++----------- shine-UI/js/pages/device-qr-view.js | 17 +- shine-UI/js/pages/key-rotation-view.js | 61 +++++- shine-UI/js/pages/key-storage-view.js | 103 --------- shine-UI/js/pages/login-camera-view.js | 4 +- shine-UI/js/pages/login-other-device-view.js | 5 +- shine-UI/js/pages/login-password-view.js | 39 +++- shine-UI/js/pages/register-view.js | 2 +- shine-UI/js/pages/registration-faq-view.js | 18 +- shine-UI/js/pages/registration-keys-view.js | 204 ------------------ .../js/pages/registration-payment-view.js | 15 +- shine-UI/js/pages/show-keys-view.js | 91 ++++---- shine-UI/js/pages/wallet-view.js | 43 ++-- shine-UI/js/router.js | 8 +- .../js/services/arweave-wallet-service.js | 33 +-- shine-UI/js/services/auth-service.js | 27 ++- shine-UI/js/services/client-key-utils.js | 15 +- .../js/services/device-pairing-service.js | 15 +- shine-UI/js/services/key-rotation-service.js | 17 +- shine-UI/js/services/key-vault.js | 34 ++- .../js/services/qr-key-transfer-service.js | 35 ++- shine-UI/js/services/sawd-v1.js | 36 ++-- shine-UI/js/services/solana-wallet-service.js | 76 ++++++- shine-UI/js/services/ui-error-texts.js | 2 +- shine-UI/js/state.js | 16 -- shine-UI/styles/components/avatar.css | 2 +- shine-UI/styles/features/developer-tools.css | 22 -- 51 files changed, 565 insertions(+), 898 deletions(-) create mode 100644 TODO/Близкое/Переработать_authority_Solana_PDA_через_Recovery_key.md delete mode 100644 shine-UI/js/pages/key-storage-view.js delete mode 100644 shine-UI/js/pages/registration-keys-view.js 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({ ? `
Выберите, какие ключи передать на подключаемое устройство
- - - +Что будет передано
+ + +Что передаётся при расширенном подключении
+Что передаётся новому устройству
+ `; 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}) { + `; @@ -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(formatSol(oldB.sol,9))} SOLНовый кошелёк${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',``); + } + 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 = ` +