From c24c712ab9fdb9dec3a3203b6a225e4ea32a03f4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:07:18 -0400 Subject: [PATCH 1/2] feat(chat-cipher): derive, seal and open group invite links DefaultInviteCipher implements chat.v1.Invite, InviteKeyEnvelope and InviteSecretEnvelope from flipcash2-protobuf-api: the invite token, wrap key and invite id derived from the link's 32-byte secret, the invite's signed copy of the chat key, and the secret wrapped for its owner. It reuses DefaultChatCipher's HKDF, XChaCha20-Poly1305 and X25519 steps, so pairKey, seal, randomNonce and ensureSodium go from private to internal. openInviteKeyBundle checks wrapped_by, then the signature against the creator's key, then the member's secret envelope, then the copy, and returns the step that failed. It takes the bundle's invite_id because the secret envelope's info and aad bind it, so the secret cannot be opened without it. InviteLink encodes https://app.flipcash.com/chat/invite#s= in unpadded base64url, parses a fragment strictly (one s= pair, canonical encoding of 32 bytes), and classifies a URL as invite, invalid invite, chat or other so a router can match /chat/invite ahead of /chat/{uuid}. None of the types holding a secret, token or chat key print their bytes. InviteVectorTest runs every section of invite.json from the orchestrator repo, including each reject case at its failsAt step. --- libs/encryption/chat-cipher/build.gradle.kts | 1 + .../getcode/chatcipher/DefaultChatCipher.kt | 8 +- .../getcode/chatcipher/DefaultInviteCipher.kt | 164 ++++++ .../com/getcode/chatcipher/InviteCipher.kt | 114 ++++ .../com/getcode/chatcipher/InviteLink.kt | 83 +++ .../getcode/chatcipher/InviteVectorTest.kt | 214 +++++++ .../src/commonTest/resources/invite.json | 556 ++++++++++++++++++ 7 files changed, 1136 insertions(+), 4 deletions(-) create mode 100644 libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultInviteCipher.kt create mode 100644 libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/InviteCipher.kt create mode 100644 libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/InviteLink.kt create mode 100644 libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/InviteVectorTest.kt create mode 100644 libs/encryption/chat-cipher/src/commonTest/resources/invite.json diff --git a/libs/encryption/chat-cipher/build.gradle.kts b/libs/encryption/chat-cipher/build.gradle.kts index 3491f546a8..7f42fb2702 100644 --- a/libs/encryption/chat-cipher/build.gradle.kts +++ b/libs/encryption/chat-cipher/build.gradle.kts @@ -43,6 +43,7 @@ kotlin { // KeyPair is part of this module's API. api(project(":libs:encryption:ed25519")) implementation(project(":libs:encryption:hmac")) + implementation(project(":libs:encryption:sha256")) implementation(libs.sodium.bindings.kmp) } } diff --git a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt index 3df52cc9fc..0007c9d645 100644 --- a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt +++ b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt @@ -70,7 +70,7 @@ object DefaultChatCipher : ChatCipher { * Steps 1–3 shared by the DM chat key and the group key wrap: X25519 between the two members, * then HKDF-SHA256 salted with both Ed25519 public keys in bytewise order. */ - private fun pairKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, info: ByteArray): ByteArray { + internal fun pairKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, info: ByteArray): ByteArray { if (ownKeyPair.publicKey.size != KEY_SIZE || ownKeyPair.privateKey.size != 64) { throw ChatCipherException("own key pair must be a 32-byte public and 64-byte private key") } @@ -249,7 +249,7 @@ object DefaultChatCipher : ChatCipher { return out.copyOfRange(0, length) } - private fun seal(plain: ByteArray, aad: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { + internal fun seal(plain: ByteArray, aad: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { checkKeyAndNonce(key, nonce) ensureSodium() return AuthenticatedEncryptionWithAssociatedData.xChaCha20Poly1305IetfEncrypt( @@ -275,7 +275,7 @@ object DefaultChatCipher : ChatCipher { if (nonce.size != NONCE_SIZE) throw ChatCipherException("nonce must be 24 bytes") } - private fun randomNonce(): ByteArray { + internal fun randomNonce(): ByteArray { ensureSodium() return LibsodiumRandom.buf(NONCE_SIZE).toByteArray() } @@ -289,7 +289,7 @@ object DefaultChatCipher : ChatCipher { return a.size - b.size } - private fun ensureSodium() { + internal fun ensureSodium() { // Synchronous on JVM/Android/Native; the app also initializes it at startup. if (!LibsodiumInitializer.isInitialized()) LibsodiumInitializer.initializeWithCallback { } } diff --git a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultInviteCipher.kt b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultInviteCipher.kt new file mode 100644 index 0000000000..aee41adc67 --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultInviteCipher.kt @@ -0,0 +1,164 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + +package com.getcode.chatcipher + +import com.getcode.crypt.Sha256Hash +import com.getcode.ed25519kmp.Ed25519Kmp +import com.getcode.ed25519kmp.KeyPair +import com.ionspin.kotlin.crypto.util.LibsodiumRandom + +/** + * [InviteCipher] on the same HKDF, XChaCha20-Poly1305 and X25519 steps as [DefaultChatCipher], with + * Ed25519 from `:libs:encryption:ed25519`. Stateless. + */ +object DefaultInviteCipher : InviteCipher { + private const val KEY_SIZE = 32 + private const val ID_SIZE = 16 + private const val SIGNATURE_SIZE = 64 + private val TOKEN_INFO = "flipcash-group-invite-token-v1".encodeToByteArray() + private val WRAP_LABEL = "flipcash-group-invite-wrap-v1".encodeToByteArray() + private val ID_LABEL = "flipcash-group-invite-id-v1".encodeToByteArray() + private val SIG_LABEL = "flipcash-group-invite-sig-v1".encodeToByteArray() + private val SECRET_LABEL = "flipcash-group-invite-secret-v1".encodeToByteArray() + + /** RFC 5869 2.2: an empty salt is HashLen zeros. Passed explicitly rather than as an empty HMAC key. */ + private val EMPTY_SALT = ByteArray(32) + + override fun newSecret(): ByteArray { + DefaultChatCipher.ensureSodium() + return LibsodiumRandom.buf(KEY_SIZE).toByteArray() + } + + override fun inviteToken(secret: ByteArray): ByteArray { + checkSecret(secret) + return DefaultChatCipher.hkdfSha256(ikm = secret, salt = EMPTY_SALT, info = TOKEN_INFO, length = KEY_SIZE) + } + + override fun inviteId(secret: ByteArray): ByteArray = + Sha256Hash.hash(ID_LABEL + inviteToken(secret)).copyOfRange(0, ID_SIZE) + + /** Opens the invite's copy of the chat key. Never sent anywhere, so it stays internal. */ + internal fun wrapKey(secret: ByteArray, chatId: ByteArray): ByteArray { + checkSecret(secret) + return DefaultChatCipher.hkdfSha256(ikm = secret, salt = EMPTY_SALT, info = WRAP_LABEL + chatId, length = KEY_SIZE) + } + + override fun sealInviteKeyEnvelope(inviter: KeyPair, secret: ByteArray, chatId: ByteArray, chatKey: ByteArray): InviteKeyEnvelope = + sealInviteKeyEnvelope(inviter, secret, chatId, chatKey, DefaultChatCipher.randomNonce()) + + internal fun sealInviteKeyEnvelope( + inviter: KeyPair, + secret: ByteArray, + chatId: ByteArray, + chatKey: ByteArray, + nonce: ByteArray, + ): InviteKeyEnvelope { + if (chatKey.size != KEY_SIZE) throw ChatCipherException("chat key must be 32 bytes") + if (inviter.publicKey.size != KEY_SIZE || inviter.privateKey.size != 64) { + throw ChatCipherException("inviter key pair must be a 32-byte public and 64-byte private key") + } + val inviteId = inviteId(secret) + val ciphertext = DefaultChatCipher.seal(chatKey, keyAad(chatId, inviteId), nonce, wrapKey(secret, chatId)) + val signature = Ed25519Kmp.sign(signedMessage(chatId, inviteId, nonce, ciphertext), inviter.publicKey, inviter.privateKey) + return InviteKeyEnvelope(nonce, ciphertext, signature) + } + + override fun openInviteKeyEnvelope(secret: ByteArray, chatId: ByteArray, inviterPk: ByteArray, envelope: InviteKeyEnvelope): ByteArray = + when (val result = openKeyEnvelope(secret, chatId, inviteId(secret), inviterPk, envelope)) { + is InviteBundleResult.Opened -> result.chatKey + is InviteBundleResult.Rejected -> when (result.failsAt) { + InviteBundleFailure.Signature -> throw ChatCipherException("invite copy signature did not verify") + else -> throw ChatCipherException("invite copy did not open") + } + } + + /** The signature, then the AEAD. Rejects at [InviteBundleFailure.Signature] or [InviteBundleFailure.KeyOpen]. */ + internal fun openKeyEnvelope( + secret: ByteArray, + chatId: ByteArray, + inviteId: ByteArray, + inviterPk: ByteArray, + envelope: InviteKeyEnvelope, + ): InviteBundleResult { + val message = signedMessage(chatId, inviteId, envelope.nonce, envelope.ciphertext) + if (!verifies(envelope.signature, message, inviterPk)) return InviteBundleResult.Rejected(InviteBundleFailure.Signature) + val chatKey = try { + DefaultChatCipher.open(envelope.ciphertext, keyAad(chatId, inviteId), envelope.nonce, wrapKey(secret, chatId)) + } catch (e: ChatCipherException) { + return InviteBundleResult.Rejected(InviteBundleFailure.KeyOpen) + } + if (chatKey.size != KEY_SIZE) return InviteBundleResult.Rejected(InviteBundleFailure.KeyOpen) + return InviteBundleResult.Opened(chatKey) + } + + override fun sealInviteSecretEnvelope(owner: KeyPair, secret: ByteArray, chatId: ByteArray, inviteId: ByteArray): EncryptedPayload = + sealInviteSecretEnvelope(owner, secret, chatId, inviteId, DefaultChatCipher.randomNonce()) + + internal fun sealInviteSecretEnvelope( + owner: KeyPair, + secret: ByteArray, + chatId: ByteArray, + inviteId: ByteArray, + nonce: ByteArray, + ): EncryptedPayload { + checkSecret(secret) + val ciphertext = DefaultChatCipher.seal(secret, secretAad(chatId, inviteId, owner.publicKey), nonce, secretKey(owner, chatId, inviteId)) + return EncryptedPayload(nonce, ciphertext) + } + + override fun openInviteSecretEnvelope(owner: KeyPair, chatId: ByteArray, inviteId: ByteArray, envelope: EncryptedPayload): ByteArray { + val aad = secretAad(chatId, inviteId, owner.publicKey) + val secret = DefaultChatCipher.open(envelope.ciphertext, aad, envelope.nonce, secretKey(owner, chatId, inviteId)) + checkSecret(secret) + return secret + } + + override fun openInviteKeyBundle( + member: KeyPair, + creatorPk: ByteArray, + wrappedByCreator: Boolean, + chatId: ByteArray, + inviteId: ByteArray, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: EncryptedPayload, + ): InviteBundleResult { + if (!wrappedByCreator) return InviteBundleResult.Rejected(InviteBundleFailure.WrappedBy) + val message = signedMessage(chatId, inviteId, keyEnvelope.nonce, keyEnvelope.ciphertext) + if (!verifies(keyEnvelope.signature, message, creatorPk)) return InviteBundleResult.Rejected(InviteBundleFailure.Signature) + val secret = try { + openInviteSecretEnvelope(member, chatId, inviteId, secretEnvelope) + } catch (e: ChatCipherException) { + return InviteBundleResult.Rejected(InviteBundleFailure.SecretOpen) + } + // The envelope's aad already binds inviteId; this also catches a secret sealed under the wrong one. + if (!inviteId(secret).contentEquals(inviteId)) return InviteBundleResult.Rejected(InviteBundleFailure.SecretOpen) + return openKeyEnvelope(secret, chatId, inviteId, creatorPk, keyEnvelope) + } + + // -- steps ------------------------------------------------------------------------------- + + /** The self-wrapped `KeyEnvelope` steps 1–3, with the owner as both wrapper and recipient. */ + internal fun secretKey(owner: KeyPair, chatId: ByteArray, inviteId: ByteArray): ByteArray = + DefaultChatCipher.pairKey(owner, owner.publicKey, SECRET_LABEL + chatId + inviteId) + + private fun keyAad(chatId: ByteArray, inviteId: ByteArray) = WRAP_LABEL + chatId + inviteId + + private fun secretAad(chatId: ByteArray, inviteId: ByteArray, ownerPk: ByteArray) = + SECRET_LABEL + chatId + inviteId + ownerPk + ownerPk + + internal fun signedMessage(chatId: ByteArray, inviteId: ByteArray, nonce: ByteArray, ciphertext: ByteArray) = + SIG_LABEL + chatId + inviteId + nonce + ciphertext + + private fun verifies(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean { + if (signature.size != SIGNATURE_SIZE || publicKey.size != KEY_SIZE) return false + return try { + Ed25519Kmp.verify(signature, message, publicKey) + } catch (e: Throwable) { + false + } + } + + private fun checkSecret(secret: ByteArray) { + if (secret.size != KEY_SIZE) throw ChatCipherException("invite secret must be 32 bytes") + } +} diff --git a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/InviteCipher.kt b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/InviteCipher.kt new file mode 100644 index 0000000000..0cf78e72de --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/InviteCipher.kt @@ -0,0 +1,114 @@ +package com.getcode.chatcipher + +import com.getcode.ed25519kmp.KeyPair + +/** + * An invite's copy of a private group's chat key, as carried by `chat.v1.InviteKeyEnvelope` + * scheme `XCHACHA20POLY1305`: the 24-byte nonce, the 48-byte ciphertext with its tag, and the + * inviter's 64-byte Ed25519 signature over both. + */ +class InviteKeyEnvelope(val nonce: ByteArray, val ciphertext: ByteArray, val signature: ByteArray) + +/** The step at which [InviteCipher.openInviteKeyBundle] rejected a bundle, in the order they run. */ +enum class InviteBundleFailure { + /** The bundle's `wrapped_by` is not the chat's creator. */ + WrappedBy, + + /** The copy's signature does not verify against the creator's public key. */ + Signature, + + /** The member's `InviteSecretEnvelope` does not open, or opens to a secret of another invite. */ + SecretOpen, + + /** The copy does not open under the wrap key derived from the secret. */ + KeyOpen, +} + +/** What [InviteCipher.openInviteKeyBundle] makes of a bundle. Either way, the chat key is never in [toString]. */ +sealed interface InviteBundleResult { + class Opened(val chatKey: ByteArray) : InviteBundleResult { + override fun toString(): String = "Opened" + } + + /** The member treats this as the wrong key: it warns and does not send in the chat. */ + class Rejected(val failsAt: InviteBundleFailure) : InviteBundleResult { + override fun toString(): String = "Rejected($failsAt)" + } +} + +/** + * Invite links for private groups: the derivations in `chat.v1.Invite`, the invite's copy of the + * chat key (`InviteKeyEnvelope`) and the link's secret wrapped for oneself (`InviteSecretEnvelope`), + * from flipcash2-protobuf-api. `test-vectors/invite.json` in the orchestrator repo pins it. + * [DefaultInviteCipher] is the implementation; the interface is the seam for fakes in either app. + * + * The link's 32-byte secret, the invite token and the wrap key let anyone join or read the chat: + * callers never log them, and no type here prints them. `chatId` is the raw `ChatId.value`, + * `inviteId` the raw 16-byte `InviteId.value`. Envelope schemes are the caller's to check: these + * functions implement the one scheme each message defines. + * + * Link encoding and routing are in [InviteLink]. + */ +interface InviteCipher { + /** 32 fresh random bytes: the secret of a new invite's link. */ + fun newSecret(): ByteArray + + /** The 32-byte `InviteToken.value` for [secret]. It does not depend on the chat. */ + @Throws(ChatCipherException::class) + fun inviteToken(secret: ByteArray): ByteArray + + /** The 16-byte `InviteId.value` for [secret], as the server computes it from the token. */ + @Throws(ChatCipherException::class) + fun inviteId(secret: ByteArray): ByteArray + + /** + * Seals [chatKey] as [secret]'s invite copy for [chatId], under a fresh random nonce, and signs + * it with [inviter], the account creating the invite. + * + * @throws ChatCipherException if [secret] or [chatKey] is not 32 bytes, or [inviter] is malformed. + */ + @Throws(ChatCipherException::class) + fun sealInviteKeyEnvelope(inviter: KeyPair, secret: ByteArray, chatId: ByteArray, chatKey: ByteArray): InviteKeyEnvelope + + /** + * Verifies [envelope]'s signature against [inviterPk], then opens it with [secret]'s wrap key + * for [chatId], and returns the chat key. A member opening the copy they joined with uses + * [openInviteKeyBundle] instead, which also runs the checks before this one. + * + * @throws ChatCipherException if the signature does not verify or the copy does not open. + */ + @Throws(ChatCipherException::class) + fun openInviteKeyEnvelope(secret: ByteArray, chatId: ByteArray, inviterPk: ByteArray, envelope: InviteKeyEnvelope): ByteArray + + /** + * Wraps [secret] for [owner] alone as `InviteSecretEnvelope` scheme `X25519_XCHACHA20POLY1305`, + * under a fresh random nonce. The inviter makes one with `CreateInvite`, a joiner with `JoinChat`. + * + * @throws ChatCipherException if [secret] is not 32 bytes or [owner] is malformed. + */ + @Throws(ChatCipherException::class) + fun sealInviteSecretEnvelope(owner: KeyPair, secret: ByteArray, chatId: ByteArray, inviteId: ByteArray): EncryptedPayload + + /** @throws ChatCipherException if [envelope] does not open for [owner], [chatId] and [inviteId]. */ + @Throws(ChatCipherException::class) + fun openInviteSecretEnvelope(owner: KeyPair, chatId: ByteArray, inviteId: ByteArray, envelope: EncryptedPayload): ByteArray + + /** + * Opens `GetKeyEnvelopeResponse.invite_key_bundle` for [member], in the order the + * `InviteKeyEnvelope` comment sets: [wrappedByCreator] (whether `wrapped_by` is + * `Metadata.creator`), then the copy's signature against [creatorPk], then the member's + * [secretEnvelope], then the copy itself. [creatorPk] is one learned outside the service when + * the caller has it, otherwise `wrapped_by_public_key`. [inviteId] is the bundle's `invite_id`. + * + * Never throws: every failure is a [InviteBundleResult.Rejected] naming its step. + */ + fun openInviteKeyBundle( + member: KeyPair, + creatorPk: ByteArray, + wrappedByCreator: Boolean, + chatId: ByteArray, + inviteId: ByteArray, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: EncryptedPayload, + ): InviteBundleResult +} diff --git a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/InviteLink.kt b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/InviteLink.kt new file mode 100644 index 0000000000..707fc6ebc4 --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/InviteLink.kt @@ -0,0 +1,83 @@ +package com.getcode.chatcipher + +import kotlin.io.encoding.Base64 + +/** What a URL is to the apps' routers, per [InviteLink.route]. */ +sealed interface InviteLinkRoute { + /** `/chat/invite` with a valid secret. [secret] is never in [toString]. */ + class Invite(val secret: ByteArray) : InviteLinkRoute { + override fun toString(): String = "Invite" + } + + /** `/chat/invite` without a valid secret: a broken invite link, not a chat. */ + data object InvalidInvite : InviteLinkRoute + + /** `/chat/{uuid}`, a group's plain link, for the existing chat route. */ + data object Chat : InviteLinkRoute + + /** Anything else, left to the existing routes. */ + data object Other : InviteLinkRoute +} + +/** + * The invite link, `https://app.flipcash.com/chat/invite#s=` (spec Decision 5), with the + * secret in unpadded base64url. The fragment is `/`-separated `key=value` pairs, as both apps' + * deep-link parsers read it, which is why the secret cannot use standard base64. + * + * The link is as good as the chat key: never log it, its fragment or the secret. + */ +object InviteLink { + private const val ORIGIN = "https://app.flipcash.com" + private const val INVITE_PATH = "/chat/invite" + private const val SECRET_KEY = "s=" + private const val SECRET_SIZE = 32 + private val codec = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + private val uuid = Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + + /** @throws ChatCipherException if [secret] is not 32 bytes. */ + @Throws(ChatCipherException::class) + fun url(secret: ByteArray): String { + if (secret.size != SECRET_SIZE) throw ChatCipherException("invite secret must be 32 bytes") + return "$ORIGIN$INVITE_PATH#$SECRET_KEY${codec.encode(secret)}" + } + + /** + * The secret in [fragment] (without its `#`), or `null` when it holds none. Exactly one `s=` + * pair, in canonical unpadded base64url of 32 bytes; other keys are ignored. + */ + fun parseFragment(fragment: String): ByteArray? { + val values = fragment.split('/').filter { it.startsWith(SECRET_KEY) } + val value = values.singleOrNull()?.removePrefix(SECRET_KEY) ?: return null + val secret = try { + codec.decode(value) + } catch (e: IllegalArgumentException) { + return null + } + // Re-encoding rejects non-zero trailing bits, which the decoder accepts. + if (secret.size != SECRET_SIZE || codec.encode(secret) != value) return null + return secret + } + + /** + * Classifies [url] so a router can match the invite path ahead of `/chat/{uuid}`. Only + * `https://app.flipcash.com` links are [InviteLinkRoute.Invite], [InviteLinkRoute.InvalidInvite] + * or [InviteLinkRoute.Chat]; a query string is ignored. + */ + fun route(url: String): InviteLinkRoute { + val hash = url.indexOf('#') + val fragment = if (hash < 0) null else url.substring(hash + 1) + val beforeFragment = if (hash < 0) url else url.substring(0, hash) + val query = beforeFragment.indexOf('?') + val address = if (query < 0) beforeFragment else beforeFragment.substring(0, query) + if (!address.startsWith("$ORIGIN/", ignoreCase = true)) return InviteLinkRoute.Other + val path = address.substring(ORIGIN.length) + + if (path == INVITE_PATH || path == "$INVITE_PATH/") { + val secret = fragment?.let(::parseFragment) ?: return InviteLinkRoute.InvalidInvite + return InviteLinkRoute.Invite(secret) + } + val segments = path.removePrefix("/").split('/') + if (segments.size >= 2 && segments[0] == "chat" && uuid.matches(segments[1])) return InviteLinkRoute.Chat + return InviteLinkRoute.Other + } +} diff --git a/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/InviteVectorTest.kt b/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/InviteVectorTest.kt new file mode 100644 index 0000000000..89399c3b9c --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/InviteVectorTest.kt @@ -0,0 +1,214 @@ +package com.getcode.chatcipher + +import com.getcode.ed25519kmp.Ed25519Kmp +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** Reproduces `invite.json`, which `gen_invite.py` in the orchestrator repo builds from libsodium and `cryptography`. */ +class InviteVectorTest { + private val root = Json.parseToJsonElement(readTestResource("invite.json")).jsonObject + private fun list(key: String) = root[key]!!.jsonArray.map { it.jsonObject } + private fun rejects(key: String) = root["rejects"]!!.jsonObject[key]!!.jsonArray.map { it.jsonObject } + + @Test + fun derivation() { + val vectors = list("derivation") + assertEquals(8, vectors.size) + for (v in vectors) { + val secret = v.hex("secret") + assertContentEquals(v.hex("inviteToken"), DefaultInviteCipher.inviteToken(secret), v.name()) + assertContentEquals(v.hex("wrapKey"), DefaultInviteCipher.wrapKey(secret, v.hex("chatId")), v.name()) + assertContentEquals(v.hex("inviteId"), DefaultInviteCipher.inviteId(secret), v.name()) + } + } + + @Test + fun keyEnvelopes_sealAndOpen() { + val vectors = list("keyEnvelopes") + assertEquals(4, vectors.size) + for (v in vectors) { + val inviter = Ed25519Kmp.createKeyPair(v.hex("inviterSeed")) + assertContentEquals(v.hex("inviterPublicKey"), inviter.publicKey, v.name()) + assertContentEquals(v.hex("inviteId"), DefaultInviteCipher.inviteId(v.hex("secret")), v.name()) + assertContentEquals(v.hex("wrapKey"), DefaultInviteCipher.wrapKey(v.hex("secret"), v.hex("chatId")), v.name()) + assertContentEquals( + v.hex("signedMessage"), + DefaultInviteCipher.signedMessage(v.hex("chatId"), v.hex("inviteId"), v.hex("nonce"), v.hex("ciphertext")), + v.name(), + ) + val sealed = DefaultInviteCipher.sealInviteKeyEnvelope(inviter, v.hex("secret"), v.hex("chatId"), v.hex("chatKey"), v.hex("nonce")) + assertContentEquals(v.hex("ciphertext"), sealed.ciphertext, v.name()) + assertContentEquals(v.hex("signature"), sealed.signature, v.name()) + val chatKey = DefaultInviteCipher.openInviteKeyEnvelope(v.hex("secret"), v.hex("chatId"), inviter.publicKey, v.keyEnvelope()) + assertContentEquals(v.hex("chatKey"), chatKey, v.name()) + } + } + + @Test + fun secretEnvelopes_sealAndOpen() { + val vectors = list("secretEnvelopes") + assertEquals(4, vectors.size) + for (v in vectors) { + val owner = Ed25519Kmp.createKeyPair(v.hex("ownerSeed")) + assertContentEquals(v.hex("ownerPublicKey"), owner.publicKey, v.name()) + assertContentEquals(v.hex("wrapKey"), DefaultInviteCipher.secretKey(owner, v.hex("chatId"), v.hex("inviteId")), v.name()) + val sealed = DefaultInviteCipher.sealInviteSecretEnvelope(owner, v.hex("secret"), v.hex("chatId"), v.hex("inviteId"), v.hex("nonce")) + assertContentEquals(v.hex("ciphertext"), sealed.ciphertext, v.name()) + val secret = DefaultInviteCipher.openInviteSecretEnvelope(owner, v.hex("chatId"), v.hex("inviteId"), v.payload()) + assertContentEquals(v.hex("secret"), secret, v.name()) + } + } + + @Test + fun bundles_open() { + val vectors = list("bundles") + assertTrue(vectors.isNotEmpty()) + for (v in vectors) { + val result = v.openBundle(v.hex("inviteId")) + assertIs(result, v.name()) + assertContentEquals(v.hex("chatKey"), result.chatKey, v.name()) + } + } + + @Test + fun links_encode() { + val vectors = list("links") + assertEquals(4, vectors.size) + for (v in vectors) { + assertEquals(v.string("url"), InviteLink.url(v.hex("secret")), v.name()) + assertEquals(v.string("url"), "https://app.flipcash.com/chat/invite#" + v.string("fragment"), v.name()) + assertContentEquals(v.hex("secret"), InviteLink.parseFragment(v.string("fragment")), v.name()) + } + } + + @Test + fun fragmentParse() { + val vectors = list("fragmentParse") + assertEquals(13, vectors.size) + for (v in vectors) { + val parsed = InviteLink.parseFragment(v.string("fragment")) + if (v["secret"] is JsonNull) assertNull(parsed, v.name()) else assertContentEquals(v.hex("secret"), parsed, v.name()) + } + } + + @Test + fun routes() { + val vectors = list("routes") + assertEquals(7, vectors.size) + for (v in vectors) { + val route = InviteLink.route(v.string("url")) + when (v.string("route")) { + "invite" -> { + assertIs(route, v.name()) + assertContentEquals(v.hex("secret"), route.secret, v.name()) + } + "invalidInvite" -> assertEquals(InviteLinkRoute.InvalidInvite, route, v.name()) + "chat" -> assertEquals(InviteLinkRoute.Chat, route, v.name()) + "other" -> assertEquals(InviteLinkRoute.Other, route, v.name()) + else -> fail("${v.name()}: unknown route ${v.string("route")}") + } + } + } + + @Test + fun rejects_keyEnvelopes_failAtTheirStep() { + val vectors = rejects("keyEnvelope") + assertEquals(8, vectors.size) + for (v in vectors) { + val secret = v.hex("secret") + val result = DefaultInviteCipher.openKeyEnvelope( + secret, v.hex("chatId"), DefaultInviteCipher.inviteId(secret), v.hex("inviterPublicKey"), v.keyEnvelope(), + ) + val expected = when (v.string("failsAt")) { + "signature" -> InviteBundleFailure.Signature + "open" -> InviteBundleFailure.KeyOpen + else -> fail("${v.name()}: unknown step ${v.string("failsAt")}") + } + assertIs(result, v.name()) + assertEquals(expected, result.failsAt, v.name()) + assertFailsWith(v.name()) { + DefaultInviteCipher.openInviteKeyEnvelope(secret, v.hex("chatId"), v.hex("inviterPublicKey"), v.keyEnvelope()) + } + } + } + + @Test + fun rejects_secretEnvelopes() { + val vectors = rejects("secretEnvelope") + assertEquals(6, vectors.size) + for (v in vectors) { + assertEquals("open", v.string("failsAt"), v.name()) + val owner = Ed25519Kmp.createKeyPair(v.hex("ownerSeed")) + assertContentEquals(v.hex("ownerPublicKey"), owner.publicKey, v.name()) + assertFailsWith(v.name()) { + DefaultInviteCipher.openInviteSecretEnvelope(owner, v.hex("chatId"), v.hex("inviteId"), v.payload()) + } + } + } + + @Test + fun rejects_bundles_failAtTheirStep() { + val vectors = rejects("bundles") + assertEquals(3, vectors.size) + // The reject cases carry no inviteId; every bundle in the fixture is for the same invite. + val inviteId = list("bundles").single().hex("inviteId") + for (v in vectors) { + val result = v.openBundle(inviteId) + val expected = InviteBundleFailure.entries.single { it.name.equals(v.string("failsAt"), ignoreCase = true) } + assertIs(result, v.name()) + assertEquals(expected, result.failsAt, v.name()) + } + } + + @Test + fun randomSeal_roundTripsAndDiffers() { + val v = list("keyEnvelopes").first() + val inviter = Ed25519Kmp.createKeyPair(v.hex("inviterSeed")) + val a = DefaultInviteCipher.sealInviteKeyEnvelope(inviter, v.hex("secret"), v.hex("chatId"), v.hex("chatKey")) + val b = DefaultInviteCipher.sealInviteKeyEnvelope(inviter, v.hex("secret"), v.hex("chatId"), v.hex("chatKey")) + assertTrue(!a.nonce.contentEquals(b.nonce)) + assertContentEquals(v.hex("chatKey"), DefaultInviteCipher.openInviteKeyEnvelope(v.hex("secret"), v.hex("chatId"), inviter.publicKey, a)) + + val secret = DefaultInviteCipher.newSecret() + assertEquals(32, secret.size) + assertContentEquals(secret, InviteLink.parseFragment(InviteLink.url(secret).substringAfter('#'))) + } + + private fun JsonObject.openBundle(inviteId: ByteArray): InviteBundleResult { + val member = Ed25519Kmp.createKeyPair(hex("memberSeed")) + assertContentEquals(hex("memberPublicKey"), member.publicKey, name()) + val key = this["keyEnvelope"]!!.jsonObject + val secret = this["secretEnvelope"]!!.jsonObject + return DefaultInviteCipher.openInviteKeyBundle( + member = member, + creatorPk = hex("creatorPublicKey"), + wrappedByCreator = this["wrappedByIsCreator"]!!.jsonPrimitive.boolean, + chatId = hex("chatId"), + inviteId = inviteId, + keyEnvelope = key.keyEnvelope(), + secretEnvelope = secret.payload(), + ) + } + + private fun JsonObject.keyEnvelope() = InviteKeyEnvelope(hex("nonce"), hex("ciphertext"), hex("signature")) + private fun JsonObject.payload() = EncryptedPayload(hex("nonce"), hex("ciphertext")) + private fun JsonObject.name() = this["name"]?.jsonPrimitive?.content ?: "bundle" + private fun JsonObject.string(key: String) = this[key]!!.jsonPrimitive.content + private fun JsonObject.hex(key: String): ByteArray { + val s = this[key]!!.jsonPrimitive.content + return ByteArray(s.length / 2) { i -> ((s[i * 2].digitToInt(16) shl 4) or s[i * 2 + 1].digitToInt(16)).toByte() } + } +} diff --git a/libs/encryption/chat-cipher/src/commonTest/resources/invite.json b/libs/encryption/chat-cipher/src/commonTest/resources/invite.json new file mode 100644 index 0000000000..9dba1a5db8 --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonTest/resources/invite.json @@ -0,0 +1,556 @@ +{ + "algorithm": "flipcash-group-invite-v1: HKDF-SHA256 derivation, InviteKeyEnvelope XCHACHA20POLY1305 + Ed25519, InviteSecretEnvelope X25519_XCHACHA20POLY1305", + "spec": "Invite, InviteKeyEnvelope, InviteSecretEnvelope and KeyEnvelope in flipcash2-protobuf-api proto/chat/v1/model.proto (flipcash2-client-protocol 0.22.0). Link format: docs/specs/group-invites.md Decision 5. All fields are lowercase hex unless noted.", + "note": "Cross-platform parity gate for the invite cipher in :libs:encryption:chat-cipher, exported to iOS through :kmp:shared-core. chatId is the raw ChatId.value (16-byte UUID for a group). An empty HKDF salt is 32 zero bytes. Nonces are fixed here; production nonces are random. `rejects` cases must fail at the step named in `failsAt`.", + "derivation": [ + { + "name": "pattern/chat-1", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteToken": "2b5e3a8574e4ddfb0b428d2fa8dc371e41354e4733af6265757cd980b03495e2", + "wrapKey": "dad952b94a2169287b8941822fe1d51eebe72681b09b674846d1df40a6ee6506", + "inviteId": "a93f48c2a250501856a290e1babf91aa" + }, + { + "name": "pattern/chat-2", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteToken": "2b5e3a8574e4ddfb0b428d2fa8dc371e41354e4733af6265757cd980b03495e2", + "wrapKey": "ba3813bb89fe2bd56143271f6080b0756e3009cf63a2cb79c0d142d6b9254b78", + "inviteId": "a93f48c2a250501856a290e1babf91aa" + }, + { + "name": "all-zero/chat-1", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteToken": "2d1d00f091442a5b96a998b9bb64ed31a99ff8281df1b082f3a40fa6e133517f", + "wrapKey": "3039b1e294240d3eee722baff8bff9e25c2ec41c8f43f77a57bae7477efce330", + "inviteId": "3b18832cb378a9083e2f3449a8570840" + }, + { + "name": "all-zero/chat-2", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteToken": "2d1d00f091442a5b96a998b9bb64ed31a99ff8281df1b082f3a40fa6e133517f", + "wrapKey": "1d5b97383504cbbc248dc1877dfbbb18fc3fe620004ad58224e24bb652e3b647", + "inviteId": "3b18832cb378a9083e2f3449a8570840" + }, + { + "name": "all-ff/chat-1", + "secret": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteToken": "d89701b8276e318a5944844c84de82509a8b76ff8452acb7e4a7588f67a2175f", + "wrapKey": "69273581173a38a169cfac66b388fbd684862bc2e6dd65621ea53c204a96a27d", + "inviteId": "6c9d7e871a4904ae635db92c7452e44e" + }, + { + "name": "all-ff/chat-2", + "secret": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteToken": "d89701b8276e318a5944844c84de82509a8b76ff8452acb7e4a7588f67a2175f", + "wrapKey": "7f5b6af4e74aa9101c1eed3929326d79afc168c2a69fb487ab3ebf45c7eacbcb", + "inviteId": "6c9d7e871a4904ae635db92c7452e44e" + }, + { + "name": "sha256-seed/chat-1", + "secret": "c23f9c80a99760bae87574ea4090ab1f4f4929e262b973bd30608392d3b7ba1d", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteToken": "32f96288d18ac73d6a7ce9334226a243b98eed8a82f66ab7e708525e88bb4503", + "wrapKey": "ba68222d4afb6a434a5c8b35b38f4b474714ee578b776059902dc31c5f651fa1", + "inviteId": "8b53332bd9ecda59b786d416c51916a8" + }, + { + "name": "sha256-seed/chat-2", + "secret": "c23f9c80a99760bae87574ea4090ab1f4f4929e262b973bd30608392d3b7ba1d", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteToken": "32f96288d18ac73d6a7ce9334226a243b98eed8a82f66ab7e708525e88bb4503", + "wrapKey": "709e9a2f9a5c330705368b6979034c944106b89d9fe5422edddaf70d11e5154d", + "inviteId": "8b53332bd9ecda59b786d416c51916a8" + } + ], + "keyEnvelopes": [ + { + "name": "pattern/chat-1", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "wrapKey": "dad952b94a2169287b8941822fe1d51eebe72681b09b674846d1df40a6ee6506", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687", + "inviterSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "aad": "666c6970636173682d67726f75702d696e766974652d777261702d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signedMessage": "666c6970636173682d67726f75702d696e766974652d7369672d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa000102030405060708090a0b0c0d0e0f101112131415161708c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01" + }, + { + "name": "pattern/chat-2", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "wrapKey": "ba3813bb89fe2bd56143271f6080b0756e3009cf63a2cb79c0d142d6b9254b78", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687", + "inviterSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122", + "aad": "666c6970636173682d67726f75702d696e766974652d777261702d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa", + "ciphertext": "daefc0d29e1f0a58d724d82d1416a78aecec4571461d725697228df524639d7dff48e77b68d55a7171542702d7dd18e2", + "signedMessage": "666c6970636173682d67726f75702d696e766974652d7369672d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122daefc0d29e1f0a58d724d82d1416a78aecec4571461d725697228df524639d7dff48e77b68d55a7171542702d7dd18e2", + "signature": "53c82f3ab691cec3ebc7ea5c18d59666a0bfcf4f8d01d4f93830821089af7c3c3720fedc33283ddfc6bed1e30833dc54ffafce2f14d0c3aa7d636a0db7a8a902" + }, + { + "name": "all-zero/chat-1", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "3b18832cb378a9083e2f3449a8570840", + "wrapKey": "3039b1e294240d3eee722baff8bff9e25c2ec41c8f43f77a57bae7477efce330", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687", + "inviterSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "161718191a1b1c1d1e1f202122232425262728292a2b2c2d", + "aad": "666c6970636173682d67726f75702d696e766974652d777261702d76316f9619ff8b86d011b42d00c04fc964ff3b18832cb378a9083e2f3449a8570840", + "ciphertext": "c9f4e2a0553a2387876a3b223183e22142c5fce883b37db8bf1fdc72547adfc59d385557f1b5859819b531cd03c93807", + "signedMessage": "666c6970636173682d67726f75702d696e766974652d7369672d76316f9619ff8b86d011b42d00c04fc964ff3b18832cb378a9083e2f3449a8570840161718191a1b1c1d1e1f202122232425262728292a2b2c2dc9f4e2a0553a2387876a3b223183e22142c5fce883b37db8bf1fdc72547adfc59d385557f1b5859819b531cd03c93807", + "signature": "83e4269e2ac62836462ae16b4ea92234b2b861d3048b85476c26bfe84c6173a9b761cdc4955d4b7fd18cb4f8cf5fbe5a7f48baa2577195f59ea88880a3c54f08" + }, + { + "name": "all-zero/chat-2", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "3b18832cb378a9083e2f3449a8570840", + "wrapKey": "1d5b97383504cbbc248dc1877dfbbb18fc3fe620004ad58224e24bb652e3b647", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687", + "inviterSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "2122232425262728292a2b2c2d2e2f303132333435363738", + "aad": "666c6970636173682d67726f75702d696e766974652d777261702d76310b1c2d3e4f5061728394a5b6c7d8e9f03b18832cb378a9083e2f3449a8570840", + "ciphertext": "dea46b00e8706bbd3162ea727cfb9bf3297ea5e787ba7d0d36e2a17e7601d708d06f8a5336f4cb0bafe5bc7a9b056289", + "signedMessage": "666c6970636173682d67726f75702d696e766974652d7369672d76310b1c2d3e4f5061728394a5b6c7d8e9f03b18832cb378a9083e2f3449a85708402122232425262728292a2b2c2d2e2f303132333435363738dea46b00e8706bbd3162ea727cfb9bf3297ea5e787ba7d0d36e2a17e7601d708d06f8a5336f4cb0bafe5bc7a9b056289", + "signature": "de4a0731ba5d6394b58317e1c902770526e038f3c11d2a70e0c96ba62f68468f130edfb8cf3919a6739101d59b6b72dd5049b434be3c997f7a17c17b87abbc06" + } + ], + "secretEnvelopes": [ + { + "name": "host/chat-1", + "ownerSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "ownerPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "sharedSecret": "ef5bcef3cc6b22665571c18e97fed5ae78a55b6182b3a9b070178856e1c6b409", + "salt": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "info": "666c6970636173682d67726f75702d696e766974652d7365637265742d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa", + "wrapKey": "2066bbaece7e97d71418c5eeb39c23236a21d0661a6f0d8d2ff122969464d7d0", + "nonce": "6e6f707172737475767778797a7b7c7d7e7f808182838485", + "aad": "666c6970636173682d67726f75702d696e766974652d7365637265742d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "ciphertext": "4d5c41b7c0657e2e38465ffa9d35878a80fb3da9024721225a516a12204f79fa12c62b55044372f18800072dd2f30013" + }, + { + "name": "host/chat-2", + "ownerSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "ownerPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "sharedSecret": "ef5bcef3cc6b22665571c18e97fed5ae78a55b6182b3a9b070178856e1c6b409", + "salt": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "info": "666c6970636173682d67726f75702d696e766974652d7365637265742d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa", + "wrapKey": "f520c73b24b05ac4c9bd8d02362846443784d94247a3b35a96775db4d8543231", + "nonce": "797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f90", + "aad": "666c6970636173682d67726f75702d696e766974652d7365637265742d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "ciphertext": "2de1501b1f72b4550588c1bd5d748ea29e48c0bf8d0581b292a2ecf86e3745a49d6576ca92daf0410787fef2f15bcefd" + }, + { + "name": "joiner/chat-1", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "sharedSecret": "0ba70c7a7b16889b5a16d4bf6d3555938e7c76335e4e105d8360fed7383fc279", + "salt": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "info": "666c6970636173682d67726f75702d696e766974652d7365637265742d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa", + "wrapKey": "b459853ea32abdab18a40340bfccf5a259de4d2547aa387a4df851c975440dcd", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "aad": "666c6970636173682d67726f75702d696e766974652d7365637265742d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aae7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8" + }, + { + "name": "joiner/chat-2", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "sharedSecret": "0ba70c7a7b16889b5a16d4bf6d3555938e7c76335e4e105d8360fed7383fc279", + "salt": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "info": "666c6970636173682d67726f75702d696e766974652d7365637265742d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa", + "wrapKey": "de50d72a089810a4d44759d00ff398473657632fa273987c4da34376fd9819ab", + "nonce": "8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6", + "aad": "666c6970636173682d67726f75702d696e766974652d7365637265742d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aae7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "ciphertext": "a032f892d4884d55503c3f6983bf6e0240b11fae6e26761d3cc9546b23aff33a109421e06d09dcf8ad118f0066b01485" + } + ], + "bundles": [ + { + "name": "opens", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "creatorPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByIsCreator": true, + "memberSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "memberPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "keyEnvelope": { + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01" + }, + "secretEnvelope": { + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8" + }, + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687" + } + ], + "links": [ + { + "name": "pattern", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "url": "https://app.flipcash.com/chat/invite#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g" + }, + { + "name": "all-zero", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "fragment": "s=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "url": "https://app.flipcash.com/chat/invite#s=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + { + "name": "all-ff", + "secret": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "fragment": "s=__________________________________________8", + "url": "https://app.flipcash.com/chat/invite#s=__________________________________________8" + }, + { + "name": "sha256-seed", + "secret": "c23f9c80a99760bae87574ea4090ab1f4f4929e262b973bd30608392d3b7ba1d", + "fragment": "s=wj-cgKmXYLrodXTqQJCrH09JKeJiuXO9MGCDktO3uh0", + "url": "https://app.flipcash.com/chat/invite#s=wj-cgKmXYLrodXTqQJCrH09JKeJiuXO9MGCDktO3uh0" + } + ], + "fragmentParse": [ + { + "name": "plain", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "unknown-key-ignored", + "fragment": "v=1/s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "unknown-key-after", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g/x=y", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "empty", + "fragment": "", + "secret": null + }, + { + "name": "missing-s", + "fragment": "t=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": null + }, + { + "name": "empty-value", + "fragment": "s=", + "secret": null + }, + { + "name": "padded", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g=", + "secret": null + }, + { + "name": "standard-alphabet", + "fragment": "s=+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/s", + "secret": null + }, + { + "name": "short-31-bytes", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-iw", + "secret": null + }, + { + "name": "long-33-bytes", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5gA", + "secret": null + }, + { + "name": "non-canonical-pad-bits", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5h", + "secret": null + }, + { + "name": "duplicate-s", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g/s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": null + }, + { + "name": "whitespace", + "fragment": "s= BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": null + } + ], + "routes": [ + { + "name": "invite", + "url": "https://app.flipcash.com/chat/invite#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "route": "invite", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "invite-trailing-slash", + "url": "https://app.flipcash.com/chat/invite/#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "route": "invite", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "invite-with-query", + "url": "https://app.flipcash.com/chat/invite?utm=x#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "route": "invite", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "invite-no-fragment", + "url": "https://app.flipcash.com/chat/invite", + "route": "invalidInvite" + }, + { + "name": "invite-bad-fragment", + "url": "https://app.flipcash.com/chat/invite#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g=", + "route": "invalidInvite" + }, + { + "name": "chat-uuid", + "url": "https://app.flipcash.com/chat/6f9619ff-8b86-d011-b42d-00c04fc964ff", + "route": "chat" + }, + { + "name": "not-invite-prefix", + "url": "https://app.flipcash.com/chat/invites#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "route": "other" + } + ], + "rejects": { + "keyEnvelope": [ + { + "name": "tampered-ciphertext", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e27", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "tampered-nonce", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "010102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "tampered-signature", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7cfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "signed-by-other-key", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "62d9e45de749b823fe8f91ae0a06122255d29f1e3e9d01c972b49c98ccae292d3297b3a281b571c2911e943bc0b04d07eb775e42a871189e1c58605e2961b00e", + "failsAt": "signature" + }, + { + "name": "wrong-chat-id", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "resigned-tampered-ciphertext", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e27", + "signature": "c79bacc7c570a5cd20d9c67250065926112dc9a28d4a630972bb0cd47722f4499553e650b0fae2723ec6df3f4d6e1dd75b0d2733f74f4b63335faea0b62ac90e", + "failsAt": "open" + }, + { + "name": "wrong-secret", + "secret": "c23f9c80a99760bae87574ea4090ab1f4f4929e262b973bd30608392d3b7ba1d", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "other-chats-copy", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122", + "ciphertext": "daefc0d29e1f0a58d724d82d1416a78aecec4571461d725697228df524639d7dff48e77b68d55a7171542702d7dd18e2", + "signature": "04179b56ba2648ccde865aadca2f0e7a31690dbcbf9e8594a11ed3fb045c86f6f1e9730360307949bbb8cb81728de88025371d20cf50e6c7e8eef5f31a7d910b", + "failsAt": "open" + } + ], + "secretEnvelope": [ + { + "name": "tampered-tag", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b9", + "failsAt": "open" + }, + { + "name": "tampered-body", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f0511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + }, + { + "name": "wrong-owner", + "ownerSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "ownerPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + }, + { + "name": "wrong-chat-id", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + }, + { + "name": "wrong-invite-id", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91ab", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + }, + { + "name": "wrong-nonce", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "4142434445464748494a4b4c4d4e4f505152535455565758", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + } + ], + "bundles": [ + { + "name": "wrapped-by-not-creator", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "creatorPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByPublicKey": "adc14011f82d1c56d956aa4f9d73d8858361a606048525e0d08c638dc75dd8c7", + "wrappedByIsCreator": false, + "memberSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "memberPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "keyEnvelope": { + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "62d9e45de749b823fe8f91ae0a06122255d29f1e3e9d01c972b49c98ccae292d3297b3a281b571c2911e943bc0b04d07eb775e42a871189e1c58605e2961b00e" + }, + "secretEnvelope": { + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8" + }, + "failsAt": "wrappedBy" + }, + { + "name": "resigned-tampered-copy", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "creatorPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByIsCreator": true, + "memberSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "memberPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "keyEnvelope": { + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e27", + "signature": "c79bacc7c570a5cd20d9c67250065926112dc9a28d4a630972bb0cd47722f4499553e650b0fae2723ec6df3f4d6e1dd75b0d2733f74f4b63335faea0b62ac90e" + }, + "secretEnvelope": { + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8" + }, + "failsAt": "keyOpen" + }, + { + "name": "secret-envelope-of-another-owner", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "creatorPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByIsCreator": true, + "memberSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "memberPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "keyEnvelope": { + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01" + }, + "secretEnvelope": { + "nonce": "6e6f707172737475767778797a7b7c7d7e7f808182838485", + "ciphertext": "4d5c41b7c0657e2e38465ffa9d35878a80fb3da9024721225a516a12204f79fa12c62b55044372f18800072dd2f30013" + }, + "failsAt": "secretOpen" + } + ] + } +} From 00ad89e929378d9707b36d3c0e2fc39c2d918147 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:07:18 -0400 Subject: [PATCH 2/2] feat(shared-core): expose the invite cipher to Swift SharedInviteCipher wraps DefaultInviteCipher and InviteLink with Data in and out, maps InviteBundleResult to a thrown BundleError and InviteLinkRoute to a Swift enum. InviteCipherTests runs invite.json through the facade; sealing under the fixture's fixed nonces stays on the Kotlin side, so here sealing is checked by round trip. --- .../Sources/SharedCoreKit/InviteCipher.swift | 205 +++++++ .../SharedCoreKitTests/Fixtures/invite.json | 556 ++++++++++++++++++ .../InviteCipherTests.swift | 212 +++++++ 3 files changed, 973 insertions(+) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/InviteCipher.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/invite.json create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/InviteCipherTests.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/InviteCipher.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/InviteCipher.swift new file mode 100644 index 0000000000..f90d8037f1 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/InviteCipher.swift @@ -0,0 +1,205 @@ +import Foundation +import SharedCore + +/// Invite links for private groups, computed by the shared Kotlin (`DefaultInviteCipher` and +/// `InviteLink` in chat-cipher). `test-vectors/invite.json` pins it on both platforms. +/// +/// The link's secret, its token and the link itself are as good as the chat key: never log them. +/// `chatId` is the raw `ChatId.value` and `inviteId` the raw 16-byte `InviteId.value`. Key pairs are +/// the ones `SharedEd25519.keyPair(seed:)` returns. +public enum SharedInviteCipher { + + /// `chat.v1.InviteKeyEnvelope` scheme `XCHACHA20POLY1305`, without its scheme. + public struct KeyEnvelope: Equatable { + public let nonce: Data + public let ciphertext: Data + public let signature: Data + + public init(nonce: Data, ciphertext: Data, signature: Data) { + self.nonce = nonce + self.ciphertext = ciphertext + self.signature = signature + } + } + + /// `chat.v1.InviteSecretEnvelope` scheme `X25519_XCHACHA20POLY1305`, without its scheme. + public struct SecretEnvelope: Equatable { + public let nonce: Data + public let ciphertext: Data + + public init(nonce: Data, ciphertext: Data) { + self.nonce = nonce + self.ciphertext = ciphertext + } + } + + /// The step at which `openKeyBundle` rejected a bundle. Every case is the wrong-key state. + public enum BundleError: Error, Equatable { + case wrappedBy + case signature + case secretOpen + case keyOpen + } + + /// What a URL is to the router. `invite` and `invalidInvite` are matched ahead of `/chat/{uuid}`. + public enum Route: Equatable { + case invite(secret: Data) + case invalidInvite + case chat + case other + } + + /// 32 fresh random bytes: the secret of a new invite's link. + public static func newSecret() -> Data { + Data(DefaultInviteCipher.shared.doNewSecret()) + } + + public static func inviteToken(secret: Data) throws -> Data { + Data(try DefaultInviteCipher.shared.inviteToken(secret: secret.kotlinByteArray)) + } + + public static func inviteId(secret: Data) throws -> Data { + Data(try DefaultInviteCipher.shared.inviteId(secret: secret.kotlinByteArray)) + } + + /// Seals `chatKey` as the invite's copy under a fresh random nonce, signed by `inviter`. + public static func sealKeyEnvelope( + inviter: SharedEd25519.KeyPair, + secret: Data, + chatId: Data, + chatKey: Data + ) throws -> KeyEnvelope { + let envelope = try DefaultInviteCipher.shared.sealInviteKeyEnvelope( + inviter: inviter.kotlin, + secret: secret.kotlinByteArray, + chatId: chatId.kotlinByteArray, + chatKey: chatKey.kotlinByteArray + ) + return KeyEnvelope( + nonce: Data(envelope.nonce), + ciphertext: Data(envelope.ciphertext), + signature: Data(envelope.signature) + ) + } + + /// Verifies the copy against `inviterPublicKey`, then opens it. A member opening the copy they + /// joined with uses `openKeyBundle`. + public static func openKeyEnvelope( + secret: Data, + chatId: Data, + inviterPublicKey: Data, + envelope: KeyEnvelope + ) throws -> Data { + Data(try DefaultInviteCipher.shared.openInviteKeyEnvelope( + secret: secret.kotlinByteArray, + chatId: chatId.kotlinByteArray, + inviterPk: inviterPublicKey.kotlinByteArray, + envelope: envelope.kotlin + )) + } + + /// Wraps `secret` for `owner` alone, under a fresh random nonce. + public static func sealSecretEnvelope( + owner: SharedEd25519.KeyPair, + secret: Data, + chatId: Data, + inviteId: Data + ) throws -> SecretEnvelope { + let envelope = try DefaultInviteCipher.shared.sealInviteSecretEnvelope( + owner: owner.kotlin, + secret: secret.kotlinByteArray, + chatId: chatId.kotlinByteArray, + inviteId: inviteId.kotlinByteArray + ) + return SecretEnvelope(nonce: Data(envelope.nonce), ciphertext: Data(envelope.ciphertext)) + } + + public static func openSecretEnvelope( + owner: SharedEd25519.KeyPair, + chatId: Data, + inviteId: Data, + envelope: SecretEnvelope + ) throws -> Data { + Data(try DefaultInviteCipher.shared.openInviteSecretEnvelope( + owner: owner.kotlin, + chatId: chatId.kotlinByteArray, + inviteId: inviteId.kotlinByteArray, + envelope: envelope.kotlin + )) + } + + /// Opens `GetKeyEnvelopeResponse.invite_key_bundle` and returns the chat key. `wrappedByCreator` + /// is whether `wrapped_by` is `Metadata.creator`; `creatorPublicKey` is one learned outside the + /// service when there is one, otherwise `wrapped_by_public_key`; `inviteId` is the bundle's. + public static func openKeyBundle( + member: SharedEd25519.KeyPair, + creatorPublicKey: Data, + wrappedByCreator: Bool, + chatId: Data, + inviteId: Data, + keyEnvelope: KeyEnvelope, + secretEnvelope: SecretEnvelope + ) throws -> Data { + let result = DefaultInviteCipher.shared.openInviteKeyBundle( + member: member.kotlin, + creatorPk: creatorPublicKey.kotlinByteArray, + wrappedByCreator: wrappedByCreator, + chatId: chatId.kotlinByteArray, + inviteId: inviteId.kotlinByteArray, + keyEnvelope: keyEnvelope.kotlin, + secretEnvelope: secretEnvelope.kotlin + ) + if let opened = result as? InviteBundleResultOpened { + return Data(opened.chatKey) + } + let failsAt = (result as? InviteBundleResultRejected)?.failsAt + switch failsAt { + case InviteBundleFailure.wrappedby: throw BundleError.wrappedBy + case InviteBundleFailure.signature: throw BundleError.signature + case InviteBundleFailure.secretopen: throw BundleError.secretOpen + default: throw BundleError.keyOpen + } + } + + /// `https://app.flipcash.com/chat/invite#s=`. + public static func link(secret: Data) throws -> String { + try InviteLink.shared.url(secret: secret.kotlinByteArray) + } + + /// The secret in a URL fragment (without its `#`), or `nil` when it holds none. + public static func secret(fragment: String) -> Data? { + InviteLink.shared.parseFragment(fragment: fragment).map { Data($0) } + } + + public static func route(url: String) -> Route { + let route = InviteLink.shared.route(url: url) + if let invite = route as? InviteLinkRouteInvite { + return .invite(secret: Data(invite.secret)) + } + if route is InviteLinkRouteInvalidInvite { return .invalidInvite } + if route is InviteLinkRouteChat { return .chat } + return .other + } +} + +private extension SharedEd25519.KeyPair { + var kotlin: SharedCore.KeyPair { + SharedCore.KeyPair(publicKey: publicKey.kotlinByteArray, privateKey: privateKey.kotlinByteArray) + } +} + +private extension SharedInviteCipher.KeyEnvelope { + var kotlin: InviteKeyEnvelope { + InviteKeyEnvelope( + nonce: nonce.kotlinByteArray, + ciphertext: ciphertext.kotlinByteArray, + signature: signature.kotlinByteArray + ) + } +} + +private extension SharedInviteCipher.SecretEnvelope { + var kotlin: EncryptedPayload { + EncryptedPayload(nonce: nonce.kotlinByteArray, ciphertext: ciphertext.kotlinByteArray) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/invite.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/invite.json new file mode 100644 index 0000000000..9dba1a5db8 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/invite.json @@ -0,0 +1,556 @@ +{ + "algorithm": "flipcash-group-invite-v1: HKDF-SHA256 derivation, InviteKeyEnvelope XCHACHA20POLY1305 + Ed25519, InviteSecretEnvelope X25519_XCHACHA20POLY1305", + "spec": "Invite, InviteKeyEnvelope, InviteSecretEnvelope and KeyEnvelope in flipcash2-protobuf-api proto/chat/v1/model.proto (flipcash2-client-protocol 0.22.0). Link format: docs/specs/group-invites.md Decision 5. All fields are lowercase hex unless noted.", + "note": "Cross-platform parity gate for the invite cipher in :libs:encryption:chat-cipher, exported to iOS through :kmp:shared-core. chatId is the raw ChatId.value (16-byte UUID for a group). An empty HKDF salt is 32 zero bytes. Nonces are fixed here; production nonces are random. `rejects` cases must fail at the step named in `failsAt`.", + "derivation": [ + { + "name": "pattern/chat-1", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteToken": "2b5e3a8574e4ddfb0b428d2fa8dc371e41354e4733af6265757cd980b03495e2", + "wrapKey": "dad952b94a2169287b8941822fe1d51eebe72681b09b674846d1df40a6ee6506", + "inviteId": "a93f48c2a250501856a290e1babf91aa" + }, + { + "name": "pattern/chat-2", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteToken": "2b5e3a8574e4ddfb0b428d2fa8dc371e41354e4733af6265757cd980b03495e2", + "wrapKey": "ba3813bb89fe2bd56143271f6080b0756e3009cf63a2cb79c0d142d6b9254b78", + "inviteId": "a93f48c2a250501856a290e1babf91aa" + }, + { + "name": "all-zero/chat-1", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteToken": "2d1d00f091442a5b96a998b9bb64ed31a99ff8281df1b082f3a40fa6e133517f", + "wrapKey": "3039b1e294240d3eee722baff8bff9e25c2ec41c8f43f77a57bae7477efce330", + "inviteId": "3b18832cb378a9083e2f3449a8570840" + }, + { + "name": "all-zero/chat-2", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteToken": "2d1d00f091442a5b96a998b9bb64ed31a99ff8281df1b082f3a40fa6e133517f", + "wrapKey": "1d5b97383504cbbc248dc1877dfbbb18fc3fe620004ad58224e24bb652e3b647", + "inviteId": "3b18832cb378a9083e2f3449a8570840" + }, + { + "name": "all-ff/chat-1", + "secret": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteToken": "d89701b8276e318a5944844c84de82509a8b76ff8452acb7e4a7588f67a2175f", + "wrapKey": "69273581173a38a169cfac66b388fbd684862bc2e6dd65621ea53c204a96a27d", + "inviteId": "6c9d7e871a4904ae635db92c7452e44e" + }, + { + "name": "all-ff/chat-2", + "secret": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteToken": "d89701b8276e318a5944844c84de82509a8b76ff8452acb7e4a7588f67a2175f", + "wrapKey": "7f5b6af4e74aa9101c1eed3929326d79afc168c2a69fb487ab3ebf45c7eacbcb", + "inviteId": "6c9d7e871a4904ae635db92c7452e44e" + }, + { + "name": "sha256-seed/chat-1", + "secret": "c23f9c80a99760bae87574ea4090ab1f4f4929e262b973bd30608392d3b7ba1d", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteToken": "32f96288d18ac73d6a7ce9334226a243b98eed8a82f66ab7e708525e88bb4503", + "wrapKey": "ba68222d4afb6a434a5c8b35b38f4b474714ee578b776059902dc31c5f651fa1", + "inviteId": "8b53332bd9ecda59b786d416c51916a8" + }, + { + "name": "sha256-seed/chat-2", + "secret": "c23f9c80a99760bae87574ea4090ab1f4f4929e262b973bd30608392d3b7ba1d", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteToken": "32f96288d18ac73d6a7ce9334226a243b98eed8a82f66ab7e708525e88bb4503", + "wrapKey": "709e9a2f9a5c330705368b6979034c944106b89d9fe5422edddaf70d11e5154d", + "inviteId": "8b53332bd9ecda59b786d416c51916a8" + } + ], + "keyEnvelopes": [ + { + "name": "pattern/chat-1", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "wrapKey": "dad952b94a2169287b8941822fe1d51eebe72681b09b674846d1df40a6ee6506", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687", + "inviterSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "aad": "666c6970636173682d67726f75702d696e766974652d777261702d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signedMessage": "666c6970636173682d67726f75702d696e766974652d7369672d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa000102030405060708090a0b0c0d0e0f101112131415161708c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01" + }, + { + "name": "pattern/chat-2", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "wrapKey": "ba3813bb89fe2bd56143271f6080b0756e3009cf63a2cb79c0d142d6b9254b78", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687", + "inviterSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122", + "aad": "666c6970636173682d67726f75702d696e766974652d777261702d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa", + "ciphertext": "daefc0d29e1f0a58d724d82d1416a78aecec4571461d725697228df524639d7dff48e77b68d55a7171542702d7dd18e2", + "signedMessage": "666c6970636173682d67726f75702d696e766974652d7369672d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122daefc0d29e1f0a58d724d82d1416a78aecec4571461d725697228df524639d7dff48e77b68d55a7171542702d7dd18e2", + "signature": "53c82f3ab691cec3ebc7ea5c18d59666a0bfcf4f8d01d4f93830821089af7c3c3720fedc33283ddfc6bed1e30833dc54ffafce2f14d0c3aa7d636a0db7a8a902" + }, + { + "name": "all-zero/chat-1", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "3b18832cb378a9083e2f3449a8570840", + "wrapKey": "3039b1e294240d3eee722baff8bff9e25c2ec41c8f43f77a57bae7477efce330", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687", + "inviterSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "161718191a1b1c1d1e1f202122232425262728292a2b2c2d", + "aad": "666c6970636173682d67726f75702d696e766974652d777261702d76316f9619ff8b86d011b42d00c04fc964ff3b18832cb378a9083e2f3449a8570840", + "ciphertext": "c9f4e2a0553a2387876a3b223183e22142c5fce883b37db8bf1fdc72547adfc59d385557f1b5859819b531cd03c93807", + "signedMessage": "666c6970636173682d67726f75702d696e766974652d7369672d76316f9619ff8b86d011b42d00c04fc964ff3b18832cb378a9083e2f3449a8570840161718191a1b1c1d1e1f202122232425262728292a2b2c2dc9f4e2a0553a2387876a3b223183e22142c5fce883b37db8bf1fdc72547adfc59d385557f1b5859819b531cd03c93807", + "signature": "83e4269e2ac62836462ae16b4ea92234b2b861d3048b85476c26bfe84c6173a9b761cdc4955d4b7fd18cb4f8cf5fbe5a7f48baa2577195f59ea88880a3c54f08" + }, + { + "name": "all-zero/chat-2", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "3b18832cb378a9083e2f3449a8570840", + "wrapKey": "1d5b97383504cbbc248dc1877dfbbb18fc3fe620004ad58224e24bb652e3b647", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687", + "inviterSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "2122232425262728292a2b2c2d2e2f303132333435363738", + "aad": "666c6970636173682d67726f75702d696e766974652d777261702d76310b1c2d3e4f5061728394a5b6c7d8e9f03b18832cb378a9083e2f3449a8570840", + "ciphertext": "dea46b00e8706bbd3162ea727cfb9bf3297ea5e787ba7d0d36e2a17e7601d708d06f8a5336f4cb0bafe5bc7a9b056289", + "signedMessage": "666c6970636173682d67726f75702d696e766974652d7369672d76310b1c2d3e4f5061728394a5b6c7d8e9f03b18832cb378a9083e2f3449a85708402122232425262728292a2b2c2d2e2f303132333435363738dea46b00e8706bbd3162ea727cfb9bf3297ea5e787ba7d0d36e2a17e7601d708d06f8a5336f4cb0bafe5bc7a9b056289", + "signature": "de4a0731ba5d6394b58317e1c902770526e038f3c11d2a70e0c96ba62f68468f130edfb8cf3919a6739101d59b6b72dd5049b434be3c997f7a17c17b87abbc06" + } + ], + "secretEnvelopes": [ + { + "name": "host/chat-1", + "ownerSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "ownerPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "sharedSecret": "ef5bcef3cc6b22665571c18e97fed5ae78a55b6182b3a9b070178856e1c6b409", + "salt": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "info": "666c6970636173682d67726f75702d696e766974652d7365637265742d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa", + "wrapKey": "2066bbaece7e97d71418c5eeb39c23236a21d0661a6f0d8d2ff122969464d7d0", + "nonce": "6e6f707172737475767778797a7b7c7d7e7f808182838485", + "aad": "666c6970636173682d67726f75702d696e766974652d7365637265742d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "ciphertext": "4d5c41b7c0657e2e38465ffa9d35878a80fb3da9024721225a516a12204f79fa12c62b55044372f18800072dd2f30013" + }, + { + "name": "host/chat-2", + "ownerSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "ownerPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "sharedSecret": "ef5bcef3cc6b22665571c18e97fed5ae78a55b6182b3a9b070178856e1c6b409", + "salt": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "info": "666c6970636173682d67726f75702d696e766974652d7365637265742d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa", + "wrapKey": "f520c73b24b05ac4c9bd8d02362846443784d94247a3b35a96775db4d8543231", + "nonce": "797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f90", + "aad": "666c6970636173682d67726f75702d696e766974652d7365637265742d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "ciphertext": "2de1501b1f72b4550588c1bd5d748ea29e48c0bf8d0581b292a2ecf86e3745a49d6576ca92daf0410787fef2f15bcefd" + }, + { + "name": "joiner/chat-1", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "sharedSecret": "0ba70c7a7b16889b5a16d4bf6d3555938e7c76335e4e105d8360fed7383fc279", + "salt": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "info": "666c6970636173682d67726f75702d696e766974652d7365637265742d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aa", + "wrapKey": "b459853ea32abdab18a40340bfccf5a259de4d2547aa387a4df851c975440dcd", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "aad": "666c6970636173682d67726f75702d696e766974652d7365637265742d76316f9619ff8b86d011b42d00c04fc964ffa93f48c2a250501856a290e1babf91aae7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8" + }, + { + "name": "joiner/chat-2", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "sharedSecret": "0ba70c7a7b16889b5a16d4bf6d3555938e7c76335e4e105d8360fed7383fc279", + "salt": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "info": "666c6970636173682d67726f75702d696e766974652d7365637265742d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aa", + "wrapKey": "de50d72a089810a4d44759d00ff398473657632fa273987c4da34376fd9819ab", + "nonce": "8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6", + "aad": "666c6970636173682d67726f75702d696e766974652d7365637265742d76310b1c2d3e4f5061728394a5b6c7d8e9f0a93f48c2a250501856a290e1babf91aae7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "ciphertext": "a032f892d4884d55503c3f6983bf6e0240b11fae6e26761d3cc9546b23aff33a109421e06d09dcf8ad118f0066b01485" + } + ], + "bundles": [ + { + "name": "opens", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "creatorPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByIsCreator": true, + "memberSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "memberPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "keyEnvelope": { + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01" + }, + "secretEnvelope": { + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8" + }, + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "chatKey": "1d70c778f0315928b0ddfc81eca0f50ae31d4af419382e1da3cad79374b54687" + } + ], + "links": [ + { + "name": "pattern", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "url": "https://app.flipcash.com/chat/invite#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g" + }, + { + "name": "all-zero", + "secret": "0000000000000000000000000000000000000000000000000000000000000000", + "fragment": "s=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "url": "https://app.flipcash.com/chat/invite#s=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + { + "name": "all-ff", + "secret": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "fragment": "s=__________________________________________8", + "url": "https://app.flipcash.com/chat/invite#s=__________________________________________8" + }, + { + "name": "sha256-seed", + "secret": "c23f9c80a99760bae87574ea4090ab1f4f4929e262b973bd30608392d3b7ba1d", + "fragment": "s=wj-cgKmXYLrodXTqQJCrH09JKeJiuXO9MGCDktO3uh0", + "url": "https://app.flipcash.com/chat/invite#s=wj-cgKmXYLrodXTqQJCrH09JKeJiuXO9MGCDktO3uh0" + } + ], + "fragmentParse": [ + { + "name": "plain", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "unknown-key-ignored", + "fragment": "v=1/s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "unknown-key-after", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g/x=y", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "empty", + "fragment": "", + "secret": null + }, + { + "name": "missing-s", + "fragment": "t=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": null + }, + { + "name": "empty-value", + "fragment": "s=", + "secret": null + }, + { + "name": "padded", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g=", + "secret": null + }, + { + "name": "standard-alphabet", + "fragment": "s=+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/s", + "secret": null + }, + { + "name": "short-31-bytes", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-iw", + "secret": null + }, + { + "name": "long-33-bytes", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5gA", + "secret": null + }, + { + "name": "non-canonical-pad-bits", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5h", + "secret": null + }, + { + "name": "duplicate-s", + "fragment": "s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g/s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": null + }, + { + "name": "whitespace", + "fragment": "s= BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "secret": null + } + ], + "routes": [ + { + "name": "invite", + "url": "https://app.flipcash.com/chat/invite#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "route": "invite", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "invite-trailing-slash", + "url": "https://app.flipcash.com/chat/invite/#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "route": "invite", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "invite-with-query", + "url": "https://app.flipcash.com/chat/invite?utm=x#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "route": "invite", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98" + }, + { + "name": "invite-no-fragment", + "url": "https://app.flipcash.com/chat/invite", + "route": "invalidInvite" + }, + { + "name": "invite-bad-fragment", + "url": "https://app.flipcash.com/chat/invite#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g=", + "route": "invalidInvite" + }, + { + "name": "chat-uuid", + "url": "https://app.flipcash.com/chat/6f9619ff-8b86-d011-b42d-00c04fc964ff", + "route": "chat" + }, + { + "name": "not-invite-prefix", + "url": "https://app.flipcash.com/chat/invites#s=BRIfLDlGU2BteoeUoa67yNXi7_wJFiMwPUpXZHF-i5g", + "route": "other" + } + ], + "rejects": { + "keyEnvelope": [ + { + "name": "tampered-ciphertext", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e27", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "tampered-nonce", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "010102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "tampered-signature", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7cfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "signed-by-other-key", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "62d9e45de749b823fe8f91ae0a06122255d29f1e3e9d01c972b49c98ccae292d3297b3a281b571c2911e943bc0b04d07eb775e42a871189e1c58605e2961b00e", + "failsAt": "signature" + }, + { + "name": "wrong-chat-id", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "resigned-tampered-ciphertext", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e27", + "signature": "c79bacc7c570a5cd20d9c67250065926112dc9a28d4a630972bb0cd47722f4499553e650b0fae2723ec6df3f4d6e1dd75b0d2733f74f4b63335faea0b62ac90e", + "failsAt": "open" + }, + { + "name": "wrong-secret", + "secret": "c23f9c80a99760bae87574ea4090ab1f4f4929e262b973bd30608392d3b7ba1d", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01", + "failsAt": "signature" + }, + { + "name": "other-chats-copy", + "secret": "05121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviterPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "nonce": "0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122", + "ciphertext": "daefc0d29e1f0a58d724d82d1416a78aecec4571461d725697228df524639d7dff48e77b68d55a7171542702d7dd18e2", + "signature": "04179b56ba2648ccde865aadca2f0e7a31690dbcbf9e8594a11ed3fb045c86f6f1e9730360307949bbb8cb81728de88025371d20cf50e6c7e8eef5f31a7d910b", + "failsAt": "open" + } + ], + "secretEnvelope": [ + { + "name": "tampered-tag", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b9", + "failsAt": "open" + }, + { + "name": "tampered-body", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f0511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + }, + { + "name": "wrong-owner", + "ownerSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "ownerPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + }, + { + "name": "wrong-chat-id", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "0b1c2d3e4f5061728394a5b6c7d8e9f0", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + }, + { + "name": "wrong-invite-id", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91ab", + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + }, + { + "name": "wrong-nonce", + "ownerSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "ownerPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "inviteId": "a93f48c2a250501856a290e1babf91aa", + "nonce": "4142434445464748494a4b4c4d4e4f505152535455565758", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8", + "failsAt": "open" + } + ], + "bundles": [ + { + "name": "wrapped-by-not-creator", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "creatorPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByPublicKey": "adc14011f82d1c56d956aa4f9d73d8858361a606048525e0d08c638dc75dd8c7", + "wrappedByIsCreator": false, + "memberSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "memberPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "keyEnvelope": { + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "62d9e45de749b823fe8f91ae0a06122255d29f1e3e9d01c972b49c98ccae292d3297b3a281b571c2911e943bc0b04d07eb775e42a871189e1c58605e2961b00e" + }, + "secretEnvelope": { + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8" + }, + "failsAt": "wrappedBy" + }, + { + "name": "resigned-tampered-copy", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "creatorPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByIsCreator": true, + "memberSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "memberPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "keyEnvelope": { + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e27", + "signature": "c79bacc7c570a5cd20d9c67250065926112dc9a28d4a630972bb0cd47722f4499553e650b0fae2723ec6df3f4d6e1dd75b0d2733f74f4b63335faea0b62ac90e" + }, + "secretEnvelope": { + "nonce": "8485868788898a8b8c8d8e8f909192939495969798999a9b", + "ciphertext": "f1511a8bdc0f1f0ca68e1d459234e23c1eb154cbaa581f115e0934d18bdca70a4c5f066ef41625029eb5ab23d96b77b8" + }, + "failsAt": "keyOpen" + }, + { + "name": "secret-envelope-of-another-owner", + "chatId": "6f9619ff8b86d011b42d00c04fc964ff", + "creatorPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "wrappedByIsCreator": true, + "memberSeed": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "memberPublicKey": "e7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0", + "keyEnvelope": { + "nonce": "000102030405060708090a0b0c0d0e0f1011121314151617", + "ciphertext": "08c106a61e81b932b4b6c6616b977968ca37017b25677ebae5ee96d5885c7b760f915eb3dea88f22609b5b7560d34e26", + "signature": "7dfffc895aa4fb96e0a75c837d6f389b12c38cdcb7f9054e28c8282f5ab82ebea4578869d50b5e86348615df49d01e1b3de4b70f1dedb18b8b1dd4663bed2e01" + }, + "secretEnvelope": { + "nonce": "6e6f707172737475767778797a7b7c7d7e7f808182838485", + "ciphertext": "4d5c41b7c0657e2e38465ffa9d35878a80fb3da9024721225a516a12204f79fa12c62b55044372f18800072dd2f30013" + }, + "failsAt": "secretOpen" + } + ] + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/InviteCipherTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/InviteCipherTests.swift new file mode 100644 index 0000000000..261af10269 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/InviteCipherTests.swift @@ -0,0 +1,212 @@ +import Foundation +import Testing +@testable import SharedCoreKit + +/// The facade over `invite.json`. Sealing under the fixture's fixed nonces is internal to the Kotlin +/// and covered by its `InviteVectorTest`; here sealing is checked by round trip. +@Suite struct InviteCipherTests { + + /// Every section's fields, all optional: each vector uses a subset. + struct Vector: Decodable { + struct Envelope: Decodable { + let nonce: String + let ciphertext: String + let signature: String? + } + + let name: String + let secret: String? + let chatId: String? + let inviteToken: String? + let inviteId: String? + let chatKey: String? + let inviterSeed: String? + let inviterPublicKey: String? + let ownerSeed: String? + let nonce: String? + let ciphertext: String? + let signature: String? + let creatorPublicKey: String? + let wrappedByIsCreator: Bool? + let memberSeed: String? + let keyEnvelope: Envelope? + let secretEnvelope: Envelope? + let failsAt: String? + let fragment: String? + let url: String? + let route: String? + + func bytes(_ path: KeyPath) throws -> Data { + let hex = try #require(self[keyPath: path], "\(name): missing field") + return try #require(Data(hex: hex), "\(name): bad fixture hex") + } + } + + struct Fixture: Decodable { + struct Rejects: Decodable { + let keyEnvelope: [Vector] + let secretEnvelope: [Vector] + let bundles: [Vector] + } + + let derivation: [Vector] + let keyEnvelopes: [Vector] + let secretEnvelopes: [Vector] + let bundles: [Vector] + let links: [Vector] + let fragmentParse: [Vector] + let routes: [Vector] + let rejects: Rejects + } + + let fixture: Fixture + + init() throws { + fixture = try Fixtures.load("invite", as: Fixture.self) + } + + @Test func derivesTokenAndId() throws { + #expect(fixture.derivation.count == 8) + for v in fixture.derivation { + let secret = try v.bytes(\.secret) + #expect(try SharedInviteCipher.inviteToken(secret: secret) == v.bytes(\.inviteToken), "\(v.name)") + #expect(try SharedInviteCipher.inviteId(secret: secret) == v.bytes(\.inviteId), "\(v.name)") + } + } + + @Test func opensAndRoundTripsKeyEnvelopes() throws { + for v in fixture.keyEnvelopes { + let inviter = SharedEd25519.keyPair(seed: try v.bytes(\.inviterSeed)) + let envelope = SharedInviteCipher.KeyEnvelope( + nonce: try v.bytes(\.nonce), + ciphertext: try v.bytes(\.ciphertext), + signature: try v.bytes(\.signature) + ) + let opened = try SharedInviteCipher.openKeyEnvelope( + secret: v.bytes(\.secret), chatId: v.bytes(\.chatId), inviterPublicKey: inviter.publicKey, envelope: envelope + ) + #expect(opened == (try v.bytes(\.chatKey)), "\(v.name)") + + let sealed = try SharedInviteCipher.sealKeyEnvelope( + inviter: inviter, secret: v.bytes(\.secret), chatId: v.bytes(\.chatId), chatKey: v.bytes(\.chatKey) + ) + #expect(sealed.nonce != envelope.nonce, "\(v.name)") + let reopened = try SharedInviteCipher.openKeyEnvelope( + secret: v.bytes(\.secret), chatId: v.bytes(\.chatId), inviterPublicKey: inviter.publicKey, envelope: sealed + ) + #expect(reopened == (try v.bytes(\.chatKey)), "\(v.name)") + } + } + + @Test func opensAndRoundTripsSecretEnvelopes() throws { + for v in fixture.secretEnvelopes { + let owner = SharedEd25519.keyPair(seed: try v.bytes(\.ownerSeed)) + let envelope = SharedInviteCipher.SecretEnvelope(nonce: try v.bytes(\.nonce), ciphertext: try v.bytes(\.ciphertext)) + let opened = try SharedInviteCipher.openSecretEnvelope( + owner: owner, chatId: v.bytes(\.chatId), inviteId: v.bytes(\.inviteId), envelope: envelope + ) + #expect(opened == (try v.bytes(\.secret)), "\(v.name)") + + let sealed = try SharedInviteCipher.sealSecretEnvelope( + owner: owner, secret: v.bytes(\.secret), chatId: v.bytes(\.chatId), inviteId: v.bytes(\.inviteId) + ) + let reopened = try SharedInviteCipher.openSecretEnvelope( + owner: owner, chatId: v.bytes(\.chatId), inviteId: v.bytes(\.inviteId), envelope: sealed + ) + #expect(reopened == (try v.bytes(\.secret)), "\(v.name)") + } + } + + @Test func opensBundles() throws { + for v in fixture.bundles { + #expect(try openBundle(v, inviteId: v.bytes(\.inviteId)) == v.bytes(\.chatKey), "\(v.name)") + } + } + + @Test func rejectsBundlesAtTheirStep() throws { + // The reject cases carry no inviteId; every bundle in the fixture is for the same invite. + let inviteId = try #require(fixture.bundles.first).bytes(\.inviteId) + let steps: [String: SharedInviteCipher.BundleError] = [ + "wrappedBy": .wrappedBy, "signature": .signature, "secretOpen": .secretOpen, "keyOpen": .keyOpen, + ] + #expect(fixture.rejects.bundles.count == 3) + for v in fixture.rejects.bundles { + let expected = try #require(steps[v.failsAt ?? ""], "\(v.name): unknown step") + #expect(throws: expected, "\(v.name)") { try openBundle(v, inviteId: inviteId) } + } + } + + @Test func rejectsKeyEnvelopes() throws { + for v in fixture.rejects.keyEnvelope { + let envelope = SharedInviteCipher.KeyEnvelope( + nonce: try v.bytes(\.nonce), ciphertext: try v.bytes(\.ciphertext), signature: try v.bytes(\.signature) + ) + #expect(throws: (any Error).self, "\(v.name)") { + try SharedInviteCipher.openKeyEnvelope( + secret: v.bytes(\.secret), chatId: v.bytes(\.chatId), + inviterPublicKey: v.bytes(\.inviterPublicKey), envelope: envelope + ) + } + } + } + + @Test func rejectsSecretEnvelopes() throws { + for v in fixture.rejects.secretEnvelope { + let owner = SharedEd25519.keyPair(seed: try v.bytes(\.ownerSeed)) + let envelope = SharedInviteCipher.SecretEnvelope(nonce: try v.bytes(\.nonce), ciphertext: try v.bytes(\.ciphertext)) + #expect(throws: (any Error).self, "\(v.name)") { + try SharedInviteCipher.openSecretEnvelope( + owner: owner, chatId: v.bytes(\.chatId), inviteId: v.bytes(\.inviteId), envelope: envelope + ) + } + } + } + + @Test func encodesLinks() throws { + for v in fixture.links { + #expect(try SharedInviteCipher.link(secret: v.bytes(\.secret)) == v.url, "\(v.name)") + } + } + + @Test func parsesFragments() throws { + #expect(fixture.fragmentParse.count == 13) + for v in fixture.fragmentParse { + let parsed = SharedInviteCipher.secret(fragment: try #require(v.fragment)) + #expect(parsed == (v.secret == nil ? nil : try v.bytes(\.secret)), "\(v.name)") + } + } + + @Test func classifiesRoutes() throws { + for v in fixture.routes { + let route = SharedInviteCipher.route(url: try #require(v.url)) + switch v.route { + case "invite": #expect(route == .invite(secret: try v.bytes(\.secret)), "\(v.name)") + case "invalidInvite": #expect(route == .invalidInvite, "\(v.name)") + case "chat": #expect(route == .chat, "\(v.name)") + default: #expect(route == .other, "\(v.name)") + } + } + } + + private func openBundle(_ v: Vector, inviteId: Data) throws -> Data { + let key = try #require(v.keyEnvelope) + let secret = try #require(v.secretEnvelope) + let signature = try #require(key.signature) + return try SharedInviteCipher.openKeyBundle( + member: SharedEd25519.keyPair(seed: v.bytes(\.memberSeed)), + creatorPublicKey: v.bytes(\.creatorPublicKey), + wrappedByCreator: try #require(v.wrappedByIsCreator), + chatId: v.bytes(\.chatId), + inviteId: inviteId, + keyEnvelope: .init( + nonce: try #require(Data(hex: key.nonce)), + ciphertext: try #require(Data(hex: key.ciphertext)), + signature: try #require(Data(hex: signature)) + ), + secretEnvelope: .init( + nonce: try #require(Data(hex: secret.nonce)), + ciphertext: try #require(Data(hex: secret.ciphertext)) + ) + ) + } +}