From d6694df981031735ba3af735c35a3dcb7b5a163f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 21 Sep 2026 14:36:11 -0400 Subject: [PATCH 1/2] chore(services): sign flipcash requests with a terminal builder authenticate() signs buildPartial(), so a field assigned after the call sits outside the signature while the server still verifies over it, and the request comes back UNAUTHENTICATED. Correct ordering was the only thing holding this together, and nothing in the type system or the tests checked it. buildAuthenticated() signs and builds in one step, leaving no builder to set a field on afterwards. All 62 call sites across 17 files move to it; the emitted bytes are unchanged. Two tests hold the ordering. BuildAuthenticatedTest verifies a real signature over the request with auth cleared, and its second case executes the failure the helper prevents. AuthenticateCallSiteTest fails if a new API reaches for authenticate() directly, allowlisting ReportingApi, the live violator being fixed alongside a wider reporting change, and BlobStorageApi, which passes authenticate into getBlobsRequest where it is assigned last under an existing test. Signing in a unit test needs real crypto, so the module picks up the ed25519 shadow that services/opencode already uses. --- services/flipcash/build.gradle.kts | 1 + .../internal/network/api/AccountApi.kt | 8 +-- .../internal/network/api/ActivityFeedApi.kt | 12 ++-- .../internal/network/api/BlobStorageApi.kt | 10 ++- .../internal/network/api/BlocklistApi.kt | 14 ++-- .../services/internal/network/api/ChatApi.kt | 26 +++----- .../internal/network/api/ChatMessagingApi.kt | 44 +++++-------- .../internal/network/api/ContactListApi.kt | 14 ++-- .../network/api/EmailVerificationApi.kt | 11 ++-- .../internal/network/api/ModerationApi.kt | 8 +-- .../network/api/PhoneVerificationApi.kt | 14 ++-- .../internal/network/api/ProfileApi.kt | 25 +++---- .../internal/network/api/PurchaseApi.kt | 5 +- .../services/internal/network/api/PushApi.kt | 8 +-- .../internal/network/api/ResolverApi.kt | 5 +- .../internal/network/api/SettingsApi.kt | 5 +- .../internal/network/api/ThirdPartyApi.kt | 5 +- .../network/extensions/AuthenticateMessage.kt | 33 ++++++++++ .../network/services/EventStreamingService.kt | 5 +- .../extensions/AuthenticateCallSiteTest.kt | 55 ++++++++++++++++ .../extensions/BuildAuthenticatedTest.kt | 66 +++++++++++++++++++ 21 files changed, 234 insertions(+), 140 deletions(-) create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateCallSiteTest.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/BuildAuthenticatedTest.kt diff --git a/services/flipcash/build.gradle.kts b/services/flipcash/build.gradle.kts index e8fc17062d..069fa3f76b 100644 --- a/services/flipcash/build.gradle.kts +++ b/services/flipcash/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.flipcash.android.library) + alias(libs.plugins.flipcash.android.ed25519.shadow) id("com.google.devtools.ksp") id("org.jetbrains.kotlin.plugin.parcelize") } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/AccountApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/AccountApi.kt index fd6c0b52d6..eb821aabbd 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/AccountApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/AccountApi.kt @@ -7,7 +7,7 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asCountryCode import com.flipcash.services.internal.network.extensions.asPublicKey import com.flipcash.services.internal.network.extensions.asUserId -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.internal.network.extensions.sign import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi @@ -53,8 +53,7 @@ internal class AccountApi @Inject constructor( suspend fun login(owner: KeyPair): RpcAccountService.LoginResponse { val request = RpcAccountService.LoginRequest.newBuilder() .setTimestamp(Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1_000)) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -75,8 +74,7 @@ internal class AccountApi @Inject constructor( .setUserId(userId.asUserId()) .setPlatform(Common.Platform.GOOGLE) .setCountryCode(countryCode.asCountryCode()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ActivityFeedApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ActivityFeedApi.kt index 27c4815e4a..eec3ce3bc7 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ActivityFeedApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ActivityFeedApi.kt @@ -6,7 +6,7 @@ import com.codeinc.flipcash.gen.activity.v1.Model import com.codeinc.flipcash.gen.activity.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asQueryOptions -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.internal.network.extensions.toNotificationIds import com.flipcash.services.models.ActivityFeedType import com.flipcash.services.models.QueryOptions @@ -44,8 +44,7 @@ internal class ActivityFeedApi @Inject constructor( val request = ActivityFeedService.GetLatestNotificationsRequest.newBuilder() .setType(Model.ActivityFeedType.forNumber(type.ordinal)) .setMaxItems(maxItems) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -69,8 +68,7 @@ internal class ActivityFeedApi @Inject constructor( val request = ActivityFeedService.GetPagedNotificationsRequest.newBuilder() .setType(Model.ActivityFeedType.forNumber(type.ordinal)) .setQueryOptions(queryOptions.asQueryOptions()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -92,9 +90,7 @@ internal class ActivityFeedApi @Inject constructor( val request = ActivityFeedService.GetBatchNotificationsRequest.newBuilder() .apply { addAllIds(ids.toNotificationIds()) - }.apply { - setAuth(authenticate(owner)) - }.build() + }.buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt index f8740c37e6..293402d88b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt @@ -9,6 +9,7 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asChatId import com.flipcash.services.internal.network.extensions.asUserId import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.BlobId import com.getcode.ed25519.Ed25519 @@ -38,8 +39,7 @@ internal class BlobStorageApi @Inject constructor( suspend fun getUploadPolicy(owner: Ed25519.KeyPair): RpcBlobStorageService.GetUploadPolicyResponse { val request = RpcBlobStorageService.GetUploadPolicyRequest.newBuilder() - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -56,8 +56,7 @@ internal class BlobStorageApi @Inject constructor( val request = RpcBlobStorageService.InitiateExternalUploadRequest.newBuilder() .setMimeType(mimeType) .setSizeBytes(sizeBytes) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -72,8 +71,7 @@ internal class BlobStorageApi @Inject constructor( ): RpcBlobStorageService.CompleteExternalUploadResponse { val request = RpcBlobStorageService.CompleteExternalUploadRequest.newBuilder() .setBlobId(blobId.toProto()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlocklistApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlocklistApi.kt index 829de957a5..15b952436a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlocklistApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlocklistApi.kt @@ -6,7 +6,7 @@ import com.codeinc.flipcash.gen.blocklist.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.asUserId -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.models.QueryOptions import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi @@ -34,8 +34,7 @@ internal class BlocklistApi @Inject constructor( suspend fun blockUser(userId: ID, owner: KeyPair): BlocklistService.BlockUserResponse { val request = BlocklistService.BlockUserRequest.newBuilder() .setUserId(userId.asUserId()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -51,8 +50,7 @@ internal class BlocklistApi @Inject constructor( suspend fun unblockUser(userId: ID, owner: KeyPair): BlocklistService.UnblockUserResponse { val request = BlocklistService.UnblockUserRequest.newBuilder() .setUserId(userId.asUserId()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -67,8 +65,7 @@ internal class BlocklistApi @Inject constructor( suspend fun isBlocked(userId: ID, owner: KeyPair): BlocklistService.IsBlockedResponse { val request = BlocklistService.IsBlockedRequest.newBuilder() .setUserId(userId.asUserId()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -87,8 +84,7 @@ internal class BlocklistApi @Inject constructor( ): BlocklistService.GetBlocklistResponse { val request = BlocklistService.GetBlocklistRequest.newBuilder() .setQueryOptions(queryOptions.asQueryOptions()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index 82bf7160eb..c55c83759e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -11,7 +11,7 @@ import com.flipcash.services.internal.network.extensions.asProtoMuteState import com.flipcash.services.internal.network.extensions.asProtoRules import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.asViewMode -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType @@ -46,8 +46,7 @@ internal class ChatApi @Inject constructor( val request = RpcChatService.GetChatRequest.newBuilder() .setChatId(chatId.asChatId()) .setViewMode(viewMode.asViewMode()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -64,8 +63,7 @@ internal class ChatApi @Inject constructor( val request = RpcChatService.GetDmChatFeedRequest.newBuilder() .setQueryOptions(queryOptions.asQueryOptions()) .setDmChatType(chatType.asProtoChatType()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -80,8 +78,7 @@ internal class ChatApi @Inject constructor( ): RpcChatService.GetGroupChatFeedResponse { val request = RpcChatService.GetGroupChatFeedRequest.newBuilder() .setQueryOptions(queryOptions.asQueryOptions()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -119,8 +116,7 @@ internal class ChatApi @Inject constructor( val request = requestBuilder .setIdempotencyKey(idempotencyKey.asProtoIdempotencyKey()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -135,8 +131,7 @@ internal class ChatApi @Inject constructor( ): RpcChatService.JoinChatResponse { val request = RpcChatService.JoinChatRequest.newBuilder() .setChatId(chatId.asChatId()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -151,8 +146,7 @@ internal class ChatApi @Inject constructor( ): RpcChatService.LeaveChatResponse { val request = RpcChatService.LeaveChatRequest.newBuilder() .setChatId(chatId.asChatId()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -169,8 +163,7 @@ internal class ChatApi @Inject constructor( val request = RpcChatService.MuteChatRequest.newBuilder() .setChatId(chatId.asChatId()) .setMute(mute.asProtoMuteState()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -185,8 +178,7 @@ internal class ChatApi @Inject constructor( ): RpcChatService.UnmuteChatResponse { val request = RpcChatService.UnmuteChatRequest.newBuilder() .setChatId(chatId.asChatId()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt index 70222b90b9..ab63661962 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt @@ -14,7 +14,7 @@ import com.flipcash.services.internal.network.extensions.asPointerType import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.asTypingState import com.flipcash.services.internal.network.extensions.asViewMode -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ClientMessageId @@ -54,8 +54,7 @@ internal class ChatMessagingApi @Inject constructor( .setChatId(chatId.asChatId()) .setMessageId(MessagingModel.MessageId.newBuilder().setValue(messageId)) .setViewMode(viewMode.asViewMode()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -74,8 +73,7 @@ internal class ChatMessagingApi @Inject constructor( .setChatId(chatId.asChatId()) .setOptions(queryOptions.asQueryOptions()) .setViewMode(viewMode.asViewMode()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -97,8 +95,7 @@ internal class ChatMessagingApi @Inject constructor( .addAllMessageIds(messageIds.map { MessagingModel.MessageId.newBuilder().setValue(it).build() }) ) .setViewMode(viewMode.asViewMode()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -117,8 +114,7 @@ internal class ChatMessagingApi @Inject constructor( .setChatId(chatId.asChatId()) .addAllContent(content.map { it.asContent() }) .setClientMessageId(clientMessageId.asClientMessageId()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -138,8 +134,7 @@ internal class ChatMessagingApi @Inject constructor( .setChatId(chatId.asChatId()) .setPointerType(pointerType.asPointerType()) .setNewValue(MessagingModel.MessageId.newBuilder().setValue(messageId)) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -156,8 +151,7 @@ internal class ChatMessagingApi @Inject constructor( val request = RpcMessagingService.NotifyIsTypingRequest.newBuilder() .setChatId(chatId.asChatId()) .setState(state.asTypingState()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -176,8 +170,7 @@ internal class ChatMessagingApi @Inject constructor( .setChatId(chatId.asChatId()) .setAfterSequence(afterSequence) .setViewMode(viewMode.asViewMode()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -196,8 +189,7 @@ internal class ChatMessagingApi @Inject constructor( .setMessageId(messageId.asMessageId()) .addAllContent(content.map { it.asContent() }) .setExpectedEventSequence(expectedEventSequence) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -216,8 +208,7 @@ internal class ChatMessagingApi @Inject constructor( .setChatId(chatId.asChatId()) .setMessageId(messageId.asMessageId()) .setExpectedEventSequence(expectedEventSequence) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -236,8 +227,7 @@ internal class ChatMessagingApi @Inject constructor( .setChatId(chatId.asChatId()) .setMessageId(messageId.asMessageId()) .setEmoji(emoji.asEmoji()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -256,8 +246,7 @@ internal class ChatMessagingApi @Inject constructor( .setChatId(chatId.asChatId()) .setMessageId(messageId.asMessageId()) .setEmoji(emoji.asEmoji()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -278,8 +267,7 @@ internal class ChatMessagingApi @Inject constructor( .setMessageId(messageId.asMessageId()) .setEmoji(emoji.asEmoji()) .setOptions(queryOptions.asQueryOptions()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -296,8 +284,7 @@ internal class ChatMessagingApi @Inject constructor( val request = RpcMessagingService.GetReactionSummaryRequest.newBuilder() .setChatId(chatId.asChatId()) .setMessageId(messageId.asMessageId()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -314,8 +301,7 @@ internal class ChatMessagingApi @Inject constructor( val request = RpcMessagingService.GetReactionSummariesRequest.newBuilder() .setChatId(chatId.asChatId()) .setOptions(queryOptions.asQueryOptions()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ContactListApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ContactListApi.kt index 46dfb6272d..95169ccb0a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ContactListApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ContactListApi.kt @@ -6,7 +6,7 @@ import com.codeinc.flipcash.gen.contact.v1.validate import com.flipcash.services.models.ContactMethod import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asHash -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi import com.getcode.solana.keys.Checksum @@ -34,8 +34,7 @@ internal class ContactListApi @Inject constructor( ): RpcContactListService.CheckSyncResponse { val request = RpcContactListService.CheckSyncRequest.newBuilder() .setClientChecksum(clientChecksum.asHash()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -56,8 +55,7 @@ internal class ContactListApi @Inject constructor( .addAllRemoves(removes.map { Common.PhoneNumber.newBuilder().setValue(it.phoneNumber).build() }) .setOldChecksum(oldChecksum.asHash()) .setNewChecksum(newChecksum.asHash()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -76,8 +74,7 @@ internal class ContactListApi @Inject constructor( val request = RpcContactListService.FullUploadRequest.newBuilder() .addAllPhones(batch.map { Common.PhoneNumber.newBuilder().setValue(it.phoneNumber).build() }) .setExpectedChecksum(expectedChecksum.asHash()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() emit(request) @@ -95,8 +92,7 @@ internal class ContactListApi @Inject constructor( ): Flow { val request = RpcContactListService.GetFlipcashContactsRequest.newBuilder() .setChecksum(checksum.asHash()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } return api.getFlipcashContacts(request) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/EmailVerificationApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/EmailVerificationApi.kt index e8ca56740d..e357875a19 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/EmailVerificationApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/EmailVerificationApi.kt @@ -5,7 +5,7 @@ import com.codeinc.flipcash.gen.email.v1.EmailVerificationService import com.codeinc.flipcash.gen.email.v1.Model import com.codeinc.flipcash.gen.common.v1.Common import com.flipcash.services.internal.annotations.FlipcashManagedChannel -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.models.ContactMethod import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.core.GrpcApi @@ -38,8 +38,7 @@ internal class EmailVerificationApi @Inject constructor( val request = EmailVerificationService.SendVerificationCodeRequest.newBuilder() .setEmailAddress(Common.EmailAddress.newBuilder().setValue(request.emailAddress).build()) .setClientData(request.clientData) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -59,8 +58,7 @@ internal class EmailVerificationApi @Inject constructor( val request = EmailVerificationService.CheckVerificationCodeRequest.newBuilder() .setEmailAddress(Common.EmailAddress.newBuilder().setValue(request.emailAddress).build()) .setCode(Model.VerificationCode.newBuilder().setValue(code).build()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -75,8 +73,7 @@ internal class EmailVerificationApi @Inject constructor( ): EmailVerificationService.UnlinkResponse { val request = EmailVerificationService.UnlinkRequest.newBuilder() .setEmailAddress(Common.EmailAddress.newBuilder().setValue(request.emailAddress).build()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ModerationApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ModerationApi.kt index d5a28fb07b..7eb85c8c88 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ModerationApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ModerationApi.kt @@ -4,7 +4,7 @@ import com.codeinc.flipcash.gen.moderation.v1.ModerationGrpcKt import com.codeinc.flipcash.gen.moderation.v1.ModerationService import com.codeinc.flipcash.gen.moderation.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.core.GrpcApi import com.getcode.utils.toByteString @@ -26,8 +26,7 @@ internal class ModerationApi @Inject constructor( suspend fun moderateText(text: String, owner: Ed25519.KeyPair): ModerationService.ModerateTextResponse { val request = ModerationService.ModerateTextRequest.newBuilder() .setText(text) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -39,8 +38,7 @@ internal class ModerationApi @Inject constructor( suspend fun moderateImage(imageBytes: ByteArray, owner: Ed25519.KeyPair): ModerationService.ModerateImageResponse { val request = ModerationService.ModerateImageRequest.newBuilder() .setImageData(imageBytes.toByteString()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PhoneVerificationApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PhoneVerificationApi.kt index 0cf8fc42a3..d7a6789081 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PhoneVerificationApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PhoneVerificationApi.kt @@ -5,7 +5,7 @@ import com.codeinc.flipcash.gen.phone.v1.Model import com.codeinc.flipcash.gen.phone.v1.PhoneVerificationGrpcKt import com.codeinc.flipcash.gen.phone.v1.PhoneVerificationService import com.flipcash.services.internal.annotations.FlipcashManagedChannel -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.models.ContactMethod import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.core.GrpcApi @@ -38,8 +38,7 @@ internal class PhoneVerificationApi @Inject constructor( val request = PhoneVerificationService.SendVerificationCodeRequest.newBuilder() .setPhoneNumber(Common.PhoneNumber.newBuilder().setValue(request.phoneNumber).build()) .setPlatform(Common.Platform.GOOGLE) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -59,8 +58,7 @@ internal class PhoneVerificationApi @Inject constructor( val request = PhoneVerificationService.CheckVerificationCodeRequest.newBuilder() .setPhoneNumber(Common.PhoneNumber.newBuilder().setValue(request.phoneNumber).build()) .setCode(Model.VerificationCode.newBuilder().setValue(code).build()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -78,8 +76,7 @@ internal class PhoneVerificationApi @Inject constructor( ): PhoneVerificationService.UnlinkResponse { val request = PhoneVerificationService.UnlinkRequest.newBuilder() .setPhoneNumber(Common.PhoneNumber.newBuilder().setValue(request.phoneNumber).build()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -94,8 +91,7 @@ internal class PhoneVerificationApi @Inject constructor( ): PhoneVerificationService.LinkForPaymentResponse { val request = PhoneVerificationService.LinkForPaymentRequest.newBuilder() .setPhoneNumber(Common.PhoneNumber.newBuilder().setValue(request.phoneNumber).build()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt index f546aadfc1..704552c7d0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt @@ -7,7 +7,7 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asFiatPaymentAmount import com.flipcash.services.internal.network.extensions.asUserId import com.flipcash.services.internal.network.extensions.asUsername -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.internal.network.extensions.linkingToken import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.models.SocialAccountLinkRequest @@ -48,8 +48,7 @@ internal class ProfileApi @Inject constructor( is ProfileIdentifier.Username -> setUsername(identifier.username.asUsername()) } } - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -67,8 +66,7 @@ internal class ProfileApi @Inject constructor( ): ProfileService.SetDisplayNameResponse { val request = ProfileService.SetDisplayNameRequest.newBuilder() .setDisplayName(displayName) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -86,8 +84,7 @@ internal class ProfileApi @Inject constructor( ): ProfileService.SetUsernameResponse { val request = ProfileService.SetUsernameRequest.newBuilder() .setUsername(username.asUsername()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -110,8 +107,7 @@ internal class ProfileApi @Inject constructor( com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() .setValue(blobId.bytes.toByteString()) ) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -129,8 +125,7 @@ internal class ProfileApi @Inject constructor( ): ProfileService.LinkSocialAccountResponse { val apiRequest = ProfileService.LinkSocialAccountRequest.newBuilder() .setLinkingToken(request.linkingToken()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } return withContext(Dispatchers.IO) { api.linkSocialAccount(apiRequest) @@ -150,8 +145,7 @@ internal class ProfileApi @Inject constructor( .setHex(hexColor) .build() ) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -170,8 +164,7 @@ internal class ProfileApi @Inject constructor( ): ProfileService.SetMinDmChatInitFeeResponse { val request = ProfileService.SetMinDmChatInitFeeRequest.newBuilder() .setMinDmChatInitFee(fee.asFiatPaymentAmount()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -193,7 +186,7 @@ internal class ProfileApi @Inject constructor( is SocialAccountUnlinkRequest.X -> builder.setXUserId(request.userId) } - val apiRequest = builder.apply { setAuth(authenticate(owner)) }.build() + val apiRequest = builder.buildAuthenticated(owner) { setAuth(it) } return withContext(Dispatchers.IO) { api.unlinkSocialAccount(apiRequest) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PurchaseApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PurchaseApi.kt index e357917859..2afc46d484 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PurchaseApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PurchaseApi.kt @@ -7,7 +7,7 @@ import com.codeinc.flipcash.gen.iap.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.model.billing.IapMetadata import com.flipcash.services.internal.model.billing.Receipt -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow @@ -39,8 +39,7 @@ internal class PurchaseApi @Inject constructor( .setCurrency(metadata.currency.name.lowercase()) .setAmount(metadata.amount) ) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt index 3ee2494b5b..6e864db70c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt @@ -6,7 +6,7 @@ import com.codeinc.flipcash.gen.push.v1.PushGrpcKt import com.codeinc.flipcash.gen.push.v1.PushService import com.codeinc.flipcash.gen.push.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow @@ -40,8 +40,7 @@ internal class PushApi @Inject constructor( .setPushToken(token) .setAppInstall(Common.AppInstallId.newBuilder().setValue(installationId)) .setTokenType(Model.TokenType.FCM_ANDROID) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() @@ -60,8 +59,7 @@ internal class PushApi @Inject constructor( val request = PushService.DeleteTokensRequest.newBuilder() .setAppInstall(Common.AppInstallId.newBuilder().setValue(installationId)) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt index 36ee7fa64a..9aa330b2ba 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt @@ -6,7 +6,7 @@ import com.codeinc.flipcash.gen.resolver.v1.validate import com.flipcash.services.models.ResolveIdentifier import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asUserId -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.internal.network.extensions.asUsername import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi @@ -34,8 +34,7 @@ internal class ResolverApi @Inject constructor( ): RpcResolverService.ResolveResponse { val request = RpcResolverService.ResolveRequest.newBuilder() .setIdentifier(identifier.asProtoIdentifier()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/SettingsApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/SettingsApi.kt index 13db7db1dc..9eeee9e2a6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/SettingsApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/SettingsApi.kt @@ -5,7 +5,7 @@ import com.codeinc.flipcash.gen.settings.v1.SettingsGrpcKt import com.codeinc.flipcash.gen.settings.v1.SettingsService import com.codeinc.flipcash.gen.settings.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow @@ -38,8 +38,7 @@ internal class SettingsApi @Inject constructor( setRegion(Common.Region.newBuilder().setValue(region.lowercase())) } } - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ThirdPartyApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ThirdPartyApi.kt index 157c7b8a8f..91c240ae28 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ThirdPartyApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ThirdPartyApi.kt @@ -5,7 +5,7 @@ import com.codeinc.flipcash.gen.thirdparty.v1.ThirdPartyService import com.codeinc.flipcash.gen.thirdparty.v1.validate import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asApiKey -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.getcode.ed25519.Ed25519 import com.getcode.network.jwt.JwtSecuredEndpoint import com.getcode.opencode.internal.network.core.GrpcApi @@ -36,8 +36,7 @@ internal class ThirdPartyApi @Inject constructor( .setHost(endpoint.host) .setPath(endpoint.path) .setMethod(endpoint.method) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateMessage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateMessage.kt index 4ac8d6eece..4f87ed3e64 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateMessage.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateMessage.kt @@ -5,6 +5,39 @@ import com.getcode.ed25519.Ed25519 import com.google.protobuf.GeneratedMessageLite import java.io.ByteArrayOutputStream +/** + * Builds the request with an [Common.Auth] that covers every field set on it. + * + * The signature is taken over the builder as it stands, and the server verifies it against the + * whole request minus the auth field. A field set after signing is therefore in what the server + * checks and missing from what the client signed, and the call comes back `UNAUTHENTICATED` — as + * GetBlobs did, where a late access context meant re-minting another user's avatar URL always + * failed. + * + * Ordering is the caller's problem only if the caller can get it wrong. This signs and builds in + * one step, so there is no builder left to set a field on. Prefer it to [authenticate]. + * + * @param attachAuth assigns the auth onto the request, almost always `{ setAuth(it) }`. It takes + * the builder as a receiver because the generated request types share no `setAuth` supertype. + */ +internal fun , B : GeneratedMessageLite.Builder> GeneratedMessageLite.Builder.buildAuthenticated( + owner: Ed25519.KeyPair, + attachAuth: B.(Common.Auth) -> Unit, +): M { + val auth = authenticate(owner) + + @Suppress("UNCHECKED_CAST") + (this as B).attachAuth(auth) + + return build()!! +} + +/** + * Signs the builder as it stands and returns the auth to assign onto it. + * + * Anything set after this call is outside the signature, which the type system does not enforce. + * Use [buildAuthenticated] instead unless the auth genuinely cannot be the last thing assigned. + */ internal fun , B : GeneratedMessageLite.Builder> GeneratedMessageLite.Builder.authenticate(owner: Ed25519.KeyPair): Common.Auth { // dump message up until this point into a ByteArray val bos = ByteArrayOutputStream() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/EventStreamingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/EventStreamingService.kt index 6147f3a773..0b7dac5abc 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/EventStreamingService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/EventStreamingService.kt @@ -3,7 +3,7 @@ package com.flipcash.services.internal.network.services import com.codeinc.flipcash.gen.events.v1.EventStreamingService as RpcEventStreamingService import com.codeinc.flipcash.gen.events.v1.Model as EventModel import com.flipcash.services.internal.network.api.EventStreamingApi -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.internal.network.extensions.toBlobUpdate import com.flipcash.services.internal.network.extensions.toChatUpdate import com.flipcash.services.models.StreamEventsError @@ -66,8 +66,7 @@ internal class EventStreamingService @Inject constructor( initialRequest = { val params = RpcEventStreamingService.StreamEventsRequest.Params.newBuilder() .setTs(currentTimestamp()) - .apply { setAuth(authenticate(owner)) } - .build() + .buildAuthenticated(owner) { setAuth(it) } RpcEventStreamingService.StreamEventsRequest.newBuilder() .setParams(params) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateCallSiteTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateCallSiteTest.kt new file mode 100644 index 0000000000..ad33f18268 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateCallSiteTest.kt @@ -0,0 +1,55 @@ +package com.flipcash.services.internal.network.extensions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * `authenticate` signs the builder as it stands, so a field assigned after it is outside the + * signature and the call comes back `UNAUTHENTICATED`. Nothing in the type system says so, and the + * mistake is invisible at a glance — it is a matter of which line a `setAuth` sits on. + * + * [buildAuthenticated] removes the choice by signing and building together. This keeps it that way: + * a new API that reaches for the lower-level `authenticate` has to justify itself here first. + */ +class AuthenticateCallSiteTest { + + /** + * `ReportingApi` sets its `target` oneof after signing and is being fixed alongside a wider + * reporting change. `BlobStorageApi` hands `authenticate` to `getBlobsRequest`, which assigns + * it last under the cover of `GetBlobsRequestSigningTest`. + */ + private val allowed = setOf("ReportingApi.kt", "BlobStorageApi.kt") + + @Test + fun `authenticate is only called where the ordering is already accounted for`() { + val callers = networkSources() + .filter { it.readText().contains("authenticate(") } + .map { it.name } + .sorted() + + assertEquals( + "Use buildAuthenticated so auth cannot be assigned before another field.", + allowed.sorted(), + callers, + ) + } + + private fun networkSources(): List { + val root = File("src/main/kotlin/com/flipcash/services/internal/network") + assertTrue( + "Expected to scan sources from the module directory, looked in ${root.absolutePath}", + root.isDirectory, + ) + + val sources = root.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filterNot { it.name == "AuthenticateMessage.kt" } + .toList() + + // Guards against a silent pass if the scan ever stops finding the sources. + assertTrue("Scanned only ${sources.size} sources", sources.size > 20) + return sources + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/BuildAuthenticatedTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/BuildAuthenticatedTest.kt new file mode 100644 index 0000000000..e7c754b490 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/BuildAuthenticatedTest.kt @@ -0,0 +1,66 @@ +package com.flipcash.services.internal.network.extensions + +import com.codeinc.flipcash.gen.common.v1.Common +import com.getcode.ed25519.Ed25519 +import com.getcode.utils.toByteString +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel + +/** + * The server verifies a request's auth against the request with auth cleared, so the signature has + * to be taken once every other field is set. [buildAuthenticated] signs and builds in one step to + * make that ordering impossible to get wrong; these tests hold it to that, and the second one + * executes the failure it prevents. + */ +class BuildAuthenticatedTest { + + private val owner: Ed25519.KeyPair = Ed25519.createKeyPair() + + private val chatId = Common.ChatId.newBuilder() + .setValue(ByteArray(32) { it.toByte() }.toByteString()) + .build() + + @Test + fun `auth covers every field set on the request`() { + val request = RpcChatService.GetChatRequest.newBuilder() + .setChatId(chatId) + .setViewMode(MessagingModel.ViewMode.REDACTED) + .buildAuthenticated(owner) { setAuth(it) } + + assertTrue(request.authCoversWholeRequest()) + } + + @Test + fun `a field set after the signature falls outside it`() { + // The hazard, written out: setViewMode lands after the bytes were signed, so the server + // verifies over a request the client never signed and rejects it as UNAUTHENTICATED. + val request = RpcChatService.GetChatRequest.newBuilder() + .setChatId(chatId) + .apply { setAuth(authenticate(owner)) } + .setViewMode(MessagingModel.ViewMode.REDACTED) + .build() + + assertFalse(request.authCoversWholeRequest()) + } + + @Test + fun `auth is not signed over itself`() { + val request = RpcChatService.GetChatRequest.newBuilder() + .setChatId(chatId) + .buildAuthenticated(owner) { setAuth(it) } + + assertTrue(request.hasAuth()) + assertFalse(request.toBuilder().clearAuth().build().hasAuth()) + assertTrue(request.authCoversWholeRequest()) + } + + /** Verifies the way the server does: over the request with its auth cleared. */ + private fun RpcChatService.GetChatRequest.authCoversWholeRequest(): Boolean = Ed25519.verify( + auth.keyPair.signature.value.toByteArray(), + toBuilder().clearAuth().build().toByteArray(), + auth.keyPair.pubKey.value.toByteArray(), + ) +} From bdf5475bd21593fd32e840e24732012fad6f10d9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 21 Sep 2026 14:55:39 -0400 Subject: [PATCH 2/2] fix(reporting): cover the report target with the request signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit report() signed the builder while it still held only the description, then set the target oneof, so nothing identifying what was being reported — user, chat, message or blob — fell inside the signature. The server verifies over the whole request, so every report failed UNAUTHENTICATED. Building through buildAuthenticated() takes the signature after the oneof is assigned, which drops ReportingApi from the AuthenticateCallSiteTest allowlist. --- .../flipcash/services/internal/network/api/ReportingApi.kt | 5 ++--- .../network/extensions/AuthenticateCallSiteTest.kt | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ReportingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ReportingApi.kt index 64877e2a00..46e0c61d09 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ReportingApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ReportingApi.kt @@ -6,7 +6,7 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asChatId import com.flipcash.services.internal.network.extensions.asMessageId import com.flipcash.services.internal.network.extensions.asUserId -import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.internal.network.extensions.buildAuthenticated import com.flipcash.services.models.ReportTarget import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi @@ -35,7 +35,6 @@ internal class ReportingApi @Inject constructor( ): RpcReportingService.ReportResponse { val builder = RpcReportingService.ReportRequest.newBuilder() .setDescription(description) - .apply { setAuth(authenticate(owner)) } when (target) { is ReportTarget.User -> builder.setUserId(target.userId.asUserId()) @@ -51,7 +50,7 @@ internal class ReportingApi @Inject constructor( ) } - val request = builder.build() + val request = builder.buildAuthenticated(owner) { setAuth(it) } request.validate().orThrow() return withContext(Dispatchers.IO) { diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateCallSiteTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateCallSiteTest.kt index ad33f18268..8fff803f3a 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateCallSiteTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/AuthenticateCallSiteTest.kt @@ -16,11 +16,10 @@ import java.io.File class AuthenticateCallSiteTest { /** - * `ReportingApi` sets its `target` oneof after signing and is being fixed alongside a wider - * reporting change. `BlobStorageApi` hands `authenticate` to `getBlobsRequest`, which assigns - * it last under the cover of `GetBlobsRequestSigningTest`. + * `BlobStorageApi` hands `authenticate` to `getBlobsRequest`, which assigns it last under the + * cover of `GetBlobsRequestSigningTest`. */ - private val allowed = setOf("ReportingApi.kt", "BlobStorageApi.kt") + private val allowed = setOf("BlobStorageApi.kt") @Test fun `authenticate is only called where the ordering is already accounted for`() {