Skip to content

feat(relay): add relay-verified identity binding - #1476

Open
fsola-sq wants to merge 19 commits into
mainfrom
fsola/corporate-identity
Open

feat(relay): add relay-verified identity binding#1476
fsola-sq wants to merge 19 commits into
mainfrom
fsola/corporate-identity

Conversation

@fsola-sq

@fsola-sq fsola-sq commented Jul 2, 2026

Copy link
Copy Markdown

Why

Add a provider-neutral relay-side identity foundation that binds a verified JWT principal to a NIP-proven Nostr key.

What

  • Add configurable JWT validation for JWKS URI, issuer, audience, header, subject, display-name, and optional pubkey claims.
  • Persist issuer-qualified (community, issuer, uid) bindings with fail-closed conflict handling and durable revocation tombstones.
  • Make direct-JWT versus delegated-owner precedence explicit and configurable instead of inferring it from the presence of a NIP-OA owner declaration.
  • Enforce the verified identity consistently across WebSocket, HTTP bridge, media, Git, and audio transports.
  • Publish relay-signed NIP-85 assertions without disclosing the upstream identity-provider issuer, and present them as relay-verified identity in the desktop UI.
  • Run the ignored PostgreSQL identity-binding and relay identity lifecycle tests in hosted CI.

Risk Assessment

Medium. This touches every relay authentication transport, but the feature is disabled by default behind BUZZ_REQUIRE_CORPORATE_IDENTITY and fails closed when enabled.

Follow-ups

  • Add client-held OIDC with system-browser PKCE.
  • Add operator revocation, rotation, offboarding, and bad-binding repair workflows.
  • Add bounded authorization sessions, lease expiry and renewal, and live connection and delegated-agent eviction.
  • Add an optional startup JWKS reachability probe.

Notes

When enabled, the configured JWT acts as an enrollment authority: first use binds the verified issuer-qualified principal to the NIP-proven pubkey. A compromised valid JWT can therefore create an incorrect binding until an operator repairs it; automated recovery remains follow-up lifecycle work.

Trusted deployments must overwrite the configured identity header at the relay boundary. JWKS failures fail closed, and configured display-name claims may result in stored personal information.

Update — 2026-07-28

  • Qualified persisted principals by issuer.
  • Removed upstream issuer disclosure from public assertions.
  • Made direct-versus-delegated authentication precedence provider-neutral and configurable.
  • Replaced deployment-specific terminology and fixtures with generic relay/proxy/identity-provider terminology.
  • Renamed user-facing verification language to “Relay-verified identity.”
  • Preserved revoked-binding tombstones and added hosted lifecycle coverage.

Update — 2026-07-29

  • Required JWTs to carry the configured audience claim instead of only validating it when present.
  • Forced NIP-98 proof on HTTP bridge routes whenever corporate identity is required, preventing caller-controlled pubkeys from reaching identity binding.
  • Applied corporate identity enforcement to moderation reads and protected media GET/HEAD.
  • Added negative regression coverage for missing audiences, unsigned bridge pubkeys, moderation reads, and protected media reads.

Generated with Codex

Update — 2026-07-29 (security hardening)

This supersedes the earlier lifecycle follow-ups and first-use enrollment caveat.

  • Split corporate identity into read-only JWT/delegation verification, community authorization, then binding/assertion finalization; invite membership and first binding now commit atomically.
  • Added explicit principal disablement, key revocation, authorized rotation, legacy tombstone backfill, bounded assertions, Desktop expiry/tombstones, and direct/delegated session eviction.
  • Centralized the complete HTTP route policy with named exemptions and fail-closed handling for unclassified routes and methods.
  • Upgraded jsonwebtoken, required and type-checked exp/iss/aud, enforced nbf, asymmetric algorithms, JWK alg/use/key_ops, strict corporate config, mandatory configured npub, and bounded single-flight JWKS fetching.
  • Restricted transport-wide NIP-OA delegation to unconditional tags and made public identity labels opt-in; private display attributes are not projected by default.
  • Added focused RSA/JWKS, transaction/lifecycle, route matrix, session eviction, and Desktop replaceable-event/expiry regression coverage.

@fsola-sq
fsola-sq force-pushed the fsola/corporate-identity branch from bd822f3 to 7ba8616 Compare July 24, 2026 21:29
@fsola-sq
fsola-sq marked this pull request as ready for review July 24, 2026 21:29
@fsola-sq
fsola-sq requested a review from a team as a code owner July 24, 2026 21:29
@cea-block
cea-block force-pushed the fsola/corporate-identity branch from b441953 to c1452ef Compare July 28, 2026 18:27
@cea-block
cea-block force-pushed the fsola/corporate-identity branch 3 times, most recently from 419e851 to 7cd8b9e Compare July 28, 2026 23:03
@cea-block cea-block changed the title feat(relay): add corporate identity binding feat(relay): add relay-verified identity binding Jul 28, 2026

@jmecom jmecom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edit: Sorry, Codex posted this...
edit2: Ok, reviewed Codex's comments and I agree

The binding model and transport wiring are thoughtfully structured, and the current CI run is green. I found three security-boundary gaps that make BUZZ_REQUIRE_CORPORATE_IDENTITY weaker than its configuration contract: tokens without aud are accepted, HTTP bridge bindings can originate from the unverified X-Pubkey fallback, and existing authenticated read paths bypass the identity gate. Requesting changes until these are closed and covered by regression tests.


let mut validation = Validation::new(header.alg);
validation.set_issuer(&[self.config.issuer.as_str()]);
validation.set_audience(&[self.config.audience.as_str()]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Require the audience claim itself

In jsonwebtoken 9, Validation::new requires only exp. set_audience configures the allowed value, but validation is skipped when aud is absent. As written, a correctly signed issuer token with no audience can enroll and bind a principal even though it was not issued for Buzz. Add aud to required_spec_claims (and a missing-aud regression test) before decoding.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Addressed on current head. JWT validation now explicitly requires exp, iss, and aud, and rejects missing or malformed audiences. Regression tests cover both cases.

Comment thread crates/buzz-relay/src/api/bridge.rs Outdated
let pubkey_bytes = pubkey.to_bytes().to_vec();

let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
enforce_bridge_corporate_identity(state, tenant, headers, pubkey, auth_tag).await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Require cryptographic Nostr proof before persisting this binding

verify_bridge_auth still returns the caller-controlled X-Pubkey fallback whenever BUZZ_REQUIRE_AUTH_TOKEN=false, which is the default. This call then binds the validated JWT uid to that arbitrary key. A valid JWT holder can therefore bind another user pubkey to their own principal and leave the real owner hitting a persistent pubkey conflict. When corporate identity is enabled, force NIP-98 on these routes or reject the incompatible startup configuration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Addressed. Enabling corporate identity now forces signed NIP-98 authentication even when ordinary token auth is disabled, so the caller-controlled X-Pubkey fallback cannot create a durable binding. A regression test verifies this returns 401.

Comment thread crates/buzz-relay/src/api/bridge.rs Outdated
headers: &HeaderMap,
pubkey: nostr::PublicKey,
auth_tag: Option<&str>,
) -> Result<(), (StatusCode, Json<Value>)> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Apply this gate to the other authenticated bridge paths

The new helper is called by /events, /query, and /count, but authorize_moderation_read in this same module still stops after NIP-98 replay and role authorization. With BUZZ_REQUIRE_CORPORATE_IDENTITY=true, a moderator without any identity JWT can still read reports, audit records, and restrictions. Route every community-scoped NIP-authenticated bridge entry point through the same post-proof identity check.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Addressed. /moderation/reports, /moderation/audit, and /moderation/restricted now share the same corporate-identity gate after NIP-98 verification and before database reads. Binding finalization occurs only after moderator authorization succeeds.

Comment thread crates/buzz-relay/src/api/media.rs Outdated
crate::corporate_identity::enforce_corporate_identity(
state,
tenant.community(),
auth_event.pubkey,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Gate authenticated media reads as well as uploads

This covers upload, but authenticate_media_read still verifies only the Blossom GET event and relay membership. When BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_CORPORATE_IDENTITY are both true, a member key can continue GET/HEAD access to protected blobs without a JWT. Apply the same identity enforcement there using auth_event.pubkey before admitting the read.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Addressed. Protected Blossom GET and HEAD requests now verify the Blossom proof, validate corporate identity, enforce membership, and only then finalize the binding before accessing storage. Public media remains an intentional documented exemption.

jmecom
jmecom previously approved these changes Jul 29, 2026

@jmecom jmecom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the current head. The four prior blockers are addressed: JWT audience is required, corporate mode requires signed NIP-98 proof on the HTTP bridge, and corporate identity is enforced on moderation and protected media reads.

I also reviewed the staged/finalized authorization flow, atomic invite enrollment, revocation and rotation handling, session eviction, route inventory, and expiring desktop trust projection. I found no additional blocking issues.

fsola-sq and others added 12 commits August 3, 2026 06:58
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
@cea-block
cea-block force-pushed the fsola/corporate-identity branch from 0390d06 to a28957d Compare August 3, 2026 13:36
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage-ready Appropriate for agentic review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants