diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java index 4b979459..bfd51a62 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java @@ -3,7 +3,9 @@ package server.archive; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import shine.db.dao.BlocksDAO; +import shine.db.dao.KeyRotationCandidateBlocksDAO; import shine.db.entities.BlockEntry; +import shine.db.entities.KeyRotationCandidateBlockEntry; import java.util.ArrayList; import java.util.List; @@ -14,6 +16,7 @@ public final class ArweaveBlockPublisherService { private final ArweaveBlocksConfig cfg; private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); + private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance(); private final ArweaveL1Uploader arweaveUploader; private final TurboDataItemUploader turboUploader; @@ -25,13 +28,57 @@ public final class ArweaveBlockPublisherService { public int runCycle() throws Exception { if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0; - List candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems()); - if (candidates.isEmpty()) return 0; - return switch (cfg.publishMode()) { + + int maxItems = cfg.publishMaxItems(); + int attempted = 0; + int published = 0; + + // Ротация приоритетна: пользователь уже заблокирован для обычной записи и ждёт завершения копирования. + List rotationCandidates = candidateBlocksDAO.listPendingArweave(maxItems); + if (!rotationCandidates.isEmpty()) { + attempted += rotationCandidates.size(); + published += switch (cfg.publishMode()) { + case TURBO -> publishTurboRotation(rotationCandidates); + case ARWEAVE -> publishDirectArweaveRotation(rotationCandidates); + case NONE -> 0; + }; + } + + int remaining = Math.max(0, maxItems - attempted); + if (remaining == 0) return published; + + List candidates = blocksDAO.listPendingArweave(remaining); + if (candidates.isEmpty()) return published; + published += switch (cfg.publishMode()) { case TURBO -> publishTurbo(candidates); case ARWEAVE -> publishDirectArweave(candidates); case NONE -> 0; }; + return published; + } + + private int publishTurboRotation(List candidates) throws Exception { + int published = 0; + Exception firstFailure = null; + for (KeyRotationCandidateBlockEntry e : candidates) { + byte[] raw = e.getBlockBytes(); + byte[] id = e.getDataItemId(); + if (raw == null || raw.length == 0 || id == null || id.length != 32) continue; + try { + TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id); + candidateBlocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis()); + published++; + log.debug("Turbo published key-rotation DataItem {} chain={} block={}", + result.dataItemId(), e.getCandidateBlockchainName(), e.getBlockNumber()); + } catch (Exception ex) { + if (firstFailure == null) firstFailure = ex; + log.warn("Turbo key-rotation publish failed: chain={} block={} bytes={} error={}", + e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length, ex.getMessage()); + } + } + if (published > 0) log.info("Published {} SHiNE key-rotation DataItems through Turbo", published); + if (published == 0 && firstFailure != null) throw firstFailure; + return published; } private int publishTurbo(List candidates) throws Exception { @@ -57,6 +104,40 @@ public final class ArweaveBlockPublisherService { return published; } + private int publishDirectArweaveRotation(List candidates) throws Exception { + List items = new ArrayList<>(); + List ids = new ArrayList<>(); + long estimated = 32; + for (KeyRotationCandidateBlockEntry e : candidates) { + byte[] raw = e.getBlockBytes(); + byte[] id = e.getDataItemId(); + if (raw == null || raw.length == 0 || id == null || id.length != 32) continue; + long next = estimated + 64L + raw.length; + if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break; + if (next > cfg.publishMaxBundleBytes()) { + log.error("One key-rotation DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}", + e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length); + continue; + } + items.add(raw); + ids.add(id); + estimated = next; + } + if (items.isEmpty()) return 0; + + byte[] bundle = Ans104Bundle.write(items); + List rootTags = List.of( + new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"), + new ArweaveL1Uploader.Tag("Bundle-Format", "binary"), + new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0") + ); + ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags); + candidateBlocksDAO.markArweavePublished(ids, System.currentTimeMillis()); + log.info("Published {} SHiNE key-rotation DataItems in direct Arweave root tx {} (bundle={} bytes)", + items.size(), result.txId(), bundle.length); + return items.size(); + } + private int publishDirectArweave(List candidates) throws Exception { List items = new ArrayList<>(); List ids = new ArrayList<>(); diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java index 35cf71b0..a06cdd51 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java @@ -25,6 +25,10 @@ public final class BodyRecordParser { && (v == (CreateChannelBody.VER & 0xFFFF))) { return new CreateChannelBody(subType, version, bodyBytes).check(); } + if (st == (ForkBody.SUBTYPE & 0xFFFF) + && (v == (ForkBody.VER & 0xFFFF))) { + return new ForkBody(subType, version, bodyBytes).check(); + } throw new IllegalArgumentException( String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st) ); diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index b04a25b3..9ceea601 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -35,6 +35,8 @@ public final class MsgSubType { /** HeaderBody: subType всегда 0 (compat). */ public static final short HEADER_COMPAT = 0; public static final short TECH_CREATE_CHANNEL = 1; + /** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */ + public static final short TECH_FORK = 2; /* ===================== TEXT (msg_type=1) ===================== */ @@ -53,7 +55,7 @@ public final class MsgSubType { /** * REPLY — ответ на сообщение. - * НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32). + * НЕ в линии. Имеет target (toLogin + blockNumber + hash32). * Может указывать на чужой блокчейн/чужую линию/чужой канал. */ public static final short TEXT_REPLY = 20; @@ -69,7 +71,7 @@ public final class MsgSubType { /** * REPOST — отложенная будущая заготовка репоста сообщения в линии канала. - * Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария. + * Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария. */ public static final short TEXT_REPOST = 50; 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 bd8e1780..7bfb734a 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 @@ -1,31 +1,32 @@ package blockchain.body; -import utils.blockchain.BlockchainNameUtil; - /** - * BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля). + * BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель. * - * Новое правило: - * - toLogin НЕ храним в байтах блока. - * - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN". + * Актуальное правило target: + * - в подписываемых байтах хранится login цели; + * - номер fork/blockchainName в target не хранится; + * - идентичность цели задаётся как login + blockNumber + blockHash. * - * Все методы могут возвращать null. + * Это позволяет одной и той же логической записи сохранять ссылки после fork, + * если её номер и SHA-256 hash остались прежними. */ public interface BodyHasTarget { - /** login цели (nullable). Вычисляется из toBchName(). */ - default String toLogin() { - String bch = toBchName(); - if (bch == null) return null; - return BlockchainNameUtil.loginFromBlockchainName(bch); - } + /** login цели. Актуальные runtime-body обязаны переопределять этот метод. */ + default String toLogin() { return null; } - /** blockchainName цели (nullable). */ - String toBchName(); + /** + * Legacy source-level accessor. В новом подписанном target blockchainName не хранится. + * Оставлен временно только чтобы старые вспомогательные классы компилировались; + * runtime-логика не должна использовать его для новых блоков. + */ + @Deprecated + default String toBchName() { return null; } - /** globalNumber цели (nullable). */ + /** globalNumber цели (nullable только если конкретный subtype не содержит target). */ Integer toBlockGlobalNumber(); /** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */ byte[] toBlockHashBytes(); -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java index 1d8da91a..b202c2ef 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java @@ -1,7 +1,6 @@ package blockchain.body; import blockchain.MsgSubType; -import utils.blockchain.BlockchainNameUtil; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -12,96 +11,38 @@ import java.util.Objects; /** * ConnectionBody — type=3, ver=1 (в заголовке блока). * - * subType (в заголовке блока) как MsgSubType: - * FRIEND=10, UNFRIEND=11 - * CONTACT=20, UNCONTACT=21 - * FOLLOW=30, UNFOLLOW=31 - * SPOUSE=40, UNSPOUSE=41 - * PARENT=50, UNPARENT=51 - * CHILD=52, UNCHILD=53 - * SIBLING=54, UNSIBLING=55 - * FRIEND=14, UNFRIEND=15 - * KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI) - * SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71 - * SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI) - * OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81 - * - * bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ): + * bodyBytes (BigEndian): * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber - * - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 - * [4] toBlockGlobalNumber (int32) + * [1] toLoginLen (uint8) + * [N] toLogin UTF-8 + * [4] toBlockGlobalNumber (int32) * [32] toBlockHash32 (raw 32 bytes) * - * toLogin вычисляется автоматически из toBlockchainName: - * toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) + * Номер fork/blockchainName в target не хранится. */ - -/** - * ========================================================================= - * ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов) - * ========================================================================= - * - * Термины: - * - ROOT линии/канала = блок, который "начинает" линию: - * * для канала "0" root = HEADER (blockNumber=0) - * * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока) - * - * 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*): - * FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя: - * toBlockNumber = 0 - * toBlockHash32 = hash32(HEADER цели) - * - * 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE): - * FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER): - * toBlockNumber = 0 - * toBlockHash32 = hash32(HEADER цели) - * - * FOLLOW/подписка на конкретный канал пользователя -> - * цель = ROOT этого канала: - * - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER) - * - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL), - * toBlockHash32=hash32(CREATE_CHANNEL) - * - * 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД): - * - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). - * - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала: - * разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL). - * - * Зачем так: - * - связи и подписки всегда стабильны и не ломаются при новых постах, - * - один понятный инвариант: "подписка всегда указывает на root линии". - * ========================================================================= - */ - public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine { public static final short TYPE = 3; public static final short VER = 1; - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - public final short subType; // из header - public final short version; // из header + public final short subType; + public final short version; - // line public final int lineCode; public final int prevLineNumber; public final byte[] prevLineHash32; public final int thisLineNumber; - // payload - public final String toBlockchainName; + public final String toLogin; public final int toBlockGlobalNumber; public final byte[] toBlockHash32; public ConnectionBody(short subType, short version, byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - this.subType = subType; this.version = version; @@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL if (!isValidSubType(this.subType)) { throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF)); } - - // минимум: - // lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32] - if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) { + if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) { throw new IllegalArgumentException("ConnectionBody too short"); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - this.lineCode = bb.getInt(); - this.prevLineNumber = bb.getInt(); - this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); - this.thisLineNumber = bb.getInt(); - int bchLen = Byte.toUnsignedInt(bb.get()); - if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); - if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short"); - - byte[] bchBytes = new byte[bchLen]; - bb.get(bchBytes); - this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8); + int loginLen = Byte.toUnsignedInt(bb.get()); + if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0"); + if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short"); + byte[] loginBytes = new byte[loginLen]; + bb.get(loginBytes); + this.toLogin = new String(loginBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); - this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); @@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL byte[] prevLineHash32, int thisLineNumber, short subType, - String toBlockchainName, + String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) { - - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toLogin, "toLogin == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); - - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); - // Железное правило формата: bchName -> login + "-NNN" - if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) { - throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName); - } - + if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); this.lineCode = lineCode; - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32); this.thisLineNumber = thisLineNumber; - this.subType = subType; this.version = VER; - - this.toBlockchainName = toBlockchainName; + this.toLogin = toLogin; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } + @Override + public ConnectionBody check() { + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); + + if (prevLineNumber == -1) { + if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1"); + if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1"); + } else if (prevLineHash32 == null || prevLineHash32.length != 32) { + throw new IllegalArgumentException("prevLineHash32 invalid"); + } + + if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); + return this; + } + + @Override + public byte[] toBytes() { + byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8); + if (loginBytes.length == 0 || loginBytes.length > 255) + throw new IllegalArgumentException("toLogin utf8 len must be 1..255"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("toBlockHash32 != 32"); + + int cap = 4 + 4 + 32 + 4 + 1 + loginBytes.length + 4 + 32; + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putInt(lineCode); + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + bb.put((byte) loginBytes.length); + bb.put(loginBytes); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + return bb.array(); + } + private static boolean isValidSubType(short st) { int v = st & 0xFFFF; return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) @@ -211,76 +172,18 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL || v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF); } - @Override - public ConnectionBody check() { - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); - - // line rule (как было) - if (prevLineNumber == -1) { - if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1"); - if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1"); - } else { - if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); - } - - if (toBlockchainName == null || toBlockchainName.isBlank()) - throw new IllegalArgumentException("toBlockchainName is blank"); - - // гарантируем вычислимый toLogin (иначе target “битый” по стандарту) - if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) - throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName); - - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); - - return this; - } - - @Override - public byte[] toBytes() { - byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (bchBytes.length == 0 || bchBytes.length > 255) - throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); - - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("toBlockHash32 != 32"); - - int cap = 4 + (4 + 32 + 4) - + 1 + bchBytes.length - + 4 + 32; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - - bb.putInt(lineCode); - - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); - - bb.put((byte) bchBytes.length); - bb.put(bchBytes); - - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - - return bb.array(); - } - private static boolean isAllZero32(byte[] b) { if (b == null || b.length != 32) return true; - for (int i = 0; i < 32; i++) if (b[i] != 0) return false; + for (byte value : b) if (value != 0) return false; return true; } - /* ====================== BodyHasLine ====================== */ @Override public int lineCode() { return lineCode; } @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } @Override public int lineSeq() { return thisLineNumber; } - /* ====================== BodyHasTarget ===================== */ - @Override public String toBchName() { return toBlockchainName; } + @Override public String toLogin() { return toLogin; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ForkBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ForkBody.java new file mode 100644 index 00000000..37903223 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ForkBody.java @@ -0,0 +1,194 @@ +package blockchain.body; + +import blockchain.MsgSubType; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * TECH_FORK body (type=0, subType=2, version=1). + * + * Записывается первым новым блоком после точной перепубликации выбранного + * префикса старой цепочки новым blockchain key. + * + * body bytes (BigEndian): + * [32] parentBlockchainKey + * [4] forkPointBlockNumber + * [32] forkPointBlockHash32 + * [8] forkPointTimestampMs + * [4] parentTipBlockNumber + * [32] parentTipBlockHash32 + * [8] parentTipTimestampMs + * [4] discardedBlocksCount + * [1] reasonCode + * [2] commentUtf8Length + * [N] comment UTF-8 (0..1024 bytes) + */ +public final class ForkBody implements BodyRecord { + + public static final short TYPE = 0; + public static final short VER = 1; + public static final short SUBTYPE = MsgSubType.TECH_FORK; + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public static final int REASON_ROUTINE_ROTATION = 1; + public static final int REASON_POSSIBLE_COMPROMISE = 2; + public static final int REASON_CONFIRMED_COMPROMISE_ROLLBACK = 3; + public static final int REASON_RECOVERY = 4; + + public static final int MAX_COMMENT_UTF8_LEN = 1024; + private static final int FIXED_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2; + + public final short subType; + public final short version; + public final byte[] parentBlockchainKey32; + public final int forkPointBlockNumber; + public final byte[] forkPointBlockHash32; + public final long forkPointTimestampMs; + public final int parentTipBlockNumber; + public final byte[] parentTipBlockHash32; + public final long parentTipTimestampMs; + public final int discardedBlocksCount; + public final int reasonCode; + public final String comment; + + public ForkBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + this.subType = subType; + this.version = version; + + if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) { + throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)"); + } + if ((version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("ForkBody version must be 1"); + } + if (bodyBytes.length < FIXED_LEN) { + throw new IllegalArgumentException("ForkBody too short"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + this.parentBlockchainKey32 = new byte[32]; + bb.get(this.parentBlockchainKey32); + this.forkPointBlockNumber = bb.getInt(); + this.forkPointBlockHash32 = new byte[32]; + bb.get(this.forkPointBlockHash32); + this.forkPointTimestampMs = bb.getLong(); + this.parentTipBlockNumber = bb.getInt(); + this.parentTipBlockHash32 = new byte[32]; + bb.get(this.parentTipBlockHash32); + this.parentTipTimestampMs = bb.getLong(); + this.discardedBlocksCount = bb.getInt(); + this.reasonCode = Byte.toUnsignedInt(bb.get()); + int commentLen = Short.toUnsignedInt(bb.getShort()); + if (commentLen > MAX_COMMENT_UTF8_LEN) { + throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024"); + } + if (bb.remaining() != commentLen) { + throw new IllegalArgumentException("ForkBody tail mismatch: remaining=" + bb.remaining() + " commentLen=" + commentLen); + } + byte[] commentBytes = new byte[commentLen]; + bb.get(commentBytes); + this.comment = new String(commentBytes, StandardCharsets.UTF_8); + } + + public ForkBody(byte[] parentBlockchainKey32, + int forkPointBlockNumber, + byte[] forkPointBlockHash32, + long forkPointTimestampMs, + int parentTipBlockNumber, + byte[] parentTipBlockHash32, + long parentTipTimestampMs, + int discardedBlocksCount, + int reasonCode, + String comment) { + this.subType = SUBTYPE; + this.version = VER; + this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32"); + this.forkPointBlockNumber = forkPointBlockNumber; + this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32"); + this.forkPointTimestampMs = forkPointTimestampMs; + this.parentTipBlockNumber = parentTipBlockNumber; + this.parentTipBlockHash32 = copy32(parentTipBlockHash32, "parentTipBlockHash32"); + this.parentTipTimestampMs = parentTipTimestampMs; + this.discardedBlocksCount = discardedBlocksCount; + this.reasonCode = reasonCode; + this.comment = normalizeComment(comment); + } + + @Override + public ForkBody check() { + if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) { + throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)"); + } + if ((version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("ForkBody version must be 1"); + } + require32(parentBlockchainKey32, "parentBlockchainKey32"); + require32(forkPointBlockHash32, "forkPointBlockHash32"); + require32(parentTipBlockHash32, "parentTipBlockHash32"); + if (forkPointBlockNumber < 0) throw new IllegalArgumentException("forkPointBlockNumber < 0"); + if (parentTipBlockNumber < forkPointBlockNumber) { + throw new IllegalArgumentException("parentTipBlockNumber < forkPointBlockNumber"); + } + if (forkPointTimestampMs < 0) throw new IllegalArgumentException("forkPointTimestampMs < 0"); + if (parentTipTimestampMs < 0) throw new IllegalArgumentException("parentTipTimestampMs < 0"); + int expectedDiscarded = parentTipBlockNumber - forkPointBlockNumber; + if (discardedBlocksCount != expectedDiscarded) { + throw new IllegalArgumentException("discardedBlocksCount must equal parentTipBlockNumber - forkPointBlockNumber"); + } + if (!isReasonSupported(reasonCode)) { + throw new IllegalArgumentException("Unsupported fork reasonCode=" + reasonCode); + } + byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8); + if (commentUtf8.length > MAX_COMMENT_UTF8_LEN) { + throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024"); + } + return this; + } + + @Override + public byte[] toBytes() { + check(); + byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8); + ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN); + bb.put(parentBlockchainKey32); + bb.putInt(forkPointBlockNumber); + bb.put(forkPointBlockHash32); + bb.putLong(forkPointTimestampMs); + bb.putInt(parentTipBlockNumber); + bb.put(parentTipBlockHash32); + bb.putLong(parentTipTimestampMs); + bb.putInt(discardedBlocksCount); + bb.put((byte) reasonCode); + bb.putShort((short) commentUtf8.length); + bb.put(commentUtf8); + return bb.array(); + } + + public static boolean isReasonSupported(int code) { + return code == REASON_ROUTINE_ROTATION + || code == REASON_POSSIBLE_COMPROMISE + || code == REASON_CONFIRMED_COMPROMISE_ROLLBACK + || code == REASON_RECOVERY; + } + + private static byte[] copy32(byte[] value, String name) { + require32(value, name); + return Arrays.copyOf(value, 32); + } + + private static void require32(byte[] value, String name) { + if (value == null || value.length != 32) { + throw new IllegalArgumentException(name + " must be 32 bytes"); + } + } + + private static String normalizeComment(String value) { + if (value == null) return ""; + return value.trim().replace("\r\n", "\n").replace('\r', '\n'); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java index 432c3ab4..f8cc7294 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java @@ -15,12 +15,13 @@ import java.util.Objects; * 1 = LIKE * 2 = UNLIKE * - * bodyBytes (BigEndian), новый формат: - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 + * bodyBytes (BigEndian): + * [1] toLoginLen (uint8) + * [N] toLogin UTF-8 * [4] toBlockGlobalNumber (int32) * [32] toBlockHash32 (raw 32 bytes) * + * Номер fork/blockchainName в target не хранится. * ЛИНИИ НЕТ. */ public final class ReactionBody implements BodyRecord, BodyHasTarget { @@ -30,10 +31,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget { public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - public final short subType; // из header - public final short version; // из header + public final short subType; + public final short version; - public final String toBlockchainName; + public final String toLogin; public final int toBlockGlobalNumber; public final byte[] toBlockHash32; @@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget { if (!isSupportedSubType(this.subType)) { throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF)); } - - // минимум: nameLen[1]+name[1]+global[4]+hash[32] if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short"); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); - if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short"); + int loginLen = Byte.toUnsignedInt(bb.get()); + if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0"); + if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short"); - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + byte[] loginBytes = new byte[loginLen]; + bb.get(loginBytes); + this.toLogin = new String(loginBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); - this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); } - public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) { + Objects.requireNonNull(toLogin, "toLogin == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); this.subType = MsgSubType.REACTION_LIKE; this.version = VER; - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - this.toBlockchainName = toBlockchainName; + this.toLogin = toLogin; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } @@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget { public ReactionBody check() { if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF)); - - if (toBlockchainName == null || toBlockchainName.isBlank()) - throw new IllegalArgumentException("toBlockchainName is blank"); + if (toLogin == null || toLogin.isBlank()) + throw new IllegalArgumentException("toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); - return this; } @Override public byte[] toBytes() { - byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (nameBytes.length == 0 || nameBytes.length > 255) - throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); + byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8); + if (loginBytes.length == 0 || loginBytes.length > 255) + throw new IllegalArgumentException("toLogin utf8 len must be 1..255"); - int cap = 1 + nameBytes.length + 4 + 32; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.put((byte) nameBytes.length); - bb.put(nameBytes); + ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN); + bb.put((byte) loginBytes.length); + bb.put(loginBytes); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); - return bb.array(); } - /* ====================== BodyHasTarget ====================== */ - - @Override public String toBchName() { return toBlockchainName; } + @Override public String toLogin() { return toLogin; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java index 3a3bd0b9..7f2b3de6 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java @@ -13,11 +13,11 @@ import java.util.Objects; /** * StatusActionBody — type=5, ver=1. * - * Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение. + * Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение. * * Формат bodyBytes (BigEndian): - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 + * [1] toLoginLen (uint8) + * [N] toLogin UTF-8 * [4] toBlockGlobalNumber * [32] toBlockHash32 * [2] textLenBytes (uint16) @@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget { public final short subType; public final short version; - public final String toBlockchainName; + public final String toLogin; public final int toBlockGlobalNumber; public final byte[] toBlockHash32; public final String message; @@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget { ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short"); - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0"); - ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short"); + int loginLen = Byte.toUnsignedInt(bb.get()); + if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0"); + ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short"); - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + byte[] loginBytes = new byte[loginLen]; + bb.get(loginBytes); + this.toLogin = new String(loginBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text"); - ensureNoTail(bb, "StatusActionBody"); } - public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { + Objects.requireNonNull(toLogin, "toLogin == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(message, "message == null"); if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType"); - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); this.subType = subType; this.version = VER; - this.toBlockchainName = toBlockchainName; + this.toLogin = toLogin; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.message = message; @@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget { @Override public StatusActionBody check() { - if (!isSupportedSubType(subType)) { - throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF)); - } - if (toBlockchainName == null || toBlockchainName.isBlank()) { - throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank"); - } + if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF)); + if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32 == null || toBlockHash32.length != 32) { - throw new IllegalArgumentException("toBlockHash32 invalid"); - } + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); if (message == null) throw new IllegalArgumentException("message is null"); return this; } @@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget { byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); - byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (nameUtf8.length == 0 || nameUtf8.length > 255) { - throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255"); - } + byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8); + if (loginUtf8.length == 0 || loginUtf8.length > 255) + throw new IllegalArgumentException("STATUS_ACTION toLogin utf8 len must be 1..255"); - ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) + ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length) .order(ByteOrder.BIG_ENDIAN); - bb.put((byte) nameUtf8.length); - bb.put(nameUtf8); + bb.put((byte) loginUtf8.length); + bb.put(loginUtf8); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); bb.putShort((short) msgUtf8.length); @@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget { return bb.array(); } - @Override public String toBchName() { return toBlockchainName; } + @Override public String toLogin() { return toLogin; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } @@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget { int len = Short.toUnsignedInt(bb.getShort()); if (len == 0) return ""; if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); - byte[] bytes = new byte[len]; bb.get(bytes); - var decoder = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); - try { return decoder.decode(ByteBuffer.wrap(bytes)).toString(); } catch (CharacterCodingException e) { diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java index 8f3b4917..a90bc95b 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java @@ -33,23 +33,13 @@ import java.util.Objects; * [2] textLenBytes (uint16) * [N] text UTF-8 * - * EDIT_POST: + * EDIT_POST / REPOST: * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber - * [4] toBlockGlobalNumber (int32) - * [32] toBlockHash32 - * [2] textLenBytes (uint16) - * [N] text UTF-8 - * - * REPOST: - * [4] lineCode - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 + * [1] toLoginLen (uint8) + * [N] toLogin UTF-8 * [4] toBlockGlobalNumber (int32) * [32] toBlockHash32 * [2] textLenBytes (uint16) @@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge public final int thisLineNumber; // target (для EDIT_POST / REPOST) - public final String toBlockchainName; // nullable для POST/EDIT_POST + public final String toLogin; // nullable для сообщений без target public final Integer toBlockGlobalNumber; // nullable для POST public final byte[] toBlockHash32; // nullable для POST @@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge this.thisLineNumber = bb.getInt(); - if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - // нужен target - ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target"); - int tgtNum = bb.getInt(); - byte[] tgtHash = new byte[32]; - bb.get(tgtHash); - - this.toBlockchainName = null; - this.toBlockGlobalNumber = tgtNum; - this.toBlockHash32 = tgtHash; - } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { - ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target"); - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0"); - ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short"); - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing"); + int loginLen = Byte.toUnsignedInt(bb.get()); + if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0"); + ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short"); + byte[] loginBytes = new byte[loginLen]; + bb.get(loginBytes); + this.toLogin = new String(loginBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); } else { - this.toBlockchainName = null; + this.toLogin = null; this.toBlockGlobalNumber = null; this.toBlockHash32 = null; } @@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge short subType, Integer toBlockGlobalNumber, byte[] toBlockHash32, - String toBlockchainName, + String toLogin, String message) { Objects.requireNonNull(message, "message == null"); @@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge this.thisLineNumber = thisLineNumber; if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + Objects.requireNonNull(toLogin, "toLogin == null"); Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - this.toBlockchainName = null; + this.toLogin = toLogin; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + Objects.requireNonNull(toLogin, "toLogin == null"); + if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - this.toBlockchainName = toBlockchainName; + this.toLogin = toLogin; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } else { - this.toBlockchainName = null; + this.toLogin = null; this.toBlockGlobalNumber = null; this.toBlockHash32 = null; } @@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); - if (toBlockchainName != null) - throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName"); + if (toLogin == null || toLogin.isBlank()) + throw new IllegalArgumentException("EDIT_POST toLogin is blank"); } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { if (message == null || message.isBlank()) throw new IllegalArgumentException("REPOST message is blank"); - if (toBlockchainName == null || toBlockchainName.isBlank()) - throw new IllegalArgumentException("REPOST toBlockchainName is blank"); + if (toLogin == null || toLogin.isBlank()) + throw new IllegalArgumentException("REPOST toLogin is blank"); if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid"); if (toBlockHash32 == null || toBlockHash32.length != 32) @@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge } else if (message == null) { throw new IllegalArgumentException("Text message is null"); } - if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) + if (toLogin != null || toBlockGlobalNumber != null || toBlockHash32 != null) throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields"); } @@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge || st == (MsgSubType.TEXT_SERVICE & 0xFFFF) || st == (MsgSubType.TEXT_COURSE & 0xFFFF)) { cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; - } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - // EDIT_POST - if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32"); - cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; } else { - if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName"); - byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (toLogin == null) throw new IllegalArgumentException("target missing toLogin"); + byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8); if (nameUtf8.length == 0 || nameUtf8.length > 255) { - throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255"); + throw new IllegalArgumentException("target toLogin utf8 len must be 1..255"); } - if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32"); + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("target toBlockHash32 != 32"); cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length; } @@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); bb.putInt(thisLineNumber); - if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { - byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); - bb.put((byte) nameUtf8.length); - bb.put(nameUtf8); + if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { + byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8); + bb.put((byte) loginUtf8.length); + bb.put(loginUtf8); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); } @@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge @Override public int lineSeq() { return thisLineNumber; } /* ====================== BodyHasTarget ===================== */ - @Override public String toBchName() { return toBlockchainName; } + @Override public String toLogin() { return toLogin; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java index 270a3904..be39e614 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java @@ -11,14 +11,11 @@ import java.util.Arrays; import java.util.Objects; /** - * TextRatingBody — type=1, ver=1. - * - * subType: - * - RATING (30) + * TextRatingBody — type=1, ver=1, subType=30. * * Формат bodyBytes (BigEndian): - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 + * [1] toLoginLen (uint8) + * [N] toLogin UTF-8 * [4] toBlockGlobalNumber * [32] toBlockHash32 * [2] textLenBytes (uint16) @@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget { public final short subType; public final short version; - public final String toBlockchainName; + public final String toLogin; public final int toBlockGlobalNumber; public final byte[] toBlockHash32; public final String message; @@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget { ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short"); - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0"); - ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short"); + int loginLen = Byte.toUnsignedInt(bb.get()); + if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0"); + ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short"); - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + byte[] loginBytes = new byte[loginLen]; + bb.get(loginBytes); + this.toLogin = new String(loginBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); this.message = readStrictUtf8Len16(bb, "TextRatingBody text"); - ensureNoTail(bb, "TextRatingBody"); } - public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { + Objects.requireNonNull(toLogin, "toLogin == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(message, "message == null"); - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (message.isBlank()) throw new IllegalArgumentException("message is blank"); this.subType = MsgSubType.TEXT_RATING; this.version = VER; - this.toBlockchainName = toBlockchainName; + this.toLogin = toLogin; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.message = message; @@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget { @Override public TextRatingBody check() { - if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) { - throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF)); - } - if (toBlockchainName == null || toBlockchainName.isBlank()) { - throw new IllegalArgumentException("RATING toBlockchainName is blank"); - } + if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) + throw new IllegalArgumentException("Bad RATING subType"); + if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32 == null || toBlockHash32.length != 32) { - throw new IllegalArgumentException("toBlockHash32 invalid"); - } + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank"); return this; } @@ -106,15 +97,14 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget { if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); - byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (nameUtf8.length == 0 || nameUtf8.length > 255) { - throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255"); - } + byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8); + if (loginUtf8.length == 0 || loginUtf8.length > 255) + throw new IllegalArgumentException("RATING toLogin utf8 len must be 1..255"); - ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) + ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length) .order(ByteOrder.BIG_ENDIAN); - bb.put((byte) nameUtf8.length); - bb.put(nameUtf8); + bb.put((byte) loginUtf8.length); + bb.put(loginUtf8); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); bb.putShort((short) msgUtf8.length); @@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget { return bb.array(); } - @Override public String toBchName() { return toBlockchainName; } + @Override public String toLogin() { return toLogin; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } @@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget { int len = Short.toUnsignedInt(bb.getShort()); if (len == 0) throw new IllegalArgumentException(fieldName + " is empty"); if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); - byte[] bytes = new byte[len]; bb.get(bytes); - var decoder = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); - try { String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextReplyBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextReplyBody.java index ceeae92a..e65a3996 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextReplyBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextReplyBody.java @@ -17,84 +17,56 @@ import java.util.Objects; * - REPLY (20) * - EDIT_REPLY (21) * - * Форматы bodyBytes (BigEndian): - * - * REPLY: - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 + * Оба subtype используют одинаковый target-формат: + * [1] toLoginLen (uint8) + * [N] toLogin UTF-8 * [4] toBlockGlobalNumber * [32] toBlockHash32 * [2] textLenBytes (uint16) * [M] text UTF-8 * - * EDIT_REPLY: - * [4] toBlockGlobalNumber - * [32] toBlockHash32 - * [2] textLenBytes (uint16) - * [N] text UTF-8 + * Для EDIT_REPLY text может быть пустым (логическое удаление). */ public final class TextReplyBody implements BodyRecord, BodyHasTarget { public static final short TYPE = 1; public static final short VER = 1; - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - public final short subType; // из header - public final short version; // (=1) + public final short subType; + public final short version; - // target - public final String toBlockchainName; // nullable для EDIT_REPLY + public final String toLogin; public final int toBlockGlobalNumber; - public final byte[] toBlockHash32; // 32 - - // text + public final byte[] toBlockHash32; public final String message; public TextReplyBody(short subType, short version, byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - this.subType = subType; this.version = version; if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF)); } - int st = this.subType & 0xFFFF; if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short"); - if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - // минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2] - ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); + int loginLen = Byte.toUnsignedInt(bb.get()); + if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0"); + ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short"); - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0"); - ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short"); - - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); - - this.toBlockGlobalNumber = bb.getInt(); - - this.toBlockHash32 = new byte[32]; - bb.get(this.toBlockHash32); - - } else { - // EDIT_REPLY: target без имени - ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short"); - - this.toBlockchainName = null; - this.toBlockGlobalNumber = bb.getInt(); - - this.toBlockHash32 = new byte[32]; - bb.get(this.toBlockHash32); - } + byte[] loginBytes = new byte[loginLen]; + bb.get(loginBytes); + this.toLogin = new String(loginBytes, StandardCharsets.UTF_8); + this.toBlockGlobalNumber = bb.getInt(); + this.toBlockHash32 = new byte[32]; + bb.get(this.toBlockHash32); this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)); ensureNoTail(bb, "TextReplyBody"); @@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { public TextReplyBody(short subType, int toBlockGlobalNumber, byte[] toBlockHash32, - String toBlockchainName, + String toLogin, String message) { - Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + Objects.requireNonNull(toLogin, "toLogin == null"); int st = subType & 0xFFFF; if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { @@ -116,25 +88,15 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) { throw new IllegalArgumentException("message is blank"); } - + if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); - this.toBlockchainName = toBlockchainName; - } else { - // EDIT_REPLY: имя не хранить - this.toBlockchainName = null; - } - this.subType = subType; this.version = VER; - + this.toLogin = toLogin; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); - this.message = message; } @@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { int st = subType & 0xFFFF; if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) throw new IllegalArgumentException("Bad TextReplyBody subType: " + st); - - if (toBlockGlobalNumber < 0) - throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("toBlockHash32 invalid"); - + if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - if (message == null || message.isBlank()) - throw new IllegalArgumentException("Text message is blank"); - if (toBlockchainName == null || toBlockchainName.isBlank()) - throw new IllegalArgumentException("REPLY toBlockchainName is blank"); - } else { - if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null"); - if (toBlockchainName != null) - throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName"); + if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank"); + } else if (message == null) { + throw new IllegalArgumentException("EDIT_REPLY message is null"); } - return this; } @@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { public byte[] toBytes() { byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); - int st = subType & 0xFFFF; if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) { throw new IllegalArgumentException("Text payload is empty"); } - if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); + byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8); + if (loginUtf8.length == 0 || loginUtf8.length > 255) + throw new IllegalArgumentException("TextReplyBody toLogin utf8 len must be 1..255"); - byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (nameUtf8.length == 0 || nameUtf8.length > 255) - throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255"); - - int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.put((byte) nameUtf8.length); - bb.put(nameUtf8); - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - - return bb.array(); - } - - // EDIT_REPLY - int cap = (4 + 32) + 2 + msgUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length) + .order(ByteOrder.BIG_ENDIAN); + bb.put((byte) loginUtf8.length); + bb.put(loginUtf8); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); bb.putShort((short) msgUtf8.length); bb.put(msgUtf8); - return bb.array(); } - /* ====================== BodyHasTarget ====================== */ - - @Override public String toBchName() { return toBlockchainName; } + @Override public String toLogin() { return toLogin; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } @@ -215,8 +148,6 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); } - /* ====================== helpers ====================== */ - private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) { int len = Short.toUnsignedInt(bb.getShort()); if (len == 0) { @@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { throw new IllegalArgumentException(fieldName + " is empty"); } if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); - byte[] bytes = new byte[len]; bb.get(bytes); - var decoder = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); - try { String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/ForkBodyTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/ForkBodyTest.java new file mode 100644 index 00000000..13593fe5 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/ForkBodyTest.java @@ -0,0 +1,85 @@ +package blockchain; + +import blockchain.body.ForkBody; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +final class ForkBodyTest { + + @Test + void roundTripThroughBodyRecordParser() { + byte[] parentKey = filled(32, 0x11); + byte[] forkHash = filled(32, 0x22); + byte[] tipHash = filled(32, 0x33); + + ForkBody source = new ForkBody( + parentKey, + 120, + forkHash, + 1_700_000_000_000L, + 137, + tipHash, + 1_700_100_000_000L, + 17, + ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK, + "Удаляю неизвестные записи" + ).check(); + + ForkBody parsed = assertInstanceOf( + ForkBody.class, + BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes()) + ); + + assertArrayEquals(parentKey, parsed.parentBlockchainKey32); + assertEquals(120, parsed.forkPointBlockNumber); + assertArrayEquals(forkHash, parsed.forkPointBlockHash32); + assertEquals(137, parsed.parentTipBlockNumber); + assertArrayEquals(tipHash, parsed.parentTipBlockHash32); + assertEquals(17, parsed.discardedBlocksCount); + assertEquals(ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK, parsed.reasonCode); + assertEquals("Удаляю неизвестные записи", parsed.comment); + } + + @Test + void routineRotationMayDiscardNothing() { + ForkBody body = new ForkBody( + filled(32, 1), + 10, + filled(32, 2), + 1000, + 10, + filled(32, 2), + 1000, + 0, + ForkBody.REASON_ROUTINE_ROTATION, + "" + ); + assertDoesNotThrow(body::check); + } + + @Test + void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() { + ForkBody body = new ForkBody( + filled(32, 1), + 10, + filled(32, 2), + 1000, + 12, + filled(32, 3), + 2000, + 99, + ForkBody.REASON_POSSIBLE_COMPROMISE, + "" + ); + assertThrows(IllegalArgumentException.class, body::check); + } + + private static byte[] filled(int size, int value) { + byte[] out = new byte[size]; + Arrays.fill(out, (byte) value); + return out; + } +} 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 new file mode 100644 index 00000000..f2fcbf10 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java @@ -0,0 +1,73 @@ +package blockchain; + +import blockchain.body.ConnectionBody; +import blockchain.body.ReactionBody; +import blockchain.body.StatusActionBody; +import blockchain.body.TextLineBody; +import blockchain.body.TextReplyBody; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +final class TargetLoginFormatTest { + + private static final byte[] HASH = hash(7); + + @Test + void reactionRoundTripStoresLoginNotBlockchainName() { + ReactionBody source = new ReactionBody("alice", 42, HASH); + ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check(); + + assertEquals("alice", parsed.toLogin()); + assertEquals(42, parsed.toBlockGlobalNumber()); + assertArrayEquals(HASH, parsed.toBlockHashBytes()); + assertFalse(new String(source.toBytes()).contains("alice-001")); + } + + @Test + void replyAndEditReplyUseSameLoginTargetShape() { + TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply"); + TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check(); + assertEquals("alice", parsedReply.toLogin()); + + TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", ""); + TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check(); + assertEquals("alice", parsedEdit.toLogin()); + assertEquals("", parsedEdit.message); + } + + @Test + void editPostAndConnectionCarryLoginTarget() { + TextLineBody editPost = new TextLineBody( + 0, -1, new byte[32], -1, + MsgSubType.TEXT_EDIT_POST, + 5, HASH, "alice", "edited" + ); + TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check(); + assertEquals("alice", parsedEditPost.toLogin()); + + ConnectionBody connection = new ConnectionBody( + 0, -1, new byte[32], -1, + MsgSubType.CONNECTION_FOLLOW, + "alice", 0, HASH + ); + ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check(); + assertEquals("alice", parsedConnection.toLogin()); + } + + @Test + void statusActionCarriesLoginTarget() { + StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, ""); + StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check(); + assertEquals("alice", parsed.toLogin()); + assertEquals(77, parsed.toBlockGlobalNumber()); + } + + private static byte[] hash(int seed) { + byte[] out = new byte[32]; + Arrays.fill(out, (byte) seed); + return out; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 9c19edf7..5140d61e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -42,6 +42,8 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_23 = 23; public static final int SCHEMA_VERSION_24 = 24; public static final int SCHEMA_VERSION_25 = 25; + public static final int SCHEMA_VERSION_26 = 26; + public static final int SCHEMA_VERSION_27 = 27; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -67,6 +69,8 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql"; public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql"; + public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql"; + public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql"; private DatabaseInitializer() {} @@ -236,6 +240,14 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE); currentVersion = SCHEMA_VERSION_25; } + if (currentVersion < SCHEMA_VERSION_26) { + runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE); + currentVersion = SCHEMA_VERSION_26; + } + if (currentVersion < SCHEMA_VERSION_27) { + runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE); + currentVersion = SCHEMA_VERSION_27; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index 4f461d3f..0868e179 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -127,6 +127,100 @@ public final class BlockchainResyncCleanupDAO { } } + + /** + * После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок. + * Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value + * не меняет смысл ссылки и не требует переподписывать чужие блоки. + */ + public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException { + if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) { + throw new IllegalArgumentException("login/newBlockchainName are required"); + } + try (Connection c = db.getConnection()) { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + updateTargetCacheByLogin(c, "blocks", login, newBlockchainName); + updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName); + updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName); + updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName); + if (oldBlockchainName != null && !oldBlockchainName.isBlank()) { + try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) { + ps.setString(1, newBlockchainName); + ps.setString(2, oldBlockchainName); + ps.executeUpdate(); + } + try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) { + ps.setString(1, newBlockchainName); + ps.setString(2, oldBlockchainName); + ps.executeUpdate(); + } + } + rebuildInboundMessageCounters(c, login, newBlockchainName); + rebuildStatsState(c); + c.commit(); + } catch (Exception e) { + c.rollback(); + if (e instanceof SQLException sql) throw sql; + throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e); + } finally { + c.setAutoCommit(oldAutoCommit); + } + } + } + + /** + * После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы + * других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем + * агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache. + */ + private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE message_stats ms + SET likes_count = ( + SELECT COUNT(*)::INTEGER + FROM reactions_state rs + WHERE rs.reaction_type = ? + AND rs.last_sub_type = ? + AND LOWER(rs.to_login) = LOWER(ms.to_login) + AND rs.to_bch_name = ms.to_bch_name + AND rs.to_block_number = ms.to_block_number + AND rs.to_block_hash = ms.to_block_hash + ), + replies_count = ( + SELECT COUNT(*)::INTEGER + FROM blocks b + WHERE b.msg_type = 1 + AND b.msg_sub_type = ? + AND LOWER(b.to_login) = LOWER(ms.to_login) + AND b.to_bch_name = ms.to_bch_name + AND b.to_block_number = ms.to_block_number + AND b.to_block_hash = ms.to_block_hash + ) + WHERE LOWER(ms.to_login) = LOWER(?) + AND ms.to_bch_name = ? + """)) { + ps.setInt(1, DatabaseInitializer.REACTION_LIKE); + ps.setInt(2, DatabaseInitializer.REACTION_LIKE); + ps.setInt(3, DatabaseInitializer.TEXT_REPLY); + ps.setString(4, login); + ps.setString(5, newBlockchainName); + ps.executeUpdate(); + } + } + + private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException { + String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, newBlockchainName); + ps.setString(2, login); + ps.setString(3, newBlockchainName); + ps.executeUpdate(); + } + } + + private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException { String sql = """ SELECT login diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/KeyRotationCandidateBlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/KeyRotationCandidateBlocksDAO.java new file mode 100644 index 00000000..2e83c419 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/KeyRotationCandidateBlocksDAO.java @@ -0,0 +1,236 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.KeyRotationCandidateBlockEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Хранилище candidate-блоков будущего fork во время ротации ключей. */ +public final class KeyRotationCandidateBlocksDAO { + private static volatile KeyRotationCandidateBlocksDAO instance; + private final DbController db = DbController.getInstance(); + + private KeyRotationCandidateBlocksDAO() { } + + public static KeyRotationCandidateBlocksDAO getInstance() { + if (instance == null) { + synchronized (KeyRotationCandidateBlocksDAO.class) { + if (instance == null) instance = new KeyRotationCandidateBlocksDAO(); + } + } + return instance; + } + + /** + * Идемпотентно сохраняет один candidate-блок. + * Повтор того же blockNumber допустим только при полном совпадении hash и DataItem id. + */ + public KeyRotationCandidateBlockEntry insertOrGet(Connection c, KeyRotationCandidateBlockEntry entry) throws SQLException { + KeyRotationCandidateBlockEntry existing = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber()); + if (existing != null) { + if (Arrays.equals(existing.getBlockHash(), entry.getBlockHash()) + && Arrays.equals(existing.getDataItemId(), entry.getDataItemId())) { + return existing; + } + throw new SQLException("Candidate block conflict at number=" + entry.getBlockNumber()); + } + + String sql = """ + INSERT INTO key_rotation_candidate_blocks ( + rotation_session_id, login, candidate_blockchain_name, + block_number, block_hash, data_item_id, block_bytes, + arweave_publish_pending, arweave_published_at_ms, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, NULL, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + int i = 1; + ps.setLong(i++, entry.getRotationSessionId()); + ps.setString(i++, entry.getLogin()); + ps.setString(i++, entry.getCandidateBlockchainName()); + ps.setInt(i++, entry.getBlockNumber()); + ps.setBytes(i++, entry.getBlockHash()); + ps.setBytes(i++, entry.getDataItemId()); + ps.setBytes(i++, entry.getBlockBytes()); + ps.setLong(i++, entry.getCreatedAtMs()); + ps.executeUpdate(); + try (ResultSet keys = ps.getGeneratedKeys()) { + if (!keys.next()) throw new SQLException("candidate block insert returned no id"); + entry.setId(keys.getLong(1)); + } + } catch (SQLException insertFailure) { + // Конкурирующий повтор из другой сессии мог успеть вставиться между SELECT и INSERT. + KeyRotationCandidateBlockEntry raced = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber()); + if (raced != null + && Arrays.equals(raced.getBlockHash(), entry.getBlockHash()) + && Arrays.equals(raced.getDataItemId(), entry.getDataItemId())) { + return raced; + } + throw insertFailure; + } + entry.setArweavePublishPending(true); + return entry; + } + + public KeyRotationCandidateBlockEntry getByNumber(Connection c, long sessionId, int blockNumber) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(""" + SELECT * FROM key_rotation_candidate_blocks + WHERE rotation_session_id = ? AND block_number = ? + """)) { + ps.setLong(1, sessionId); + ps.setInt(2, blockNumber); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? map(rs) : null; + } + } + } + + public int countStored(Connection c, long sessionId) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(""" + SELECT COUNT(*) FROM key_rotation_candidate_blocks WHERE rotation_session_id = ? + """)) { + ps.setLong(1, sessionId); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getInt(1); + } + } + } + + public int countPublished(Connection c, long sessionId) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(""" + SELECT COUNT(*) FROM key_rotation_candidate_blocks + WHERE rotation_session_id = ? AND arweave_publish_pending = FALSE + """)) { + ps.setLong(1, sessionId); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getInt(1); + } + } + } + + /** Все candidate-блоки одной ротации в строгом порядке block_number. */ + public List listBySession(Connection c, long sessionId) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(""" + SELECT * FROM key_rotation_candidate_blocks + WHERE rotation_session_id = ? + ORDER BY block_number + """)) { + ps.setLong(1, sessionId); + List out = new ArrayList<>(); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(map(rs)); + } + return out; + } + } + + /** Candidate-блоки имеют приоритет перед обычной очередью publisher-а. */ + public List listPendingArweave(int limit) throws SQLException { + if (limit <= 0) return List.of(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + SELECT * FROM key_rotation_candidate_blocks + WHERE arweave_publish_pending = TRUE + ORDER BY id + LIMIT ? + """)) { + ps.setInt(1, limit); + List out = new ArrayList<>(); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(map(rs)); + } + return out; + } + } + + /** + * Помечает DataItem опубликованными и синхронизирует progress_current ротации + * с реальным количеством DataItem, подтверждённых publisher-ом. + */ + public void markArweavePublished(List dataItemIds, long publishedAtMs) throws SQLException { + if (dataItemIds == null || dataItemIds.isEmpty()) return; + try (Connection c = db.getConnection()) { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + java.util.Set sessions = new java.util.HashSet<>(); + try (PreparedStatement find = c.prepareStatement(""" + SELECT rotation_session_id + FROM key_rotation_candidate_blocks + WHERE data_item_id = ? + """)) { + for (byte[] id : dataItemIds) { + find.setBytes(1, id); + try (ResultSet rs = find.executeQuery()) { + if (rs.next()) sessions.add(rs.getLong(1)); + } + } + } + + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE key_rotation_candidate_blocks + SET arweave_publish_pending = FALSE, + arweave_published_at_ms = COALESCE(arweave_published_at_ms, ?) + WHERE data_item_id = ? + """)) { + for (byte[] id : dataItemIds) { + ps.setLong(1, publishedAtMs); + ps.setBytes(2, id); + ps.addBatch(); + } + ps.executeBatch(); + } + + try (PreparedStatement progress = c.prepareStatement(""" + UPDATE key_rotation_sessions s + SET progress_current = LEAST(s.progress_total, ( + SELECT COUNT(*)::INTEGER + FROM key_rotation_candidate_blocks b + WHERE b.rotation_session_id = s.id + AND b.arweave_publish_pending = FALSE + )), + updated_at_ms = ? + WHERE s.id = ? AND s.status = 'COPYING_CHAIN' + """)) { + for (Long sessionId : sessions) { + progress.setLong(1, publishedAtMs); + progress.setLong(2, sessionId); + progress.addBatch(); + } + progress.executeBatch(); + } + c.commit(); + } catch (Exception e) { + c.rollback(); + if (e instanceof SQLException sql) throw sql; + throw new SQLException("Failed to mark candidate DataItems published", e); + } finally { + c.setAutoCommit(oldAutoCommit); + } + } + } + + private static KeyRotationCandidateBlockEntry map(ResultSet rs) throws SQLException { + KeyRotationCandidateBlockEntry e = new KeyRotationCandidateBlockEntry(); + e.setId(rs.getLong("id")); + e.setRotationSessionId(rs.getLong("rotation_session_id")); + e.setLogin(rs.getString("login")); + e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name")); + e.setBlockNumber(rs.getInt("block_number")); + e.setBlockHash(rs.getBytes("block_hash")); + e.setDataItemId(rs.getBytes("data_item_id")); + e.setBlockBytes(rs.getBytes("block_bytes")); + e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); + e.setArweavePublishedAtMs((Long) rs.getObject("arweave_published_at_ms")); + e.setCreatedAtMs(rs.getLong("created_at_ms")); + return e; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/KeyRotationSessionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/KeyRotationSessionsDAO.java new file mode 100644 index 00000000..ab87eb33 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/KeyRotationSessionsDAO.java @@ -0,0 +1,610 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * DAO для серверной машины смены ключей. + * + * Все методы изменения статуса синхронно обновляют и key_rotation_sessions, + * и solana_user_pda_current.rotation_status / rotation_session_id. + */ +public final class KeyRotationSessionsDAO { + + private static volatile KeyRotationSessionsDAO instance; + private final DbController db = DbController.getInstance(); + + private KeyRotationSessionsDAO() { } + + public static KeyRotationSessionsDAO getInstance() { + if (instance == null) { + synchronized (KeyRotationSessionsDAO.class) { + if (instance == null) instance = new KeyRotationSessionsDAO(); + } + } + return instance; + } + + /** + * Атомарно создаёт первую серверную запись ротации сразу в COPYING_CHAIN. + * PREPARING в БД отсутствует: до этого момента всё является локальным UI-состоянием. + */ + public KeyRotationSessionEntry createCopyingSession(KeyRotationSessionEntry entry) throws SQLException { + validateNewSession(entry); + try (Connection c = db.getConnection()) { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + KeyRotationSessionEntry created = createCopyingSession(c, entry); + c.commit(); + return created; + } catch (Exception e) { + c.rollback(); + if (e instanceof SQLException sql) throw sql; + throw new SQLException("Failed to create key rotation session", e); + } finally { + c.setAutoCommit(oldAutoCommit); + } + } + } + + public KeyRotationSessionEntry createCopyingSession(Connection c, KeyRotationSessionEntry entry) throws SQLException { + validateNewSession(entry); + + String currentStatus; + try (PreparedStatement ps = c.prepareStatement(""" + SELECT rotation_status + FROM solana_user_pda_current + WHERE login = ? + FOR UPDATE + """)) { + ps.setString(1, entry.getLogin()); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) throw new SQLException("Unknown login for key rotation: " + entry.getLogin()); + currentStatus = rs.getString(1); + } + } + + if (!KeyRotationStatus.NONE.name().equals(currentStatus)) { + throw new SQLException("Key rotation already active for login=" + entry.getLogin() + ", status=" + currentStatus); + } + + long now = entry.getCreatedAtMs() > 0 ? entry.getCreatedAtMs() : System.currentTimeMillis(); + entry.setCreatedAtMs(now); + entry.setUpdatedAtMs(now); + entry.setStatus(KeyRotationStatus.COPYING_CHAIN); + if (entry.getComment() == null) entry.setComment(""); + if (entry.getWalletMigrationStatus() == null) entry.setWalletMigrationStatus("PENDING"); + if (entry.getMessageMigrationStatus() == null) entry.setMessageMigrationStatus("PENDING"); + + String sql = """ + INSERT INTO key_rotation_sessions ( + login, status, + source_blockchain_name, candidate_blockchain_name, + old_root_key, old_blockchain_key, old_client_key, + new_root_key, new_blockchain_key, new_client_key, + fork_from_block, fork_from_hash, + source_tip_block, source_tip_hash, + reason_code, comment, + progress_current, progress_total, + pda_rotation_signature, + wallet_migration_status, message_migration_status, + last_error, last_error_at_ms, retry_count, + created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + """; + + long id; + try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + int i = 1; + ps.setString(i++, entry.getLogin()); + ps.setString(i++, entry.getStatus().name()); + ps.setString(i++, entry.getSourceBlockchainName()); + ps.setString(i++, entry.getCandidateBlockchainName()); + ps.setString(i++, entry.getOldRootKey()); + ps.setString(i++, entry.getOldBlockchainKey()); + ps.setString(i++, entry.getOldClientKey()); + ps.setString(i++, entry.getNewRootKey()); + ps.setString(i++, entry.getNewBlockchainKey()); + ps.setString(i++, entry.getNewClientKey()); + ps.setInt(i++, entry.getForkFromBlock()); + ps.setBytes(i++, entry.getForkFromHash()); + ps.setInt(i++, entry.getSourceTipBlock()); + ps.setBytes(i++, entry.getSourceTipHash()); + ps.setShort(i++, entry.getReasonCode()); + ps.setString(i++, entry.getComment()); + ps.setInt(i++, entry.getProgressCurrent()); + ps.setInt(i++, entry.getProgressTotal()); + ps.setString(i++, entry.getPdaRotationSignature()); + ps.setString(i++, entry.getWalletMigrationStatus()); + ps.setString(i++, entry.getMessageMigrationStatus()); + ps.setString(i++, entry.getLastError()); + if (entry.getLastErrorAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getLastErrorAtMs()); + ps.setInt(i++, entry.getRetryCount()); + ps.setLong(i++, entry.getCreatedAtMs()); + ps.setLong(i++, entry.getUpdatedAtMs()); + if (entry.getCompletedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getCompletedAtMs()); + if (entry.getAbortedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getAbortedAtMs()); + ps.executeUpdate(); + try (ResultSet keys = ps.getGeneratedKeys()) { + if (!keys.next()) throw new SQLException("key_rotation_sessions insert returned no id"); + id = keys.getLong(1); + } + } + + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE solana_user_pda_current + SET rotation_status = ?, rotation_session_id = ? + WHERE login = ? AND rotation_status = 'NONE' + """)) { + ps.setString(1, KeyRotationStatus.COPYING_CHAIN.name()); + ps.setLong(2, id); + ps.setString(3, entry.getLogin()); + if (ps.executeUpdate() != 1) { + throw new SQLException("Failed to attach key rotation session to login=" + entry.getLogin()); + } + } + + entry.setId(id); + return entry; + } + + public KeyRotationSessionEntry getById(long id) throws SQLException { + try (Connection c = db.getConnection()) { + return getById(c, id); + } + } + + public KeyRotationSessionEntry getById(Connection c, long id) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ?")) { + ps.setLong(1, id); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapRow(rs) : null; + } + } + } + + public KeyRotationSessionEntry getActiveByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return getActiveByLogin(c, login); + } + } + + public KeyRotationSessionEntry getActiveByLogin(Connection c, String login) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(selectBase() + """ + WHERE login = ? + AND status NOT IN ('COMPLETE', 'ABORTED') + ORDER BY id DESC + LIMIT 1 + """)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapRow(rs) : null; + } + } + } + + /** Возвращает несколько сессий одного состояния для фоновых workers. */ + public java.util.List listByStatus(KeyRotationStatus status, int limit) throws SQLException { + if (status == null || status == KeyRotationStatus.NONE) return java.util.List.of(); + int safeLimit = Math.max(1, Math.min(limit, 100)); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(selectBase() + """ + WHERE status = ? + ORDER BY updated_at_ms, id + LIMIT ? + """)) { + ps.setString(1, status.name()); + ps.setInt(2, safeLimit); + java.util.List out = new java.util.ArrayList<>(); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapRow(rs)); + } + return out; + } + } + + /** + * Атомарный переход state machine. Смена статуса выполняется только из expected. + */ + public KeyRotationSessionEntry transition(long id, + KeyRotationStatus expected, + KeyRotationStatus next) throws SQLException { + if (expected == null || next == null || !expected.canTransitionTo(next)) { + throw new IllegalArgumentException("Forbidden key rotation transition: " + expected + " -> " + next); + } + + try (Connection c = db.getConnection()) { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + KeyRotationSessionEntry current = getByIdForUpdate(c, id); + if (current == null) throw new SQLException("Unknown key rotation session id=" + id); + if (current.getStatus() != expected) { + throw new SQLException("Key rotation status mismatch: expected=" + expected + ", actual=" + current.getStatus()); + } + + long now = System.currentTimeMillis(); + Long completed = next == KeyRotationStatus.COMPLETE ? now : current.getCompletedAtMs(); + Long aborted = next == KeyRotationStatus.ABORTED ? now : current.getAbortedAtMs(); + + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE key_rotation_sessions + SET status = ?, updated_at_ms = ?, completed_at_ms = ?, aborted_at_ms = ? + WHERE id = ? AND status = ? + """)) { + ps.setString(1, next.name()); + ps.setLong(2, now); + if (completed == null) ps.setNull(3, java.sql.Types.BIGINT); else ps.setLong(3, completed); + if (aborted == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, aborted); + ps.setLong(5, id); + ps.setString(6, expected.name()); + if (ps.executeUpdate() != 1) throw new SQLException("Concurrent key rotation transition for id=" + id); + } + + KeyRotationStatus userStatus = next.userStatus(); + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE solana_user_pda_current + SET rotation_status = ?, rotation_session_id = ? + WHERE login = ? AND rotation_session_id = ? + """)) { + ps.setString(1, userStatus.name()); + if (userStatus == KeyRotationStatus.NONE) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, id); + ps.setString(3, current.getLogin()); + ps.setLong(4, id); + if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin()); + } + + // Candidate-копии нужны только пока ротация активна. В Arweave они уже опубликованы, + // а в PostgreSQL после COMPLETE/ABORTED временные строки больше не нужны. + if (next.isTerminalSessionStatus()) { + try (PreparedStatement ps = c.prepareStatement( + "DELETE FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?")) { + ps.setLong(1, id); + ps.executeUpdate(); + } + } + + c.commit(); + current.setStatus(next); + current.setUpdatedAtMs(now); + current.setCompletedAtMs(completed); + current.setAbortedAtMs(aborted); + return current; + } catch (Exception e) { + c.rollback(); + if (e instanceof SQLException sql) throw sql; + throw new SQLException("Failed key rotation transition", e); + } finally { + c.setAutoCommit(oldAutoCommit); + } + } + } + + public void updateProgress(long id, int current, int total) throws SQLException { + if (current < 0 || total < 0 || current > total) { + throw new IllegalArgumentException("Invalid rotation progress: " + current + "/" + total); + } + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE key_rotation_sessions + SET progress_current = ?, progress_total = ?, updated_at_ms = ? + WHERE id = ? AND status = 'COPYING_CHAIN' + """)) { + ps.setInt(1, current); + ps.setInt(2, total); + ps.setLong(3, System.currentTimeMillis()); + ps.setLong(4, id); + if (ps.executeUpdate() != 1) throw new SQLException("Rotation progress can only be updated in COPYING_CHAIN, id=" + id); + } + } + + /** + * Атомарно фиксирует tx signature и переводит CHAIN_READY -> ROTATING_PDA. + * Это точка, после которой Abort запрещён: отправленная Solana-транзакция может подтвердиться позже. + */ + public KeyRotationSessionEntry beginPdaRotation(long id, String signature) throws SQLException { + if (signature == null || signature.isBlank()) throw new IllegalArgumentException("signature is empty"); + try (Connection c = db.getConnection()) { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + KeyRotationSessionEntry current = getByIdForUpdate(c, id); + if (current == null) throw new SQLException("Unknown key rotation session id=" + id); + if (current.getStatus() != KeyRotationStatus.CHAIN_READY) { + throw new SQLException("PDA rotation requires CHAIN_READY, actual=" + current.getStatus()); + } + long now = System.currentTimeMillis(); + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE key_rotation_sessions + SET status = 'ROTATING_PDA', pda_rotation_signature = ?, updated_at_ms = ?, + last_error = NULL, last_error_at_ms = NULL + WHERE id = ? AND status = 'CHAIN_READY' + """)) { + ps.setString(1, signature); + ps.setLong(2, now); + ps.setLong(3, id); + if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation start for id=" + id); + } + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE solana_user_pda_current + SET rotation_status = 'ROTATING_PDA', rotation_session_id = ? + WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'CHAIN_READY' + """)) { + ps.setLong(1, id); + ps.setString(2, current.getLogin()); + ps.setLong(3, id); + if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin()); + } + c.commit(); + current.setStatus(KeyRotationStatus.ROTATING_PDA); + current.setPdaRotationSignature(signature); + current.setUpdatedAtMs(now); + current.setLastError(null); + current.setLastErrorAtMs(null); + return current; + } catch (Exception e) { + c.rollback(); + if (e instanceof SQLException sql) throw sql; + throw new SQLException("Failed to begin PDA rotation", e); + } finally { + c.setAutoCommit(oldAutoCommit); + } + } + } + + /** + * Если Solana sync уже записал в current-PDA весь ожидаемый новый набор ключей, + * атомарно переводит ROTATING_PDA -> PDA_ROTATED. Иначе возвращает null. + */ + public KeyRotationSessionEntry tryMarkPdaRotatedFromCurrentState(long id) throws SQLException { + try (Connection c = db.getConnection()) { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + KeyRotationSessionEntry current = getByIdForUpdate(c, id); + if (current == null || current.getStatus() != KeyRotationStatus.ROTATING_PDA) { + c.rollback(); + return current != null && current.getStatus() == KeyRotationStatus.PDA_ROTATED ? current : null; + } + + String root; + String blockchain; + String client; + String blockchainName; + try (PreparedStatement ps = c.prepareStatement(""" + SELECT root_key, blockchain_key, client_key, blockchain_name + FROM solana_user_pda_current + WHERE login = ? + FOR UPDATE + """)) { + ps.setString(1, current.getLogin()); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { c.rollback(); return null; } + root = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("root_key")); + blockchain = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")); + client = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key")); + blockchainName = rs.getString("blockchain_name"); + } + } + + if (!current.getNewRootKey().equals(root) + || !current.getNewBlockchainKey().equals(blockchain) + || !current.getNewClientKey().equals(client) + || !current.getCandidateBlockchainName().equals(blockchainName)) { + c.rollback(); + return null; + } + + long now = System.currentTimeMillis(); + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE key_rotation_sessions + SET status = 'PDA_ROTATED', updated_at_ms = ?, last_error = NULL, last_error_at_ms = NULL + WHERE id = ? AND status = 'ROTATING_PDA' + """)) { + ps.setLong(1, now); + ps.setLong(2, id); + if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation confirm for id=" + id); + } + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE solana_user_pda_current + SET rotation_status = 'PDA_ROTATED', rotation_session_id = ? + WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'ROTATING_PDA' + """)) { + ps.setLong(1, id); + ps.setString(2, current.getLogin()); + ps.setLong(3, id); + if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to current PDA login=" + current.getLogin()); + } + c.commit(); + current.setStatus(KeyRotationStatus.PDA_ROTATED); + current.setUpdatedAtMs(now); + current.setLastError(null); + current.setLastErrorAtMs(null); + return current; + } catch (Exception e) { + c.rollback(); + if (e instanceof SQLException sql) throw sql; + throw new SQLException("Failed to confirm rotated PDA", e); + } finally { + c.setAutoCommit(oldAutoCommit); + } + } + } + + public void setPdaRotationSignature(long id, String signature) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE key_rotation_sessions + SET pda_rotation_signature = ?, updated_at_ms = ? + WHERE id = ? AND status = 'ROTATING_PDA' + """)) { + ps.setString(1, signature); + ps.setLong(2, System.currentTimeMillis()); + ps.setLong(3, id); + if (ps.executeUpdate() != 1) throw new SQLException("PDA signature requires ROTATING_PDA, id=" + id); + } + } + + /** + * Фиксирует результат необязательного этапа wallet/DM. + * Имя колонки выбирается только из закрытого списка — внешние значения в SQL не подставляются. + */ + public void setMigrationSubStatus(long id, String column, String value) throws SQLException { + String safeColumn = switch (String.valueOf(column)) { + case "wallet_migration_status" -> "wallet_migration_status"; + case "message_migration_status" -> "message_migration_status"; + default -> throw new IllegalArgumentException("Unsupported migration status column: " + column); + }; + String safeValue = switch (String.valueOf(value)) { + case "PENDING", "COMPLETE", "SKIPPED", "NOT_IMPLEMENTED" -> String.valueOf(value); + default -> throw new IllegalArgumentException("Unsupported migration status value: " + value); + }; + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement( + "UPDATE key_rotation_sessions SET " + safeColumn + " = ?, updated_at_ms = ? WHERE id = ?")) { + ps.setString(1, safeValue); + ps.setLong(2, System.currentTimeMillis()); + ps.setLong(3, id); + if (ps.executeUpdate() != 1) throw new SQLException("Unknown key rotation session id=" + id); + } + } + + public void recordError(long id, String error) throws SQLException { + long now = System.currentTimeMillis(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE key_rotation_sessions + SET last_error = ?, last_error_at_ms = ?, retry_count = retry_count + 1, updated_at_ms = ? + WHERE id = ? AND status NOT IN ('COMPLETE', 'ABORTED') + """)) { + ps.setString(1, error); + ps.setLong(2, now); + ps.setLong(3, now); + ps.setLong(4, id); + if (ps.executeUpdate() != 1) throw new SQLException("Cannot record error for inactive rotation id=" + id); + } + } + + public void clearError(long id) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE key_rotation_sessions + SET last_error = NULL, last_error_at_ms = NULL, updated_at_ms = ? + WHERE id = ? + """)) { + ps.setLong(1, System.currentTimeMillis()); + ps.setLong(2, id); + ps.executeUpdate(); + } + } + + private KeyRotationSessionEntry getByIdForUpdate(Connection c, long id) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ? FOR UPDATE")) { + ps.setLong(1, id); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapRow(rs) : null; + } + } + } + + private static String selectBase() { + return """ + SELECT + id, login, status, + source_blockchain_name, candidate_blockchain_name, + old_root_key, old_blockchain_key, old_client_key, + new_root_key, new_blockchain_key, new_client_key, + fork_from_block, fork_from_hash, + source_tip_block, source_tip_hash, + reason_code, comment, + progress_current, progress_total, + pda_rotation_signature, + wallet_migration_status, message_migration_status, + last_error, last_error_at_ms, retry_count, + created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms + FROM key_rotation_sessions + """; + } + + private static KeyRotationSessionEntry mapRow(ResultSet rs) throws SQLException { + KeyRotationSessionEntry e = new KeyRotationSessionEntry(); + e.setId(rs.getLong("id")); + e.setLogin(rs.getString("login")); + e.setStatus(KeyRotationStatus.valueOf(rs.getString("status"))); + e.setSourceBlockchainName(rs.getString("source_blockchain_name")); + e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name")); + e.setOldRootKey(rs.getString("old_root_key")); + e.setOldBlockchainKey(rs.getString("old_blockchain_key")); + e.setOldClientKey(rs.getString("old_client_key")); + e.setNewRootKey(rs.getString("new_root_key")); + e.setNewBlockchainKey(rs.getString("new_blockchain_key")); + e.setNewClientKey(rs.getString("new_client_key")); + e.setForkFromBlock(rs.getInt("fork_from_block")); + e.setForkFromHash(rs.getBytes("fork_from_hash")); + e.setSourceTipBlock(rs.getInt("source_tip_block")); + e.setSourceTipHash(rs.getBytes("source_tip_hash")); + e.setReasonCode(rs.getShort("reason_code")); + e.setComment(rs.getString("comment")); + e.setProgressCurrent(rs.getInt("progress_current")); + e.setProgressTotal(rs.getInt("progress_total")); + e.setPdaRotationSignature(rs.getString("pda_rotation_signature")); + e.setWalletMigrationStatus(rs.getString("wallet_migration_status")); + e.setMessageMigrationStatus(rs.getString("message_migration_status")); + e.setLastError(rs.getString("last_error")); + long lastErrorAt = rs.getLong("last_error_at_ms"); + e.setLastErrorAtMs(rs.wasNull() ? null : lastErrorAt); + e.setRetryCount(rs.getInt("retry_count")); + e.setCreatedAtMs(rs.getLong("created_at_ms")); + e.setUpdatedAtMs(rs.getLong("updated_at_ms")); + long completed = rs.getLong("completed_at_ms"); + e.setCompletedAtMs(rs.wasNull() ? null : completed); + long aborted = rs.getLong("aborted_at_ms"); + e.setAbortedAtMs(rs.wasNull() ? null : aborted); + return e; + } + + private static void validateNewSession(KeyRotationSessionEntry e) { + if (e == null) throw new IllegalArgumentException("entry is null"); + if (blank(e.getLogin())) throw new IllegalArgumentException("login is required"); + if (blank(e.getSourceBlockchainName()) || blank(e.getCandidateBlockchainName())) { + throw new IllegalArgumentException("source/candidate blockchain names are required"); + } + if (blank(e.getOldRootKey()) || blank(e.getOldBlockchainKey()) || blank(e.getOldClientKey()) + || blank(e.getNewRootKey()) || blank(e.getNewBlockchainKey()) || blank(e.getNewClientKey())) { + throw new IllegalArgumentException("all old/new public keys are required"); + } + if (e.getForkFromHash() == null || e.getForkFromHash().length != 32) { + throw new IllegalArgumentException("forkFromHash must be 32 bytes"); + } + if (e.getSourceTipHash() == null || e.getSourceTipHash().length != 32) { + throw new IllegalArgumentException("sourceTipHash must be 32 bytes"); + } + if (e.getForkFromBlock() < 0 || e.getSourceTipBlock() < e.getForkFromBlock()) { + throw new IllegalArgumentException("invalid fork/source tip block numbers"); + } + if (e.getReasonCode() < 1 || e.getReasonCode() > 4) { + throw new IllegalArgumentException("reasonCode must be 1..4"); + } + String comment = e.getComment() == null ? "" : e.getComment(); + if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) { + throw new IllegalArgumentException("comment must be <= 1024 UTF-8 bytes"); + } + if (e.getProgressCurrent() < 0 || e.getProgressTotal() < e.getProgressCurrent()) { + throw new IllegalArgumentException("invalid initial progress"); + } + } + + private static boolean blank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java index e98d887e..2dc23111 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java @@ -37,6 +37,27 @@ public final class SolanaUserPdaCurrentDAO { } } + + public SolanaUserPdaCurrentEntry getByLogin(String login) throws SQLException { + String sql = """ + SELECT + login, + blockchain_name, + blockchain_key, + paid_limit_bytes + FROM solana_user_pda_current + WHERE LOWER(login) = LOWER(?) + LIMIT 1 + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { String sql = """ SELECT diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationCandidateBlockEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationCandidateBlockEntry.java new file mode 100644 index 00000000..5ffa3729 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationCandidateBlockEntry.java @@ -0,0 +1,45 @@ +package shine.db.entities; + +/** + * Candidate-блок будущего fork во время смены ключей. + * + * Эти записи намеренно не попадают в обычную таблицу blocks и поэтому + * не влияют на лайки, ответы, каналы и другие materialized state до + * финального переключения активного fork. + */ +public final class KeyRotationCandidateBlockEntry { + private long id; + private long rotationSessionId; + private String login; + private String candidateBlockchainName; + private int blockNumber; + private byte[] blockHash; + private byte[] dataItemId; + private byte[] blockBytes; + private boolean arweavePublishPending; + private Long arweavePublishedAtMs; + private long createdAtMs; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public long getRotationSessionId() { return rotationSessionId; } + public void setRotationSessionId(long rotationSessionId) { this.rotationSessionId = rotationSessionId; } + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public String getCandidateBlockchainName() { return candidateBlockchainName; } + public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; } + public int getBlockNumber() { return blockNumber; } + public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } + public byte[] getBlockHash() { return blockHash; } + public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; } + public byte[] getDataItemId() { return dataItemId; } + public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; } + public byte[] getBlockBytes() { return blockBytes; } + public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; } + public boolean isArweavePublishPending() { return arweavePublishPending; } + public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; } + public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; } + public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; } + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationSessionEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationSessionEntry.java new file mode 100644 index 00000000..b8235f25 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationSessionEntry.java @@ -0,0 +1,96 @@ +package shine.db.entities; + +/** + * Публичное/runtime-состояние одной смены ключей. + * Приватные ключи и пароли в этой сущности не хранятся никогда. + */ +public final class KeyRotationSessionEntry { + private long id; + private String login; + private KeyRotationStatus status; + private String sourceBlockchainName; + private String candidateBlockchainName; + private String oldRootKey; + private String oldBlockchainKey; + private String oldClientKey; + private String newRootKey; + private String newBlockchainKey; + private String newClientKey; + private int forkFromBlock; + private byte[] forkFromHash; + private int sourceTipBlock; + private byte[] sourceTipHash; + private short reasonCode; + private String comment; + private int progressCurrent; + private int progressTotal; + private String pdaRotationSignature; + private String walletMigrationStatus; + private String messageMigrationStatus; + private String lastError; + private Long lastErrorAtMs; + private int retryCount; + private long createdAtMs; + private long updatedAtMs; + private Long completedAtMs; + private Long abortedAtMs; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public KeyRotationStatus getStatus() { return status; } + public void setStatus(KeyRotationStatus status) { this.status = status; } + public String getSourceBlockchainName() { return sourceBlockchainName; } + public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; } + public String getCandidateBlockchainName() { return candidateBlockchainName; } + public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; } + public String getOldRootKey() { return oldRootKey; } + public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; } + public String getOldBlockchainKey() { return oldBlockchainKey; } + public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; } + public String getOldClientKey() { return oldClientKey; } + public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; } + public String getNewRootKey() { return newRootKey; } + public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; } + public String getNewBlockchainKey() { return newBlockchainKey; } + public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; } + public String getNewClientKey() { return newClientKey; } + public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; } + public int getForkFromBlock() { return forkFromBlock; } + public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; } + public byte[] getForkFromHash() { return forkFromHash; } + public void setForkFromHash(byte[] forkFromHash) { this.forkFromHash = forkFromHash; } + public int getSourceTipBlock() { return sourceTipBlock; } + public void setSourceTipBlock(int sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; } + public byte[] getSourceTipHash() { return sourceTipHash; } + public void setSourceTipHash(byte[] sourceTipHash) { this.sourceTipHash = sourceTipHash; } + public short getReasonCode() { return reasonCode; } + public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; } + public String getComment() { return comment; } + public void setComment(String comment) { this.comment = comment; } + public int getProgressCurrent() { return progressCurrent; } + public void setProgressCurrent(int progressCurrent) { this.progressCurrent = progressCurrent; } + public int getProgressTotal() { return progressTotal; } + public void setProgressTotal(int progressTotal) { this.progressTotal = progressTotal; } + public String getPdaRotationSignature() { return pdaRotationSignature; } + public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; } + public String getWalletMigrationStatus() { return walletMigrationStatus; } + public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; } + public String getMessageMigrationStatus() { return messageMigrationStatus; } + public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; } + public String getLastError() { return lastError; } + public void setLastError(String lastError) { this.lastError = lastError; } + public Long getLastErrorAtMs() { return lastErrorAtMs; } + public void setLastErrorAtMs(Long lastErrorAtMs) { this.lastErrorAtMs = lastErrorAtMs; } + public int getRetryCount() { return retryCount; } + public void setRetryCount(int retryCount) { this.retryCount = retryCount; } + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + public long getUpdatedAtMs() { return updatedAtMs; } + public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; } + public Long getCompletedAtMs() { return completedAtMs; } + public void setCompletedAtMs(Long completedAtMs) { this.completedAtMs = completedAtMs; } + public Long getAbortedAtMs() { return abortedAtMs; } + public void setAbortedAtMs(Long abortedAtMs) { this.abortedAtMs = abortedAtMs; } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationStatus.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationStatus.java new file mode 100644 index 00000000..dc049188 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/KeyRotationStatus.java @@ -0,0 +1,71 @@ +package shine.db.entities; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Состояния серверной машины смены ключей. + * + * NONE используется только в solana_user_pda_current.rotation_status. + * COMPLETE/ABORTED остаются в истории key_rotation_sessions, после чего + * пользователь снова получает rotation_status=NONE. + */ +public enum KeyRotationStatus { + NONE, + COPYING_CHAIN, + CHAIN_READY, + ROTATING_PDA, + PDA_ROTATED, + REBUILDING_SERVER, + WALLET_MIGRATION, + MESSAGE_MIGRATION, + FINALIZING, + COMPLETE, + ABORTED; + + public boolean isTerminalSessionStatus() { + return this == COMPLETE || this == ABORTED; + } + + public boolean isUserActiveStatus() { + return this != NONE && !isTerminalSessionStatus(); + } + + /** + * Допустимые переходы одной ротации. + * Abort сознательно разрешён только до начала ROTATING_PDA: после отправки + * Solana-транзакции нельзя надёжно знать, не будет ли она подтверждена позже. + */ + public boolean canTransitionTo(KeyRotationStatus next) { + if (next == null) return false; + return switch (this) { + case COPYING_CHAIN -> EnumSet.of(CHAIN_READY, ABORTED).contains(next); + case CHAIN_READY -> EnumSet.of(ROTATING_PDA, ABORTED).contains(next); + case ROTATING_PDA -> next == PDA_ROTATED; + case PDA_ROTATED -> next == REBUILDING_SERVER; + case REBUILDING_SERVER -> next == WALLET_MIGRATION; + case WALLET_MIGRATION -> next == MESSAGE_MIGRATION; + case MESSAGE_MIGRATION -> next == FINALIZING; + case FINALIZING -> next == COMPLETE; + case NONE, COMPLETE, ABORTED -> false; + }; + } + + public static Set activeStatuses() { + return EnumSet.of( + COPYING_CHAIN, + CHAIN_READY, + ROTATING_PDA, + PDA_ROTATED, + REBUILDING_SERVER, + WALLET_MIGRATION, + MESSAGE_MIGRATION, + FINALIZING + ); + } + + /** Статус, который должен храниться у пользователя для данной session-state. */ + public KeyRotationStatus userStatus() { + return isTerminalSessionStatus() ? NONE : this; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v26.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v26.sql new file mode 100644 index 00000000..e3352475 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v26.sql @@ -0,0 +1,123 @@ +-- v26: серверная state machine смены ключей пользователя. +-- +-- Важно: +-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера, +-- а не полями Solana PDA; +-- - Solana sync не должен перезаписывать эти колонки при upsert PDA; +-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации. + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE'; + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT; + +ALTER TABLE solana_user_pda_current + DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status; + +ALTER TABLE solana_user_pda_current + ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK ( + rotation_status IN ( + 'NONE', + 'COPYING_CHAIN', + 'CHAIN_READY', + 'ROTATING_PDA', + 'PDA_ROTATED', + 'REBUILDING_SERVER', + 'WALLET_MIGRATION', + 'MESSAGE_MIGRATION', + 'FINALIZING' + ) + ); + +CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active + ON solana_user_pda_current(rotation_status) + WHERE rotation_status <> 'NONE'; + +CREATE TABLE IF NOT EXISTS key_rotation_sessions ( + id BIGSERIAL PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + status TEXT NOT NULL CHECK ( + status IN ( + 'COPYING_CHAIN', + 'CHAIN_READY', + 'ROTATING_PDA', + 'PDA_ROTATED', + 'REBUILDING_SERVER', + 'WALLET_MIGRATION', + 'MESSAGE_MIGRATION', + 'FINALIZING', + 'COMPLETE', + 'ABORTED' + ) + ), + + source_blockchain_name TEXT NOT NULL, + candidate_blockchain_name TEXT NOT NULL, + + old_root_key TEXT NOT NULL, + old_blockchain_key TEXT NOT NULL, + old_client_key TEXT NOT NULL, + new_root_key TEXT NOT NULL, + new_blockchain_key TEXT NOT NULL, + new_client_key TEXT NOT NULL, + + fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0), + fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32), + source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0), + source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32), + + reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4), + comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024), + + progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0), + progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0), + + pda_rotation_signature TEXT, + + wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK ( + wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED') + ), + message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK ( + message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED') + ), + + last_error TEXT, + last_error_at_ms BIGINT, + retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0), + + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + completed_at_ms BIGINT, + aborted_at_ms BIGINT, + + CHECK (progress_current <= progress_total), + CHECK (fork_from_block <= source_tip_block) +); + +-- У одного login одновременно может существовать только одна незавершённая ротация. +CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login + ON key_rotation_sessions(login) + WHERE status NOT IN ('COMPLETE', 'ABORTED'); + +CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created + ON key_rotation_sessions(login, created_at_ms DESC); + +CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status + ON key_rotation_sessions(status); + +-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию. +-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок. +ALTER TABLE solana_user_pda_current + DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session; + +ALTER TABLE solana_user_pda_current + ADD CONSTRAINT fk_solana_user_pda_current_rotation_session + FOREIGN KEY (rotation_session_id) + REFERENCES key_rotation_sessions(id) + ON DELETE SET NULL; + +UPDATE db_schema_version +SET schema_version = 26, + updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) +WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v27.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v27.sql new file mode 100644 index 00000000..1c33e1e0 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v27.sql @@ -0,0 +1,31 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks ( + id BIGSERIAL PRIMARY KEY, + rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE, + login TEXT NOT NULL, + candidate_blockchain_name TEXT NOT NULL, + block_number INTEGER NOT NULL CHECK (block_number >= 0), + block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32), + data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32), + block_bytes BYTEA NOT NULL, + arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE, + arweave_published_at_ms BIGINT, + created_at_ms BIGINT NOT NULL, + UNIQUE (rotation_session_id, block_number), + UNIQUE (rotation_session_id, data_item_id) +); + +CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending + ON key_rotation_candidate_blocks(id) + WHERE arweave_publish_pending = TRUE; + +CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session + ON key_rotation_candidate_blocks(rotation_session_id, block_number); + +UPDATE db_schema_version +SET schema_version = 27, + updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) +WHERE id = 1; + +COMMIT; 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 17216960..de4add12 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 @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version ( ); INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) + VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) ON CONFLICT (id) DO UPDATE SET schema_version = EXCLUDED.schema_version, updated_at_ms = EXCLUDED.updated_at_ms; @@ -2081,8 +2081,111 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending ON solana_user_pda_current(is_server, archive_imported) WHERE archive_head_tx_id <> ''; +-- Server-local key rotation state machine (schema v26). +-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом. +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE'; +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT; + +ALTER TABLE solana_user_pda_current + DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status; +ALTER TABLE solana_user_pda_current + ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK ( + rotation_status IN ( + 'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED', + 'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING' + ) + ); + +CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active + ON solana_user_pda_current(rotation_status) + WHERE rotation_status <> 'NONE'; + +CREATE TABLE IF NOT EXISTS key_rotation_sessions ( + id BIGSERIAL PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + status TEXT NOT NULL CHECK ( + status IN ( + 'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED', + 'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING', + 'COMPLETE','ABORTED' + ) + ), + source_blockchain_name TEXT NOT NULL, + candidate_blockchain_name TEXT NOT NULL, + old_root_key TEXT NOT NULL, + old_blockchain_key TEXT NOT NULL, + old_client_key TEXT NOT NULL, + new_root_key TEXT NOT NULL, + new_blockchain_key TEXT NOT NULL, + new_client_key TEXT NOT NULL, + fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0), + fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32), + source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0), + source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32), + reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4), + comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024), + progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0), + progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0), + pda_rotation_signature TEXT, + wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK ( + wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED') + ), + message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK ( + message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED') + ), + last_error TEXT, + last_error_at_ms BIGINT, + retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0), + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + completed_at_ms BIGINT, + aborted_at_ms BIGINT, + CHECK (progress_current <= progress_total), + CHECK (fork_from_block <= source_tip_block) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login + ON key_rotation_sessions(login) + WHERE status NOT IN ('COMPLETE','ABORTED'); +CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created + ON key_rotation_sessions(login, created_at_ms DESC); +CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status + ON key_rotation_sessions(status); + +CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks ( + id BIGSERIAL PRIMARY KEY, + rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE, + login TEXT NOT NULL, + candidate_blockchain_name TEXT NOT NULL, + block_number INTEGER NOT NULL CHECK (block_number >= 0), + block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32), + data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32), + block_bytes BYTEA NOT NULL, + arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE, + arweave_published_at_ms BIGINT, + created_at_ms BIGINT NOT NULL, + UNIQUE (rotation_session_id, block_number), + UNIQUE (rotation_session_id, data_item_id) +); + +CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending + ON key_rotation_candidate_blocks(id) + WHERE arweave_publish_pending = TRUE; +CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session + ON key_rotation_candidate_blocks(rotation_session_id, block_number); + +ALTER TABLE solana_user_pda_current + DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session; +ALTER TABLE solana_user_pda_current + ADD CONSTRAINT fk_solana_user_pda_current_rotation_session + FOREIGN KEY (rotation_session_id) + REFERENCES key_rotation_sessions(id) + ON DELETE SET NULL; + INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 10406a25..322b6e8f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -42,8 +42,25 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request; + +import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; @@ -191,6 +208,16 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()), + Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()), + + // --- key rotation --- + Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()), + Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()), + Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()), + Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()), + Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()), + Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()), + Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()), // --- userParams --- Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), @@ -283,6 +310,16 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class), + Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class), + + // --- key rotation --- + Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class), + Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class), + Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class), + Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class), + Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class), + Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class), + Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class), // --- userParams --- Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), 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 77663501..d98c93ce 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 @@ -98,6 +98,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); lock.lock(); try { + try { + if (isKeyRotationActive(blockchainName)) { + BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName); + int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1; + String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : ""; + return error(req, 423, "key_rotation_in_progress", lastNum, lastHash); + } + } catch (Exception e) { + log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e); + return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, ""); + } + AddBlockResult r = addBlock( blockchainName, req.getBlockNumber(), // старое поле, пока оставляем @@ -152,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return resp; } + private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException { + String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); + if (login == null || login.isBlank()) return false; + try (Connection c = shine.db.DbController.getInstance().getConnection(); + PreparedStatement ps = c.prepareStatement(""" + SELECT rotation_status + FROM solana_user_pda_current + WHERE normalized_login = LOWER(BTRIM(?)) + LIMIT 1 + """)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() && !"NONE".equals(rs.getString(1)); + } + } + } + private static String humanMessage(String code) { if (code == null) return "Ошибка добавления блока"; return switch (code) { @@ -184,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; + case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены"; + case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей"; default -> "Ошибка: " + code; }; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetMyBlockchain_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetMyBlockchain_Handler.java new file mode 100644 index 00000000..dd514723 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetMyBlockchain_Handler.java @@ -0,0 +1,98 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain; + +import blockchain.BchBlockEntry; +import blockchain.body.BodyHasTarget; +import server.logic.ws_protocol.Base64Ws; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.BlockchainStateDAO; +import shine.db.dao.BlocksDAO; +import shine.db.dao.SolanaUserPdaCurrentDAO; +import shine.db.entities.BlockEntry; +import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.SolanaUserPdaCurrentEntry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Authenticated, paginated current-chain view used by "Мой блокчейн" and key rotation. */ +public final class Net_GetMyBlockchain_Handler implements JsonMessageHandler { + private final BlocksDAO blocks = BlocksDAO.getInstance(); + private final BlockchainStateDAO states = BlockchainStateDAO.getInstance(); + private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { + Net_GetMyBlockchain_Request req = (Net_GetMyBlockchain_Request) baseReq; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия"); + } + try { + String login = ctx.getLogin().trim(); + SolanaUserPdaCurrentEntry user = users.getByLogin(login); + if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) { + return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "Текущий блокчейн пользователя не найден"); + } + String bch = user.getBlockchainName(); + BlockchainStateEntry state = states.getByBlockchainName(bch); + int tip = state == null ? -1 : state.getLastBlockNumber(); + String tipHash = state == null ? null : hex(state.getLastBlockHash()); + int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(100, req.getLimit())); + int before = req.getBeforeBlock() == null || req.getBeforeBlock() < 0 ? tip : Math.min(tip, req.getBeforeBlock()); + boolean includeBytes = Boolean.TRUE.equals(req.getIncludeBlockBytes()); + + List rows = before < 0 + ? List.of() + : blocks.listRangeByNumber(bch, Math.max(0, before - limit + 1), before); + Collections.reverse(rows); + List items = new ArrayList<>(rows.size()); + for (BlockEntry row : rows) { + BchBlockEntry parsed = new BchBlockEntry(row.getBlockBytes()); + Net_GetMyBlockchain_Response.BlockItem item = new Net_GetMyBlockchain_Response.BlockItem(); + item.setBlockNumber(row.getBlockNumber()); + item.setBlockHash(hex(row.getBlockHash())); + item.setPrevBlockHash(hex(parsed.prevHash32)); + item.setTimestampMs(parsed.timestamp * 1000L); + item.setMsgType(Short.toUnsignedInt(parsed.type)); + item.setMsgSubType(Short.toUnsignedInt(parsed.subType)); + item.setMsgVersion(Short.toUnsignedInt(parsed.version)); + if (parsed.body instanceof BodyHasTarget target) { + item.setToLogin(target.toLogin()); + item.setToBlockNumber(target.toBlockGlobalNumber()); + item.setToBlockHash(hex(target.toBlockHashBytes())); + } + if (includeBytes) item.setBlockBytesB64(Base64Ws.encode(row.getBlockBytes())); + items.add(item); + } + + Net_GetMyBlockchain_Response resp = new Net_GetMyBlockchain_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(login); + resp.setBlockchainName(bch); + resp.setTipBlockNumber(tip); + resp.setTipBlockHash(tipHash); + resp.setBlocks(items); + int lowest = rows.isEmpty() ? -1 : rows.get(rows.size() - 1).getBlockNumber(); + resp.setNextBeforeBlock(lowest > 0 ? lowest - 1 : null); + return resp; + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "GET_MY_BLOCKCHAIN_FAILED", "Не удалось прочитать блокчейн пользователя"); + } + } + + private static String hex(byte[] bytes) { + if (bytes == null) return null; + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b & 0xff)); + return sb.toString(); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetMyBlockchain_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetMyBlockchain_Request.java new file mode 100644 index 00000000..c2333237 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetMyBlockchain_Request.java @@ -0,0 +1,20 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** Paginated read of the authenticated user's current materialized blockchain. */ +public final class Net_GetMyBlockchain_Request extends Net_Request { + /** Inclusive highest block number to return. Null/<0 means current tip. */ + private Integer beforeBlock; + /** 1..100, default 50. */ + private Integer limit; + /** Include complete serialized ANS-104 DataItem for rotation/UI inspection. */ + private Boolean includeBlockBytes; + + public Integer getBeforeBlock() { return beforeBlock; } + public void setBeforeBlock(Integer beforeBlock) { this.beforeBlock = beforeBlock; } + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } + public Boolean getIncludeBlockBytes() { return includeBlockBytes; } + public void setIncludeBlockBytes(Boolean includeBlockBytes) { this.includeBlockBytes = includeBlockBytes; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetMyBlockchain_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetMyBlockchain_Response.java new file mode 100644 index 00000000..22ea7bbf --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetMyBlockchain_Response.java @@ -0,0 +1,64 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import java.util.ArrayList; +import java.util.List; + +public final class Net_GetMyBlockchain_Response extends Net_Response { + private String login; + private String blockchainName; + private int tipBlockNumber; + private String tipBlockHash; + private Integer nextBeforeBlock; + private List blocks = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + public int getTipBlockNumber() { return tipBlockNumber; } + public void setTipBlockNumber(int tipBlockNumber) { this.tipBlockNumber = tipBlockNumber; } + public String getTipBlockHash() { return tipBlockHash; } + public void setTipBlockHash(String tipBlockHash) { this.tipBlockHash = tipBlockHash; } + public Integer getNextBeforeBlock() { return nextBeforeBlock; } + public void setNextBeforeBlock(Integer nextBeforeBlock) { this.nextBeforeBlock = nextBeforeBlock; } + public List getBlocks() { return blocks; } + public void setBlocks(List blocks) { this.blocks = blocks; } + + public static final class BlockItem { + private int blockNumber; + private String blockHash; + private String prevBlockHash; + private long timestampMs; + private int msgType; + private int msgSubType; + private int msgVersion; + private String toLogin; + private Integer toBlockNumber; + private String toBlockHash; + private String blockBytesB64; + + public int getBlockNumber() { return blockNumber; } + public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } + public String getBlockHash() { return blockHash; } + public void setBlockHash(String blockHash) { this.blockHash = blockHash; } + public String getPrevBlockHash() { return prevBlockHash; } + public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; } + public long getTimestampMs() { return timestampMs; } + public void setTimestampMs(long timestampMs) { this.timestampMs = timestampMs; } + public int getMsgType() { return msgType; } + public void setMsgType(int msgType) { this.msgType = msgType; } + public int getMsgSubType() { return msgSubType; } + public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; } + public int getMsgVersion() { return msgVersion; } + public void setMsgVersion(int msgVersion) { this.msgVersion = msgVersion; } + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + public Integer getToBlockNumber() { return toBlockNumber; } + public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; } + public String getToBlockHash() { return toBlockHash; } + public void setToBlockHash(String toBlockHash) { this.toBlockHash = toBlockHash; } + public String getBlockBytesB64() { return blockBytesB64; } + public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetPersonalDiary_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetPersonalDiary_Handler.java index 2b2d6021..45bdd5bf 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetPersonalDiary_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetPersonalDiary_Handler.java @@ -105,7 +105,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler { String order = asc ? "ASC" : "DESC"; List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(""" - SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type + SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type, + to_login, to_bch_name, to_block_number, to_block_hash FROM blocks WHERE login = ? AND msg_type = ? ORDER BY block_number @@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler { item.setLikedByMe(false); item.setRepliesCount(0); item.setRatingsCount(0); - item.setTargetBlockchainName(statusBody.toBchName()); - item.setTargetBlockNumber(statusBody.toBlockGlobalNumber()); - item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes())); + item.setTargetBlockchainName(rs.getString("to_bch_name")); + item.setTargetBlockNumber((Integer) rs.getObject("to_block_number")); + item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash"))); List versions = loadVersionsForDiaryItem( c, @@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler { item.setVersionsTotal(versions.size()); item.setText(versions.get(versions.size() - 1).getText()); - fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes()); + fillTargetDetails(c, item, rs.getString("to_bch_name"), + (Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash")); out.add(item); } } @@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler { return; } try (PreparedStatement ps = c.prepareStatement(""" - SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type + SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type, + to_login, to_bch_name, to_block_number, to_block_hash FROM blocks WHERE bch_name = ? AND block_number = ? LIMIT 1 diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/KeyRotationApiSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/KeyRotationApiSupport.java new file mode 100644 index 00000000..caf91a07 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/KeyRotationApiSupport.java @@ -0,0 +1,120 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation; + +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationState_Response; +import shine.db.KeyEncodingUtil; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; + +import java.util.Arrays; +import java.util.Base64; + +final class KeyRotationApiSupport { + private KeyRotationApiSupport() {} + + static String normalizePublicKey32(String raw) { + String normalized = KeyEncodingUtil.normalizeKeyToBase64_32(raw); + if (normalized == null || normalized.isBlank()) { + throw new IllegalArgumentException("public key is empty"); + } + try { + byte[] decoded = Base64.getDecoder().decode(normalized); + if (decoded.length != 32) throw new IllegalArgumentException("public key must be 32 bytes"); + return Base64.getEncoder().encodeToString(decoded); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("public key must be Base58/Base64 of 32 bytes", e); + } + } + + static byte[] parseHash32(String hex) { + if (hex == null) throw new IllegalArgumentException("hash is null"); + String s = hex.trim(); + if (s.length() != 64) throw new IllegalArgumentException("hash must contain 64 hex chars"); + byte[] out = new byte[32]; + for (int i = 0; i < 32; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) throw new IllegalArgumentException("hash contains non-hex chars"); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + static String toHex(byte[] bytes) { + if (bytes == null) return null; + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b & 0xff)); + return sb.toString(); + } + + static String nextBlockchainName(String login, String source) { + if (login == null || login.isBlank() || source == null || source.length() < 5) { + throw new IllegalArgumentException("bad source blockchain name"); + } + String prefix = login + "-"; + if (!source.startsWith(prefix) || source.length() != prefix.length() + 3) { + throw new IllegalArgumentException("source blockchain name does not match login"); + } + String suffix = source.substring(prefix.length()); + if (!suffix.chars().allMatch(Character::isDigit)) { + throw new IllegalArgumentException("bad blockchain suffix"); + } + int n = Integer.parseInt(suffix); + if (n >= 999) throw new IllegalArgumentException("blockchain fork limit reached"); + return login + "-" + String.format("%03d", n + 1); + } + + static void requireKeysChangedAndDistinct(String oldRoot, String oldBlockchain, String oldClient, + String newRoot, String newBlockchain, String newClient) { + java.util.Set oldKeys = java.util.Set.of(oldRoot, oldBlockchain, oldClient); + java.util.Set newKeys = java.util.Set.of(newRoot, newBlockchain, newClient); + if (newKeys.size() != 3) { + throw new IllegalArgumentException("new root/blockchain/client keys must be distinct"); + } + for (String newKey : newKeys) { + if (oldKeys.contains(newKey)) { + throw new IllegalArgumentException("new keys must not reuse any key from the old key set"); + } + } + } + + static boolean hashesEqual(byte[] a, byte[] b) { + return Arrays.equals(a, b); + } + + static Net_KeyRotationState_Response response(String op, String requestId, KeyRotationSessionEntry e) { + Net_KeyRotationState_Response r = new Net_KeyRotationState_Response(); + r.setOp(op); + r.setRequestId(requestId); + r.setStatus(200); + if (e == null) { + r.setRotationStatus(KeyRotationStatus.NONE.name()); + return r; + } + r.setRotationSessionId(e.getId()); + r.setRotationStatus(e.getStatus().name()); + r.setSourceBlockchainName(e.getSourceBlockchainName()); + r.setCandidateBlockchainName(e.getCandidateBlockchainName()); + r.setOldRootKey(e.getOldRootKey()); + r.setOldBlockchainKey(e.getOldBlockchainKey()); + r.setOldClientKey(e.getOldClientKey()); + r.setNewRootKey(e.getNewRootKey()); + r.setNewBlockchainKey(e.getNewBlockchainKey()); + r.setNewClientKey(e.getNewClientKey()); + r.setForkFromBlock(e.getForkFromBlock()); + r.setForkFromHash(toHex(e.getForkFromHash())); + r.setSourceTipBlock(e.getSourceTipBlock()); + r.setSourceTipHash(toHex(e.getSourceTipHash())); + r.setReasonCode(e.getReasonCode()); + r.setComment(e.getComment()); + r.setProgressCurrent(e.getProgressCurrent()); + r.setProgressTotal(e.getProgressTotal()); + r.setPdaRotationSignature(e.getPdaRotationSignature()); + r.setWalletMigrationStatus(e.getWalletMigrationStatus()); + r.setMessageMigrationStatus(e.getMessageMigrationStatus()); + r.setLastError(e.getLastError()); + r.setRetryCount(e.getRetryCount()); + r.setCreatedAtMs(e.getCreatedAtMs()); + r.setUpdatedAtMs(e.getUpdatedAtMs()); + return r; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAbort_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAbort_Handler.java new file mode 100644 index 00000000..4e6c9036 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAbort_Handler.java @@ -0,0 +1,36 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import shine.db.dao.KeyRotationSessionsDAO; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; + +/** Прерывание разрешено только пока Solana-транзакция ротации ещё не могла быть отправлена. */ +public final class Net_KeyRotationAbort_Handler implements JsonMessageHandler { + private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { + Net_KeyRotationAbort_Request req = (Net_KeyRotationAbort_Request) baseReq; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия"); + } + try { + KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim()); + if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null); + if (current.getStatus() != KeyRotationStatus.COPYING_CHAIN && current.getStatus() != KeyRotationStatus.CHAIN_READY) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ABORT_TOO_LATE", + "После начала ротации PDA процесс можно только завершить"); + } + KeyRotationSessionEntry aborted = rotations.transition(current.getId(), current.getStatus(), KeyRotationStatus.ABORTED); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), aborted); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ABORT_FAILED", "Не удалось прервать смену ключей"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAddBlock_Handler.java new file mode 100644 index 00000000..570bcd34 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAddBlock_Handler.java @@ -0,0 +1,224 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation; + +import blockchain.BchBlockEntry; +import blockchain.BchCryptoVerifier; +import blockchain.MsgSubType; +import blockchain.body.ForkBody; +import server.logic.ws_protocol.Base64Ws; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import shine.db.DbController; +import shine.db.dao.BlocksDAO; +import shine.db.dao.KeyRotationCandidateBlocksDAO; +import shine.db.dao.KeyRotationSessionsDAO; +import shine.db.entities.BlockEntry; +import shine.db.entities.KeyRotationCandidateBlockEntry; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; + +import java.sql.Connection; +import java.util.Arrays; +import java.util.Base64; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Принимает DataItem будущего fork во время COPYING_CHAIN. + * Candidate-блок сохраняется отдельно от blocks и не влияет на materialized state. + */ +public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler { + private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance(); + private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance(); + private final BlocksDAO blocks = BlocksDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { + Net_KeyRotationAddBlock_Request req = (Net_KeyRotationAddBlock_Request) baseReq; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия"); + } + if (req.getBlockNumber() < 0 || req.getBlockBytesB64() == null || req.getBlockBytesB64().isBlank()) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "blockNumber и blockBytesB64 обязательны"); + } + + String login = ctx.getLogin().trim(); + KeyRotationSessionEntry initial; + try { + initial = rotations.getActiveByLogin(login); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей"); + } + if (initial == null || initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN"); + } + + ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName()); + lock.lock(); + try (Connection c = DbController.getInstance().getConnection()) { + KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login); + if (session == null || session.getStatus() != KeyRotationStatus.COPYING_CHAIN) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN"); + } + + int stored = candidates.countStored(c, session.getId()); + if (req.getBlockNumber() < stored) { + KeyRotationCandidateBlockEntry existing = candidates.getByNumber(c, session.getId(), req.getBlockNumber()); + if (existing == null) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CANDIDATE_GAP", "Нарушена последовательность candidate-блоков"); + } + BchBlockEntry repeated; + try { repeated = new BchBlockEntry(Base64Ws.decode(req.getBlockBytesB64())); } + catch (Exception e) { return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Не удалось распарсить DataItem"); } + if (!Arrays.equals(existing.getBlockHash(), repeated.getHash32()) + || !Arrays.equals(existing.getDataItemId(), repeated.getDataItemId32())) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "На этом номере уже сохранён другой candidate-блок"); + } + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session); + } + if (req.getBlockNumber() != stored) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_OUT_OF_ORDER", "Candidate-блоки нужно добавлять последовательно с block 0"); + } + if (stored >= session.getProgressTotal()) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_ALREADY_COMPLETE", "Все candidate-блоки уже приняты сервером"); + } + + final byte[] raw; + final BchBlockEntry block; + try { + raw = Base64Ws.decode(req.getBlockBytesB64()); + block = new BchBlockEntry(raw); + block.body.check(); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Некорректный SHiNE/ANS-104 блок"); + } + if (block.blockNumber != req.getBlockNumber()) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BLOCK_NUMBER_MISMATCH", "blockNumber запроса не совпадает с блоком"); + } + if (!block.getDataItem().hasTag("App", "test5590")) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_APP_TAG", "Отсутствует App=test5590"); + } + if (!requestPrevHashMatches(req.getPrevBlockHash(), block.prevHash32)) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_PREV_HASH_MISMATCH", "prevBlockHash запроса не совпадает с блоком"); + } + + byte[] newBlockchainKey; + try { + newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey()); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_BAD_STORED_KEY", "Некорректный new blockchain public key в rotation session"); + } + if (newBlockchainKey.length != 32 || !BchCryptoVerifier.verifyBlock(block, newBlockchainKey)) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SIGNATURE", "Candidate DataItem должен быть подписан новым blockchain key"); + } + + String validationError; + try { + validationError = validateCandidate(c, session, block); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_VALIDATE_FAILED", "Не удалось проверить candidate-блок"); + } + if (validationError != null) { + return NetExceptionResponseFactory.error(req, 400, validationError, "Candidate-блок не соответствует выбранному fork"); + } + + KeyRotationCandidateBlockEntry entry = new KeyRotationCandidateBlockEntry(); + entry.setRotationSessionId(session.getId()); + entry.setLogin(login); + entry.setCandidateBlockchainName(session.getCandidateBlockchainName()); + entry.setBlockNumber(block.blockNumber); + entry.setBlockHash(block.getHash32()); + entry.setDataItemId(block.getDataItemId32()); + entry.setBlockBytes(raw); + entry.setCreatedAtMs(System.currentTimeMillis()); + + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + candidates.insertOrGet(c, entry); + c.commit(); + } catch (Exception e) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "Не удалось сохранить candidate-блок"); + } finally { + c.setAutoCommit(oldAutoCommit); + } + + // progressCurrent обновляет Arweave publisher только после реальной публикации. + KeyRotationSessionEntry refreshed = rotations.getActiveByLogin(login); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), refreshed == null ? session : refreshed); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ADD_BLOCK_FAILED", "Не удалось принять candidate-блок"); + } finally { + lock.unlock(); + } + } + + private String validateCandidate(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception { + if (candidate.blockNumber <= session.getForkFromBlock()) { + BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber); + if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING"; + BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes()); + + // Перепубликуется тот же Frame и те же ANS-104 tags/target/anchor; меняются только owner/signature/DataItem id. + if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH"; + if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH"; + if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH"; + if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH"; + return null; + } + + int techForkNumber = session.getForkFromBlock() + 1; + if (candidate.blockNumber != techForkNumber) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER"; + if ((candidate.type & 0xFFFF) != 0 + || (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF) + || !(candidate.body instanceof ForkBody fork)) { + return "KEY_ROTATION_TECH_FORK_REQUIRED"; + } + if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH"; + + BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock()); + BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock()); + if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING"; + BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes()); + BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes()); + + byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey()); + if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY"; + if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT"; + if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH"; + if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME"; + if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP"; + if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH"; + if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME"; + if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED"; + if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON"; + if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT"; + return null; + } + + private static boolean requestPrevHashMatches(String raw, byte[] actual) { + if (raw == null || raw.isBlank()) { + return isZero32(actual); + } + try { + return Arrays.equals(KeyRotationApiSupport.parseHash32(raw), actual); + } catch (Exception e) { + return false; + } + } + + private static boolean isZero32(byte[] value) { + if (value == null || value.length != 32) return false; + for (byte b : value) if (b != 0) return false; + return true; + } + + private static String normalizeComment(String value) { + if (value == null) return ""; + return value.trim().replace("\r\n", "\n").replace('\r', '\n'); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationContinue_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationContinue_Handler.java new file mode 100644 index 00000000..ac0a7b6d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationContinue_Handler.java @@ -0,0 +1,53 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import shine.db.dao.KeyRotationSessionsDAO; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; + +/** + * Сейчас wallet/DM migration — явные заглушки. Continue фиксирует NOT_IMPLEMENTED + * и переводит state machine дальше, не делая вид, что данные были реально мигрированы. + */ +public final class Net_KeyRotationContinue_Handler implements JsonMessageHandler { + private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { + Net_KeyRotationContinue_Request req = (Net_KeyRotationContinue_Request) baseReq; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия"); + } + try { + KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim()); + if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null); + + if (current.getStatus() == KeyRotationStatus.WALLET_MIGRATION) { + rotations.setMigrationSubStatus(current.getId(), "wallet_migration_status", "NOT_IMPLEMENTED"); + current = rotations.transition(current.getId(), KeyRotationStatus.WALLET_MIGRATION, KeyRotationStatus.MESSAGE_MIGRATION); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current); + } + if (current.getStatus() == KeyRotationStatus.MESSAGE_MIGRATION) { + rotations.setMigrationSubStatus(current.getId(), "message_migration_status", "NOT_IMPLEMENTED"); + current = rotations.transition(current.getId(), KeyRotationStatus.MESSAGE_MIGRATION, KeyRotationStatus.FINALIZING); + // FINALIZING пока не содержит отдельной пользовательской работы: сразу завершаем. + current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current); + } + if (current.getStatus() == KeyRotationStatus.FINALIZING) { + current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current); + } + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CONTINUE_NOT_ALLOWED", + "На текущем этапе продолжение этой операцией не требуется"); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_CONTINUE_FAILED", + "Не удалось продолжить смену ключей"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationFinishChain_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationFinishChain_Handler.java new file mode 100644 index 00000000..c4c5779d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationFinishChain_Handler.java @@ -0,0 +1,212 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation; + +import blockchain.BchBlockEntry; +import blockchain.BchCryptoVerifier; +import blockchain.MsgSubType; +import blockchain.body.ForkBody; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import shine.db.DbController; +import shine.db.dao.BlocksDAO; +import shine.db.dao.KeyRotationCandidateBlocksDAO; +import shine.db.dao.KeyRotationSessionsDAO; +import shine.db.entities.BlockEntry; +import shine.db.entities.KeyRotationCandidateBlockEntry; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; + +import java.sql.Connection; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Проверяет, что candidate-chain полностью сохранена и опубликована в Arweave/Turbo, + * затем переводит ротацию COPYING_CHAIN -> CHAIN_READY. + */ +public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHandler { + private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance(); + private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance(); + private final BlocksDAO blocks = BlocksDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { + Net_KeyRotationFinishChain_Request req = (Net_KeyRotationFinishChain_Request) baseReq; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия"); + } + String login = ctx.getLogin().trim(); + + KeyRotationSessionEntry initial; + try { + initial = rotations.getActiveByLogin(login); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей"); + } + if (initial == null) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует"); + } + if (initial.getStatus() == KeyRotationStatus.CHAIN_READY) { + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), initial); + } + if (initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN"); + } + + ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName()); + lock.lock(); + try (Connection c = DbController.getInstance().getConnection()) { + KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login); + if (session == null) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует"); + } + if (session.getStatus() == KeyRotationStatus.CHAIN_READY) { + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session); + } + if (session.getStatus() != KeyRotationStatus.COPYING_CHAIN) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN"); + } + + int expected = session.getProgressTotal(); + int stored = candidates.countStored(c, session.getId()); + int published = candidates.countPublished(c, session.getId()); + if (stored != expected) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_INCOMPLETE", + "Candidate-chain ещё не полностью загружена: " + stored + "/" + expected); + } + if (published != expected) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_NOT_PUBLISHED", + "Candidate-chain ещё не полностью опубликована в Arweave/Turbo: " + published + "/" + expected); + } + + List entries = candidates.listBySession(c, session.getId()); + String validationError = validateCompleteChain(c, session, entries); + if (validationError != null) { + try { rotations.recordError(session.getId(), validationError); } catch (Exception ignored) { } + return NetExceptionResponseFactory.error(req, 409, validationError, "Финальная проверка candidate-chain не пройдена"); + } + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_FINISH_CHAIN_FAILED", "Не удалось завершить проверку candidate-chain"); + } finally { + lock.unlock(); + } + + try { + KeyRotationSessionEntry ready = rotations.transition( + initial.getId(), KeyRotationStatus.COPYING_CHAIN, KeyRotationStatus.CHAIN_READY); + rotations.clearError(initial.getId()); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), ready); + } catch (Exception transitionFailure) { + // Идемпотентность для параллельного FinishChain из другой сессии. + try { + KeyRotationSessionEntry now = rotations.getActiveByLogin(login); + if (now != null && now.getStatus() == KeyRotationStatus.CHAIN_READY) { + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), now); + } + } catch (Exception ignored) { } + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FINISH_CHAIN_RACE", "Состояние ротации изменилось во время FinishChain"); + } + } + + private String validateCompleteChain(Connection c, + KeyRotationSessionEntry session, + List entries) throws Exception { + if (entries == null || entries.size() != session.getProgressTotal()) return "KEY_ROTATION_CHAIN_INCOMPLETE"; + if (session.getProgressTotal() != session.getForkFromBlock() + 2) return "KEY_ROTATION_BAD_PROGRESS_TOTAL"; + + byte[] newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey()); + if (newBlockchainKey.length != 32) return "KEY_ROTATION_BAD_STORED_KEY"; + + BchBlockEntry previous = null; + for (int i = 0; i < entries.size(); i++) { + KeyRotationCandidateBlockEntry stored = entries.get(i); + if (stored.getBlockNumber() != i) return "KEY_ROTATION_CANDIDATE_GAP"; + if (stored.isArweavePublishPending() || stored.getArweavePublishedAtMs() == null) { + return "KEY_ROTATION_CHAIN_NOT_PUBLISHED"; + } + + BchBlockEntry candidate; + try { + candidate = new BchBlockEntry(stored.getBlockBytes()); + candidate.body.check(); + } catch (Exception e) { + return "KEY_ROTATION_BAD_STORED_BLOCK"; + } + if (candidate.blockNumber != i) return "KEY_ROTATION_BLOCK_NUMBER_MISMATCH"; + if (!Arrays.equals(candidate.getHash32(), stored.getBlockHash())) return "KEY_ROTATION_STORED_HASH_MISMATCH"; + if (!Arrays.equals(candidate.getDataItemId32(), stored.getDataItemId())) return "KEY_ROTATION_STORED_DATAITEM_MISMATCH"; + if (!candidate.getDataItem().hasTag("App", "test5590")) return "KEY_ROTATION_BAD_APP_TAG"; + if (!BchCryptoVerifier.verifyBlock(candidate, newBlockchainKey)) return "KEY_ROTATION_BAD_SIGNATURE"; + + if (i == 0) { + if (!isZero32(candidate.prevHash32)) return "KEY_ROTATION_GENESIS_PREV_HASH"; + } else { + if (previous == null || !Arrays.equals(candidate.prevHash32, previous.getHash32())) { + return "KEY_ROTATION_CHAIN_HASH_MISMATCH"; + } + } + + String semanticError = validateAgainstRotation(c, session, candidate); + if (semanticError != null) return semanticError; + previous = candidate; + } + return null; + } + + private String validateAgainstRotation(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception { + if (candidate.blockNumber <= session.getForkFromBlock()) { + BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber); + if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING"; + BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes()); + if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH"; + if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH"; + if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH"; + if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH"; + return null; + } + + if (candidate.blockNumber != session.getForkFromBlock() + 1) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER"; + if ((candidate.type & 0xFFFF) != 0 + || (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF) + || !(candidate.body instanceof ForkBody fork)) { + return "KEY_ROTATION_TECH_FORK_REQUIRED"; + } + if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH"; + + BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock()); + BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock()); + if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING"; + BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes()); + BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes()); + + byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey()); + if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY"; + if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT"; + if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH"; + if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME"; + if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP"; + if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH"; + if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME"; + if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED"; + if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON"; + if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT"; + return null; + } + + private static boolean isZero32(byte[] value) { + if (value == null || value.length != 32) return false; + for (byte b : value) if (b != 0) return false; + return true; + } + + private static String normalizeComment(String value) { + if (value == null) return ""; + return value.trim().replace("\r\n", "\n").replace('\r', '\n'); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationRotatePda_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationRotatePda_Handler.java new file mode 100644 index 00000000..0237bcc6 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationRotatePda_Handler.java @@ -0,0 +1,83 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import shine.db.dao.KeyRotationSessionsDAO; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; + +/** + * Фиксирует уже отправленную клиентом Solana-транзакцию ротации PDA. + * Приватные ключи и подписанная транзакция через сервер не проходят: клиент передаёт только tx signature. + */ +public final class Net_KeyRotationRotatePda_Handler implements JsonMessageHandler { + private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { + Net_KeyRotationRotatePda_Request req = (Net_KeyRotationRotatePda_Request) baseReq; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия"); + } + String login = ctx.getLogin().trim(); + + final String signature; + try { + signature = normalizeSolanaSignature(req.getPdaRotationSignature()); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SOLANA_SIGNATURE", e.getMessage()); + } + + try { + KeyRotationSessionEntry current = rotations.getActiveByLogin(login); + if (current == null) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует"); + } + + if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) { + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current); + } + + if (current.getStatus() == KeyRotationStatus.ROTATING_PDA) { + String stored = current.getPdaRotationSignature(); + if (stored != null && !stored.isBlank() && !stored.equals(signature)) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_PDA_SIGNATURE_CONFLICT", + "Для этой ротации уже сохранена другая Solana transaction signature"); + } + if (stored == null || stored.isBlank()) { + rotations.setPdaRotationSignature(current.getId(), signature); + } + } else if (current.getStatus() == KeyRotationStatus.CHAIN_READY) { + current = rotations.beginPdaRotation(current.getId(), signature); + } else { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_CHAIN_READY", + "PDA можно ротировать только после CHAIN_READY"); + } + + // Solana sync мог увидеть новое PDA ещё до этого API-вызова. + KeyRotationSessionEntry confirmed = rotations.tryMarkPdaRotatedFromCurrentState(current.getId()); + rotations.clearError(current.getId()); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), confirmed != null ? confirmed : rotations.getById(current.getId())); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ROTATE_PDA_FAILED", + "Не удалось зафиксировать Solana-ротацию PDA"); + } + } + + private static String normalizeSolanaSignature(String raw) { + if (raw == null || raw.isBlank()) throw new IllegalArgumentException("pdaRotationSignature обязательна"); + String s = raw.trim(); + if (s.length() > 128) throw new IllegalArgumentException("Слишком длинная Solana transaction signature"); + final String alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + for (int i = 0; i < s.length(); i++) { + if (alphabet.indexOf(s.charAt(i)) < 0) { + throw new IllegalArgumentException("Solana transaction signature должна быть Base58"); + } + } + return s; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationStart_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationStart_Handler.java new file mode 100644 index 00000000..c905b55d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationStart_Handler.java @@ -0,0 +1,209 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import shine.db.DbController; +import shine.db.dao.BlockchainStateDAO; +import shine.db.dao.BlocksDAO; +import shine.db.dao.KeyRotationSessionsDAO; +import shine.db.entities.BlockEntry; +import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Создаёт серверную ротацию сразу в COPYING_CHAIN. + * До этого момента всё заполнение формы остаётся только локальным UI-состоянием. + */ +public final class Net_KeyRotationStart_Handler implements JsonMessageHandler { + private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance(); + private final BlockchainStateDAO states = BlockchainStateDAO.getInstance(); + private final BlocksDAO blocks = BlocksDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { + Net_KeyRotationStart_Request req = (Net_KeyRotationStart_Request) baseReq; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия"); + } + String login = ctx.getLogin().trim(); + String ctxBlockchainName = ctx.getCurrentUser() != null ? ctx.getCurrentUser().getBlockchainName() : null; + if (ctxBlockchainName == null || ctxBlockchainName.isBlank()) { + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE", + "В сессии отсутствует актуальный blockchainName; войдите заново"); + } + + final String newRoot; + final String newBlockchain; + final String newClient; + final byte[] requestedForkHash; + try { + newRoot = KeyRotationApiSupport.normalizePublicKey32(req.getNewRootKey()); + newBlockchain = KeyRotationApiSupport.normalizePublicKey32(req.getNewBlockchainKey()); + newClient = KeyRotationApiSupport.normalizePublicKey32(req.getNewClientKey()); + requestedForkHash = KeyRotationApiSupport.parseHash32(req.getForkFromHash()); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FIELDS", e.getMessage()); + } + if (req.getReasonCode() < 1 || req.getReasonCode() > 4) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_REASON", "reasonCode должен быть 1..4"); + } + String comment = req.getComment() == null ? "" : req.getComment(); + if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_COMMENT_TOO_LONG", "Комментарий должен быть не длиннее 1024 UTF-8 байт"); + } + if (req.getForkFromBlock() < 0) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "forkFromBlock должен быть >= 0"); + } + + ReentrantLock lock = BlockchainLocks.lockFor(ctxBlockchainName); + lock.lock(); + try (Connection c = DbController.getInstance().getConnection()) { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + PdaSnapshot pda = loadPdaForUpdate(c, login); + if (pda == null) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_USER_NOT_FOUND", "Пользователь не найден в текущем Solana PDA state"); + } + if (!KeyRotationStatus.NONE.name().equals(pda.rotationStatus)) { + KeyRotationSessionEntry active = rotations.getActiveByLogin(c, login); + c.rollback(); + if (active != null + && sameStartRequest(active, newRoot, newBlockchain, newClient, + req.getForkFromBlock(), requestedForkHash, req.getReasonCode(), comment)) { + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), active); + } + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ALREADY_ACTIVE", "Смена ключей уже выполняется с другими параметрами"); + } + if (!ctxBlockchainName.equals(pda.blockchainName)) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE", + "Активный blockchain изменился; обновите сессию и повторите"); + } + + String oldRoot = KeyRotationApiSupport.normalizePublicKey32(pda.rootKey); + String oldBlockchain = KeyRotationApiSupport.normalizePublicKey32(pda.blockchainKey); + String oldClient = KeyRotationApiSupport.normalizePublicKey32(pda.clientKey); + try { + KeyRotationApiSupport.requireKeysChangedAndDistinct( + oldRoot, oldBlockchain, oldClient, newRoot, newBlockchain, newClient); + } catch (IllegalArgumentException e) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_NEW_KEYS", e.getMessage()); + } + + BlockchainStateEntry state = states.getByBlockchainName(c, pda.blockchainName); + if (state == null || state.getLastBlockHash() == null || state.getLastBlockHash().length != 32) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_STATE_MISSING", "Не найдено корректное текущее состояние блокчейна"); + } + if (req.getForkFromBlock() > state.getLastBlockNumber()) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Точка fork находится после текущего tip"); + } + BlockEntry forkBlock = blocks.getByNumber(c, pda.blockchainName, req.getForkFromBlock()); + if (forkBlock == null || forkBlock.getBlockHash() == null || forkBlock.getBlockHash().length != 32) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_FORK_BLOCK_NOT_FOUND", "Выбранный блок не найден на сервере"); + } + if (!KeyRotationApiSupport.hashesEqual(forkBlock.getBlockHash(), requestedForkHash)) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FORK_HASH_MISMATCH", "Хэш выбранного блока не совпадает с сервером"); + } + + final String candidateName; + try { + candidateName = KeyRotationApiSupport.nextBlockchainName(login, pda.blockchainName); + } catch (IllegalArgumentException e) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCKCHAIN_NAME", e.getMessage()); + } + + KeyRotationSessionEntry entry = new KeyRotationSessionEntry(); + entry.setLogin(login); + entry.setSourceBlockchainName(pda.blockchainName); + entry.setCandidateBlockchainName(candidateName); + entry.setOldRootKey(oldRoot); + entry.setOldBlockchainKey(oldBlockchain); + entry.setOldClientKey(oldClient); + entry.setNewRootKey(newRoot); + entry.setNewBlockchainKey(newBlockchain); + entry.setNewClientKey(newClient); + entry.setForkFromBlock(req.getForkFromBlock()); + entry.setForkFromHash(requestedForkHash); + entry.setSourceTipBlock(state.getLastBlockNumber()); + entry.setSourceTipHash(state.getLastBlockHash()); + entry.setReasonCode(req.getReasonCode()); + entry.setComment(comment); + entry.setProgressCurrent(0); + entry.setProgressTotal(Math.addExact(req.getForkFromBlock(), 2)); // 0..N + TECH_FORK + + KeyRotationSessionEntry created = rotations.createCopyingSession(c, entry); + c.commit(); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), created); + } catch (ArithmeticException e) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Слишком большой номер блока"); + } catch (Exception e) { + c.rollback(); + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось запустить смену ключей"); + } finally { + c.setAutoCommit(oldAutoCommit); + } + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось открыть транзакцию смены ключей"); + } finally { + lock.unlock(); + } + } + + + private static boolean sameStartRequest(KeyRotationSessionEntry active, + String newRoot, String newBlockchain, String newClient, + int forkFromBlock, byte[] forkFromHash, short reasonCode, String comment) { + return active != null + && java.util.Objects.equals(active.getNewRootKey(), newRoot) + && java.util.Objects.equals(active.getNewBlockchainKey(), newBlockchain) + && java.util.Objects.equals(active.getNewClientKey(), newClient) + && active.getForkFromBlock() == forkFromBlock + && java.util.Arrays.equals(active.getForkFromHash(), forkFromHash) + && active.getReasonCode() == reasonCode + && java.util.Objects.equals(active.getComment() == null ? "" : active.getComment(), comment == null ? "" : comment); + } + + private static PdaSnapshot loadPdaForUpdate(Connection c, String login) throws Exception { + try (PreparedStatement ps = c.prepareStatement(""" + SELECT root_key, blockchain_key, client_key, blockchain_name, rotation_status + FROM solana_user_pda_current + WHERE normalized_login = LOWER(BTRIM(?)) + FOR UPDATE + """)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return new PdaSnapshot( + rs.getString("root_key"), + rs.getString("blockchain_key"), + rs.getString("client_key"), + rs.getString("blockchain_name"), + rs.getString("rotation_status") + ); + } + } + } + + private record PdaSnapshot(String rootKey, String blockchainKey, String clientKey, + String blockchainName, String rotationStatus) { } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationStatus_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationStatus_Handler.java new file mode 100644 index 00000000..182830f6 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationStatus_Handler.java @@ -0,0 +1,29 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import shine.db.dao.KeyRotationSessionsDAO; +import shine.db.entities.KeyRotationSessionEntry; + +public final class Net_KeyRotationStatus_Handler implements JsonMessageHandler { + private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { + Net_KeyRotationStatus_Request req = (Net_KeyRotationStatus_Request) baseReq; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия"); + } + try { + KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim()); + return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_STATUS_FAILED", + "Не удалось прочитать состояние смены ключей"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationAbort_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationAbort_Request.java new file mode 100644 index 00000000..134828ec --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationAbort_Request.java @@ -0,0 +1,7 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** Прервать ротацию до отправки Solana-транзакции. */ +public final class Net_KeyRotationAbort_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationAddBlock_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationAddBlock_Request.java new file mode 100644 index 00000000..dec03050 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationAddBlock_Request.java @@ -0,0 +1,17 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** Один подписанный новым blockchain key DataItem будущего fork. */ +public final class Net_KeyRotationAddBlock_Request extends Net_Request { + private int blockNumber; + private String prevBlockHash; + private String blockBytesB64; + + public int getBlockNumber() { return blockNumber; } + public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } + public String getPrevBlockHash() { return prevBlockHash; } + public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; } + public String getBlockBytesB64() { return blockBytesB64; } + public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationContinue_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationContinue_Request.java new file mode 100644 index 00000000..2f127d01 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationContinue_Request.java @@ -0,0 +1,7 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** Продолжить один из интерактивных/заглушечных этапов после rebuild. */ +public final class Net_KeyRotationContinue_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationFinishChain_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationFinishChain_Request.java new file mode 100644 index 00000000..01fabbf9 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationFinishChain_Request.java @@ -0,0 +1,7 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** Финальная проверка полностью опубликованной candidate-chain. */ +public final class Net_KeyRotationFinishChain_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationRotatePda_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationRotatePda_Request.java new file mode 100644 index 00000000..20c36be5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationRotatePda_Request.java @@ -0,0 +1,14 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Клиент уже локально подписал и отправил Solana-транзакцию ротации PDA. + * Серверу передаётся только публичная Solana transaction signature для аудита/возобновления. + */ +public final class Net_KeyRotationRotatePda_Request extends Net_Request { + private String pdaRotationSignature; + + public String getPdaRotationSignature() { return pdaRotationSignature; } + public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationStart_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationStart_Request.java new file mode 100644 index 00000000..8677843b --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationStart_Request.java @@ -0,0 +1,32 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Запуск смены ключей. Старые ключи сервер берёт из текущего Solana PDA; + * клиент передаёт только новые публичные ключи и выбранную точку fork. + */ +public final class Net_KeyRotationStart_Request extends Net_Request { + private String newRootKey; + private String newBlockchainKey; + private String newClientKey; + private int forkFromBlock; + private String forkFromHash; + private short reasonCode; + private String comment; + + public String getNewRootKey() { return newRootKey; } + public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; } + public String getNewBlockchainKey() { return newBlockchainKey; } + public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; } + public String getNewClientKey() { return newClientKey; } + public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; } + public int getForkFromBlock() { return forkFromBlock; } + public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; } + public String getForkFromHash() { return forkFromHash; } + public void setForkFromHash(String forkFromHash) { this.forkFromHash = forkFromHash; } + public short getReasonCode() { return reasonCode; } + public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; } + public String getComment() { return comment; } + public void setComment(String comment) { this.comment = comment; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationState_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationState_Response.java new file mode 100644 index 00000000..fc8d00d5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationState_Response.java @@ -0,0 +1,83 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** Публичное состояние текущей/только что созданной ротации. */ +public final class Net_KeyRotationState_Response extends Net_Response { + private Long rotationSessionId; + private String rotationStatus; + private String sourceBlockchainName; + private String candidateBlockchainName; + private String oldRootKey; + private String oldBlockchainKey; + private String oldClientKey; + private String newRootKey; + private String newBlockchainKey; + private String newClientKey; + private Integer forkFromBlock; + private String forkFromHash; + private Integer sourceTipBlock; + private String sourceTipHash; + private Short reasonCode; + private String comment; + private Integer progressCurrent; + private Integer progressTotal; + private String pdaRotationSignature; + private String walletMigrationStatus; + private String messageMigrationStatus; + private String lastError; + private Integer retryCount; + private Long createdAtMs; + private Long updatedAtMs; + + public Long getRotationSessionId() { return rotationSessionId; } + public void setRotationSessionId(Long rotationSessionId) { this.rotationSessionId = rotationSessionId; } + public String getRotationStatus() { return rotationStatus; } + public void setRotationStatus(String rotationStatus) { this.rotationStatus = rotationStatus; } + public String getSourceBlockchainName() { return sourceBlockchainName; } + public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; } + public String getCandidateBlockchainName() { return candidateBlockchainName; } + public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; } + public String getOldRootKey() { return oldRootKey; } + public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; } + public String getOldBlockchainKey() { return oldBlockchainKey; } + public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; } + public String getOldClientKey() { return oldClientKey; } + public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; } + public String getNewRootKey() { return newRootKey; } + public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; } + public String getNewBlockchainKey() { return newBlockchainKey; } + public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; } + public String getNewClientKey() { return newClientKey; } + public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; } + public Integer getForkFromBlock() { return forkFromBlock; } + public void setForkFromBlock(Integer forkFromBlock) { this.forkFromBlock = forkFromBlock; } + public String getForkFromHash() { return forkFromHash; } + public void setForkFromHash(String forkFromHash) { this.forkFromHash = forkFromHash; } + public Integer getSourceTipBlock() { return sourceTipBlock; } + public void setSourceTipBlock(Integer sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; } + public String getSourceTipHash() { return sourceTipHash; } + public void setSourceTipHash(String sourceTipHash) { this.sourceTipHash = sourceTipHash; } + public Short getReasonCode() { return reasonCode; } + public void setReasonCode(Short reasonCode) { this.reasonCode = reasonCode; } + public String getComment() { return comment; } + public void setComment(String comment) { this.comment = comment; } + public Integer getProgressCurrent() { return progressCurrent; } + public void setProgressCurrent(Integer progressCurrent) { this.progressCurrent = progressCurrent; } + public Integer getProgressTotal() { return progressTotal; } + public void setProgressTotal(Integer progressTotal) { this.progressTotal = progressTotal; } + public String getPdaRotationSignature() { return pdaRotationSignature; } + public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; } + public String getWalletMigrationStatus() { return walletMigrationStatus; } + public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; } + public String getMessageMigrationStatus() { return messageMigrationStatus; } + public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; } + public String getLastError() { return lastError; } + public void setLastError(String lastError) { this.lastError = lastError; } + public Integer getRetryCount() { return retryCount; } + public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; } + public Long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(Long createdAtMs) { this.createdAtMs = createdAtMs; } + public Long getUpdatedAtMs() { return updatedAtMs; } + public void setUpdatedAtMs(Long updatedAtMs) { this.updatedAtMs = updatedAtMs; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationStatus_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationStatus_Request.java new file mode 100644 index 00000000..d5eb01bb --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/entyties/Net_KeyRotationStatus_Request.java @@ -0,0 +1,7 @@ +package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** Запрос текущего состояния ротации авторизованного пользователя. */ +public final class Net_KeyRotationStatus_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java index a3ab87f5..5c23226a 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java @@ -216,10 +216,188 @@ public final class ShineUsersCodec { int formatMajor = raw[5] & 0xFF; int formatMinor = raw[6] & 0xFF; - if (formatMajor != 1 || formatMinor != 2) { + if (formatMajor != 1) { throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor); } - return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw); + if (formatMinor == 2) { + return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw); + } + if (formatMinor == 0) { + // Временная read-only совместимость: legacy PDA 1.0 снова читается + // server-sync только для bootstrap старых server-записей. Новые записи + // и update по-прежнему работают только с текущим форматом 1.2. + return parseUserPdaAccountV10(pdaAddress, slot, rawDataBase64, lastTxSignature, raw); + } + throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor); + } + + private static UserPdaSnapshot parseUserPdaAccountV10( + String pdaAddress, + long slot, + String rawDataBase64, + String lastTxSignature, + byte[] raw + ) { + int recordLen = u16le(raw, 7); + if (recordLen < 9 + 64 || recordLen > raw.length) { + throw new IllegalArgumentException("Invalid PDA 1.0 record length"); + } + + byte[] useful = new byte[recordLen]; + System.arraycopy(raw, 0, useful, 0, recordLen); + Reader reader = new Reader(useful); + reader.skip(9); + + long createdAtMs = reader.readU64(); + long updatedAtMs = reader.readU64(); + int recordNumber = (int) reader.readU32(); + String prevHash = toHex(reader.readFixed(32)); + String login = reader.readStringU8(); + int blocksCount = reader.readU8(); + + String recoveryKey = null; + String rootKey = null; + String clientKey = null; + String blockchainName = null; + String blockchainKey = null; + long paidLimitBytes = 0L; + long usedBytes = 0L; + int lastBlockNumber = 0; + String lastBlockHash = ""; + String lastBlockSignature = ""; + String arweaveTxId = ""; + String archiveHeadTxId = ""; + String archiveHeadHash = ""; + boolean isServer = false; + int addressFormatType = 0; + int addressFormatVersion = 0; + String serverAddress = ""; + List syncServers = new ArrayList<>(); + List accessServers = new ArrayList<>(); + int sessionsMode = 1; + List sessions = new ArrayList<>(); + int trustedCount = 0; + + boolean seenRecovery = false; + boolean seenRoot = false; + boolean seenClient = false; + boolean seenBlockchain = false; + + for (int i = 0; i < blocksCount; i++) { + int blockType = reader.readU8(); + int blockVersion = reader.readU8(); + if (blockVersion != BLOCK_VERSION_0) { + throw new IllegalArgumentException("Unsupported PDA 1.0 block version: type=" + blockType + " version=" + blockVersion); + } + + switch (blockType) { + case BLOCK_TYPE_RECOVERY_KEY -> { + if (seenRecovery) throw new IllegalArgumentException("Duplicate RecoveryKeyBlock in PDA 1.0"); + recoveryKey = Base58Util.encode(reader.readFixed(32)); + seenRecovery = true; + } + case BLOCK_TYPE_ROOT_KEY -> { + if (seenRoot) throw new IllegalArgumentException("Duplicate RootKeyBlock in PDA 1.0"); + rootKey = Base58Util.encode(reader.readFixed(32)); + seenRoot = true; + } + case BLOCK_TYPE_CLIENT_KEY -> { + if (seenClient) throw new IllegalArgumentException("Duplicate ClientKeyBlock in PDA 1.0"); + clientKey = Base58Util.encode(reader.readFixed(32)); + seenClient = true; + } + case BLOCK_TYPE_BLOCKCHAIN_REGISTRY -> { + if (seenBlockchain) throw new IllegalArgumentException("Duplicate BlockchainRegistryBlock in PDA 1.0"); + int count = reader.readU8(); + if (count < 1) throw new IllegalArgumentException("Empty BlockchainRegistryBlock in PDA 1.0"); + for (int j = 0; j < count; j++) { + int blockchainType = reader.readU8(); + String currentName = reader.readStringU8(); + String currentKey = Base58Util.encode(reader.readFixed(32)); + long currentPaidLimit = reader.readU64(); + long currentUsedBytes = reader.readU64(); + long currentLastBlockNumber = reader.readU32(); + String currentLastBlockHash = toHex(reader.readFixed(32)); + String currentLastBlockSignature = Base58Util.encode(reader.readFixed(64)); + int arweavePresent = reader.readU8(); + String currentArweaveTxId = arweavePresent == 1 ? reader.readStringU8() : ""; + if (arweavePresent != 0 && arweavePresent != 1) { + throw new IllegalArgumentException("Invalid PDA 1.0 arweave_present"); + } + + // Runtime SHiNE использует основной пользовательский blockchain (type=1). + // Если legacy запись содержит несколько chain records, берём первый MAIN_USER. + if (blockchainName == null && blockchainType == BLOCKCHAIN_TYPE_MAIN_USER) { + blockchainName = currentName; + blockchainKey = currentKey; + paidLimitBytes = currentPaidLimit; + usedBytes = currentUsedBytes; + lastBlockNumber = Math.toIntExact(currentLastBlockNumber); + lastBlockHash = currentLastBlockHash; + lastBlockSignature = currentLastBlockSignature; + arweaveTxId = currentArweaveTxId; + } + } + seenBlockchain = true; + } + case BLOCK_TYPE_SERVER_PROFILE -> { + int serverFlag = reader.readU8(); + if (serverFlag != 0 && serverFlag != 1) { + throw new IllegalArgumentException("Invalid PDA 1.0 is_server"); + } + isServer = serverFlag == 1; + if (isServer) { + addressFormatType = reader.readU8(); + addressFormatVersion = reader.readU8(); + serverAddress = reader.readStringU8(); + int count = reader.readU8(); + for (int j = 0; j < count; j++) syncServers.add(reader.readStringU8()); + } + } + case BLOCK_TYPE_ACCESS_SERVERS -> { + int count = reader.readU8(); + for (int j = 0; j < count; j++) accessServers.add(reader.readStringU8()); + } + case BLOCK_TYPE_SESSIONS -> { + sessionsMode = reader.readU8(); + int count = reader.readU8(); + for (int j = 0; j < count; j++) { + int sessionType = reader.readU8(); + int sessionVersion = reader.readU8(); + String sessionName = reader.readStringU8(); + String sessionPubKey = Base58Util.encode(reader.readFixed(32)); + sessions.add(new UserSessionSnapshot(sessionType, sessionVersion, sessionName, sessionPubKey)); + } + } + case BLOCK_TYPE_TRUSTED_STATE -> trustedCount = reader.readU8(); + case BLOCK_TYPE_ARCHIVE_HEAD -> { + archiveHeadTxId = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); + archiveHeadHash = toHex(reader.readFixed(32)); + } + default -> throw new IllegalArgumentException("Unsupported PDA 1.0 block type: " + blockType); + } + } + + String signature = Base58Util.encode(reader.readFixed(64)); + if (reader.remaining() != 0) { + throw new IllegalArgumentException("Unexpected tail inside PDA 1.0 record"); + } + if (!seenRecovery || !seenRoot || !seenClient || !seenBlockchain + || recoveryKey == null || rootKey == null || clientKey == null + || blockchainName == null || blockchainKey == null) { + throw new IllegalArgumentException("PDA 1.0 misses required blocks"); + } + + return new UserPdaSnapshot( + pdaAddress, login, recordNumber, slot, lastTxSignature, + recoveryKey, rootKey, clientKey, blockchainName, blockchainKey, paidLimitBytes, + usedBytes, lastBlockNumber, lastBlockHash, lastBlockSignature, arweaveTxId, + archiveHeadTxId, archiveHeadHash, + isServer, addressFormatType, addressFormatVersion, serverAddress, + List.copyOf(syncServers), List.copyOf(accessServers), sessionsMode, List.copyOf(sessions), trustedCount, + createdAtMs, updatedAtMs, prevHash, signature, rawDataBase64, + List.of(new BlockchainForkSnapshot(0, blockchainKey, createdAtMs, paidLimitBytes)) + ); } private static ParsedInstruction parseV12PdaMutationInstruction( diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index 7a1b4d1e..9fe3aa7f 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -1,5 +1,6 @@ package sync.storage.postgres; +import sync.util.Base58Util; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; @@ -272,6 +273,11 @@ public final class PostgresStorageRepository snapshots ); + reconcileConfirmedKeyRotations( + connection, + snapshots + ); + upsertSyncState( connection, newState @@ -305,6 +311,11 @@ public final class PostgresStorageRepository snapshots ); + reconcileConfirmedKeyRotations( + connection, + snapshots + ); + upsertSyncState( connection, newState @@ -536,6 +547,90 @@ public final class PostgresStorageRepository } } + /** + * Solana является источником истины для момента завершения ротации PDA. + * После upsert current-снимка сверяем все три новых public key и новый blockchainName. + * Совпадение переводит локальную машину ROTATING_PDA -> PDA_ROTATED в той же DB-транзакции. + */ + private void reconcileConfirmedKeyRotations( + Connection connection, + List snapshots + ) throws Exception { + if (snapshots == null || snapshots.isEmpty()) return; + + String select = """ + SELECT id, new_root_key, new_blockchain_key, new_client_key, candidate_blockchain_name + FROM key_rotation_sessions + WHERE login = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA') + ORDER BY id DESC + LIMIT 1 + FOR UPDATE + """; + + for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) { + if (snapshot == null || snapshot.login() == null || snapshot.login().isBlank()) continue; + + try (PreparedStatement ps = connection.prepareStatement(select)) { + ps.setString(1, snapshot.login()); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) continue; + + long rotationId = rs.getLong("id"); + String expectedRoot = rs.getString("new_root_key"); + String expectedBlockchain = rs.getString("new_blockchain_key"); + String expectedClient = rs.getString("new_client_key"); + String expectedBlockchainName = rs.getString("candidate_blockchain_name"); + + String actualRoot = keyToBase64(snapshot.rootKey()); + String actualBlockchain = keyToBase64(snapshot.blockchainKey()); + String actualClient = keyToBase64(snapshot.clientKey()); + + if (!expectedRoot.equals(actualRoot) + || !expectedBlockchain.equals(actualBlockchain) + || !expectedClient.equals(actualClient) + || !expectedBlockchainName.equals(snapshot.blockchainName())) { + continue; + } + + long now = System.currentTimeMillis(); + try (PreparedStatement update = connection.prepareStatement(""" + UPDATE key_rotation_sessions + SET status = 'PDA_ROTATED', + pda_rotation_signature = COALESCE(pda_rotation_signature, ?), + updated_at_ms = ?, + last_error = NULL, + last_error_at_ms = NULL + WHERE id = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA') + """)) { + update.setString(1, snapshot.lastTxSignature() == null || snapshot.lastTxSignature().isBlank() ? null : snapshot.lastTxSignature()); + update.setLong(2, now); + update.setLong(3, rotationId); + if (update.executeUpdate() != 1) continue; + } + try (PreparedStatement update = connection.prepareStatement(""" + UPDATE solana_user_pda_current + SET rotation_status = 'PDA_ROTATED', rotation_session_id = ? + WHERE login = ? AND rotation_session_id = ? AND rotation_status IN ('CHAIN_READY', 'ROTATING_PDA') + """)) { + update.setLong(1, rotationId); + update.setString(2, snapshot.login()); + update.setLong(3, rotationId); + if (update.executeUpdate() != 1) { + throw new SQLException("Rotation session is not attached to synced login=" + snapshot.login()); + } + } + } + } + } + } + + private static String keyToBase64(String base58) { + if (base58 == null || base58.isBlank()) return ""; + byte[] decoded = Base58Util.decode(base58); + if (decoded.length != 32) return ""; + return java.util.Base64.getEncoder().encodeToString(decoded); + } + private void bindSnapshot( PreparedStatement statement, ShineUsersCodec.UserPdaSnapshot snapshot, diff --git a/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/codec/ShineUsersCodecLegacyV10Test.java b/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/codec/ShineUsersCodecLegacyV10Test.java new file mode 100644 index 00000000..852c790b --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/codec/ShineUsersCodecLegacyV10Test.java @@ -0,0 +1,119 @@ +package sync.codec; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +class ShineUsersCodecLegacyV10Test { + + @Test + void readsLegacyV10ServerPdaForBootstrap() { + byte[] raw = buildLegacyServerPda(); + ShineUsersCodec.UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount( + "legacy-pda", 123L, Base64.getEncoder().encodeToString(raw), "legacy-tx" + ); + + assertEquals("legacy_server", snapshot.login()); + assertEquals("legacy_server-001", snapshot.blockchainName()); + assertEquals(100_000L, snapshot.paidLimitBytes()); + assertEquals(12_345L, snapshot.usedBytes()); + assertEquals(7, snapshot.lastBlockNumber()); + assertTrue(snapshot.isServer()); + assertEquals(1, snapshot.addressFormatType()); + assertEquals(0, snapshot.addressFormatVersion()); + assertEquals("wss://legacy.example/ws", snapshot.serverAddress()); + assertEquals(java.util.List.of("sync-old"), snapshot.syncServers()); + assertEquals(java.util.List.of("access-old"), snapshot.accessServers()); + assertEquals(1, snapshot.blockchainForks().size()); + assertEquals(snapshot.blockchainKey(), snapshot.blockchainForks().get(0).blockchainKey()); + } + + private static byte[] buildLegacyServerPda() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + bytes(out, "SHiNE".getBytes(StandardCharsets.UTF_8)); + u8(out, 1); + u8(out, 0); + u16(out, 0); // record_len patch later + u64(out, 1_700_000_000_000L); + u64(out, 1_700_000_001_000L); + u32(out, 3); + bytes(out, repeat(0x11, 32)); + str(out, "legacy_server"); + u8(out, 7); // recovery, root, client, blockchain, server, access, trusted + + u8(out, 0); u8(out, 0); bytes(out, repeat(0x21, 32)); + u8(out, 1); u8(out, 0); bytes(out, repeat(0x22, 32)); + u8(out, 2); u8(out, 0); bytes(out, repeat(0x23, 32)); + + u8(out, 3); u8(out, 0); + u8(out, 1); + u8(out, 1); + str(out, "legacy_server-001"); + bytes(out, repeat(0x24, 32)); + u64(out, 100_000L); + u64(out, 12_345L); + u32(out, 7); + bytes(out, repeat(0x25, 32)); + bytes(out, repeat(0x26, 64)); + u8(out, 1); + str(out, "legacy-arweave-tx"); + + u8(out, 30); u8(out, 0); + u8(out, 1); + u8(out, 1); + u8(out, 0); + str(out, "wss://legacy.example/ws"); + u8(out, 1); + str(out, "sync-old"); + + u8(out, 40); u8(out, 0); + u8(out, 1); + str(out, "access-old"); + + u8(out, 70); u8(out, 0); u8(out, 2); + + bytes(out, repeat(0x27, 64)); + + byte[] record = out.toByteArray(); + record[7] = (byte) (record.length & 0xff); + record[8] = (byte) ((record.length >>> 8) & 0xff); + return record; + } + + private static byte[] repeat(int value, int count) { + byte[] bytes = new byte[count]; + java.util.Arrays.fill(bytes, (byte) value); + return bytes; + } + + private static void str(ByteArrayOutputStream out, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + u8(out, bytes.length); + bytes(out, bytes); + } + + private static void bytes(ByteArrayOutputStream out, byte[] value) { + out.writeBytes(value); + } + + private static void u8(ByteArrayOutputStream out, long value) { + out.write((int) value & 0xff); + } + + private static void u16(ByteArrayOutputStream out, long value) { + u8(out, value); + u8(out, value >>> 8); + } + + private static void u32(ByteArrayOutputStream out, long value) { + for (int i = 0; i < 4; i++) u8(out, value >>> (8 * i)); + } + + private static void u64(ByteArrayOutputStream out, long value) { + for (int i = 0; i < 8; i++) u8(out, value >>> (8 * i)); + } +} diff --git a/SHiNE-server/src/main/java/server/keyrotation/KeyRotationRebuildScheduler.java b/SHiNE-server/src/main/java/server/keyrotation/KeyRotationRebuildScheduler.java new file mode 100644 index 00000000..44649a8d --- /dev/null +++ b/SHiNE-server/src/main/java/server/keyrotation/KeyRotationRebuildScheduler.java @@ -0,0 +1,35 @@ +package server.keyrotation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Небольшой background worker, который продолжает rebuild даже если UI был закрыт. */ +public final class KeyRotationRebuildScheduler { + private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildScheduler.class); + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final KeyRotationRebuildService SERVICE = new KeyRotationRebuildService(); + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override public Thread newThread(Runnable r) { + Thread t = new Thread(r, "key-rotation-rebuild"); + t.setDaemon(true); + return t; + } + }); + + private KeyRotationRebuildScheduler() { } + + public static void startOrLog() { + if (!STARTED.compareAndSet(false, true)) return; + EXECUTOR.scheduleWithFixedDelay(() -> { + try { SERVICE.runReady(4); } + catch (Exception e) { log.warn("Key rotation rebuild cycle failed", e); } + }, 1, 2, TimeUnit.SECONDS); + log.info("Key rotation rebuild scheduler started"); + } +} diff --git a/SHiNE-server/src/main/java/server/keyrotation/KeyRotationRebuildService.java b/SHiNE-server/src/main/java/server/keyrotation/KeyRotationRebuildService.java new file mode 100644 index 00000000..67ce5993 --- /dev/null +++ b/SHiNE-server/src/main/java/server/keyrotation/KeyRotationRebuildService.java @@ -0,0 +1,166 @@ +package server.keyrotation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import server.sync.BlockchainResyncGuard; +import shine.db.dao.BlockchainResyncCleanupDAO; +import shine.db.dao.BlockchainStateDAO; +import shine.db.dao.KeyRotationCandidateBlocksDAO; +import shine.db.dao.KeyRotationSessionsDAO; +import shine.db.dao.SolanaUserPdaCurrentDAO; +import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.KeyRotationCandidateBlockEntry; +import shine.db.entities.KeyRotationSessionEntry; +import shine.db.entities.KeyRotationStatus; +import shine.db.entities.SolanaUserPdaCurrentEntry; + +import java.util.Base64; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Автоматически materialize-ит candidate fork после того, как Solana sync подтвердил новое PDA. + * Candidate-блоки уже опубликованы в Arweave; здесь они только становятся единственной рабочей + * цепочкой PostgreSQL и повторно проходят обычный AddBlock validation/projection path. + */ +public final class KeyRotationRebuildService { + private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildService.class); + + private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance(); + private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance(); + private final BlockchainResyncCleanupDAO cleanup = BlockchainResyncCleanupDAO.getInstance(); + private final BlockchainStateDAO states = BlockchainStateDAO.getInstance(); + private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance(); + private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler(); + + public int runReady(int limit) { + int processed = 0; + try { + for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.PDA_ROTATED, limit)) { + if (tryRebuild(session)) processed++; + } + for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.REBUILDING_SERVER, Math.max(1, limit - processed))) { + if (tryRebuild(session)) processed++; + } + } catch (Exception e) { + log.warn("Key rotation rebuild scan failed", e); + } + return processed; + } + + public boolean tryRebuild(KeyRotationSessionEntry session) { + if (session == null) return false; + try { + KeyRotationSessionEntry current = rotations.getById(session.getId()); + if (current == null) return false; + if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) { + current = rotations.transition(current.getId(), KeyRotationStatus.PDA_ROTATED, KeyRotationStatus.REBUILDING_SERVER); + } + if (current.getStatus() != KeyRotationStatus.REBUILDING_SERVER) return false; + rebuild(current); + rotations.clearError(current.getId()); + rotations.transition(current.getId(), KeyRotationStatus.REBUILDING_SERVER, KeyRotationStatus.WALLET_MIGRATION); + log.info("Key rotation rebuild complete: login={} {} -> {}", current.getLogin(), current.getSourceBlockchainName(), current.getCandidateBlockchainName()); + return true; + } catch (Exception e) { + try { rotations.recordError(session.getId(), "REBUILDING_SERVER: " + safeMessage(e)); } catch (Exception ignored) { } + log.warn("Key rotation rebuild failed: id={} login={}", session.getId(), session.getLogin(), e); + return false; + } + } + + private void rebuild(KeyRotationSessionEntry session) throws Exception { + verifyCurrentPda(session); + List candidateBlocks; + try (var c = shine.db.DbController.getInstance().getConnection()) { + candidateBlocks = candidates.listBySession(c, session.getId()); + } + if (candidateBlocks.size() != session.getProgressTotal()) { + throw new IllegalStateException("candidate count mismatch: " + candidateBlocks.size() + "/" + session.getProgressTotal()); + } + for (KeyRotationCandidateBlockEntry block : candidateBlocks) { + if (block.isArweavePublishPending()) { + throw new IllegalStateException("candidate block is not published: #" + block.getBlockNumber()); + } + } + + String source = session.getSourceBlockchainName(); + String target = session.getCandidateBlockchainName(); + ReentrantLock first = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? source : target); + ReentrantLock second = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? target : source); + first.lock(); + second.lock(); + boolean sourceGuard = false; + boolean targetGuard = false; + try { + sourceGuard = BlockchainResyncGuard.tryBegin(source); + targetGuard = BlockchainResyncGuard.tryBegin(target); + if (!sourceGuard || !targetGuard) { + throw new IllegalStateException("another resync/rebuild is active"); + } + + // Повторный запуск безопасен: сначала убираем любую частично materialized candidate-цепочку. + if (states.getByBlockchainName(target) != null) { + cleanup.cleanupBlockchainForFullResync(target); + } + if (states.getByBlockchainName(source) != null) { + cleanup.cleanupBlockchainForFullResync(source); + } + + createCandidateState(session); + BlockchainResyncGuard.withBypass(target, () -> { + for (KeyRotationCandidateBlockEntry candidate : candidateBlocks) { + Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(target, candidate.getBlockBytes()); + if (!result.ok()) { + throw new IllegalStateException("candidate replay rejected at #" + candidate.getBlockNumber() + ": " + result.reasonCode()); + } + } + }); + + // Чужие подписанные ссылки сохраняют логическую identity login+number+hash. + // Здесь меняется только их локальный cache физического fork. + cleanup.refreshLogicalTargetBlockchainCache(session.getLogin(), source, target); + } finally { + if (targetGuard) BlockchainResyncGuard.end(target); + if (sourceGuard) BlockchainResyncGuard.end(source); + second.unlock(); + first.unlock(); + } + } + + private void verifyCurrentPda(KeyRotationSessionEntry session) throws Exception { + SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin()); + if (user == null) throw new IllegalStateException("current PDA not found"); + if (!session.getCandidateBlockchainName().equals(user.getBlockchainName())) { + throw new IllegalStateException("current PDA blockchainName mismatch"); + } + if (!session.getNewBlockchainKey().equals(user.getBlockchainKey())) { + throw new IllegalStateException("current PDA blockchain key mismatch"); + } + } + + private void createCandidateState(KeyRotationSessionEntry session) throws Exception { + SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin()); + byte[] key = Base64.getDecoder().decode(session.getNewBlockchainKey()); + if (key.length != 32) throw new IllegalStateException("new blockchain key length != 32"); + + BlockchainStateEntry state = new BlockchainStateEntry(); + state.setBlockchainName(session.getCandidateBlockchainName()); + state.setLogin(session.getLogin()); + state.setBlockchainKey(session.getNewBlockchainKey()); + state.setSizeLimit(user != null && user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L); + state.setFileSizeBytes(0L); + state.setLastBlockNumber(-1); + state.setLastBlockHash(null); + state.setUpdatedAtMs(System.currentTimeMillis()); + states.insertIfMissing(state); + } + + private static String safeMessage(Throwable t) { + String value = t == null ? "unknown" : String.valueOf(t.getMessage()); + if (value.length() > 1000) value = value.substring(0, 1000); + return value; + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 9a84e00a..fc043b08 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -1,5 +1,7 @@ package server.ws; +import server.keyrotation.KeyRotationRebuildScheduler; + import org.eclipse.jetty.server.Server; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; @@ -61,6 +63,7 @@ public final class WsServer { // ANS-104: publish locally-created signed user blocks and discover blocks published by other servers. ArweaveBlockPublisherScheduler.startOrLog(); ArweaveBlockSyncScheduler.startOrLog(); + KeyRotationRebuildScheduler.startOrLog(); // ============================================================ // 2) Запуск Jetty WS diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index 1d85001a..85cbd052 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -4,7 +4,8 @@ import blockchain.MsgSubType; import blockchain.body.ConnectionBody; import blockchain.body.CreateChannelBody; import blockchain.body.HeaderBody; -import blockchain.body.TextBody; +import blockchain.body.TextLineBody; +import blockchain.body.TextReplyBody; import shine.db.DbController; import test.it.blockchain.AddBlockSender; import test.it.blockchain.ChainState; @@ -27,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.*; * CONNECTION (type=3): * - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber) * - всегда имеет target: - * toBlockchainName + toBlockGlobalNumber + toBlockHash32 + * toLogin + toBlockGlobalNumber + toBlockHash32 * * Правило target для связей/подписок: * - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0) @@ -88,12 +89,12 @@ public class IT_03_AddBlock_NoAuth { // POST в канал "0" { var ln = st1.nextTextLineByRoot(root0); - sender1.send(new TextBody( - MsgSubType.TEXT_POST, + sender1.send(new TextLineBody( root0, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "U1: story/post in channel 0", - null, null, null + MsgSubType.TEXT_POST, + null, null, null, + "U1: story/post in channel 0" ), t); } @@ -148,12 +149,12 @@ public class IT_03_AddBlock_NoAuth { byte[] newsPost0Hash; { var ln = st1.nextTextLineByRoot(newsRootBlock); - sender1.send(new TextBody( - MsgSubType.TEXT_POST, + sender1.send(new TextLineBody( newsRootBlock, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "U1: News post #0", - null, null, null + MsgSubType.TEXT_POST, + null, null, null, + "U1: News post #0" ), t); newsPost0Block = st1.lastBlockNumber(); @@ -164,26 +165,26 @@ public class IT_03_AddBlock_NoAuth { // POST #1 в канал "News" { var ln = st1.nextTextLineByRoot(newsRootBlock); - sender1.send(new TextBody( - MsgSubType.TEXT_POST, + sender1.send(new TextLineBody( newsRootBlock, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "U1: News post #1", - null, null, null + MsgSubType.TEXT_POST, + null, null, null, + "U1: News post #1" ), t); } - // EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName) + // EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash { var ln = st1.nextTextLineByRoot(newsRootBlock); - sender1.send(new TextBody( - MsgSubType.TEXT_EDIT_POST, + sender1.send(new TextLineBody( newsRootBlock, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "U1: News post #0 (EDIT)", - null, + MsgSubType.TEXT_EDIT_POST, newsPost0Block, - newsPost0Hash + newsPost0Hash, + u1, + "U1: News post #0 (EDIT)" ), t); } @@ -206,17 +207,17 @@ public class IT_03_AddBlock_NoAuth { // 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2) sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW, - bch2, u2HeaderBlock, u2HeaderHash, + u2, u2HeaderBlock, u2HeaderHash, "U1 follows U2 (target=U2 HEADER)", t); // 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1) sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, - bch1, newsRootBlock, newsRootHash, + u1, newsRootBlock, newsRootHash, "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); // 3) U2 подписался на второй канал U1 "Updates" sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, - bch1, updatesRootBlock, updatesRootHash, + u1, updatesRootBlock, updatesRootHash, "U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t); assertEquals(2, countConnectionsByOwner(u2, u1), @@ -230,30 +231,31 @@ public class IT_03_AddBlock_NoAuth { // 4) FRIEND взаимно (на HEADER) sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND, - bch2, u2HeaderBlock, u2HeaderHash, + u2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: FRIEND", t); sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND, - bch1, u1HeaderBlock, u1HeaderHash, + u1, u1HeaderBlock, u1HeaderHash, "U2 -> U1: FRIEND", t); // 5) CONTACT несколько sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, - bch2, u2HeaderBlock, u2HeaderHash, + u2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: CONTACT", t); sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT, - bch1, u1HeaderBlock, u1HeaderHash, + u1, u1HeaderBlock, u1HeaderHash, "U2 -> U1: CONTACT", t); // ========================= // USER2 REPLY (ответ в чужой канал) // ========================= { - sender2.send(TextBody.newReply( - bch1, + sender2.send(new TextReplyBody( + MsgSubType.TEXT_REPLY, newsPost0Block, newsPost0Hash, + u1, "U2: reply to U1 News post #0 (cross-chain)" ), t); } @@ -273,12 +275,12 @@ public class IT_03_AddBlock_NoAuth { // U1 -> U3: CONTACT sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, - bch3, u3HeaderBlock, u3HeaderHash, + u3, u3HeaderBlock, u3HeaderHash, "U1 -> U3: CONTACT", t); // 6) U2 отписывается только от News sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW, - bch1, newsRootBlock, newsRootHash, + u1, newsRootBlock, newsRootHash, "U2 unfollows U1 channel 'News'", t); assertEquals(1, countConnectionsByOwner(u2, u1), @@ -292,7 +294,7 @@ public class IT_03_AddBlock_NoAuth { // 7) U1 убирает U2 из контактов (UNCONTACT) sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT, - bch2, u2HeaderBlock, u2HeaderHash, + u2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: UNCONTACT", t); r.ok("IT_03 сценарий блоков + connections выполнен"); @@ -313,7 +315,7 @@ public class IT_03_AddBlock_NoAuth { private static void sendConnection(AddBlockSender sender, ChainState st, short subType, - String toBlockchainName, + String toLogin, int toBlockNumber, byte[] toBlockHash32, String logNote, @@ -321,7 +323,7 @@ public class IT_03_AddBlock_NoAuth { if (TestConfig.DEBUG()) { TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF) - + " to=" + toBlockchainName + + " to=" + toLogin + " targetBlock=" + toBlockNumber + " note=" + logNote); } @@ -330,14 +332,14 @@ public class IT_03_AddBlock_NoAuth { // КОНСТРУКТОР ИЗ ТВОЕГО КОДА: // ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, - // short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) + // short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) sender.send(new ConnectionBody( 0, // lineCode для connection линии ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, subType, - toBlockchainName, + toLogin, toBlockNumber, toBlockHash32 ), timeout); diff --git a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java index b08e3cb9..cb958803 100644 --- a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java +++ b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java @@ -151,7 +151,7 @@ public final class SeedDataPopulationHelper { line.prevLineHash32, line.thisLineNumber, relationSubType, - bch(to), + to, 0, targetHeaderHash ), timeout); diff --git a/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md index e8105a06..d459e8f7 100644 --- a/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md +++ b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md @@ -113,3 +113,13 @@ cp /path/to/SHiNE-product/application.properties ./application.properties После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`. Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key. + +## PostgreSQL migration v26: key rotation runtime state + +Migration v26 добавляет server-local поля `rotation_status` / `rotation_session_id` в `solana_user_pda_current` и таблицу `key_rotation_sessions`. + +Это локальное состояние длительной смены ключей, а не часть Solana PDA. Solana users sync продолжает обновлять только PDA-поля и не должен затирать rotation-state. После обновления сервера миграция применяется стандартным `DatabaseInitializer` автоматически. + +## Миграции key rotation + +При обновлении сервера DatabaseInitializer последовательно применяет migration v26 (state machine смены ключей) и v27 (отдельные candidate-блоки будущего fork). Ручного создания таблиц не требуется. Candidate-блоки не входят в текущую таблицу `blocks` до финального переключения fork. diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index d6fac193..3b4669ec 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -43,6 +43,8 @@ - `bad_block_number` — нарушена последовательность; - `bad_prev_hash` — нарушена SHiNE hash chain; - `bad_block_bytes` — DataItem/Frame не парсится. +- `key_rotation_in_progress` (HTTP/status `423`) — для пользователя уже запущена смена ключей; обычный `AddBlock` временно запрещён до завершения/отмены ротации; +- `key_rotation_check_failed` — сервер не смог проверить локальный rotation status и отклонил запись fail-closed. ## Storage/publish diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 07553d0b..3135ef9a 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -31,6 +31,14 @@ | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | | `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна | +| `KeyRotationStart` | `19_Key_Rotation_API.md` | начать смену ключей сразу в состоянии `COPYING_CHAIN` | +| `KeyRotationStatus` | `19_Key_Rotation_API.md` | получить текущий этап и прогресс смены ключей | +| `KeyRotationAddBlock` | `19_Key_Rotation_API.md` | добавить подписанный новым blockchain key candidate-блок будущего fork | +| `KeyRotationFinishChain` | `19_Key_Rotation_API.md` | проверить полную публикацию candidate-chain и перевести её в `CHAIN_READY` | +| `KeyRotationRotatePda` | `19_Key_Rotation_API.md` | зафиксировать отправленную Solana-ротацию PDA и ждать подтверждения sync | +| `KeyRotationContinue` | `19_Key_Rotation_API.md` | продолжить интерактивный post-PDA этап; сейчас wallet/DM отмечаются как `NOT_IMPLEMENTED` и пропускаются | +| `KeyRotationAbort` | `19_Key_Rotation_API.md` | прервать ротацию до отправки Solana PDA-ротации | +| `GetMyBlockchain` | `20_Get_My_Blockchain_API.md` | постранично читать текущую активную собственную цепочку для аудита и выбора fork point | | `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки | | `Ping` | `05_Technical_Requests_API.md` | keep-alive | | `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере | diff --git a/docs/API/19_Key_Rotation_API.md b/docs/API/19_Key_Rotation_API.md new file mode 100644 index 00000000..763d2ced --- /dev/null +++ b/docs/API/19_Key_Rotation_API.md @@ -0,0 +1,320 @@ +# Key Rotation API + +Этот раздел описывает публичные JSON/WebSocket операции мастера смены ключей пользователя. + +Публично доступны: + +- `KeyRotationStart` +- `KeyRotationStatus` +- `KeyRotationAddBlock` +- `KeyRotationFinishChain` +- `KeyRotationRotatePda` +- `KeyRotationContinue` +- `KeyRotationAbort` + +После `PDA_ROTATED` отдельный фоновый worker автоматически переводит ротацию в `REBUILDING_SERVER`, делает новую candidate-chain единственной рабочей цепочкой PostgreSQL и затем переводит процесс в `WALLET_MIGRATION`. + +## Общие правила + +- операции доступны только авторизованному LOCAL-пользователю своего access server; +- login берётся из авторизованной сессии и не передаётся в payload; +- приватные ключи и пароли серверу не передаются никогда; +- в БД сохраняются только старые/новые публичные ключи; +- первая серверная запись появляется сразу в состоянии `COPYING_CHAIN`; состояния `PREPARING` в БД нет; +- во время `KeyRotationStart` сервер захватывает тот же per-blockchain lock, что использует обычный `AddBlock`, и фиксирует непротиворечивый source tip. + +## `KeyRotationStart` + +Создаёт новую серверную сессию ротации. + +### Request + +```json +{ + "op": "KeyRotationStart", + "requestId": "kr-1", + "payload": { + "newRootKey": "", + "newBlockchainKey": "", + "newClientKey": "", + "forkFromBlock": 120, + "forkFromHash": "<64 hex>", + "reasonCode": 1, + "comment": "Плановая смена пароля" + } +} +``` + +`reasonCode`: + +1. обычная ротация; +2. возможная компрометация; +3. подтверждённая компрометация / rollback; +4. recovery. + +Сервер самостоятельно берёт из текущего PDA: + +- `oldRootKey`; +- `oldBlockchainKey`; +- `oldClientKey`; +- текущий `sourceBlockchainName`. + +Также сервер самостоятельно фиксирует текущий tip цепочки. Клиент не может подменить эти значения в запросе. + +`forkFromBlock/forkFromHash` обязаны указывать на реально существующий блок текущей активной цепочки. + +Новые root/blockchain/client keys должны быть валидными 32-байтовыми публичными ключами, отличаться от соответствующих старых ключей и друг от друга. + +После успеха создаётся `key_rotation_sessions` со статусом `COPYING_CHAIN`. `progressTotal` равен количеству будущих candidate-блоков: копия `0..forkFromBlock` плюс `TECH_FORK`. + +### Success response + +Ответ использует тот же payload состояния, что и `KeyRotationStatus`. + +## `KeyRotationStatus` + +Возвращает текущее состояние ротации авторизованного пользователя. + +### Request + +```json +{ + "op": "KeyRotationStatus", + "requestId": "kr-status-1", + "payload": {} +} +``` + +Если активной ротации нет: + +```json +{ + "op": "KeyRotationStatus", + "requestId": "kr-status-1", + "status": 200, + "ok": true, + "payload": { + "rotationStatus": "NONE" + } +} +``` + +Если ротация есть, payload содержит: + +- `rotationSessionId`; +- `rotationStatus`; +- `sourceBlockchainName` / `candidateBlockchainName`; +- старые и новые публичные root/blockchain/client keys; +- `forkFromBlock` / `forkFromHash`; +- `sourceTipBlock` / `sourceTipHash`; +- `reasonCode` / `comment`; +- `progressCurrent` / `progressTotal`; +- `pdaRotationSignature` (когда появится на последующем этапе); +- `walletMigrationStatus`; +- `messageMigrationStatus`; +- `lastError` / `retryCount`; +- `createdAtMs` / `updatedAtMs`. + +Любая авторизованная сессия пользователя может читать этот статус. Состояние ротации принадлежит аккаунту, а не конкретному WebSocket-сеансу. + + +## `KeyRotationAddBlock` + +Принимает один ANS-104 DataItem будущего fork на этапе `COPYING_CHAIN`. Candidate-блоки хранятся отдельно от обычной таблицы `blocks`, поэтому до переключения fork они **не создают лайки, ответы, каналы, связи и другие materialized effects**. + +### Request + +```json +{ + "op": "KeyRotationAddBlock", + "requestId": "kr-block-1", + "payload": { + "blockNumber": 0, + "prevBlockHash": "", + "blockBytesB64": "<полный ANS-104 DataItem в Base64>" + } +} +``` + +Правила: + +- операция доступна только при `rotationStatus=COPYING_CHAIN`; +- блоки принимаются строго последовательно: `0..forkFromBlock`, затем один `TECH_FORK` с номером `forkFromBlock+1`; +- каждый DataItem обязан быть подписан `newBlockchainKey`; +- для копируемых блоков Frame, ANS-104 tags, target и anchor должны полностью совпадать с исходным блоком; меняются только owner/signature/DataItem id; +- последний блок обязан быть `TECH_FORK`, а его parent key, fork point, старый tip, reason/comment и число отброшенных блоков должны совпадать с `KeyRotationStart`; +- повтор уже принятого идентичного блока безопасен и возвращает success; другой DataItem/hash на том же номере возвращает conflict; +- candidate-блоки попадают в отдельную очередь Arweave/Turbo publisher-а и имеют приоритет перед обычными блоками; +- `progressCurrent` увеличивается **только после фактической успешной публикации** DataItem в Arweave/Turbo. Поэтому `KeyRotationStatus` показывает реальный прогресс публикации, а не только приём сервером. + +### Основные ошибки + +- `KEY_ROTATION_NOT_COPYING` — ротация не находится в `COPYING_CHAIN`; +- `KEY_ROTATION_BLOCK_OUT_OF_ORDER` — пропущен предыдущий candidate-блок; +- `KEY_ROTATION_BLOCK_CONFLICT` — на этом номере уже сохранён другой candidate-блок; +- `KEY_ROTATION_BAD_SIGNATURE` — DataItem подписан не новым blockchain key; +- `KEY_ROTATION_FRAME_MISMATCH` — копируемый Frame отличается от исходной цепочки; +- `KEY_ROTATION_TAGS_MISMATCH` — изменены ANS-104 tags копируемого блока; +- `KEY_ROTATION_TECH_FORK_REQUIRED` — вместо финального `TECH_FORK` передан другой блок; +- ошибки `KEY_ROTATION_TECH_FORK_*` — поля `TECH_FORK` не соответствуют зафиксированной rotation session. + +## Основные ошибки `KeyRotationStart` + +- `AUTH_REQUIRED` — нет авторизованной сессии; +- `KEY_ROTATION_ALREADY_ACTIVE` — для login уже выполняется ротация; +- `KEY_ROTATION_BAD_FIELDS` — некорректные public keys/hash; +- `KEY_ROTATION_BAD_NEW_KEYS` — ключи не изменились либо новые ключи совпадают друг с другом; +- `KEY_ROTATION_BAD_REASON` — reason вне диапазона `1..4`; +- `KEY_ROTATION_COMMENT_TOO_LONG` — комментарий больше 1024 UTF-8 байт; +- `KEY_ROTATION_BAD_FORK_POINT` — неверная точка fork; +- `KEY_ROTATION_FORK_BLOCK_NOT_FOUND` — выбранный блок отсутствует; +- `KEY_ROTATION_FORK_HASH_MISMATCH` — переданный hash не совпадает с сервером; +- `KEY_ROTATION_SESSION_STALE` — авторизованная сессия содержит уже неактуальный blockchainName. + + +## `KeyRotationFinishChain` + +Финализирует этап построения candidate-chain. Операция **не меняет PDA** и не переключает активную цепочку пользователя: она только доказывает, что будущий fork уже полностью сохранён и опубликован в Arweave/Turbo. + +### Request + +```json +{ + "op": "KeyRotationFinishChain", + "requestId": "kr-finish-chain-1", + "payload": {} +} +``` + +Перед переходом в `CHAIN_READY` сервер повторно проверяет: + +- количество candidate-блоков равно `progressTotal` (`0..forkFromBlock` + один `TECH_FORK`); +- каждый candidate-блок уже подтверждён Arweave/Turbo publisher-ом; +- номера идут строго `0,1,2,...` без дырок; +- `blockHash` и `DataItem id` совпадают с сохранёнными значениями; +- каждый DataItem подписан `newBlockchainKey`; +- `block 0` имеет нулевой `prevHash`, а каждый следующий блок ссылается на hash предыдущего Frame; +- копируемые блоки всё ещё байт-в-байт совпадают с выбранным префиксом исходной цепочки по Frame, tags, target и anchor; +- последний блок является корректным `TECH_FORK` и повторяет зафиксированные fork point, parent tip, reason/comment и число отброшенных блоков. + +Только после успешной финальной проверки выполняется: + +`COPYING_CHAIN -> CHAIN_READY`. + +Повторный `KeyRotationFinishChain`, когда ротация уже находится в `CHAIN_READY`, идемпотентно возвращает success. Это позволяет безопасно вызывать операцию из нескольких сессий или повторить её после потери ответа. + +### Основные ошибки + +- `KEY_ROTATION_NOT_ACTIVE` — активная ротация отсутствует; +- `KEY_ROTATION_NOT_COPYING` — текущий этап уже не позволяет завершать candidate-chain; +- `KEY_ROTATION_CHAIN_INCOMPLETE` — сервер получил не все candidate-блоки; +- `KEY_ROTATION_CHAIN_NOT_PUBLISHED` — не все DataItem подтверждены Arweave/Turbo publisher-ом; +- `KEY_ROTATION_CANDIDATE_GAP` / `KEY_ROTATION_CHAIN_HASH_MISMATCH` — нарушена последовательность candidate-chain; +- `KEY_ROTATION_BAD_SIGNATURE` — сохранённый candidate не подтверждается новым blockchain key; +- `KEY_ROTATION_TECH_FORK_*` — финальный `TECH_FORK` больше не соответствует rotation session; +- `KEY_ROTATION_FINISH_CHAIN_RACE` — состояние ротации было одновременно изменено другой операцией. + + +## `KeyRotationRotatePda` + +Фиксирует, что клиент уже локально подписал и отправил в Solana транзакцию полной ротации PDA. + +Сервер **не получает приватные ключи и не подписывает транзакцию**. Клиент передаёт только публичную Solana transaction signature. + +### Request + +```json +{ + "op": "KeyRotationRotatePda", + "requestId": "kr-rotate-pda-1", + "payload": { + "pdaRotationSignature": "" + } +} +``` + +Операция разрешена только после `CHAIN_READY`. Сервер атомарно сохраняет signature и переводит: + +`CHAIN_READY -> ROTATING_PDA`. + +После этого отмена ротации уже запрещена: отправленная Solana-транзакция может подтвердиться позже, даже если клиент потерял соединение. + +Подтверждением успешной ротации служит **не сам факт наличия signature**, а фактический current PDA, увиденный Solana sync. Sync требует одновременного совпадения: + +- `rootKey == newRootKey`; +- `blockchainKey == newBlockchainKey`; +- `clientKey == newClientKey`; +- `blockchainName == candidateBlockchainName`. + +Только после этого сервер атомарно переводит: + +`ROTATING_PDA -> PDA_ROTATED`. + +Если Solana sync успел увидеть новое PDA раньше вызова `KeyRotationRotatePda`, он умеет подтвердить ожидаемые ключи прямо из `CHAIN_READY`. Поэтому потеря клиентского запроса после уже подтверждённой Solana-транзакции не оставляет ротацию зависшей. Если API-вызов всё же приходит, handler идемпотентно возвращает текущее подтверждённое состояние. + +Повтор с той же signature идемпотентен. Другая signature для уже начатого `ROTATING_PDA` возвращает conflict. + +### Основные ошибки + +- `KEY_ROTATION_NOT_ACTIVE` — активной ротации нет; +- `KEY_ROTATION_NOT_CHAIN_READY` — candidate-chain ещё не подтверждена; +- `KEY_ROTATION_BAD_SOLANA_SIGNATURE` — signature отсутствует или не Base58; +- `KEY_ROTATION_PDA_SIGNATURE_CONFLICT` — для этой ротации уже сохранена другая signature; +- `KEY_ROTATION_ROTATE_PDA_FAILED` — внутренняя ошибка фиксации этапа. + + +## Автоматический rebuild после `PDA_ROTATED` + +После подтверждения нового current PDA сервер сам выполняет: + +`PDA_ROTATED -> REBUILDING_SERVER -> WALLET_MIGRATION`. + +Во время rebuild: + +- проверяется, что current PDA действительно указывает на `candidateBlockchainName/newBlockchainKey`; +- старая активная цепочка удаляется из рабочих PostgreSQL-таблиц; +- candidate-блоки повторно проходят обычный `AddBlock` validation/projection path; +- runtime-cache `to_bch_name` у логических ссылок `login + blockNumber + blockHash` перепривязывается к новому fork; +- входящие `likes_count/replies_count` пересчитываются; +- после `COMPLETE` временные строки candidate-chain удаляются из PostgreSQL. + +Если rebuild прерывается, `REBUILDING_SERVER` остаётся активным, ошибка записывается в `lastError/retryCount`, а worker безопасно повторяет rebuild. + +## `KeyRotationContinue` + +Продолжает интерактивные post-PDA этапы. В текущей версии два будущих этапа являются честными заглушками: + +- `WALLET_MIGRATION`: `walletMigrationStatus = NOT_IMPLEMENTED`, затем переход в `MESSAGE_MIGRATION`; +- `MESSAGE_MIGRATION`: `messageMigrationStatus = NOT_IMPLEMENTED`, затем `FINALIZING -> COMPLETE`. + +Request: + +```json +{ + "op": "KeyRotationContinue", + "requestId": "kr-continue-1", + "payload": {} +} +``` + +`KeyRotationContinue` не переводит деньги и не перешифровывает DM. Это специально оставленные точки расширения для будущей реализации. + +## `KeyRotationAbort` + +Прерывает ротацию только пока Solana-ротация PDA ещё не могла быть отправлена: + +- разрешено из `COPYING_CHAIN`; +- разрешено из `CHAIN_READY`; +- начиная с `ROTATING_PDA` отмена запрещена, процесс можно только довести вперёд. + +Request: + +```json +{ + "op": "KeyRotationAbort", + "requestId": "kr-abort-1", + "payload": {} +} +``` + +При `ABORTED` временные candidate-строки удаляются из PostgreSQL. Уже опубликованные Arweave DataItem остаются неизменяемым сиротским историческим следом и не становятся активной цепочкой, поскольку PDA не переключён. diff --git a/docs/API/20_Get_My_Blockchain_API.md b/docs/API/20_Get_My_Blockchain_API.md new file mode 100644 index 00000000..f27e587c --- /dev/null +++ b/docs/API/20_Get_My_Blockchain_API.md @@ -0,0 +1,46 @@ +# GetMyBlockchain API + +`GetMyBlockchain` — авторизованное постраничное чтение **только текущей активной версии собственного блокчейна**. API используется постоянным экраном «Мой блокчейн» и мастером смены ключей для выбора последнего доверенного блока. + +Login и активный `blockchainName` сервер определяет из авторизованной сессии/current PDA; клиент не может запросить этим методом чужую цепочку. + +## Request + +```json +{ + "op": "GetMyBlockchain", + "requestId": "my-bch-1", + "payload": { + "beforeBlock": 500, + "limit": 50, + "includeBlockBytes": false + } +} +``` + +Поля: + +- `beforeBlock` — необязательный номер верхней границы страницы; если отсутствует, чтение начинается с current tip; +- `limit` — `1..100`, default `50`; +- `includeBlockBytes` — при `true` дополнительно вернуть полный ANS-104 DataItem в Base64. + +## Response + +Payload содержит: + +- `login`; +- `blockchainName`; +- `tipBlockNumber` / `tipBlockHash`; +- `nextBeforeBlock` для следующей страницы; +- `blocks[]` в порядке от новых к старым. + +Каждый элемент `blocks[]` содержит: + +- `blockNumber`; +- `blockHash` / `prevBlockHash`; +- `timestampMs`; +- `msgType` / `msgSubType` / `msgVersion`; +- для target-блоков: `toLogin + toBlockNumber + toBlockHash`; +- `blockBytesB64`, только если запрошен `includeBlockBytes=true`. + +После fork API показывает только новую активную ветку PostgreSQL. Исторические fork при необходимости восстанавливаются из Arweave/PDA history отдельным будущим viewer-механизмом. diff --git a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md index d4336ffa..ca271ac5 100644 --- a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md +++ b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md @@ -15,7 +15,7 @@ ## Быстрая карта типов -- `type=0` — TECH: HEADER, CREATE_CHANNEL. +- `type=0` — TECH: HEADER, CREATE_CHANNEL, FORK. - `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE. - `type=2` — REACTION: LIKE/UNLIKE. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. diff --git a/docs/Blockchain/10_TECH_Blocks.md b/docs/Blockchain/10_TECH_Blocks.md index 0290844a..0bd28578 100644 --- a/docs/Blockchain/10_TECH_Blocks.md +++ b/docs/Blockchain/10_TECH_Blocks.md @@ -12,7 +12,43 @@ TECH-тип покрывает системные записи цепочки. - создание нового канала; - хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`. +3. `subType=2` — `TECH_FORK` + - первый новый блок после точной перепубликации выбранного префикса предыдущего fork новым blockchain key; + - связывает новую активную цепочку с предыдущей и фиксирует точку rollback/продолжения. + +### `TECH_FORK` body (`version=1`) + +Big-endian: + +- `parentBlockchainKey[32]` — public key предыдущего fork; +- `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork; +- `forkPointBlockHash32[32]`; +- `forkPointTimestampMs[8]`; +- `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации; +- `parentTipBlockHash32[32]`; +- `parentTipTimestampMs[8]`; +- `discardedBlocksCount[4]` — `parentTipBlockNumber - forkPointBlockNumber`; +- `reasonCode[1]`; +- `commentUtf8Length[2]`; +- `comment[N]` — произвольный комментарий пользователя, максимум 1024 UTF-8 байт. + +`reasonCode`: + +- `1` — `ROUTINE_ROTATION`: обычная смена пароля/ключей, компрометация не предполагается; +- `2` — `POSSIBLE_COMPROMISE`: возможная компрометация, неизвестные записи не подтверждены; +- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`: обнаружены нежелательные/чужие записи и выполнен rollback; +- `4` — `RECOVERY`: восстановление доступа recovery-механизмом. + +Правила: + +- блоки `0..forkPointBlockNumber` в новом fork должны быть точными Frame-копиями выбранного префикса предыдущей цепочки; +- `TECH_FORK` идёт сразу после этого префикса и является первым действительно новым Frame нового fork; +- если история сохранена полностью, `forkPointBlockNumber == parentTipBlockNumber` и `discardedBlocksCount == 0`; +- если сохраняется только genesis, новый fork содержит прежний `block 0`, а `block 1` является `TECH_FORK`; +- новый blockchain key в body не дублируется: он определяется owner/signature нового ANS-104 DataItem. + ## Назначение - инициализация блокчейна; -- управление набором каналов пользователя. +- управление набором каналов пользователя; +- фиксация происхождения нового fork и причины ротации/rollback. diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index 226934be..7abe319d 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -14,7 +14,7 @@ TEXT-тип хранит сообщения, материалы и редакт 3. `subType=20` — `TEXT_REPLY` - ответ на сообщение; - - target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст. + - target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст. 4. `subType=21` — `TEXT_EDIT_REPLY` - редактирование ответа; @@ -23,7 +23,7 @@ TEXT-тип хранит сообщения, материалы и редакт 5. `subType=30` — `TEXT_RATING` - target-based отзыв на конкретный блок; - - содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва; + - содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва; - не является сообщением линии канала. 6. `subType=50` — `TEXT_REPOST` @@ -57,6 +57,20 @@ TEXT-тип хранит сообщения, материалы и редакт Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). + +## Общий target-формат TEXT + +Для `TEXT_EDIT_POST`, `TEXT_REPLY`, `TEXT_EDIT_REPLY`, `TEXT_RATING` и `TEXT_REPOST` ссылка на цель хранится как: + +```text +[1] toLoginLen (uint8) +[N] toLogin UTF-8 +[4] toBlockGlobalNumber +[32] toBlockHash32 +``` + +`blockchainName`/номер fork в подписываемые байты target не входит. Одинаковые `login + blockNumber + blockHash` считаются одной логической целью после перепубликации сохранённого префикса при fork. + ## Правило для edit `EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit. diff --git a/docs/Blockchain/12_REACTION_Blocks.md b/docs/Blockchain/12_REACTION_Blocks.md index 58f8ac25..f119a331 100644 --- a/docs/Blockchain/12_REACTION_Blocks.md +++ b/docs/Blockchain/12_REACTION_Blocks.md @@ -4,11 +4,25 @@ 1. `subType=1` — `REACTION_LIKE` - лайк на целевой блок; - - хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`. + - хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`. 2. `subType=2` — `REACTION_UNLIKE` - снятие лайка с целевого блока; - - хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`. + - хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`. ## Назначение - реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой). + + +## Формат target + +В подписанных байтах target больше не хранит имя fork/blockchain. Формат: + +```text +[1] toLoginLen (uint8) +[N] toLogin UTF-8 +[4] toBlockGlobalNumber +[32] toBlockHash32 +``` + +Логическая идентичность цели: `toLogin + toBlockGlobalNumber + toBlockHash32`. Поэтому ссылка остаётся той же после fork, если номер и hash исходного блока сохранены. diff --git a/docs/Blockchain/13_CONNECTION_Blocks.md b/docs/Blockchain/13_CONNECTION_Blocks.md index b2991b57..6d8571d8 100644 --- a/docs/Blockchain/13_CONNECTION_Blocks.md +++ b/docs/Blockchain/13_CONNECTION_Blocks.md @@ -18,7 +18,18 @@ CONNECTION-тип описывает социальные связи и подп ## Общий формат payload - line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`) -- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) +- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + +## Бинарный target + +```text +[1] toLoginLen (uint8) +[N] toLogin UTF-8 +[4] toBlockGlobalNumber +[32] toBlockHash32 +``` + +Имя fork/blockchain в target не хранится. ## Правила target diff --git a/docs/Blockchain/15_STATUS_ACTION_Blocks.md b/docs/Blockchain/15_STATUS_ACTION_Blocks.md index 1a330569..36762162 100644 --- a/docs/Blockchain/15_STATUS_ACTION_Blocks.md +++ b/docs/Blockchain/15_STATUS_ACTION_Blocks.md @@ -36,8 +36,8 @@ Все `STATUS_ACTION` используют один и тот же бинарный body-формат: ```text -[1] toBlockchainNameLen (uint8) -[N] toBlockchainName UTF-8 +[1] toLoginLen (uint8) +[N] toLogin UTF-8 [4] toBlockGlobalNumber [32] toBlockHash32 [2] textLenBytes (uint16) @@ -46,7 +46,7 @@ Где: -- `toBlockchainName` — блокчейн, в котором находится целевой материал; +- `toLogin` — login владельца целевого блока; номер fork в target не хранится; - `toBlockGlobalNumber` — номер целевого блока; - `toBlockHash32` — хэш целевого блока; - `text` — опциональное пояснение пользователя к статусу. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index bc247cb5..f82b392a 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2026-09-27 — TECH_FORK v1 + +- Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`). +- `TECH_FORK` фиксирует parent blockchain key, точку сохранённой истории, старый tip, число отброшенных блоков, reason code и пользовательский комментарий. +- Новый blockchain key не дублируется в body: он определяется подписью нового ANS-104 DataItem. +- Зафиксированы четыре причины: обычная ротация, возможная компрометация, подтверждённая компрометация с rollback и recovery. + # История изменений документации блокчейна ## 2026-09-23 — Turbo transport для individual ANS-104 DataItems diff --git a/docs/Keys/DERIVATION.md b/docs/Keys/DERIVATION.md index 468994ce..364a707a 100644 --- a/docs/Keys/DERIVATION.md +++ b/docs/Keys/DERIVATION.md @@ -2,10 +2,10 @@ > **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.** > Этот файл описывает, как из пароля получается секрет и как из секрета выводятся -> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт. +> все ключи (root, blockchain, client, homeserver) — формулами, байт-в-байт. > Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула > ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь -> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**. +> blockchain key ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**. > Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура). > Здесь — только механика. Документ намеренно краткий. @@ -55,29 +55,29 @@ seed(32) = SHA-256(material) | Ключ | Суффикс | Назначение (кратко) | |------|---------|---------------------| | root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). | -| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). | -| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. | +| blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. | +| client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. | | homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. | Полные роли каждого ключа — в `docs/Keys/README.md`. --- -## 3. Solana-ключ +## 3. Solana-кошелёк и authority -Отдельного «солана-ключа» нет. На Solana работают два ключа: +Отдельного «solana.key» нет. В актуальной модели Solana-wallet пользователя — **активный `blockchain.key`**: -- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`. - Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`. - Пополнять SOL нужно именно на этот адрес. -- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer. +- адрес кошелька = `base58(activeBlockchainPub)`; +- им оплачивается регистрация (`create_user_pda`) и обычные `update_user_pda`; +- обычное обновление PDA авторизуется активным blockchain key; +- новый fork подписывает новое PDA новым blockchain key, а старый активный blockchain key разрешает обычный переход; +- полная смена пароля/ключей — особый recovery-переход: старый `root.key` дополнительно разрешает одно новое unsigned PDA state, а **новый blockchain key** подписывает тот же hash и становится новым wallet/authority. -Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1 -(«create/update оплачиваются с `client_key`», «root_key — не fee payer»). +`root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork. -Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью -(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый -кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`. +`client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька. + +При fork Solana-адрес меняется вместе с blockchain key. Перенос SOL со старого blockchain-wallet на новый является отдельным необязательным этапом ротации; в текущей первой реализации этот этап оставлен явной заглушкой. --- @@ -117,9 +117,9 @@ homeserver.key:home-b -> ключ B - `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160). ### Solana-ключ / адрес кошелька (UI) -- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113). -- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`. - Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён. +- `shine-UI/js/pages/registration-payment-view.js` — адрес пополнения = `base58(blockchainPub)`. +- `shine-UI/js/pages/topup-view.js` — тот же адрес из `preGeneratedKeyBundle.blockchainPair`. +- `shine-UI/js/services/solana-wallet-service.js` — текущий пользовательский Solana-wallet загружается из сохранённого `blockchainKey`. ### Деривация ключей (прошивка ESP32) - `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` @@ -147,7 +147,7 @@ homeserver.key:home-b -> ключ B 1. Этот документ — источник истины по деривации секрета и ключей. 2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа, - разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес — + разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь blockchain key ↔ Solana-адрес — **обязательно** отражать здесь в том же изменении. 3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма. 4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости. diff --git a/docs/Keys/README.md b/docs/Keys/README.md index 14fcc1bd..61cdd14e 100644 --- a/docs/Keys/README.md +++ b/docs/Keys/README.md @@ -8,9 +8,9 @@ В SHiNE у пользователя есть несколько уровней ключей: -- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`). -- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. -- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. +- `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк. +- `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя. +- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является. - `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы. @@ -21,16 +21,13 @@ Назначение: -- регистрация пользователя в Solana; -- создание и обновление пользовательской PDA-записи; -- вызов критически важных Solana-функций; -- изменение главных настроек пользователя; -- управление остальными ключами; -- подтверждение операций, которые должны иметь максимальный уровень доверия. +- холодное recovery-разрешение для полной смены ключей; +- подтверждение атомарной ротации `root + client + новый blockchain fork`; +- восстановительные сценарии повышенного уровня доверия. -`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя. +Обычные PDA-update **не требуют root key**: их выполняет активный blockchain key. При полной ротации старый root подписывает тот же hash нового unsigned PDA state, который подписывает новый blockchain key. Так root разрешает переход, не становясь повседневным ключом. -Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `docs/Keys/DERIVATION.md`, §3. +Важно не путать recovery-authority и кошелёк: `root key` не является fee payer. Текущий Solana-wallet/fee payer — активный `blockchain key`. Подробнее — `docs/Keys/DERIVATION.md`, §3. ## `blockchain key` @@ -40,7 +37,8 @@ - подпись записей в персональном блокчейне пользователя; - подтверждение действий, которые должны попасть в SHiNE-блокчейн; -- разделение полномочий между главным Solana-ключом и ключом ежедневной записи. +- обычные обновления пользовательской PDA; +- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`). У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи: @@ -59,7 +57,6 @@ - повседневные входящие и исходящие личные сообщения; - звонки и связанные с ними сообщения; - self-messages, то есть внутренние сообщения пользователя самому себе; -- мелкие Solana-расходы на текущие операции; - derivation Arweave-кошелька; - оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу. @@ -158,7 +155,7 @@ Self-message - это сообщение пользователя самому ## Связанные документы -- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). +- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`blockchain.key`/`client.key`/`homeserver.key:<имя>`, Solana-wallet, ссылки на код). - `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений. - `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений. - `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. diff --git a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md index 0bcad4c4..fccd7b3c 100644 --- a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md +++ b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md @@ -584,3 +584,28 @@ SYNC_POLL_INTERVAL_SECONDS=300 - server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес. Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update. + +--- + +## Server-local key rotation state (PostgreSQL v26) + +Начиная с migration v26 сервер хранит локальное состояние длительной смены ключей отдельно от данных Solana PDA. + +В `solana_user_pda_current` добавлены локальные поля: + +- `rotation_status` — быстрый текущий статус (`NONE` в обычном режиме); +- `rotation_session_id` — ссылка на текущую запись `key_rotation_sessions`. + +Эти поля **не являются частью PDA**, не приходят из Solana и не должны перезаписываться обычным Solana sync upsert-ом. + +Подробный прогресс хранится в `key_rotation_sessions`: только публичные old/new root/blockchain/client keys, выбранная точка fork, старый tip, reason/comment, прогресс, ошибка/retry и статусы необязательных wallet/DM этапов. Пароли и приватные ключи в PostgreSQL не сохраняются. + +Первая серверная запись создаётся сразу в `COPYING_CHAIN`; состояния `PREPARING` в БД нет. Завершённые `COMPLETE`/`ABORTED` sessions остаются как журнал, а `solana_user_pda_current.rotation_status` возвращается в `NONE`. + +### Candidate blocks ротации (PostgreSQL v27) + +Начиная с migration v27 будущая ветка во время `COPYING_CHAIN` хранится в отдельной таблице `key_rotation_candidate_blocks`. Она не является частью текущего materialized blockchain state и не должна попадать в обычную `blocks` до финального переключения fork. + +Для каждого candidate DataItem сохраняются rotation session, login, candidate blockchain name, block number/hash, полный ANS-104 DataItem, DataItem id и статус публикации. Уникальность `(rotation_session_id, block_number)` запрещает две разные версии одного candidate-блока. + +Arweave/Turbo publisher обрабатывает candidate-блоки приоритетно. `key_rotation_sessions.progress_current` отражает число DataItem, уже реально опубликованных publisher-ом, а не число принятых API-сервером. diff --git a/docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md index d3b92704..dd453bbb 100644 --- a/docs/Solana_Architecture/details/shine_users.md +++ b/docs/Solana_Architecture/details/shine_users.md @@ -16,7 +16,7 @@ Основные данные: - `RootKeyBlock` — cold recovery authority; -- `ClientKeyBlock` — клиентский/кошелёчный ключ; +- `ClientKeyBlock` — клиентский ключ для DM/устройств; Solana-wallet/fee payer — активный blockchain key; - `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен; - необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера; - необязательный `AccessServersBlock` — в 1.2 максимум один access server. diff --git a/docs/Инициализация_Solana_регистрации/README.md b/docs/Инициализация_Solana_регистрации/README.md index 81eba6f7..955ff90d 100644 --- a/docs/Инициализация_Solana_регистрации/README.md +++ b/docs/Инициализация_Solana_регистрации/README.md @@ -90,14 +90,14 @@ ## Кто оплачивает create/update user_pda -- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`. -- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера. -- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию. -- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`. +- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с активного `blockchainKey`. +- В UI это означает, что Solana fee payer всегда берётся из текущего активного `blockchain key` пользователя или сервера. +- `rootKey` не оплачивает транзакцию; он дополнительно авторизует только полную ротацию ключей. +- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес текущего `blockchainKey`. ## Важно - `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`. -- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `clientKey`, а содержимое записи подписывает `rootKey`. +- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `blockchainKey`. Обычное состояние PDA подписывает blockchain key; root дополнительно участвует только в полной ротации ключей. - `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. - При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index d723590d..64b9c590 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -16,6 +16,7 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js' import { initPwaPush } from './services/pwa-push-service.js'; import { initCallUiOverlay } from './services/call-ui-service.js'; import { showToast } from './services/channels-ux.js'; +import { KeyRotationClient } from './services/key-rotation-service.js'; import { handleCallPushAction, handleIncomingCallInvite, @@ -73,6 +74,8 @@ import * as profileEditView from './pages/profile-edit-view.js'; import * as profilesView from './pages/profiles-view.js'; import * as walletView from './pages/wallet-view.js?v=202609260900'; import * as settingsView from './pages/settings-view.js'; +import * as myBlockchainView from './pages/my-blockchain-view.js'; +import * as keyRotationView from './pages/key-rotation-view.js'; import * as accessServersView from './pages/access-servers-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as advancedSettingsView from './pages/advanced-settings-view.js'; @@ -142,6 +145,8 @@ const routes = { 'profiles-view': profilesView, 'wallet-view': walletView, 'settings-view': settingsView, + 'my-blockchain-view': myBlockchainView, + 'key-rotation-view': keyRotationView, 'access-servers-view': accessServersView, 'developer-settings-view': developerSettingsView, 'advanced-settings-view': advancedSettingsView, @@ -1376,6 +1381,21 @@ function attachPageScrollToBottom(pageId, screen) { }); } +async function redirectToActiveKeyRotationIfNeeded() { + if (!state.session.isAuthorized || state.session.isLocalDemo) return false; + try { + const rotation = await new KeyRotationClient(authService).status(); + const status = String(rotation?.rotationStatus || 'NONE'); + if (status !== 'NONE' && getRoute().pageId !== 'key-rotation-view') { + navigate('key-rotation-view'); + return true; + } + } catch (error) { + console.warn('[key-rotation] status check failed', error); + } + return false; +} + function renderApp() { syncTrackedRouteHistory(window.location.pathname || '/'); const route = getRoute(); @@ -1566,6 +1586,7 @@ async function init() { setSessionAuthorizedHandler(() => { void ensureSessionRuntimeStarted(); void processPendingCallPushActionIfPossible(); + void redirectToActiveKeyRotationIfNeeded(); }); if ('serviceWorker' in navigator) { diff --git a/shine-UI/js/components/arweave-attachment-manager.js b/shine-UI/js/components/arweave-attachment-manager.js index 245efade..2edcc799 100644 --- a/shine-UI/js/components/arweave-attachment-manager.js +++ b/shine-UI/js/components/arweave-attachment-manager.js @@ -290,7 +290,7 @@ export function openArweaveAttachmentManager({ mode = 'attachment', historyPurpose = '', uploadTransport = 'turbo', - turboKeySource = 'client', + turboKeySource = 'blockchain', dialogTitle = '', uploadButtonLabel = '', initialFile = null, @@ -406,20 +406,9 @@ export function openArweaveAttachmentManager({ turboKeyChoices = await getStoredSolanaWalletChoices({ login: cleanLogin, storagePwd: cleanStoragePwd, - includeRoot: true, - }); - turboKeyChoices.sort((a, b) => { - if (a?.keySource === b?.keySource) return 0; - if (a?.keySource === 'client') return -1; - if (b?.keySource === 'client') return 1; - return 0; }); if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) { - selectedTurboKeySource = String( - turboKeyChoices.find((item) => item.keySource === 'client')?.keySource - || turboKeyChoices[0]?.keySource - || 'client' - ); + selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain'); } writeLastTurboKeySource(cleanLogin, selectedTurboKeySource); } catch (error) { diff --git a/shine-UI/js/pages/access-servers-view.js b/shine-UI/js/pages/access-servers-view.js index 9cd99d62..5e2f577c 100644 --- a/shine-UI/js/pages/access-servers-view.js +++ b/shine-UI/js/pages/access-servers-view.js @@ -177,14 +177,14 @@ function createPasswordModal() {
-

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

+

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

@@ -247,7 +247,7 @@ function createPasswordModal() { const mode = root.querySelector('input[name="access-servers-key-mode"]:checked'); close({ password, - saveRoot: mode instanceof HTMLInputElement && mode.value === 'save', + saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save', }); }); window.setTimeout(() => inputEl.focus(), 0); @@ -482,48 +482,49 @@ export function render({navigate, chrome}) { saved = null; } - const savedRoot = String(saved?.rootKey || '').trim(); + const savedBlockchain = String(saved?.blockchainKey || '').trim(); const savedClient = String(saved?.clientKey || '').trim(); - if (savedRoot && savedClient) { + if (savedBlockchain && savedClient) { return { - rootPrivatePkcs8B64: savedRoot, + blockchainPrivatePkcs8B64: savedBlockchain, clientPrivatePkcs8B64: savedClient, - clientAddress: await clientAddressFromPrivatePkcs8(savedClient), + payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain), }; } const passwordResult = await passwordModal?.open({ title: 'Нужен пароль для обновления серверов доступа', - text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.', + text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.', note: savedClient - ? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.' - : 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.', + ? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.' + : 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.', }); if (!passwordResult) { throw new Error('Операция отменена пользователем.'); } const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password); - const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64); + const derivedBlockchainPublic = base64ToBytes(keyBundle.blockchainPair.publicKeyB64); const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64); - if (!equalBytes(derivedRootPublic, currentPda.rootKey)) { - throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.'); + const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey; + if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) { + throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.'); } if (!equalBytes(derivedClientPublic, currentPda.clientKey)) { throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.'); } - if (passwordResult.saveRoot) { + if (passwordResult.saveBlockchain) { await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, { - saveRoot: true, - saveBlockchain: false, + saveRoot: false, + saveBlockchain: true, }); } return { - rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, + blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, - clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64), + payerAddress: clientAddressFromPublicB64(keyBundle.blockchainPair.publicKeyB64), }; }; @@ -550,7 +551,7 @@ export function render({navigate, chrome}) { const tx = await updateShineUserPdaOnSolana({ login: sessionLogin, solanaEndpoint, - rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64, + blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64, clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64, accessServers: normalizedList, }); @@ -562,7 +563,7 @@ export function render({navigate, chrome}) { refreshAddButton(); } catch (error) { if (isInsufficientFundsForRentError(error)) { - showTopupRequiredStatus(target, signingMaterial?.clientAddress); + showTopupRequiredStatus(target, signingMaterial?.payerAddress); } else { target.textContent = error?.message || 'Не удалось обновить сервер доступа.'; } diff --git a/shine-UI/js/pages/channel-about-view.js b/shine-UI/js/pages/channel-about-view.js index bb2d2a53..579e8c26 100644 --- a/shine-UI/js/pages/channel-about-view.js +++ b/shine-UI/js/pages/channel-about-view.js @@ -14,7 +14,7 @@ import { formatSol, getBalanceSol, getSolanaWalletFromStoredSecret, - getWalletFromStoredClientKey, + getWalletFromStoredBlockchainKey, getWalletFromStoredRootKey, solanaAddressFromPublicKeyBase64, transferSol, @@ -138,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) { if (!root) return; const recipientChoices = [ - publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'), publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'), - publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'), ].filter(Boolean); root.innerHTML = ` @@ -166,10 +164,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
—
- +
Blockchain key
—
@@ -247,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) { }; const resolveSenderWallet = async () => { - const keyId = String(senderSelect?.value || 'client-key'); + const keyId = 'blockchain-key'; if (walletCache.has(keyId)) return walletCache.get(keyId); const login = String(state.session.login || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim(); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.'); - - let wallet; - if (keyId === 'root-key') { - try { - wallet = await getWalletFromStoredRootKey({ login, storagePwd }); - } catch { - const password = window.prompt( - 'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:', - '', - ); - if (password == null) throw new Error('Операция отменена.'); - const keyBundle = await authService.derivePasswordKeyBundle(login, password); - const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim(); - if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.'); - wallet = await getSolanaWalletFromStoredSecret(rootPrivate); - } - } else { - wallet = await getWalletFromStoredClientKey({ login, storagePwd }); - } + const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd }); walletCache.set(keyId, wallet); return wallet; diff --git a/shine-UI/js/pages/channel-donate-view.js b/shine-UI/js/pages/channel-donate-view.js index 867b8a50..f5570fb1 100644 --- a/shine-UI/js/pages/channel-donate-view.js +++ b/shine-UI/js/pages/channel-donate-view.js @@ -6,7 +6,7 @@ import { formatSol, getBalanceSol, getSolanaWalletFromStoredSecret, - getWalletFromStoredClientKey, + getWalletFromStoredBlockchainKey, getWalletFromStoredRootKey, solanaAddressFromPublicKeyBase64, transferSol, @@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) { const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); const channelName = String(channel?.channelName || selector?.channelName || '').trim(); const recipientChoices = [ - publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'), publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'), - publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'), ].filter(Boolean); content.innerHTML = ` @@ -169,10 +167,7 @@ export function render({ navigate, route, chrome }) {
- +
Blockchain key
—
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) { }; const resolveSenderWallet = async () => { - const keyId = String(senderSelect?.value || 'client-key'); + const keyId = 'blockchain-key'; if (walletCache.has(keyId)) return walletCache.get(keyId); const login = String(state.session.login || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim(); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.'); - - let wallet; - if (keyId === 'root-key') { - try { - wallet = await getWalletFromStoredRootKey({ login, storagePwd }); - } catch { - const password = window.prompt( - 'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:', - '', - ); - if (password == null) throw new Error('Операция отменена.'); - const keyBundle = await authService.derivePasswordKeyBundle(login, password); - const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim(); - if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.'); - wallet = await getSolanaWalletFromStoredSecret(rootPrivate); - } - } else { - wallet = await getWalletFromStoredClientKey({ login, storagePwd }); - } + const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd }); walletCache.set(keyId, wallet); return wallet; diff --git a/shine-UI/js/pages/key-rotation-view.js b/shine-UI/js/pages/key-rotation-view.js new file mode 100644 index 00000000..3ad9a615 --- /dev/null +++ b/shine-UI/js/pages/key-rotation-view.js @@ -0,0 +1,67 @@ +import { createTopBar } from '../components/topbar.js'; +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'; + +export const pageMeta = { id:'key-rotation-view', title:'Смена ключей', hideToolbar:true }; + +const POLL_MS=1800; +function h(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} +function short(v){const s=String(v||'');return s.length>20?`${s.slice(0,10)}…${s.slice(-8)}`:s;} +function stageText(s){return ({COPYING_CHAIN:'Копирование новой цепочки',CHAIN_READY:'Новая цепочка готова',ROTATING_PDA:'Ожидание Solana',PDA_ROTATED:'PDA обновлена',REBUILDING_SERVER:'Перестройка сервера',WALLET_MIGRATION:'Перенос средств',MESSAGE_MIGRATION:'Перешифрование сообщений',FINALIZING:'Завершение',COMPLETE:'Готово',NONE:'Новая ротация'})[s]||s;} +function input(label,type='password'){const wrap=document.createElement('label');wrap.className='stack';const t=document.createElement('span');t.className='field-label';t.textContent=label;const el=document.createElement('input');el.className='text-input';el.type=type;wrap.append(t,el);return {wrap,el};} + +export function render({navigate,chrome}){ + const screen=document.createElement('section');screen.className='stack'; + chrome?.setTopbar(createTopBar({title:'Смена ключей',back:{label:'←',onClick:()=>navigate('my-blockchain-view')}})); + const root=document.createElement('div');root.className='stack';screen.append(root); + const client=new KeyRotationClient(authService);let disposed=false;let timer=null;let cachedOldBundle=null;let cachedNewBundle=null; + const login=String(state.session.login||'').trim(); const storagePwd=String(state.session.storagePwdInMemory||'').trim(); + const solanaEndpoint=String(state.entrySettings.solanaServer||'').trim(); + + async function derive(password){return authService.derivePasswordKeyBundle(login,password,{onProgress:()=>{}});} + async function verifyOldPassword(bundle){ + const pda=await readShineUserPda({login,solanaEndpoint}); + if(bundle.rootPair.publicKeyB64!==bytesToBase64(pda.rootKey) || bundle.blockchainPair.publicKeyB64!==bytesToBase64(pda.blockchain.blockchainPublicKey) || bundle.clientPair.publicKeyB64!==bytesToBase64(pda.clientKey)) throw new Error('Текущий пароль не соответствует ключам PDA'); + } + function setRoot(...nodes){root.replaceChildren(...nodes);} + function statusCard(s){const card=document.createElement('div');card.className='card stack';card.innerHTML=`${h(stageText(s.rotationStatus))}${h(s.sourceBlockchainName||'')} → ${h(s.candidateBlockchainName||'')}Прогресс: ${Number(s.progressCurrent||0)} / ${Number(s.progressTotal||0)}${s.lastError?`${h(s.lastError)}`:''}`;return card;} + function note(text){const c=document.createElement('div');c.className='card';c.textContent=text;return c;} + + async function renderNone(){ + const head=note('Выберите последний блок, которому вы доверяете. Всё до него будет точно перепубликовано новым blockchain key, после чего добавится TECH_FORK.'); + const oldP=input('Текущий пароль'); const newP=input('Новый пароль'); const new2=input('Повторите новый пароль'); + const reason=document.createElement('select');reason.className='text-input';for(const r of KEY_ROTATION_REASONS){const o=document.createElement('option');o.value=String(r.code);o.textContent=r.label;reason.append(o);} + const reasonWrap=document.createElement('label');reasonWrap.className='stack';reasonWrap.innerHTML='Причина';reasonWrap.append(reason); + const comment=document.createElement('textarea');comment.className='text-input';comment.rows=3;comment.maxLength=1024;comment.placeholder='Необязательный комментарий для истории'; + const list=document.createElement('div');list.className='stack';const more=document.createElement('button');more.className='secondary-btn';more.type='button';more.textContent='Показать более ранние блоки'; + let selected=null,before=null,loading=false; + async function load(){if(loading)return;loading=true;more.disabled=true;try{const d=await client.getMyBlockchain({beforeBlock:before,limit:100});for(const b of d.blocks||[]){const btn=document.createElement('button');btn.type='button';btn.className='nav-row';btn.innerHTML=`#${b.blockNumber} · ${new Date(Number(b.timestampMs)||0).toLocaleString('ru-RU')}${short(b.blockHash)}`;btn.addEventListener('click',()=>{selected=b;list.querySelectorAll('button').forEach(x=>x.setAttribute('aria-pressed','false'));btn.setAttribute('aria-pressed','true');});if(selected==null && b.blockNumber===d.tipBlockNumber){selected=b;btn.setAttribute('aria-pressed','true');}list.append(btn);}before=d.nextBeforeBlock;more.hidden=before==null;}finally{loading=false;more.disabled=false;}} + more.addEventListener('click',()=>void load()); + const start=document.createElement('button');start.className='primary-btn';start.type='button';start.textContent='Начать смену ключей'; + const error=document.createElement('div');error.className='meta-muted'; + start.addEventListener('click',async()=>{start.disabled=true;error.textContent='';try{if(!selected)throw new Error('Выберите последний доверенный блок');if(!oldP.el.value||!newP.el.value)throw new Error('Введите текущий и новый пароль');if(newP.el.value!==new2.el.value)throw new Error('Новые пароли не совпадают');if(oldP.el.value===newP.el.value)throw new Error('Новый пароль должен отличаться');cachedOldBundle=await derive(oldP.el.value);await verifyOldPassword(cachedOldBundle);cachedNewBundle=await derive(newP.el.value);const s=await client.start({newRootKey:cachedNewBundle.rootPair.publicKeyB64,newBlockchainKey:cachedNewBundle.blockchainPair.publicKeyB64,newClientKey:cachedNewBundle.clientPair.publicKeyB64,forkFromBlock:selected.blockNumber,forkFromHash:selected.blockHash,reasonCode:Number(reason.value),comment:comment.value});await runCopy(s); }catch(e){error.textContent=e?.message||String(e);start.disabled=false;}}); + setRoot(head,oldP.wrap,newP.wrap,new2.wrap,reasonWrap,comment,list,more,start,error);void load(); + } + + async function askNewPasswordAndCopy(s){ + const card=statusCard(s), p=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить копирование';const err=document.createElement('div');err.className='meta-muted';go.addEventListener('click',async()=>{go.disabled=true;try{cachedNewBundle=await derive(p.el.value);if(cachedNewBundle.blockchainPair.publicKeyB64!==s.newBlockchainKey)throw new Error('Этот пароль выводит другой новый blockchain key');await runCopy(s);}catch(e){err.textContent=e?.message||e;go.disabled=false;}});const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(card,note('Для продолжения на этом устройстве введите тот же новый пароль, который использовался при запуске.'),p.wrap,go,abort,err); + } + + async function runCopy(s){ + const card=statusCard(s), info=note('Создаётся новая копия выбранной части цепочки в Arweave/Turbo. Обычные записи аккаунта в это время заблокированы.');setRoot(card,info); + try{await client.copyCandidateChain({rotation:s,newBundle:cachedNewBundle,onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Прогресс: ${current} / ${total}`;}});await client.waitUntilPublished({onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Опубликовано: ${current} / ${total}`;}});await client.finishChain();await refresh();}catch(e){root.append(note(e?.message||String(e)));const retry=document.createElement('button');retry.className='primary-btn';retry.textContent='Повторить / продолжить';retry.addEventListener('click',()=>void refresh());root.append(retry);} + } + + 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); + } + + 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 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}`));}} + void refresh();screen.cleanup=()=>{disposed=true;if(timer)clearTimeout(timer);};return screen; +} diff --git a/shine-UI/js/pages/my-blockchain-view.js b/shine-UI/js/pages/my-blockchain-view.js new file mode 100644 index 00000000..e260f227 --- /dev/null +++ b/shine-UI/js/pages/my-blockchain-view.js @@ -0,0 +1,49 @@ +import { createTopBar } from '../components/topbar.js'; +import { authService } from '../state.js'; +import { KeyRotationClient } from '../services/key-rotation-service.js'; + +export const pageMeta = { id: 'my-blockchain-view', title: 'Мой блокчейн' }; + +const TYPE_LABELS = new Map([ + ['0:0','Начало блокчейна'], ['0:2','Смена ключей / fork'], + ['1:1','Публикация'], ['1:2','Ответ'], ['1:3','Редактирование'], + ['2:1','Реакция'], ['3:1','Связь'], ['4:1','Статус'], +]); +function shortHash(v){const s=String(v||'');return s.length>18?`${s.slice(0,9)}…${s.slice(-7)}`:s;} +function eventLabel(item){return TYPE_LABELS.get(`${item.msgType}:${item.msgSubType}`)||`Событие ${item.msgType}/${item.msgSubType}`;} +function formatDate(ms){try{return new Date(Number(ms)||0).toLocaleString('ru-RU');}catch{return '';}} + +export function render({ navigate, chrome }) { + const screen=document.createElement('section'); screen.className='stack'; + chrome?.setTopbar(createTopBar({ title:'Мой блокчейн', back:{label:'←',onClick:()=>navigate('settings-view')} })); + const intro=document.createElement('div'); intro.className='card stack'; + intro.innerHTML=`История ваших действийЭто текущая активная версия вашего блокчейна. Хэши записей сохраняют логическую идентичность при fork.`; + const rotate=document.createElement('button'); rotate.type='button'; rotate.className='primary-btn'; rotate.textContent='Сменить пароль / ключи'; rotate.addEventListener('click',()=>navigate('key-rotation-view')); + intro.append(rotate); screen.append(intro); + + const list=document.createElement('div'); list.className='stack'; screen.append(list); + const more=document.createElement('button'); more.type='button'; more.className='secondary-btn'; more.textContent='Показать более ранние записи'; more.hidden=true; screen.append(more); + const client=new KeyRotationClient(authService); let before=null; let loading=false; let disposed=false; + + async function load(reset=false){ + if(loading||disposed)return; loading=true; more.disabled=true; + if(reset){list.innerHTML='';before=null;} + try{ + const data=await client.getMyBlockchain({beforeBlock:before,limit:50,includeBlockBytes:false}); + if(reset){const head=document.createElement('div');head.className='card stack';head.innerHTML=`${data.blockchainName||'—'}Последний блок: #${Number(data.tipBlockNumber??-1)} · ${shortHash(data.tipBlockHash)}`;list.append(head);} + for(const item of data.blocks||[]){ + const card=document.createElement('div');card.className='card stack'; + const target=item.toLogin?`Цель: ${escapeHtml(item.toLogin)} #${item.toBlockNumber??'—'} · ${shortHash(item.toBlockHash)}`:''; + card.innerHTML=`
#${item.blockNumber} · ${escapeHtml(eventLabel(item))}
${formatDate(item.timestampMs)} · hash ${shortHash(item.blockHash)}${target}`; + list.append(card); + } + before=data.nextBeforeBlock; + more.hidden=before==null; + if(!(data.blocks||[]).length && reset){const empty=document.createElement('div');empty.className='card';empty.textContent='Записей пока нет.';list.append(empty);} + }catch(e){const err=document.createElement('div');err.className='card';err.textContent=`Не удалось загрузить блокчейн: ${e?.message||e}`;list.append(err);} + finally{loading=false;more.disabled=false;} + } + more.addEventListener('click',()=>void load(false)); void load(true); + screen.cleanup=()=>{disposed=true;}; return screen; +} +function escapeHtml(value){return String(value??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} diff --git a/shine-UI/js/pages/registration-draft-keys-view.js b/shine-UI/js/pages/registration-draft-keys-view.js index ddc97a0c..af840fc5 100644 --- a/shine-UI/js/pages/registration-draft-keys-view.js +++ b/shine-UI/js/pages/registration-draft-keys-view.js @@ -126,7 +126,7 @@ export function render({ navigate }) { // Blockchain key const bchSep = document.createElement('p'); bchSep.className = 'field-label'; - bchSep.textContent = 'Blockchain key'; + bchSep.textContent = 'Blockchain key (= Solana wallet)'; card.append(bchSep); card.append(makePublicField({ label: 'Blockchain — публичный (base58)', @@ -140,7 +140,7 @@ export function render({ navigate }) { // Client key const devSep = document.createElement('p'); devSep.className = 'field-label'; - devSep.textContent = 'Client key (= Solana wallet)'; + devSep.textContent = 'Client key'; card.append(devSep); card.append(makePublicField({ label: 'Client — публичный (base58)', diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index 7d8673e6..28fe289c 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -234,7 +234,7 @@ export function render({ navigate }) { const deriveUserWalletAddress = async () => { const keyBundle = state.registrationDraft.preGeneratedKeyBundle; if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.'); - const { publicKeyB64 } = keyBundle.clientPair; + const { publicKeyB64 } = keyBundle.blockchainPair; const raw = atob(publicKeyB64); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); @@ -387,7 +387,7 @@ export function render({ navigate }) { await refreshBalance({ addressOverride: walletAddress }); } catch (error) { status.className = 'status-line is-unavailable'; - status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`; + status.textContent = `Не удалось подготовить blockchain key: ${error?.message || 'unknown'}`; status.style.display = ''; } })(); diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 8b10f964..6942ee74 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -73,6 +73,7 @@ export function render({navigate, chrome}) {