Skip to content

fix(services): sign the whole request, not the builder so far - #1516

Closed
bmc08gt wants to merge 2 commits into
code/cashfrom
chore/grpc-auth-signing-order
Closed

bmc08gt wants to merge 2 commits into
code/cashfrom
chore/grpc-auth-signing-order

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

authenticate() signs buildPartial() — the builder as it stands when the call is made. Any field assigned after it is outside the signature, while the server verifies against the whole request with auth cleared, so the call fails UNAUTHENTICATED. This is positional, applies to every API in the module, and nothing enforced it.

The live bug

ReportingApi.report() signed the builder while it held only the description, then set the target oneof. Nothing identifying what was being reported — user, chat, message or blob — was inside the signature, so every report failed, whatever was reported.

Audit

All 64 call sites, including the one outside network/api/ that a */network/api/*.kt grep misses (EventStreamingService.kt:69). I checked .apply {} blocks, builders passed to helpers, and conditional branches that set a field on only one path. ReportingApi was the only violator.

The same hazard exists in sign() (services/flipcash and services/opencode, identical code). I audited its 15 call sites too; all assign the signature last.

The guard

The three options weighed:

  • KDoc on authenticate() — included, but it only helps someone who reads it while writing the call, which is exactly when this mistake gets made.
  • Reflection over request builders — can't work. Reflection sees the proto schema, not the order the Kotlin call site assigned fields in, and the ordering is the bug.
  • A wrapper over the fully-built request — taken.

buildAuthenticated() signs and builds together, so there is no builder left to set a field on. Call sites are a wash in length:

// before                                    // after
    .apply { setAuth(authenticate(owner)) }      .buildAuthenticated(owner) { setAuth(it) }
    .build()

It takes attachAuth as a lambda because the generated request types share no setAuth supertype. Emitted bytes are unchanged at every site that was already correct.

Two tests keep it that way. BuildAuthenticatedTest verifies a real Ed25519 signature the way the server does, and its second case writes out the hazard and asserts verification fails — the bug is reproducible in CI rather than only describable. AuthenticateCallSiteTest fails if a new API calls authenticate() directly.

What this doesn't close

authenticate() stays internal rather than private, because BlobStorageApi passes it into getBlobsRequest, which assigns it last under GetBlobsRequestSigningTest. That is the one entry left in the AuthenticateCallSiteTest allowlist. Folding that helper into buildAuthenticated would let authenticate() go private and empty the allowlist, but it means rewriting a test that deliberately injects a fake auth, so I left it.

sign() is left alone. It is called inside nested sub-builders during intent and action construction, where the signed unit often isn't the request being built, so the terminal shape doesn't transfer mechanically.

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.
@bmc08gt bmc08gt self-assigned this Sep 21, 2026
@github-actions github-actions Bot added type: chore Maintenance, config, CI/CD area: network gRPC, connectivity, API, exchange rates labels Sep 21, 2026
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.
@bmc08gt bmc08gt changed the title chore(services): sign flipcash requests with a terminal builder fix(services): sign the whole request, not the builder so far Sep 21, 2026
@github-actions github-actions Bot added the type: fix Bug fix label Sep 21, 2026
@bmc08gt bmc08gt closed this Sep 21, 2026
@bmc08gt
bmc08gt deleted the chore/grpc-auth-signing-order branch September 23, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates type: chore Maintenance, config, CI/CD type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant