feat(subgroups): schema 21 -- a parentage is a row, so a room can have two
Phase 2 of docs/subgroups-multiple-parents.md, the storage. Still dark: a lineage of two now verifies and is filed, and nothing in the app creates one until the adoption flow lands. `GroupKeyState` carried `parentChatRoomId` and `birthCertificateJson` because a group could only be founded with a parent, never adopted by a second. Those are `GroupKeyStateParent` now, one row per parent, and nothing about what they mean changed in the move -- including that the parent side is never a foreign key onto `ChatRoom`, for the three reasons that entity's comment now carries. **The primary key is the rule.** `(chatRoomId, parentChatRoomId)` is "one certificate per child per parent", which `SubgroupLineage.of` already refuses to break; the key is what makes it a property of the store rather than of a reader remembering. The cascade points at `GroupKeyState`, which is the one cascade here that is wanted: a lineage must not outlive the state that claimed it, or a room recreated at the same derived id would inherit a parentage nobody signed for it. **`replace` rewrites the lineage rather than merging it, and that is why it is a transaction.** A key state is replaceable and the newest wins *wholesale*, so the set of parents it names is the set. That is what makes a disavowal expressible at all -- a room drops a parent by re-announcing without it -- and merged rows would leave the dropped parent surviving the statement that dropped it. Deleting before inserting inside one transaction also stops a reader seeing a state beside somebody else's parents: were the writes separate, a concurrent read could land between them and get a state with the old lineage, which looks exactly like a true answer. An older state still changes nothing, now including its parents. **`stateFrom` returns `LocalGroupKeyState`, because a state and its lineage are one signed statement.** `lineageOf` checks every parent and drops the whole state if any one certificate fails -- the all-or-nothing rule generalised, for the reason it was written: keeping the provable half would file a group as having one parent when its own members see two. Each link is checked on its own, which is what lets a lineage be assembled a parent at a time, and there is a comment where somebody will look for the check that is deliberately absent: nothing requires the certificates to agree on the set. An earlier certificate cannot name a later co-parent, so requiring agreement would make every adoption cost a fresh quorum in every group that had already certified the room. The child's key state is the sole authority. **`ChatRoom.parentChatRoomId` is dropped, unread.** It was a cache of the verified value, written by `MarmotGroupCreation` and read in production by nothing at all -- the live answer went through `GroupKeyState`. Keeping it would mean a column holding *one* of several parents, which is worse than unused: a cache that is wrong. Its one query, `ChatRoomDao.observeByParentChatRoomId`, had only tests for callers and goes with it; the comment left in its place says where the two live answers are. `create`'s `parentChatRoomId` parameter threaded through four files to reach the write that is now gone, so it goes too rather than sitting dead. `MIGRATION_20_21` creates, copies, then drops -- the shape MIGRATION_9_10 established, and for the same reason it gives: `DROP TABLE` fires cascades and would delete the rows the migration had just written, so `ALTER TABLE ... DROP COLUMN` is used instead. `BundledSQLiteDriver` is pinned on every platform, so 3.35 is ours rather than the host's. The `WHERE` takes both halves of a parentage: a half row should not exist, since `stateFrom` has always dropped such a state rather than filing it, but this is the one moment the data is reshaped rather than re-derived and a half row copied forward would become a parent whose certificate is the string "null". Better to leave it behind -- the state keeps saying what the room signs with, and loses a lineage it could never prove. `parentOf` is `parentsOf` and returns a list, through the repository to `ChatRoomDetailUIState`; the parent card becomes one card per parent, keyed, because they are a set rather than a path -- neither is "the" parent and the order says nothing about which came first. Thirteen tests in `SubgroupDaoJvmTest`, rewritten for the table: the two cascades in both directions, a child with two parents appearing in both their lists, a re-announcement that drops a parent and one that clears the lineage entirely, an older state changing neither the state nor its parents, and the primary key collapsing a repeat to one row. In `GroupKeyStateTest`, Phase 1's "two parents are dropped" becomes "two parents are kept, each with its own certificate" -- it needed a third real group, since the earlier draft's second parent was the child certifying itself -- beside a new case where one bad certificate of two drops the whole state. 444 common tests, 937 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0690932252
commit
eba973331b
24 files changed
+6556
-409
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -74,6 +74,7 @@ import press.mantra.compose.database.model.GiftWrapMessage
|
||||
import press.mantra.compose.database.model.GiftWrapPayload
|
||||
import press.mantra.compose.database.model.GiftWrapSeal
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.GroupKeyStateParent
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.InReplyToRelation
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
@@ -139,6 +140,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
GiftWrapSeal::class,
|
||||
GiftWrapPayload::class,
|
||||
GroupKeyState::class,
|
||||
GroupKeyStateParent::class,
|
||||
GroupSignedEvent::class,
|
||||
InReplyToRelation::class,
|
||||
MantraArtifact::class,
|
||||
@@ -177,7 +179,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 20,
|
||||
version = 21,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -321,6 +323,15 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// which is what they were, and any identity may drain them. The broadcast
|
||||
// queue deliberately gets no owner: a signed event is anyone's to carry.
|
||||
AutoMigration(from = 19, to = 20),
|
||||
// v21 moves a room's parentage off two GroupKeyState columns onto the new
|
||||
// GroupKeyStateParent table, so a room can be the child of more than one
|
||||
// group -- founded with one and adopted by another later. It copies
|
||||
// before it drops, which no AutoMigration can express, and it drops
|
||||
// ChatRoom.parentChatRoomId as well: that column was a cache of the
|
||||
// verified value which nothing in production ever read, and keeping it
|
||||
// would mean a column holding *one* of several parents, which is worse
|
||||
// than unused. See MIGRATION_20_21 and
|
||||
// docs/subgroups-multiple-parents.md.
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
+13
-4
@@ -3,6 +3,7 @@ package press.mantra.compose.database.builder
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import press.mantra.compose.database.migrations.MIGRATION_3_4
|
||||
import press.mantra.compose.database.migrations.MIGRATION_20_21
|
||||
import press.mantra.compose.database.migrations.MIGRATION_9_10
|
||||
import press.mantra.compose.database.migrations.MIGRATION_13_14
|
||||
import press.mantra.compose.database.migrations.MIGRATION_14_15
|
||||
@@ -19,13 +20,21 @@ fun getRoomDatabase(
|
||||
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
|
||||
): press.mantra.compose.database.MantraDatabase {
|
||||
return builder
|
||||
// Everything else Room generates itself. These four move data rather than
|
||||
// Everything else Room generates itself. These five move data rather than
|
||||
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
|
||||
// chat rows, 9->10 copies a session's per-event columns onto the items
|
||||
// table before dropping them, 13->14 renames a column and rewrites the
|
||||
// chat rows that named it the old way, and 14->15 adds a column and deletes
|
||||
// the chat rows written for messages from before this device joined.
|
||||
.addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15)
|
||||
// chat rows that named it the old way, 14->15 adds a column and deletes
|
||||
// the chat rows written for messages from before this device joined, and
|
||||
// 20->21 copies each room's single parentage onto the table that can hold
|
||||
// several before dropping the columns it came from.
|
||||
.addMigrations(
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_9_10,
|
||||
MIGRATION_13_14,
|
||||
MIGRATION_14_15,
|
||||
MIGRATION_20_21
|
||||
)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.IO)
|
||||
.build()
|
||||
|
||||
@@ -66,25 +66,14 @@ interface ChatRoomDao {
|
||||
)
|
||||
fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow<List<LocalChatRoom>>
|
||||
|
||||
/**
|
||||
* The subgroups of [parentChatRoomId] this device actually holds rooms for.
|
||||
*
|
||||
* Deliberately not the answer to "what subgroups does this group have" --
|
||||
* that is `SubgroupManager.subgroupsOf`, which reads the parent's signed
|
||||
* certificates and so can name a child whose room has not been created, or
|
||||
* was created for somebody else. This names the ones there is something to
|
||||
* open.
|
||||
*
|
||||
* Soft-deleted rooms are excluded like everywhere else: a room the user has
|
||||
* cleared away should not reappear because its parent lists it.
|
||||
*/
|
||||
@Transaction
|
||||
@Query(
|
||||
CHAT_ROOM_WITH_LAST_MESSAGE +
|
||||
"WHERE ChatRoom.parentChatRoomId = :parentChatRoomId AND ChatRoom.deletedAt IS NULL " +
|
||||
ORDER_BY_LAST_ACTIVITY
|
||||
)
|
||||
fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<LocalChatRoom>>
|
||||
// "The subgroups of this room" used to be a column filter here, over a
|
||||
// ChatRoom.parentChatRoomId that production never read -- the live answer
|
||||
// went through GroupKeyState, and SubgroupManager.subgroupsOf reads the
|
||||
// parent's own certificates, which can name a child whose room this device
|
||||
// does not have. The column is gone at v21, because a room can have more
|
||||
// than one parent and a single column would have held one of them. The
|
||||
// rooms a parent's children occupy are reached through
|
||||
// GroupKeyStateDao.getByParentChatRoomId and resolved from there.
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(chatRoom: ChatRoom)
|
||||
|
||||
+83
-13
@@ -6,6 +6,8 @@ import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.GroupKeyStateParent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
|
||||
|
||||
@Dao
|
||||
abstract class GroupKeyStateDao {
|
||||
@@ -15,6 +17,18 @@ abstract class GroupKeyStateDao {
|
||||
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
|
||||
abstract fun observeByChatRoomId(chatRoomId: String): Flow<GroupKeyState?>
|
||||
|
||||
/**
|
||||
* A state with the groups that have certified it, which is how a lineage
|
||||
* should always be read -- the two are one signed statement.
|
||||
*/
|
||||
@Transaction
|
||||
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
|
||||
abstract suspend fun getLocalByChatRoomId(chatRoomId: String): LocalGroupKeyState?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
|
||||
abstract fun observeLocalByChatRoomId(chatRoomId: String): Flow<LocalGroupKeyState?>
|
||||
|
||||
/** Every room that signs with one ceremony's key. One, today. */
|
||||
@Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId")
|
||||
abstract suspend fun getByDkgSessionId(dkgSessionId: String): List<GroupKeyState>
|
||||
@@ -23,26 +37,52 @@ abstract class GroupKeyStateDao {
|
||||
* Every subgroup of [parentChatRoomId] this device holds a verified state
|
||||
* for.
|
||||
*
|
||||
* Verified is the whole of what these rows are: a state only reaches the
|
||||
* table with a parent on it once its birth certificate has been checked
|
||||
* against that parent's signature, so this is a list of children the parent
|
||||
* really did certify rather than a list of rooms claiming a parent.
|
||||
* Verified is the whole of what these rows are: a parentage only reaches the
|
||||
* table once its birth certificate has been checked against that parent's
|
||||
* signature, so this is a list of children the parent really did certify
|
||||
* rather than a list of rooms claiming a parent.
|
||||
*
|
||||
* It is not the whole list. A subgroup whose room this device does not have
|
||||
* leaves no state here at all, and the certificates in `GroupSignedEvent` are
|
||||
* the fuller answer -- see `SubgroupManager.subgroupsOf`.
|
||||
*
|
||||
* A child with two parents appears in both parents' answers, which is the
|
||||
* point of the join: the row that makes it this parent's child says nothing
|
||||
* about who else certified it.
|
||||
*/
|
||||
@Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
|
||||
abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List<GroupKeyState>
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT GroupKeyState.* FROM GroupKeyState " +
|
||||
"JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " +
|
||||
"WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " +
|
||||
"ORDER BY GroupKeyState.announcedAt DESC"
|
||||
)
|
||||
abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List<LocalGroupKeyState>
|
||||
|
||||
@Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
|
||||
abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<GroupKeyState>>
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT GroupKeyState.* FROM GroupKeyState " +
|
||||
"JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " +
|
||||
"WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " +
|
||||
"ORDER BY GroupKeyState.announcedAt DESC"
|
||||
)
|
||||
abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow<List<LocalGroupKeyState>>
|
||||
|
||||
/** The parents of one room, as rows. Prefer [getLocalByChatRoomId]. */
|
||||
@Query("SELECT * FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId")
|
||||
abstract suspend fun getParentsByChatRoomId(chatRoomId: String): List<GroupKeyStateParent>
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(groupKeyState: GroupKeyState)
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsertParents(parents: List<GroupKeyStateParent>)
|
||||
|
||||
@Query("DELETE FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId")
|
||||
abstract suspend fun deleteParentsByChatRoomId(chatRoomId: String)
|
||||
|
||||
/**
|
||||
* Files a state, keeping the newest one per room.
|
||||
* Files a state and its whole lineage, keeping the newest one per room.
|
||||
*
|
||||
* This is where the event's replaceable semantics actually happen. Relays
|
||||
* never see a `GroupKeyStateEvent` -- it lives inside a Marmot group and is
|
||||
@@ -56,13 +96,36 @@ abstract class GroupKeyStateDao {
|
||||
* state agree by construction, since both derived it from the room they are
|
||||
* standing in.
|
||||
*
|
||||
* Returns the state now on file.
|
||||
* ### The lineage is replaced, not merged, and that is the whole reason this
|
||||
* is a transaction
|
||||
*
|
||||
* A key state is replaceable and the newest wins **wholesale**, so the set of
|
||||
* parents it names is the set -- not an addition to whatever was there. That
|
||||
* is what makes a disavowal expressible at all: a room drops a parent by
|
||||
* re-announcing without it, and if these rows were merged rather than
|
||||
* rewritten the dropped parent would survive the statement that dropped it.
|
||||
*
|
||||
* Deleting before inserting inside one transaction is also what stops a
|
||||
* reader seeing a state beside somebody else's parents. Were the two writes
|
||||
* separate, a concurrent `getLocalByChatRoomId` could land between them and
|
||||
* read a state with no lineage, or with the old one -- both of which look
|
||||
* exactly like a true answer.
|
||||
*
|
||||
* Returns the state now on file, with the lineage now on file beside it.
|
||||
*/
|
||||
@Transaction
|
||||
open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState {
|
||||
open suspend fun replace(
|
||||
groupKeyState: GroupKeyState,
|
||||
parents: List<GroupKeyStateParent> = emptyList(),
|
||||
): LocalGroupKeyState {
|
||||
val known = getByChatRoomId(groupKeyState.chatRoomId)
|
||||
|
||||
if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known
|
||||
if (known != null && known.announcedAt >= groupKeyState.announcedAt) {
|
||||
return LocalGroupKeyState(
|
||||
keyState = known,
|
||||
parents = getParentsByChatRoomId(known.chatRoomId)
|
||||
)
|
||||
}
|
||||
|
||||
val stamped = groupKeyState.copy(
|
||||
createdAt = known?.createdAt ?: groupKeyState.createdAt,
|
||||
@@ -70,6 +133,13 @@ abstract class GroupKeyStateDao {
|
||||
)
|
||||
upsert(stamped)
|
||||
|
||||
return stamped
|
||||
// The state's own row has to exist before its parents', because they
|
||||
// reference it. Room turns foreign keys off around a migration but not
|
||||
// around a transaction, so the order here is load-bearing rather than
|
||||
// tidy.
|
||||
deleteParentsByChatRoomId(stamped.chatRoomId)
|
||||
if (parents.isNotEmpty()) upsertParents(parents)
|
||||
|
||||
return LocalGroupKeyState(keyState = stamped, parents = parents)
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package press.mantra.compose.database.migrations
|
||||
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
|
||||
/**
|
||||
* Moves a room's parentage off two `GroupKeyState` columns onto
|
||||
* `GroupKeyStateParent`, so a room can be the child of more than one group.
|
||||
*
|
||||
* Not an `AutoMigration`. Room can create the table and it can drop the columns,
|
||||
* but it cannot copy between them, and this migration is entirely about the copy.
|
||||
*
|
||||
* ### Every existing lineage is a set of one
|
||||
*
|
||||
* A group could only be founded with a parent, never adopted by a second, so
|
||||
* every row that carries a parentage carries exactly one. Each becomes one row
|
||||
* in the new table and nothing is lost -- and the rule the new primary key
|
||||
* states, one certificate per child per parent, is already true of all of them.
|
||||
*
|
||||
* Rows with no parentage -- every group made before subgroups, and every
|
||||
* top-level group made after -- contribute nothing, which is what "nobody's
|
||||
* child" looks like in the new shape: no rows rather than a null.
|
||||
*
|
||||
* The `WHERE` takes both halves. A row with one and not the other should not
|
||||
* exist, because `GroupKeyStateManager.stateFrom` has always dropped a state
|
||||
* carrying a half-claim rather than filing it -- but this is the one moment the
|
||||
* data is reshaped rather than re-derived, and a half row copied forward would
|
||||
* become a parent whose certificate is the string "null". Better to leave such a
|
||||
* row behind entirely: the state keeps saying what the room signs with, which is
|
||||
* the part that matters, and loses a lineage it could never have proved.
|
||||
*
|
||||
* ### `ChatRoom.parentChatRoomId` goes with them, unread
|
||||
*
|
||||
* It was a cache of the verified value, written by `MarmotGroupCreation` and
|
||||
* read -- in production -- by nothing at all; the live answer went through
|
||||
* `GroupKeyState`. Keeping it would mean a column holding *one* of several
|
||||
* parents, which is worse than unused: it is a cache that is wrong. So it is
|
||||
* dropped rather than backfilled, and nothing is copied out of it.
|
||||
*
|
||||
* ### Why `DROP COLUMN` rather than a table rebuild
|
||||
*
|
||||
* The usual way to remove columns in SQLite -- create a new table, copy, drop
|
||||
* the old one, rename -- is unsafe here and quietly so. `GroupKeyStateParent`
|
||||
* references `GroupKeyState(chatRoomId)` `ON DELETE CASCADE`, and `DROP TABLE`
|
||||
* fires cascades: with foreign keys enforced it would delete every parent row
|
||||
* this migration had just written. Dropping `ChatRoom` would be far worse, since
|
||||
* most of the schema cascades off it.
|
||||
*
|
||||
* `ALTER TABLE ... DROP COLUMN` avoids the question. It needs SQLite 3.35 and
|
||||
* the columns must be free of indices and constraints -- all three are, and
|
||||
* `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version
|
||||
* is ours rather than the host's. Both tables keep their keys, their foreign keys
|
||||
* and their indices.
|
||||
*/
|
||||
val MIGRATION_20_21 = object : Migration(20, 21) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `GroupKeyStateParent` (" +
|
||||
"`chatRoomId` TEXT NOT NULL, " +
|
||||
"`parentChatRoomId` TEXT NOT NULL, " +
|
||||
"`birthCertificateJson` TEXT NOT NULL, " +
|
||||
"PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), " +
|
||||
"FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` " +
|
||||
"ON `GroupKeyStateParent` (`parentChatRoomId`)"
|
||||
)
|
||||
|
||||
connection.execSQL(
|
||||
"INSERT OR IGNORE INTO `GroupKeyStateParent` " +
|
||||
"(`chatRoomId`, `parentChatRoomId`, `birthCertificateJson`) " +
|
||||
"SELECT `chatRoomId`, `parentChatRoomId`, `birthCertificateJson` " +
|
||||
"FROM `GroupKeyState` " +
|
||||
"WHERE `parentChatRoomId` IS NOT NULL AND `birthCertificateJson` IS NOT NULL"
|
||||
)
|
||||
|
||||
listOf("parentChatRoomId", "birthCertificateJson").forEach { column ->
|
||||
connection.execSQL("ALTER TABLE `GroupKeyState` DROP COLUMN `$column`")
|
||||
}
|
||||
|
||||
connection.execSQL("ALTER TABLE `ChatRoom` DROP COLUMN `parentChatRoomId`")
|
||||
}
|
||||
}
|
||||
@@ -112,29 +112,6 @@ data class ChatRoom(
|
||||
*/
|
||||
val chronicleRequestedAt: Instant? = null,
|
||||
|
||||
/**
|
||||
* The group this room is a subgroup of, or null for a room that is nobody's
|
||||
* child.
|
||||
*
|
||||
* A copy of the verified `GroupKeyState.parentChatRoomId`, kept here so the
|
||||
* room list and the room's own screen can answer "whose child is this"
|
||||
* without a second read. It is written as the room is created or adopts its
|
||||
* key state, and only from a state whose certificate passed
|
||||
* `SubgroupBirthCertificateEvent.certifies`.
|
||||
*
|
||||
* **Never a foreign key onto `ChatRoom`.** Every other reference to a room in
|
||||
* this schema cascades, and a self-referential cascade would mean deleting a
|
||||
* parent silently deletes its subgroups -- and then, by their own cascades,
|
||||
* those rooms' messages, participants, key states, signing sessions and
|
||||
* signed events. A user tidying up a group they left would destroy a group
|
||||
* they are still in. `RESTRICT` would be no better: it would make a parent
|
||||
* undeletable while a child row exists, which is a foreign key deciding a
|
||||
* product question. This routinely names a room this device does not have --
|
||||
* see `GroupKeyState.parentChatRoomId` -- so a dangling value is the normal,
|
||||
* expected state.
|
||||
*/
|
||||
val parentChatRoomId: HexKey? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
|
||||
+8
-33
@@ -94,41 +94,16 @@ data class GroupKeyState(
|
||||
val announcedAt: Instant,
|
||||
|
||||
/**
|
||||
* The group this one is a subgroup of, or null for a group that is nobody's
|
||||
* child -- which is every group made before subgroups existed.
|
||||
* A lineage used to live here, as a parent id and a certificate. It is
|
||||
* `GroupKeyStateParent` now, one row per parent, because a room can have
|
||||
* more than one -- see `docs/subgroups-multiple-parents.md`. Nothing about
|
||||
* what it means changed in the move, including that the parent side is never
|
||||
* a foreign key onto `ChatRoom`; that entity's comment carries the argument.
|
||||
*
|
||||
* **Not a foreign key, and that is deliberate.** The three tables around this
|
||||
* one cascade off `ChatRoom`, and a self-referential cascade here would mean
|
||||
* deleting a parent room deletes every subgroup's state beneath it. It could
|
||||
* not be `RESTRICT` either: this routinely names a room the device does not
|
||||
* have at all -- a member of a subgroup who was never in its parent holds the
|
||||
* id and nothing else -- so a dangling value is the normal state and readers
|
||||
* resolve it with a lookup allowed to return null.
|
||||
*
|
||||
* Written only from a state whose [birthCertificateJson] passed
|
||||
* `SubgroupBirthCertificateEvent.certifies`, so a row carrying this is a row
|
||||
* where the parent's quorum really did sign for this room. See
|
||||
* `GroupKeyStateManager.stateFrom`, which drops the whole state rather than
|
||||
* keeping a parentage it could not check.
|
||||
* Read the two together through `LocalGroupKeyState`, never apart: a state
|
||||
* and the lineage it claims are one signed statement, and
|
||||
* `GroupKeyStateManager.stateFrom` believes all of it or drops all of it.
|
||||
*/
|
||||
val parentChatRoomId: HexKey? = null,
|
||||
|
||||
/**
|
||||
* The parent's signed certificate, whole, as JSON -- or null when this group
|
||||
* has no parent.
|
||||
*
|
||||
* The claim itself, where [parentChatRoomId] is only an index into it. Kept
|
||||
* as the event rather than as its signature so that a reader can check it
|
||||
* again from the row alone: a signature plus a rule for rebuilding the event
|
||||
* it covers is a rule that breaks silently the first time the certificate's
|
||||
* shape changes, and a rebuild differing by one byte hashes to an id whose
|
||||
* signature fails and is indistinguishable from a forgery.
|
||||
*
|
||||
* Always set together with [parentChatRoomId]. One without the other is a
|
||||
* state that never reached this table.
|
||||
*/
|
||||
val birthCertificateJson: String? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* One group that has certified a room as its child, and the signature saying so.
|
||||
*
|
||||
* A row per parent, because a room can have more than one: it is founded with
|
||||
* one and may be adopted by another later, and an adoption is the parent
|
||||
* certifying plus the child re-announcing its key state with the pair added. See
|
||||
* `docs/subgroups-multiple-parents.md`.
|
||||
*
|
||||
* These rows were two nullable columns on [GroupKeyState] while a lineage could
|
||||
* only ever be one link. Nothing about their meaning changed in the move.
|
||||
*
|
||||
* ### The primary key is the rule
|
||||
*
|
||||
* `(chatRoomId, parentChatRoomId)` -- one certificate per child per parent. A
|
||||
* second entry for a parent already in the set is one parent asked twice and is
|
||||
* refused by `SubgroupLineage.of` before it gets here; the key is what makes
|
||||
* that a property of the store rather than of a reader remembering.
|
||||
*
|
||||
* ### Verified before it is written, every time
|
||||
*
|
||||
* A row exists only where `SubgroupBirthCertificateEvent.certifies` has said
|
||||
* that this parent's quorum really signed for this room -- see
|
||||
* `GroupKeyStateManager.stateFrom`, which drops the **whole** state when any one
|
||||
* parent's certificate fails. So the presence of a row is itself the claim that
|
||||
* the lineage was checked, and there is no state in which some parents are
|
||||
* believed and others are not.
|
||||
*
|
||||
* [birthCertificateJson] is the certificate whole rather than its signature,
|
||||
* so a reader can check it again from the row alone. A signature plus a rule for
|
||||
* rebuilding the event it covers is a rule that breaks silently the first time
|
||||
* the certificate's shape changes: a rebuild differing by one byte hashes to an
|
||||
* id whose signature fails and is indistinguishable from a forgery.
|
||||
*
|
||||
* ### The cascade points at the state, never at the parent's room
|
||||
*
|
||||
* `ON DELETE CASCADE` from [GroupKeyState] is what keeps a lineage from
|
||||
* outliving the state that claimed it -- and it is what lets a re-announcement
|
||||
* that drops a parent actually drop it, since `GroupKeyStateDao.replace`
|
||||
* rewrites these rows inside the same transaction.
|
||||
*
|
||||
* [parentChatRoomId] is deliberately **not** a foreign key onto `ChatRoom`. A
|
||||
* self-referential cascade would mean deleting a parent room deletes its
|
||||
* children's key states, and then by their own cascades those rooms' messages,
|
||||
* participants, signing sessions and signed events -- a user tidying up a group
|
||||
* they left would destroy a group they are still in. `RESTRICT` would be no
|
||||
* better: it would make a parent undeletable while a child row exists, which is
|
||||
* a foreign key deciding a product question. And this routinely names a room the
|
||||
* device does not have at all, since a member of a subgroup need never have been
|
||||
* in its parent, so a dangling value is the normal, expected state and readers
|
||||
* resolve it with a lookup allowed to return null.
|
||||
*/
|
||||
@Entity(
|
||||
primaryKeys = ["chatRoomId", "parentChatRoomId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = GroupKeyState::class,
|
||||
parentColumns = ["chatRoomId"],
|
||||
childColumns = ["chatRoomId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
// The composite primary key already indexes chatRoomId as its leftmost
|
||||
// column, so only the other direction needs one: "which rooms has this
|
||||
// group certified", which is how a parent finds the children whose state
|
||||
// this device holds.
|
||||
Index("parentChatRoomId"),
|
||||
],
|
||||
)
|
||||
data class GroupKeyStateParent(
|
||||
/** The child: the room whose key state carries this parentage. */
|
||||
val chatRoomId: String,
|
||||
|
||||
/** The parent's room id, which is also the key its certificate verifies against. */
|
||||
val parentChatRoomId: HexKey,
|
||||
|
||||
/** The parent's signed certificate, whole, as JSON. */
|
||||
val birthCertificateJson: String,
|
||||
)
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package press.mantra.compose.database.model.intermdiate
|
||||
|
||||
import androidx.room3.Embedded
|
||||
import androidx.room3.Relation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.GroupKeyStateParent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupLineage
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
|
||||
/**
|
||||
* A room's key state with every group that has certified it as a child.
|
||||
*
|
||||
* The two always travel together, because a state and the lineage it claims are
|
||||
* one signed statement: `GroupKeyStateManager.stateFrom` either believes all of
|
||||
* it or drops all of it, so a reader holding the state without the parents would
|
||||
* be holding half of something that has no halves.
|
||||
*
|
||||
* Also the shape `stateFrom` returns. It is a Room read model -- `@Embedded`
|
||||
* over `@Relation`, like `LocalChatRoom` -- and a plain data class besides, so
|
||||
* the pure verifier can build one with no database anywhere near it.
|
||||
*/
|
||||
data class LocalGroupKeyState(
|
||||
@Embedded
|
||||
val keyState: GroupKeyState,
|
||||
|
||||
@Relation(
|
||||
entity = GroupKeyStateParent::class,
|
||||
parentColumns = ["chatRoomId"],
|
||||
entityColumns = ["chatRoomId"],
|
||||
)
|
||||
val parents: List<GroupKeyStateParent> = emptyList(),
|
||||
) {
|
||||
/**
|
||||
* The lineage these rows amount to, or null for a room that is nobody's
|
||||
* child.
|
||||
*
|
||||
* Null also where the rows cannot be read back as a lineage -- a certificate
|
||||
* whose JSON no longer parses, or a repeat the store should have made
|
||||
* impossible. Both are the same answer here, and it is the safe one: a
|
||||
* lineage that cannot be stated is not half-stated, for the reason
|
||||
* `stateFrom` drops a whole state rather than keeping a parentage it could
|
||||
* not check.
|
||||
*
|
||||
* Rebuilt rather than cached, so the canonical order and the no-repeats rule
|
||||
* come from `SubgroupLineage` on every read instead of from whatever order
|
||||
* the rows came back in.
|
||||
*/
|
||||
fun lineage(): SubgroupLineage? = SubgroupLineage.of(
|
||||
parents.mapNotNull { parent ->
|
||||
Event.fromJsonOrNull(parent.birthCertificateJson)?.let { certificate ->
|
||||
SubgroupParentage(
|
||||
parentChatRoomId = parent.parentChatRoomId,
|
||||
certificate = certificate
|
||||
)
|
||||
}
|
||||
}.takeIf { it.size == parents.size } ?: return null
|
||||
)
|
||||
|
||||
/** The groups that have certified this room, in canonical order. */
|
||||
fun parentChatRoomIds(): List<String> = lineage()?.parentChatRoomIds.orEmpty()
|
||||
}
|
||||
+4
-6
@@ -55,7 +55,6 @@ class DatabaseChatRepository(
|
||||
userPublicKey: HexKey,
|
||||
keyPair: KeyPair,
|
||||
path: List<Long>,
|
||||
parentChatRoomId: HexKey?,
|
||||
): MarmotGroupCreation.Outcome = MarmotGroupCreation.create(
|
||||
database = database,
|
||||
chatRepository = this,
|
||||
@@ -66,7 +65,6 @@ class DatabaseChatRepository(
|
||||
userPublicKey = userPublicKey,
|
||||
keyPair = keyPair,
|
||||
path = path,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
)
|
||||
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> {
|
||||
return database.chatRoomDao().observeChatRoomListByUserPublicKey(publicKey)
|
||||
@@ -113,11 +111,11 @@ class DatabaseChatRepository(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override suspend fun parentOf(chatRoomId: String): HexKey? = try {
|
||||
SubgroupManager.parentOf(database, chatRoomId)
|
||||
override suspend fun parentsOf(chatRoomId: String): List<HexKey> = try {
|
||||
SubgroupManager.parentsOf(database, chatRoomId)
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error reading the parent of $chatRoomId", e)
|
||||
null
|
||||
logger.e("Error reading the parents of $chatRoomId", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try {
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
|
||||
@@ -110,7 +110,7 @@ class DatabaseDkgRepository(
|
||||
): Flow<List<LocalFrostSigningSession>> =
|
||||
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
|
||||
|
||||
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<GroupKeyState?> =
|
||||
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<LocalGroupKeyState?> =
|
||||
database.groupSignedEventDao()
|
||||
.observeByKind(GroupKeyStateEvent.KIND)
|
||||
.map { signedEvents ->
|
||||
@@ -132,7 +132,7 @@ class DatabaseDkgRepository(
|
||||
subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
|
||||
}
|
||||
|
||||
override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = try {
|
||||
override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try {
|
||||
GroupKeyStateManager.adopt(database = database, chatRoomId = chatRoomId)
|
||||
} catch (e: Throwable) {
|
||||
// The room is made and usable either way; what it loses is the shortcut
|
||||
|
||||
+97
-91
@@ -9,8 +9,10 @@ import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.GroupKeyStateParent
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupLineage
|
||||
@@ -122,18 +124,6 @@ object GroupKeyStateManager {
|
||||
"Ceremony ${key.id} has produced no key for $signingRoomId to propose"
|
||||
)
|
||||
|
||||
// The event can carry a lineage of any size; the row cannot yet. Until
|
||||
// `GroupKeyState` holds a set, a caller reaching here with two parents
|
||||
// would have one of them silently dropped on the way to the columns, so
|
||||
// it is refused outright rather than half-kept. The tags are already
|
||||
// plural -- see `GroupKeyStateEvent.assembleTags` -- which is what lets
|
||||
// the wire land before the storage does.
|
||||
check(lineage == null || lineage.size == 1) {
|
||||
"Room $signingRoomId cannot yet propose a key state naming " +
|
||||
"${lineage?.size} parents"
|
||||
}
|
||||
val parent = lineage?.parents?.single()
|
||||
|
||||
// The room this is about: the group's key walked to the admin path,
|
||||
// which is that room's id. It need not exist yet, and in the flow this
|
||||
// was written for it does not.
|
||||
@@ -150,9 +140,7 @@ object GroupKeyStateManager {
|
||||
thresholdPublicKey = thresholdPublicKey,
|
||||
derivationPath = SharedKeyDerivation.formatPath(path),
|
||||
announcedBy = userPublicKey,
|
||||
announcedAt = Instant.fromEpochSeconds(createdAt),
|
||||
parentChatRoomId = parent?.parentChatRoomId,
|
||||
birthCertificateJson = parent?.certificate?.toJson()
|
||||
announcedAt = Instant.fromEpochSeconds(createdAt)
|
||||
)
|
||||
|
||||
check(state.verifies()) {
|
||||
@@ -160,10 +148,14 @@ object GroupKeyStateManager {
|
||||
}
|
||||
|
||||
// Refused here rather than discovered by the group. Every device that
|
||||
// receives this state runs the same check in `parentageOf` and drops the
|
||||
// state when it fails, so proposing one this device would not believe
|
||||
// spends a quorum's attention on a statement nobody will keep.
|
||||
if (parent != null) {
|
||||
// receives this state runs the same checks in `lineageOf` and drops the
|
||||
// state when any of them fails, so proposing one this device would not
|
||||
// believe spends a quorum's attention on a statement nobody will keep.
|
||||
//
|
||||
// Every parent, because the receiving check is all-or-nothing: one bad
|
||||
// certificate in a set of two drops the whole state, so a proposal is
|
||||
// only worth making if every link in it holds.
|
||||
lineage?.parents?.forEach { parent ->
|
||||
check(
|
||||
SubgroupBirthCertificateEvent.certifies(
|
||||
event = parent.certificate,
|
||||
@@ -214,18 +206,19 @@ object GroupKeyStateManager {
|
||||
database: MantraDatabase,
|
||||
chatRoomId: String,
|
||||
innerEvent: Event
|
||||
): GroupKeyState? {
|
||||
val state = stateFrom(chatRoomId, innerEvent) ?: return null
|
||||
): LocalGroupKeyState? {
|
||||
val local = stateFrom(chatRoomId, innerEvent) ?: return null
|
||||
val announced = local.keyState.chatRoomId
|
||||
|
||||
if (database.chatRoomDao().findChatRoomById(state.chatRoomId) == null) {
|
||||
if (database.chatRoomDao().findChatRoomById(announced) == null) {
|
||||
logger.i(
|
||||
"The group signed a key state for ${state.chatRoomId}, which does not exist " +
|
||||
"The group signed a key state for $announced, which does not exist " +
|
||||
"yet; holding it until it does"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return database.groupKeyStateDao().replace(state)
|
||||
return database.groupKeyStateDao().replace(local.keyState, local.parents)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,12 +241,15 @@ object GroupKeyStateManager {
|
||||
* Idempotent: `replace` keeps the newest state per room, so adopting twice
|
||||
* settles on the same row.
|
||||
*/
|
||||
suspend fun adopt(database: MantraDatabase, chatRoomId: String): GroupKeyState? {
|
||||
val state = signedStateFor(database, chatRoomId) ?: return null
|
||||
suspend fun adopt(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? {
|
||||
val local = signedStateFor(database, chatRoomId) ?: return null
|
||||
|
||||
logger.i("Room $chatRoomId adopts the key state its group signed at ${state.announcedAt}")
|
||||
logger.i(
|
||||
"Room $chatRoomId adopts the key state its group signed at " +
|
||||
"${local.keyState.announcedAt}, naming ${local.parents.size} parents"
|
||||
)
|
||||
|
||||
return database.groupKeyStateDao().replace(state)
|
||||
return database.groupKeyStateDao().replace(local.keyState, local.parents)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,7 +262,7 @@ object GroupKeyStateManager {
|
||||
* the ritual screen gates the "create the room" button on, and what [adopt]
|
||||
* files once there is a room to file it against.
|
||||
*/
|
||||
suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? =
|
||||
suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? =
|
||||
stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId)
|
||||
|
||||
/**
|
||||
@@ -295,7 +291,7 @@ object GroupKeyStateManager {
|
||||
* arrive can ask the same question of each emission -- which is how the
|
||||
* ceremony screen knows the group has finished agreeing.
|
||||
*/
|
||||
fun stateAmong(signedEvents: List<GroupSignedEvent>, chatRoomId: String): GroupKeyState? =
|
||||
fun stateAmong(signedEvents: List<GroupSignedEvent>, chatRoomId: String): LocalGroupKeyState? =
|
||||
signedAmong(signedEvents, chatRoomId)?.second
|
||||
|
||||
/**
|
||||
@@ -310,7 +306,7 @@ object GroupKeyStateManager {
|
||||
private fun signedAmong(
|
||||
signedEvents: List<GroupSignedEvent>,
|
||||
chatRoomId: String
|
||||
): Pair<GroupSignedEvent, GroupKeyState>? =
|
||||
): Pair<GroupSignedEvent, LocalGroupKeyState>? =
|
||||
signedEvents
|
||||
.asSequence()
|
||||
.filter { it.kind == GroupKeyStateEvent.KIND }
|
||||
@@ -319,7 +315,7 @@ object GroupKeyStateManager {
|
||||
// every room at once, so a state that names no room names nothing.
|
||||
.filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId }
|
||||
.mapNotNull { signed -> stateFrom(chatRoomId, signed.toEvent())?.let { signed to it } }
|
||||
.maxByOrNull { (_, state) -> state.announcedAt }
|
||||
.maxByOrNull { (_, local) -> local.keyState.announcedAt }
|
||||
|
||||
/**
|
||||
* The state an event amounts to, or null if it amounts to none.
|
||||
@@ -358,20 +354,21 @@ object GroupKeyStateManager {
|
||||
* names no room at all, and appears in the logs.
|
||||
*/
|
||||
/**
|
||||
* A parentage that was checked, wrapping a value that is allowed to be
|
||||
* A lineage that was checked, wrapping a value that is allowed to be
|
||||
* absent.
|
||||
*
|
||||
* Three outcomes have to be told apart and a bare nullable can only carry
|
||||
* two. A state may claim no parent, which is every group made before
|
||||
* subgroups and every top-level group made after; it may claim one and prove
|
||||
* it; or it may claim one it cannot prove, which is not a state with a field
|
||||
* missing but a device asserting a relationship the parent never agreed to.
|
||||
* The third has to stop [stateFrom] rather than be flattened into the first.
|
||||
* subgroups and every top-level group made after; it may claim some and
|
||||
* prove them all; or it may claim some it cannot prove, which is not a state
|
||||
* with a field missing but a device asserting a relationship a parent never
|
||||
* agreed to. The third has to stop [stateFrom] rather than be flattened into
|
||||
* the first.
|
||||
*
|
||||
* So `null` here means refused, and a present [value] of `null` means none
|
||||
* claimed.
|
||||
*/
|
||||
private class Parentage(val value: SubgroupParentage?)
|
||||
private class Lineage(val value: SubgroupLineage?)
|
||||
|
||||
/**
|
||||
* The parentage an event actually establishes, or null if it claims one it
|
||||
@@ -387,12 +384,12 @@ object GroupKeyStateManager {
|
||||
* not a state with one field wrong, and half-believing it would file a group
|
||||
* as top-level that its own members will see as a subgroup.
|
||||
*/
|
||||
private fun parentageOf(
|
||||
private fun lineageOf(
|
||||
announced: String,
|
||||
thresholdPublicKey: HexKey,
|
||||
innerEvent: Event
|
||||
): Parentage? {
|
||||
if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Parentage(null)
|
||||
): Lineage? {
|
||||
if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Lineage(null)
|
||||
|
||||
// Both or neither, per parent, and no parent twice -- all of which
|
||||
// `parseLineage` decides. A parent named with no certificate is a claim
|
||||
@@ -410,55 +407,57 @@ object GroupKeyStateManager {
|
||||
return null
|
||||
}
|
||||
|
||||
// One parent is all a row can hold until `GroupKeyState` carries a set.
|
||||
// Refused rather than truncated: taking the first of two would file a
|
||||
// lineage the group did not sign and leave the device believing a group
|
||||
// has one parent when its own members see two. Nothing in this build
|
||||
// emits a second, so this fires only on a state from a newer one.
|
||||
if (lineage.size > 1) {
|
||||
logger.w(
|
||||
"Key state for $announced claims ${lineage.size} parents " +
|
||||
"(${lineage.parentChatRoomIds}), which this build cannot file; dropping"
|
||||
)
|
||||
return null
|
||||
// Every parent, or none of them. One bad certificate drops the whole
|
||||
// state rather than the one pair, for the reason the all-or-nothing rule
|
||||
// was written: a state that claims a lineage it cannot back is a device
|
||||
// asserting a relationship somebody never agreed to, and keeping the
|
||||
// provable half would file a group as having one parent when its own
|
||||
// members see two. A set that is partly believed is not a weaker set.
|
||||
lineage.parents.forEach { parentage ->
|
||||
val parentChatRoomId = parentage.parentChatRoomId
|
||||
val certificate = parentage.certificate
|
||||
|
||||
// The parent's own quorum, over this very room's id. Everything that
|
||||
// makes a lineage worth anything is in here -- see
|
||||
// `SubgroupBirthCertificateEvent.certifies`, which needs no lookup,
|
||||
// no key it has to be told, and no knowledge of the other parents.
|
||||
// Each link is checked on its own, which is why a lineage can be
|
||||
// assembled a parent at a time.
|
||||
if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) {
|
||||
logger.w(
|
||||
"Key state for $announced carries no certificate by $parentChatRoomId for " +
|
||||
"that room; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Belt and braces: the id already derives from this key, and the
|
||||
// certificate's id already derives from the key it names, so two
|
||||
// certificates for one room cannot name two keys. Stated anyway
|
||||
// because the two facts live in different places and a future change
|
||||
// to either should have to notice this.
|
||||
val certifiedKey =
|
||||
SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags)
|
||||
if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) {
|
||||
logger.w(
|
||||
"Key state for $announced names $thresholdPublicKey and the certificate " +
|
||||
"by $parentChatRoomId names $certifiedKey; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
val parentage = lineage.parents.single()
|
||||
val parentChatRoomId = parentage.parentChatRoomId
|
||||
val certificate = parentage.certificate
|
||||
|
||||
// The parent's own quorum, over this very room's id. Everything that
|
||||
// makes a lineage worth anything is in here -- see
|
||||
// `SubgroupBirthCertificateEvent.certifies`, which needs no lookup and no
|
||||
// key it has to be told.
|
||||
if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) {
|
||||
logger.w(
|
||||
"Key state for $announced carries no certificate by $parentChatRoomId for " +
|
||||
"that room; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Belt and braces: the id already derives from this key, and the
|
||||
// certificate's id already derives from the key it names, so two
|
||||
// certificates for one room cannot name two keys. Stated anyway because
|
||||
// the two facts live in different places and a future change to either
|
||||
// should have to notice this.
|
||||
val certifiedKey = SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags)
|
||||
if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) {
|
||||
logger.w(
|
||||
"Key state for $announced names $thresholdPublicKey and its certificate names " +
|
||||
"$certifiedKey; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return Parentage(
|
||||
SubgroupParentage(parentChatRoomId = parentChatRoomId, certificate = certificate)
|
||||
)
|
||||
// Deliberately not checked: whether the parents know about each other.
|
||||
// An earlier certificate cannot name a later co-parent -- the first
|
||||
// parent signed before the second was a possibility -- so requiring them
|
||||
// to agree on the set would make every adoption cost a fresh quorum in
|
||||
// every group that had already certified this room. The child's key state
|
||||
// is the sole authority on the set; each parent has a veto on being added
|
||||
// and none on who else is in it. See docs/subgroups-multiple-parents.md.
|
||||
return Lineage(lineage)
|
||||
}
|
||||
|
||||
fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? {
|
||||
fun stateFrom(chatRoomId: String, innerEvent: Event): LocalGroupKeyState? {
|
||||
val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId
|
||||
|
||||
val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content)
|
||||
@@ -491,7 +490,7 @@ object GroupKeyStateManager {
|
||||
return null
|
||||
}
|
||||
|
||||
val parent = parentageOf(announced, thresholdPublicKey, innerEvent) ?: return null
|
||||
val lineage = lineageOf(announced, thresholdPublicKey, innerEvent) ?: return null
|
||||
|
||||
val state = GroupKeyState(
|
||||
chatRoomId = announced,
|
||||
@@ -499,9 +498,7 @@ object GroupKeyStateManager {
|
||||
thresholdPublicKey = thresholdPublicKey,
|
||||
derivationPath = SharedKeyDerivation.formatPath(path),
|
||||
announcedBy = innerEvent.pubKey,
|
||||
announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt),
|
||||
parentChatRoomId = parent.value?.parentChatRoomId,
|
||||
birthCertificateJson = parent.value?.certificate?.toJson()
|
||||
announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt)
|
||||
)
|
||||
|
||||
// Half the trust model, in one line, and the half that does not care who
|
||||
@@ -514,6 +511,15 @@ object GroupKeyStateManager {
|
||||
return null
|
||||
}
|
||||
|
||||
return state
|
||||
return LocalGroupKeyState(
|
||||
keyState = state,
|
||||
parents = lineage.value?.parents?.map {
|
||||
GroupKeyStateParent(
|
||||
chatRoomId = announced,
|
||||
parentChatRoomId = it.parentChatRoomId,
|
||||
birthCertificateJson = it.certificate.toJson()
|
||||
)
|
||||
}.orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -93,10 +93,6 @@ object MarmotGroupCreation {
|
||||
* appended to it, because MIP-01 has no field for one and the path is what
|
||||
* rebuilds the `TweakCache` a signing session needs.
|
||||
*
|
||||
* [parentChatRoomId] is written onto the room when this is a subgroup. It is
|
||||
* the verified value off the child's own key state -- never a claim off a
|
||||
* proposal.
|
||||
*
|
||||
* [name] is put under [MarmotGroupName] here rather than trusted from the
|
||||
* caller. Every Marmot room on a device is a `#name`, and this is the funnel
|
||||
* both flows that make one already go through, so it is the one place the
|
||||
@@ -112,7 +108,6 @@ object MarmotGroupCreation {
|
||||
userPublicKey: HexKey,
|
||||
keyPair: KeyPair,
|
||||
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
|
||||
parentChatRoomId: HexKey? = null,
|
||||
keyPackageLookupTimeout: Long = KEY_PACKAGE_LOOKUP_TIMEOUT
|
||||
): Outcome {
|
||||
chatRepository.getChatRoomByIdentifier(groupId)?.let { return Outcome.Existing(it) }
|
||||
@@ -163,15 +158,6 @@ object MarmotGroupCreation {
|
||||
mlsGroupState = group.saveState().encodeTls().toHex()
|
||||
) ?: return Outcome.Failed("Couldn't create the group. Please try again.")
|
||||
|
||||
if (parentChatRoomId != null) {
|
||||
// Written after the room exists rather than passed into its creation,
|
||||
// because `getOrCreateChatRoom` is shared with every other way a room
|
||||
// appears and none of them has a parent to hand it.
|
||||
database.chatRoomDao().upsert(
|
||||
localChatRoom.chatRoom.copy(parentChatRoomId = parentChatRoomId)
|
||||
)
|
||||
}
|
||||
|
||||
// The room comes into existence already knowing what it signs with. The
|
||||
// group agreed that before any of this ran and this device has been
|
||||
// holding the signed statement since; this is simply the first moment
|
||||
|
||||
@@ -410,18 +410,26 @@ object SubgroupManager {
|
||||
.sortedByDescending { it.certificate.createdAt }
|
||||
|
||||
/**
|
||||
* The group a room is a subgroup of, verified, or null if it is nobody's
|
||||
* child.
|
||||
* Every group that has certified a room as its child, verified, in canonical
|
||||
* order -- empty for a room that is nobody's child.
|
||||
*
|
||||
* Off the `GroupKeyState`, which only carries a parent whose certificate was
|
||||
* checked -- see `GroupKeyStateManager.stateFrom`. A room whose state this
|
||||
* device does not hold answers null even when it really is a subgroup, which
|
||||
* is the position a member welcomed in after the founding is in; the
|
||||
* unverified hint in the room's own description is the most they get, and it
|
||||
* must never be written here.
|
||||
* Off `GroupKeyStateParent`, whose rows exist only where the certificate was
|
||||
* checked against that parent's own signature -- see
|
||||
* `GroupKeyStateManager.stateFrom`, which drops the whole state rather than
|
||||
* keeping a lineage it could not check. So every id here is a group that
|
||||
* really did certify this room.
|
||||
*
|
||||
* More than one is the ordinary case now, not a corner: a group is founded
|
||||
* with one parent and may be adopted by another later. The order is the
|
||||
* lineage's own and says nothing about which came first.
|
||||
*
|
||||
* A room whose state this device does not hold answers empty even when it
|
||||
* really is a subgroup, which is the position a member welcomed in after the
|
||||
* founding is in. Nothing unverified may be written here.
|
||||
*/
|
||||
suspend fun parentOf(database: MantraDatabase, chatRoomId: String): HexKey? =
|
||||
database.groupKeyStateDao().getByChatRoomId(chatRoomId)?.parentChatRoomId
|
||||
suspend fun parentsOf(database: MantraDatabase, chatRoomId: String): List<HexKey> =
|
||||
database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds()
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* Files a certificate that arrived, or drops it and says why.
|
||||
|
||||
@@ -70,14 +70,16 @@ interface ChatRepository {
|
||||
suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup>
|
||||
|
||||
/**
|
||||
* The group this room is a subgroup of, verified, or null if it is nobody's
|
||||
* child.
|
||||
* Every group that has certified this room as its child, verified, in
|
||||
* canonical order -- empty if it is nobody's child.
|
||||
*
|
||||
* Off the room's `GroupKeyState`, which only carries a parent whose
|
||||
* certificate was checked. A member welcomed in after the founding holds no
|
||||
* state and gets null here even when the room really is a subgroup.
|
||||
* Off `GroupKeyStateParent`, whose rows exist only where the certificate was
|
||||
* checked. More than one is ordinary: a group is founded with one parent and
|
||||
* may be adopted by another later. A member welcomed in after the founding
|
||||
* holds no state and gets an empty list even when the room really is a
|
||||
* subgroup.
|
||||
*/
|
||||
suspend fun parentOf(chatRoomId: String): HexKey?
|
||||
suspend fun parentsOf(chatRoomId: String): List<HexKey>
|
||||
|
||||
/**
|
||||
* One event the group signed, as the group signed it, or null when this
|
||||
@@ -203,7 +205,6 @@ interface ChatRepository {
|
||||
userPublicKey: HexKey,
|
||||
keyPair: KeyPair,
|
||||
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
|
||||
parentChatRoomId: HexKey? = null,
|
||||
): MarmotGroupCreation.Outcome
|
||||
|
||||
/**
|
||||
@@ -279,7 +280,7 @@ interface ChatRepository {
|
||||
override suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup> =
|
||||
emptyList()
|
||||
|
||||
override suspend fun parentOf(chatRoomId: String): HexKey? = null
|
||||
override suspend fun parentsOf(chatRoomId: String): List<HexKey> = emptyList()
|
||||
|
||||
override suspend fun groupSignedEvent(
|
||||
chatRoomId: String,
|
||||
@@ -386,7 +387,6 @@ interface ChatRepository {
|
||||
userPublicKey: HexKey,
|
||||
keyPair: KeyPair,
|
||||
path: List<Long>,
|
||||
parentChatRoomId: HexKey?,
|
||||
): MarmotGroupCreation.Outcome =
|
||||
MarmotGroupCreation.Outcome.Failed("No database")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package press.mantra.compose.repository
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
|
||||
@@ -149,10 +149,10 @@ interface DkgRepository {
|
||||
* a finished subgroup with its key state signed, its certificate signed, and
|
||||
* no way to create the room -- the lookup found no ceremony at all.
|
||||
*/
|
||||
fun observeSignedGroupKeyState(dkgSessionId: String): Flow<GroupKeyState?>
|
||||
fun observeSignedGroupKeyState(dkgSessionId: String): Flow<LocalGroupKeyState?>
|
||||
|
||||
/** Files the state the group signed for a room that now exists. */
|
||||
suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState?
|
||||
suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState?
|
||||
|
||||
/**
|
||||
* Asks the parent's quorum to certify the subgroup this ceremony produced.
|
||||
@@ -242,10 +242,10 @@ interface DkgRepository {
|
||||
chatRoomId: String
|
||||
): Flow<List<LocalFrostSigningSession>> = flowOf(emptyList())
|
||||
|
||||
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<GroupKeyState?> =
|
||||
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<LocalGroupKeyState?> =
|
||||
flowOf(null)
|
||||
|
||||
override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null
|
||||
override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = null
|
||||
|
||||
override suspend fun proposeBirthCertificate(
|
||||
parentChatRoomId: String,
|
||||
|
||||
+8
-2
@@ -259,8 +259,14 @@ fun ChatRoomDetailScreen(
|
||||
// nobody's child, and absent -- rather than guessed --
|
||||
// on a subgroup this device holds no key state for,
|
||||
// since an unverified lineage is not one worth showing.
|
||||
chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId ->
|
||||
item {
|
||||
//
|
||||
// One card per parent, because a room can have more
|
||||
// than one: founded with one and adopted by another
|
||||
// later. They are a set rather than a path -- neither
|
||||
// is "the" parent and the order says nothing about
|
||||
// which came first -- so each gets an equal row.
|
||||
chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId ->
|
||||
item(key = "parent-$parentChatRoomId") {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ class ChatRoomDetailViewModel(
|
||||
groupKeyState = chatRepository.groupKeyState(chatRoomId),
|
||||
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
|
||||
subgroups = chatRepository.subgroupsOf(chatRoomId),
|
||||
parentChatRoomId = chatRepository.parentOf(chatRoomId),
|
||||
parentChatRoomIds = chatRepository.parentsOf(chatRoomId),
|
||||
canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null &&
|
||||
chatRepository.canSign(chatRoomId),
|
||||
)
|
||||
|
||||
+1
-5
@@ -607,11 +607,7 @@ class DkgRitualViewModel(
|
||||
},
|
||||
adminPublicKeys = members,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
keyPair = keyPair,
|
||||
// Verified by the time it is written: the key state this room was
|
||||
// gated on carries the certificate, and `stateFrom` refused it
|
||||
// otherwise.
|
||||
parentChatRoomId = subgroupParent
|
||||
keyPair = keyPair
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ sealed interface ChatRoomDetailUIState {
|
||||
* founding holds no state and so sees nothing here, even in a room that
|
||||
* really is a subgroup.
|
||||
*/
|
||||
val parentChatRoomId: HexKey? = null,
|
||||
val parentChatRoomIds: List<HexKey> = emptyList(),
|
||||
/**
|
||||
* Whether this device could open a subgroup here at all.
|
||||
*
|
||||
|
||||
+98
-28
@@ -75,6 +75,15 @@ class GroupKeyStateTest {
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
/** A third group, for the lineages that name two parents. */
|
||||
private val secondParentKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("3c0c0a0000000000000000000000000000000000000000000000000000000000c3")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex()
|
||||
|
||||
/** The other group's key, for the states that name the wrong one. */
|
||||
@@ -307,15 +316,15 @@ class GroupKeyStateTest {
|
||||
fun `a state the group signed about the room it arrives in is taken`() {
|
||||
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
|
||||
|
||||
assertEquals(chatRoomId, state?.chatRoomId)
|
||||
assertEquals("ceremony-1", state?.dkgSessionId)
|
||||
assertEquals(thresholdPublicKey, state?.thresholdPublicKey)
|
||||
assertEquals("m/9420/0/0", state?.derivationPath)
|
||||
assertEquals(chatRoomId, state?.keyState?.chatRoomId)
|
||||
assertEquals("ceremony-1", state?.keyState?.dkgSessionId)
|
||||
assertEquals(thresholdPublicKey, state?.keyState?.thresholdPublicKey)
|
||||
assertEquals("m/9420/0/0", state?.keyState?.derivationPath)
|
||||
// Attribution and ordering come off the event, not off the clock. The
|
||||
// author is the room itself now, which is the whole point: nobody in
|
||||
// particular said this.
|
||||
assertEquals(chatRoomId, state?.announcedBy)
|
||||
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt)
|
||||
assertEquals(chatRoomId, state?.keyState?.announcedBy)
|
||||
assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.keyState?.announcedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -412,8 +421,8 @@ class GroupKeyStateTest {
|
||||
)
|
||||
)
|
||||
|
||||
assertNotEquals(chatRoomId, state?.chatRoomId)
|
||||
assertEquals(siblingRoom, state?.chatRoomId)
|
||||
assertNotEquals(chatRoomId, state?.keyState?.chatRoomId)
|
||||
assertEquals(siblingRoom, state?.keyState?.chatRoomId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -475,7 +484,8 @@ class GroupKeyStateTest {
|
||||
|
||||
assertEquals(
|
||||
chatRoomId,
|
||||
GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))?.chatRoomId
|
||||
GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))
|
||||
?.keyState?.chatRoomId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -760,6 +770,23 @@ class GroupKeyStateTest {
|
||||
/** The parent group's room, which is `otherKeyMaterial` wearing a second hat. */
|
||||
private val parentChatRoomId get() = identityOf(otherKeyMaterial).hex
|
||||
|
||||
/** A second, unrelated parent's room -- the group that adopts the child later. */
|
||||
private val secondParentChatRoomId get() = identityOf(secondParentKeyMaterial).hex
|
||||
|
||||
/** A lineage of both parents, each with its own group's real signature. */
|
||||
private fun bothParents() = SubgroupLineage.of(
|
||||
listOf(
|
||||
SubgroupParentage(parentChatRoomId, birthCertificate()),
|
||||
SubgroupParentage(
|
||||
secondParentChatRoomId,
|
||||
birthCertificate(
|
||||
parent = secondParentChatRoomId,
|
||||
author = secondParentKeyMaterial
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
/** A certificate by [author]'s room saying [subgroupChatRoomId] is its child. */
|
||||
private fun birthCertificate(
|
||||
subgroupChatRoomId: String = chatRoomId,
|
||||
@@ -799,18 +826,26 @@ class GroupKeyStateTest {
|
||||
fun `a state whose parent certified it keeps the parent and the certificate`() {
|
||||
val state = GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent())
|
||||
|
||||
assertEquals(parentChatRoomId, state?.parentChatRoomId)
|
||||
assertEquals(birthCertificate().toJson(), state?.birthCertificateJson)
|
||||
assertEquals(listOf(parentChatRoomId), state?.parentChatRoomIds())
|
||||
assertEquals(
|
||||
birthCertificate().toJson(),
|
||||
state?.parents?.single()?.birthCertificateJson
|
||||
)
|
||||
// And the row points back at the child, which is what makes it a row
|
||||
// rather than a column.
|
||||
assertEquals(chatRoomId, state?.parents?.single()?.chatRoomId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state claiming no parent keeps both fields null`() {
|
||||
fun `a state claiming no parent carries no parent rows at all`() {
|
||||
// Every group made before subgroups existed, and every top-level group
|
||||
// made after. Null here is the ordinary case, not missing data.
|
||||
// made after. No rows is the ordinary case, not missing data -- and it is
|
||||
// one way of saying "nobody's child" rather than two, which is why a
|
||||
// lineage of none is null rather than empty.
|
||||
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
|
||||
|
||||
assertNull(state?.parentChatRoomId)
|
||||
assertNull(state?.birthCertificateJson)
|
||||
assertEquals(emptyList(), state?.parents)
|
||||
assertNull(state?.lineage())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -924,17 +959,11 @@ class GroupKeyStateTest {
|
||||
// ids and then all the certificates, such a reader would pair one
|
||||
// parent's id with another's certificate, fail `certifies`, and drop the
|
||||
// whole state -- losing the row that says which share signs for the room.
|
||||
val second = birthCertificate(parent = chatRoomId, author = keyMaterial)
|
||||
val tags = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
lineage = SubgroupLineage.of(
|
||||
listOf(
|
||||
SubgroupParentage(parentChatRoomId, birthCertificate()),
|
||||
SubgroupParentage(chatRoomId, second)
|
||||
)
|
||||
)
|
||||
lineage = bothParents()
|
||||
)
|
||||
|
||||
val names = tags.map { it[0] }.filter {
|
||||
@@ -960,11 +989,43 @@ class GroupKeyStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state claiming two parents is dropped rather than truncated`() {
|
||||
// Until a row can hold a set. Taking the first of two would file a
|
||||
// lineage the group did not sign, and leave this device believing a group
|
||||
// has one parent while its own members see two.
|
||||
val second = birthCertificate(parent = chatRoomId, author = keyMaterial)
|
||||
fun `a state claiming two parents keeps both, each with its own certificate`() {
|
||||
// Two groups, two quorums, two signatures over the same child's id. The
|
||||
// second one signed later and could not have named the first as a
|
||||
// co-parent, which is why nothing requires the certificates to agree on
|
||||
// the set -- the child's key state is the sole authority on it.
|
||||
val state = signedKeyState(
|
||||
tags = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
dkgSessionId = "ceremony-1",
|
||||
path = path,
|
||||
lineage = bothParents()
|
||||
)
|
||||
)
|
||||
|
||||
val local = GroupKeyStateManager.stateFrom(chatRoomId, state)
|
||||
|
||||
assertEquals(
|
||||
listOf(parentChatRoomId, secondParentChatRoomId).sorted(),
|
||||
local?.parentChatRoomIds()?.sorted()
|
||||
)
|
||||
assertEquals(2, local?.parents?.size)
|
||||
// Each row carries the certificate of the parent it names, not whichever
|
||||
// one came first off the tags.
|
||||
local?.parents?.forEach { parent ->
|
||||
assertEquals(
|
||||
parent.parentChatRoomId,
|
||||
Event.fromJson(parent.birthCertificateJson).pubKey,
|
||||
"each parentage row keeps its own parent's certificate"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one bad certificate in a set of two drops the whole state`() {
|
||||
// All or nothing, generalised. Keeping the provable half would file a
|
||||
// group as having one parent when its own members see two, and a set
|
||||
// that is partly believed is not a weaker set.
|
||||
val state = signedKeyState(
|
||||
tags = GroupKeyStateEvent.assembleTags(
|
||||
chatRoomId = chatRoomId,
|
||||
@@ -973,7 +1034,16 @@ class GroupKeyStateTest {
|
||||
lineage = SubgroupLineage.of(
|
||||
listOf(
|
||||
SubgroupParentage(parentChatRoomId, birthCertificate()),
|
||||
SubgroupParentage(chatRoomId, second)
|
||||
// Really signed by the second parent, but for a different
|
||||
// child -- so it certifies something, just not this.
|
||||
SubgroupParentage(
|
||||
secondParentChatRoomId,
|
||||
birthCertificate(
|
||||
subgroupChatRoomId = otherKey,
|
||||
parent = secondParentChatRoomId,
|
||||
author = secondParentKeyMaterial
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
+182
-109
@@ -16,31 +16,38 @@ import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
import press.mantra.compose.database.model.GroupKeyStateParent
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
|
||||
/**
|
||||
* What schema 17 has to survive, and the one thing it must refuse to do.
|
||||
* What schema 21 has to survive, and the two things it must refuse to do.
|
||||
*
|
||||
* Four nullable columns say a group is somebody's child. Three properties of
|
||||
* them are invisible to the compiler and each one is a way subgroups could break
|
||||
* quietly.
|
||||
* A room's parentage is a table now rather than two nullable columns, because a
|
||||
* room can be the child of more than one group -- founded with one and adopted by
|
||||
* another later. Four properties of that are invisible to the compiler and each
|
||||
* one is a way a lineage could break quietly.
|
||||
*
|
||||
* A parent pointer **may name a room this device does not have**. A member of a
|
||||
* subgroup who was never in its parent holds the id and nothing else, so a
|
||||
* dangling value is the normal state rather than an error -- and if any of these
|
||||
* columns were ever given a foreign key, that member's rows would stop being
|
||||
* dangling value is the normal state rather than an error -- and if the parent
|
||||
* side were ever given a foreign key, that member's rows would stop being
|
||||
* writable at all.
|
||||
*
|
||||
* Deleting a parent **must not touch its children**. Every other reference to a
|
||||
* room in this schema cascades. A self-referential cascade here would mean a user
|
||||
* room in this schema cascades. A self-referential cascade would mean a user
|
||||
* tidying away a group they had left silently destroying a group they are still
|
||||
* in, together with its messages, participants, key state and signed work.
|
||||
*
|
||||
* And the two `GroupKeyState` columns **travel together**. One without the other
|
||||
* is a state whose parentage cannot be rechecked from the row, which is the whole
|
||||
* reason the certificate is stored whole rather than as a signature.
|
||||
* A state's lineage **is replaced wholesale, never merged**. A key state is
|
||||
* replaceable and the newest wins, so the set of parents it names is the set.
|
||||
* That is what makes a disavowal expressible at all: a room drops a parent by
|
||||
* re-announcing without it, and merged rows would leave the dropped parent
|
||||
* surviving the statement that dropped it.
|
||||
*
|
||||
* And a lineage **does not outlive the state that claimed it**, which is the one
|
||||
* cascade here that is wanted.
|
||||
*/
|
||||
class SubgroupDaoJvmTest {
|
||||
|
||||
@@ -59,83 +66,72 @@ class SubgroupDaoJvmTest {
|
||||
/** A parent this device has never held a room for. */
|
||||
private val absentParent = "33".repeat(32)
|
||||
|
||||
private val certificate =
|
||||
"""{"id":"aa","pubkey":"bb","created_at":1700000000,"kind":30329,"tags":[],"content":"cc","sig":"dd"}"""
|
||||
private fun certificate(by: String) =
|
||||
"""{"id":"aa","pubkey":"$by","created_at":1700000000,"kind":30329,""" +
|
||||
""""tags":[],"content":"cc","sig":"dd"}"""
|
||||
|
||||
@Test
|
||||
fun `a key state keeps its parent and the certificate behind it`() = runBlocking {
|
||||
fun `a key state keeps every parent and the certificate behind each`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = childRoom,
|
||||
parentChatRoomId = parentRoom,
|
||||
birthCertificateJson = certificate
|
||||
)
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom),
|
||||
listOf(parentage(parentRoom), parentage(absentParent))
|
||||
)
|
||||
|
||||
val stored = db.groupKeyStateDao().getByChatRoomId(childRoom)
|
||||
val stored = db.groupKeyStateDao().getLocalByChatRoomId(childRoom)
|
||||
|
||||
assertEquals(parentRoom, stored?.parentChatRoomId)
|
||||
assertEquals(certificate, stored?.birthCertificateJson)
|
||||
assertEquals(
|
||||
listOf(parentRoom, absentParent).sorted(),
|
||||
stored?.parents?.map { it.parentChatRoomId }?.sorted()
|
||||
)
|
||||
assertEquals(
|
||||
certificate(parentRoom),
|
||||
stored?.parents?.single { it.parentChatRoomId == parentRoom }?.birthCertificateJson
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group that is nobody's child keeps both columns null`() = runBlocking {
|
||||
fun `a group that is nobody's child carries no parent rows`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.groupKeyStateDao().upsert(keyState(chatRoomId = parentRoom))
|
||||
db.groupKeyStateDao().replace(keyState(chatRoomId = parentRoom))
|
||||
|
||||
val stored = db.groupKeyStateDao().getByChatRoomId(parentRoom)
|
||||
val stored = db.groupKeyStateDao().getLocalByChatRoomId(parentRoom)
|
||||
|
||||
// Every group made before subgroups existed reads back this way, and so
|
||||
// does every top-level group made after. Null here is not missing data.
|
||||
assertNull(stored?.parentChatRoomId)
|
||||
assertNull(stored?.birthCertificateJson)
|
||||
// does every top-level group made after. No rows is not missing data, and
|
||||
// it is one way of saying "nobody's child" rather than two.
|
||||
assertEquals(emptyList(), stored?.parents)
|
||||
assertNull(stored?.lineage())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a key state may name a parent this device holds no room for`() = runBlocking {
|
||||
fun `a parentage may name a parent this device holds no room for`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
// The position a subgroup member who was never in the parent is in: they
|
||||
// hold the id off a certificate and nothing else. If this column were a
|
||||
// foreign key their state could not be written at all.
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = childRoom,
|
||||
parentChatRoomId = absentParent,
|
||||
birthCertificateJson = certificate
|
||||
)
|
||||
// hold the id off a certificate and nothing else. Were the parent side a
|
||||
// foreign key their lineage could not be written at all.
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom),
|
||||
listOf(parentage(absentParent))
|
||||
)
|
||||
|
||||
assertEquals(absentParent, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
|
||||
assertNull(db.chatRoomDao().findChatRoomById(absentParent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a room may name a parent this device holds no room for`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = absentParent))
|
||||
|
||||
assertEquals(
|
||||
absentParent,
|
||||
db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
|
||||
listOf(absentParent),
|
||||
db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
|
||||
)
|
||||
assertNull(db.chatRoomDao().findChatRoomById(absentParent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting a parent room leaves its subgroup standing`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = childRoom,
|
||||
parentChatRoomId = parentRoom,
|
||||
birthCertificateJson = certificate
|
||||
)
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom),
|
||||
listOf(parentage(parentRoom))
|
||||
)
|
||||
|
||||
db.chatRoomDao().delete(room(parentRoom))
|
||||
@@ -146,10 +142,27 @@ class SubgroupDaoJvmTest {
|
||||
// them owns.
|
||||
assertNull(db.chatRoomDao().findChatRoomById(parentRoom))
|
||||
assertEquals(
|
||||
parentRoom,
|
||||
db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
|
||||
listOf(parentRoom),
|
||||
db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
|
||||
)
|
||||
assertEquals(parentRoom, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the child takes its lineage with it`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom),
|
||||
listOf(parentage(parentRoom))
|
||||
)
|
||||
|
||||
// The one cascade that is wanted: a lineage must not outlive the state
|
||||
// that claimed it, or a room recreated at the same derived id would
|
||||
// inherit a parentage nobody signed for it.
|
||||
db.chatRoomDao().delete(room(childRoom))
|
||||
|
||||
assertNull(db.groupKeyStateDao().getByChatRoomId(childRoom))
|
||||
assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -159,73 +172,130 @@ class SubgroupDaoJvmTest {
|
||||
val second = "44".repeat(32)
|
||||
db.chatRoomDao().upsert(room(second))
|
||||
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = childRoom,
|
||||
parentChatRoomId = parentRoom,
|
||||
birthCertificateJson = certificate,
|
||||
announcedAt = Instant.fromEpochSeconds(1_000)
|
||||
)
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
|
||||
listOf(parentage(parentRoom))
|
||||
)
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = second,
|
||||
parentChatRoomId = parentRoom,
|
||||
birthCertificateJson = certificate,
|
||||
announcedAt = Instant.fromEpochSeconds(2_000)
|
||||
)
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = second, announcedAt = Instant.fromEpochSeconds(2_000)),
|
||||
listOf(parentage(parentRoom, child = second))
|
||||
)
|
||||
// Another group's child, to prove the filter is on the parent rather
|
||||
// than on having a parent at all.
|
||||
db.groupKeyStateDao().upsert(
|
||||
keyState(
|
||||
chatRoomId = parentRoom,
|
||||
parentChatRoomId = absentParent,
|
||||
birthCertificateJson = certificate
|
||||
)
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = parentRoom),
|
||||
listOf(parentage(absentParent, child = parentRoom))
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(second, childRoom),
|
||||
db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.chatRoomId }
|
||||
db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.keyState.chatRoomId }
|
||||
)
|
||||
assertEquals(
|
||||
listOf(second, childRoom),
|
||||
db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first().map { it.chatRoomId }
|
||||
db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first()
|
||||
.map { it.keyState.chatRoomId }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parent lists only the subgroup rooms this device actually holds`() = runBlocking {
|
||||
fun `a child with two parents appears in both their lists`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
|
||||
db.chatRoomDao().upsert(room(absentParent))
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom),
|
||||
listOf(parentage(parentRoom), parentage(absentParent))
|
||||
)
|
||||
|
||||
val rooms = db.chatRoomDao().observeByParentChatRoomId(parentRoom).first()
|
||||
|
||||
assertEquals(listOf(childRoom), rooms.map { it.chatRoom.id })
|
||||
|
||||
// A child that was certified but whose room was never created here shows
|
||||
// up nowhere in this query. That is the difference between it and
|
||||
// SubgroupManager.subgroupsOf, which reads the parent's certificates.
|
||||
assertTrue(db.chatRoomDao().observeByParentChatRoomId(absentParent).first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a soft-deleted subgroup drops out of its parent's list`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
|
||||
db.chatRoomDao().upsert(
|
||||
room(childRoom).copy(
|
||||
parentChatRoomId = parentRoom,
|
||||
deletedAt = Instant.fromEpochSeconds(9_000)
|
||||
// The row that makes it this parent's child says nothing about who else
|
||||
// certified it, which is the whole point of the join.
|
||||
listOf(parentRoom, absentParent).forEach { parent ->
|
||||
assertEquals(
|
||||
listOf(childRoom),
|
||||
db.groupKeyStateDao().getByParentChatRoomId(parent).map { it.keyState.chatRoomId },
|
||||
"$parent should list the child it certified"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a newer state replaces the lineage rather than adding to it`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
|
||||
listOf(parentage(parentRoom), parentage(absentParent))
|
||||
)
|
||||
|
||||
// A room the user has cleared away must not come back because its parent
|
||||
// still lists it.
|
||||
assertTrue(db.chatRoomDao().observeByParentChatRoomId(parentRoom).first().isEmpty())
|
||||
// A disavowal: the child re-announces without one of them. Were these
|
||||
// rows merged, the dropped parent would survive the statement dropping
|
||||
// it -- and a parent nobody can drop is a parent for life.
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)),
|
||||
listOf(parentage(parentRoom))
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(parentRoom),
|
||||
db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
|
||||
)
|
||||
assertTrue(db.groupKeyStateDao().getByParentChatRoomId(absentParent).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a re-announcement to nobody's child clears the lineage`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
|
||||
listOf(parentage(parentRoom))
|
||||
)
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000))
|
||||
)
|
||||
|
||||
assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an older state changes neither the state nor the lineage on file`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)),
|
||||
listOf(parentage(parentRoom))
|
||||
)
|
||||
|
||||
// A session replayed after a crash must not walk the room back to a
|
||||
// state it has already moved past -- and must not walk its lineage back
|
||||
// either, which is new: the parents are part of the statement now.
|
||||
val kept = db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
|
||||
listOf(parentage(absentParent))
|
||||
)
|
||||
|
||||
assertEquals(Instant.fromEpochSeconds(2_000), kept.keyState.announcedAt)
|
||||
assertEquals(listOf(parentRoom), kept.parents.map { it.parentChatRoomId })
|
||||
assertEquals(
|
||||
listOf(parentRoom),
|
||||
db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one parent gets one row, whatever a caller tries`() = runBlocking {
|
||||
seedRooms()
|
||||
|
||||
// The primary key is the rule "one certificate per child per parent".
|
||||
// `SubgroupLineage.of` refuses a repeat long before here; this is the
|
||||
// store saying so too, so the rule does not rest on a reader remembering.
|
||||
db.groupKeyStateDao().replace(
|
||||
keyState(chatRoomId = childRoom),
|
||||
listOf(parentage(parentRoom), parentage(parentRoom))
|
||||
)
|
||||
|
||||
assertEquals(1, db.groupKeyStateDao().getParentsByChatRoomId(childRoom).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -271,10 +341,15 @@ class SubgroupDaoJvmTest {
|
||||
mlsGroupState = null,
|
||||
)
|
||||
|
||||
private fun parentage(parentChatRoomId: String, child: String = childRoom) =
|
||||
GroupKeyStateParent(
|
||||
chatRoomId = child,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
birthCertificateJson = certificate(parentChatRoomId),
|
||||
)
|
||||
|
||||
private fun keyState(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String? = null,
|
||||
birthCertificateJson: String? = null,
|
||||
announcedAt: Instant = Instant.fromEpochSeconds(1_700_000_000)
|
||||
) = GroupKeyState(
|
||||
chatRoomId = chatRoomId,
|
||||
@@ -283,8 +358,6 @@ class SubgroupDaoJvmTest {
|
||||
derivationPath = "m/9420/0/0",
|
||||
announcedBy = user,
|
||||
announcedAt = announcedAt,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
birthCertificateJson = birthCertificateJson,
|
||||
)
|
||||
|
||||
private fun session(
|
||||
|
||||
+33
-32
@@ -194,7 +194,7 @@ class SignedGroupKeyStateTest {
|
||||
/** The one event a session of one signs. */
|
||||
suspend fun item(sessionId: String) = items(sessionId).single()
|
||||
|
||||
suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId)
|
||||
suspend fun keyState() = db.groupKeyStateDao().getLocalByChatRoomId(roomId)
|
||||
|
||||
/** Everything the group has signed here, as the group signed it. */
|
||||
suspend fun signedEvents() = db.groupSignedEventDao().getByChatRoomId(roomId)
|
||||
@@ -515,13 +515,13 @@ class SignedGroupKeyStateTest {
|
||||
listOf(creator, other).forEach { device ->
|
||||
val state = device.keyState()
|
||||
assertNotNull(state, "every signer should hold the state the group signed")
|
||||
assertEquals(thresholdPublicKey, state.thresholdPublicKey)
|
||||
assertEquals(ceremonyId, state.dkgSessionId)
|
||||
assertEquals(SharedKeyDerivation.formatPath(), state.derivationPath)
|
||||
assertEquals(thresholdPublicKey, state.keyState.thresholdPublicKey)
|
||||
assertEquals(ceremonyId, state.keyState.dkgSessionId)
|
||||
assertEquals(SharedKeyDerivation.formatPath(), state.keyState.derivationPath)
|
||||
// Signed by the room, so attributed to the room -- nobody in
|
||||
// particular said this.
|
||||
assertEquals(adminRoomId, state.announcedBy)
|
||||
assertTrue(state.verifies())
|
||||
assertEquals(adminRoomId, state.keyState.announcedBy)
|
||||
assertTrue(state.keyState.verifies())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ class SignedGroupKeyStateTest {
|
||||
absent.keyState(),
|
||||
"a member the quorum did not need still learns what the room signs with"
|
||||
)
|
||||
assertEquals(adminRoomId, absent.keyState()?.announcedBy)
|
||||
assertEquals(adminRoomId, absent.keyState()?.keyState?.announcedBy)
|
||||
}
|
||||
|
||||
// ---- The key state, signed before the room exists ----------------------
|
||||
@@ -722,22 +722,22 @@ class SignedGroupKeyStateTest {
|
||||
val adopted = GroupKeyStateManager.adopt(device.db, adminRoomId)
|
||||
assertNotNull(adopted, "a room should adopt the state its group signed for it")
|
||||
|
||||
assertEquals(adminRoomId, adopted.chatRoomId)
|
||||
assertEquals(thresholdPublicKey, adopted.thresholdPublicKey)
|
||||
assertEquals(ceremonyId, adopted.dkgSessionId)
|
||||
assertEquals(SharedKeyDerivation.formatPath(), adopted.derivationPath)
|
||||
assertEquals(adminRoomId, adopted.announcedBy)
|
||||
assertTrue(adopted.verifies())
|
||||
assertEquals(adminRoomId, adopted.keyState.chatRoomId)
|
||||
assertEquals(thresholdPublicKey, adopted.keyState.thresholdPublicKey)
|
||||
assertEquals(ceremonyId, adopted.keyState.dkgSessionId)
|
||||
assertEquals(SharedKeyDerivation.formatPath(), adopted.keyState.derivationPath)
|
||||
assertEquals(adminRoomId, adopted.keyState.announcedBy)
|
||||
assertTrue(adopted.keyState.verifies())
|
||||
|
||||
// On file, not merely returned. Compared field by field because the
|
||||
// store keeps whole seconds and the row in hand does not.
|
||||
val stored = device.db.groupKeyStateDao().getByChatRoomId(adminRoomId)
|
||||
assertNotNull(stored)
|
||||
assertEquals(adopted.chatRoomId, stored.chatRoomId)
|
||||
assertEquals(adopted.thresholdPublicKey, stored.thresholdPublicKey)
|
||||
assertEquals(adopted.dkgSessionId, stored.dkgSessionId)
|
||||
assertEquals(adopted.derivationPath, stored.derivationPath)
|
||||
assertEquals(adopted.announcedAt, stored.announcedAt)
|
||||
assertEquals(adopted.keyState.chatRoomId, stored.chatRoomId)
|
||||
assertEquals(adopted.keyState.thresholdPublicKey, stored.thresholdPublicKey)
|
||||
assertEquals(adopted.keyState.dkgSessionId, stored.dkgSessionId)
|
||||
assertEquals(adopted.keyState.derivationPath, stored.derivationPath)
|
||||
assertEquals(adopted.keyState.announcedAt, stored.announcedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,14 +763,14 @@ class SignedGroupKeyStateTest {
|
||||
|
||||
assertNotNull(first)
|
||||
assertNotNull(second)
|
||||
assertEquals(first.thresholdPublicKey, second.thresholdPublicKey)
|
||||
assertEquals(first.dkgSessionId, second.dkgSessionId)
|
||||
assertEquals(first.derivationPath, second.derivationPath)
|
||||
assertEquals(first.announcedAt, second.announcedAt)
|
||||
assertEquals(first.keyState.thresholdPublicKey, second.keyState.thresholdPublicKey)
|
||||
assertEquals(first.keyState.dkgSessionId, second.keyState.dkgSessionId)
|
||||
assertEquals(first.keyState.derivationPath, second.keyState.derivationPath)
|
||||
assertEquals(first.keyState.announcedAt, second.keyState.announcedAt)
|
||||
// The second adopt is a no-op rather than a rewrite: `replace` keeps the
|
||||
// state already on file when nothing newer has been signed. To whole
|
||||
// seconds, which is all the store keeps.
|
||||
assertEquals(first.createdAt.epochSeconds, second.createdAt.epochSeconds)
|
||||
assertEquals(first.keyState.createdAt.epochSeconds, second.keyState.createdAt.epochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1380,9 +1380,9 @@ class SignedGroupKeyStateTest {
|
||||
|
||||
val state = creator.keyState()
|
||||
assertNotNull(state, "a room off the default path must still reach a signed state")
|
||||
assertEquals(SharedKeyDerivation.formatPath(sibling), state.derivationPath)
|
||||
assertEquals(siblingRoomId, state.announcedBy)
|
||||
assertTrue(state.verifies())
|
||||
assertEquals(SharedKeyDerivation.formatPath(sibling), state.keyState.derivationPath)
|
||||
assertEquals(siblingRoomId, state.keyState.announcedBy)
|
||||
assertTrue(state.keyState.verifies())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1572,10 +1572,11 @@ class SignedGroupKeyStateTest {
|
||||
|
||||
// The whole join. Neither device was sent a row; both derived the
|
||||
// event from their own items, both re-ran `certifies` against the
|
||||
// parent's id, and both wrote the same two columns.
|
||||
assertEquals(parentRoomId, state.parentChatRoomId)
|
||||
assertEquals(certificate.toJson(), state.birthCertificateJson)
|
||||
assertTrue(state.verifies())
|
||||
// parent's id, and both wrote the same parentage row.
|
||||
assertEquals(listOf(parentRoomId), state.parentChatRoomIds())
|
||||
assertEquals(certificate.toJson(), state.parents.single().birthCertificateJson)
|
||||
assertEquals(adminRoomId, state.parents.single().chatRoomId)
|
||||
assertTrue(state.keyState.verifies())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1626,8 +1627,8 @@ class SignedGroupKeyStateTest {
|
||||
// does every top-level group made after.
|
||||
listOf(creator, other).forEach { device ->
|
||||
val state = assertNotNull(device.keyState())
|
||||
assertNull(state.parentChatRoomId)
|
||||
assertNull(state.birthCertificateJson)
|
||||
assertEquals(emptyList(), state.parents)
|
||||
assertNull(state.lineage())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user