Skip to content

feat: corporate identity binding — proxy/hybrid mode for enterprise deployments#293

Open
fsola-sq wants to merge 5 commits intomainfrom
fsola/identity-pubkey-binding
Open

feat: corporate identity binding — proxy/hybrid mode for enterprise deployments#293
fsola-sq wants to merge 5 commits intomainfrom
fsola/identity-pubkey-binding

Conversation

@fsola-sq
Copy link
Copy Markdown
Collaborator

Corporate identity binding — proxy/hybrid mode for enterprise deployments

Problem

Enterprise deployments need to tie Nostr pubkeys to corporate identities (SSO) so the relay can verify who is behind each connection, support multi-device key management, and display verified usernames. Standard Nostr auth (NIP-42) only proves pubkey ownership — it says nothing about the human or service account behind the key.

Solution

Add a proxy identity mode where the relay sits behind a trusted auth proxy that injects identity JWTs. The relay binds each client's self-generated Nostr pubkey to a (corporate_uid, device_cn) pair, creating an immutable identity binding. Three modes controlled by SPROUT_IDENTITY_MODE:

  • disabled (default) — standard Nostr key-based auth only. No changes to existing behavior.
  • proxy — all connections must present a valid identity JWT. The relay validates the JWT at connection time and binds the pubkey to the corporate identity during NIP-42 AUTH.
  • hybrid — identity JWT preferred for human users; connections without the header fall through to standard auth (API tokens, Okta JWTs). Allows agents to authenticate alongside identity-bound humans.

Architecture

Two-phase binding (WebSocket)

  1. WS Upgrade — The relay validates the identity JWT (signature + expiry via JWKS), extracts uid and username claims, and stashes them as PendingProxyIdentity on the connection. The client's pubkey is not yet known.
  2. NIP-42 AUTH — The client proves pubkey ownership by signing the challenge. The AUTH handler calls bind_or_validate_identity(uid, device_cn, pubkey) to create or validate the binding, then transitions to Authenticated.

REST API path

Proxy-authenticated REST requests validate the identity JWT, then look up the existing (uid, device_cn) → pubkey binding from the identity_bindings table. A registration endpoint (POST /api/identity/register) allows initial binding via NIP-98 proof of key ownership.

Identity-bound guard

Once a pubkey is bound to a corporate identity, it cannot authenticate via standard auth (API tokens, NIP-42 pubkey-only) without presenting the identity JWT. This prevents key compromise bypass in hybrid mode — a stolen key can't be used to impersonate the corporate user from outside the proxy.

Schema

New identity_bindings table (declarative, in schema/schema.sql):

CREATE TABLE identity_bindings (
    uid          TEXT NOT NULL,
    device_cn    TEXT NOT NULL,
    pubkey       BYTEA NOT NULL,
    username     VARCHAR(255),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (uid, device_cn),
    CONSTRAINT chk_identity_bindings_pubkey_len CHECK (LENGTH(pubkey) = 32)
);

Bindings are immutable — once a (uid, device_cn) is bound to a pubkey, a different pubkey for the same slot returns a mismatch error. Use sprout-admin unbind-identity for key rotation or offboarding.

New verified_name column on users — set from the identity JWT username claim, displayed with a verified badge in the desktop UI.

Changes by crate

Crate Changes
sprout-auth New identity module: IdentityMode enum, IdentityConfig (mode, JWT/device-CN header names, claim names, JWKS config), ProxyIdentityClaims, validate_identity_jwt(), verify_nip42_event() (public for proxy path). Scope::all_non_admin() for identity-authenticated users.
sprout-db New identity_binding module: bind_or_validate_identity() (SELECT FOR UPDATE with 3s lock_timeout), get_identity_binding(), is_pubkey_identity_bound(), delete_identity_binding(), get_bindings_for_uid(). ensure_user_with_verified_name(). BindingResult enum (Created/Matched/Mismatch).
sprout-relay ConnectionState: new PendingProxyIdentity struct, AuthState::Pending carries optional proxy identity. handle_auth: proxy identity binding path, API token identity-bound guard, standard auth identity-bound guard. extract_auth_context: proxy/hybrid REST path with binding lookup. AppState::is_identity_bound() with moka cache (2-min TTL). New api/identity.rs registration endpoint. NIP-11 advertises identity_mode. Configurable header names via SPROUT_IDENTITY_JWT_HEADER / SPROUT_IDENTITY_DEVICE_CN_HEADER.
sprout-admin New unbind-identity command (by UID, optional device_cn, optional --clear-name).
desktop IdentityGate component: calls initializeIdentity at startup, configures relay client auth mode. MessageRow: verified badge for identity-bound users. identity.ts: identity initialization logic.

Configuration

# Identity mode: "disabled" (default), "proxy", "hybrid"
SPROUT_IDENTITY_MODE=disabled

# JWT claim names
SPROUT_IDENTITY_UID_CLAIM=uid
SPROUT_IDENTITY_USER_CLAIM=user

# Identity provider JWKS (falls back to OKTA_* if unset)
SPROUT_IDENTITY_JWKS_URI=
SPROUT_IDENTITY_ISSUER=
SPROUT_IDENTITY_AUDIENCE=

# Proxy header names (defaults shown)
SPROUT_IDENTITY_JWT_HEADER=x-forwarded-identity-token
SPROUT_IDENTITY_DEVICE_CN_HEADER=x-block-client-cert-subject-cn

Security considerations

  • Trusted-proxy assumption: The relay trusts proxy-injected headers unconditionally. It must be deployed behind the auth proxy — direct access would allow header injection. require_auth_token is forced true when identity mode is active.
  • Immutable bindings: Once bound, a (uid, device_cn) → pubkey mapping cannot be changed without admin intervention (sprout-admin unbind-identity).
  • Identity-bound guard: Bound pubkeys are blocked from standard auth paths, preventing key compromise bypass.
  • Cache invalidation: Binding creation immediately invalidates the moka cache entry so the guard takes effect without waiting for TTL expiry.
  • SELECT FOR UPDATE: Binding creation uses row-level locking with a 3-second timeout to prevent races.

Testing

  • Unit tests for IdentityMode, IdentityConfig, verify_nip42_event (public API).
  • E2E tests for the identity flow are planned as a follow-up.
  • All existing tests pass (just ci).

fsola-sq and others added 3 commits April 10, 2026 13:12
Add proxy/hybrid identity mode where the relay sits behind a trusted
reverse proxy (cf-doorman) that injects identity JWTs. The relay derives
deterministic Nostr keypairs from the corporate UID claim via HMAC-SHA256.

Backend:
- sprout-auth: IdentityMode enum, IdentityConfig, derive_keypair_from_uid
- sprout-relay: config env vars, POST /api/identity/bootstrap endpoint,
  WS + REST pre-auth from x-forwarded-identity-token header
- sprout-db: ensure_user_with_verified_name for trusted display names
- NIP-11: advertise identity_mode for desktop auto-discovery
- Automatically force require_auth_token when identity mode is active

Desktop:
- IdentityGate component gates app on successful bootstrap
- Tauri identity commands: bootstrap_identity, set_identity_from_secret_key
- WARP connectivity error detection with user-friendly messaging

Security:
- Relay trusts x-forwarded-identity-token unconditionally (must be behind
  cf-doorman); startup warning documents the trusted-proxy assumption
- Bootstrap response uses POST + Cache-Control: no-store to prevent
  intermediary caching of secret keys
- Identity secret never leaves the relay; each user gets only their own key

Amp-Thread-ID: https://ampcode.com/threads/T-019d7027-eacf-763c-8e72-7361f6f14bf4
Co-authored-by: Amp <amp@ampcode.com>
…rated pubkey binding

Replace the relay-derived HMAC keypair approach with client-generated keys
and server-side identity binding. The relay no longer knows private keys.

Key changes:
- New identity_bindings table: maps (uid, device_cn) to pubkey, enabling
  multi-device support under one corporate identity
- POST /api/identity/register replaces /api/identity/bootstrap: client
  proves pubkey ownership via NIP-98, relay binds it to JWT identity
- WS proxy mode now requires NIP-42 AUTH (no more pre-authentication):
  proxy identity claims are stashed at upgrade time, binding resolved
  when client sends AUTH event with its own key
- REST proxy auth resolves pubkey from binding lookup instead of HMAC
- Desktop generates/persists its own keypair locally and registers it
  with the relay on startup (no secret key returned from server)
- Removed: HMAC key derivation, SPROUT_IDENTITY_SECRET/CONTEXT env vars,
  bootstrap endpoint, set_identity_from_secret_key Tauri command

Security improvements:
- Relay never sees or transmits private keys
- No server-side secret whose compromise would rotate all identities
- Per-device key isolation via (uid, device_cn) binding
- Proof-of-possession required for both WS (NIP-42) and REST (NIP-98)

Amp-Thread-ID: https://ampcode.com/threads/T-019d731d-8157-752e-adf2-387f5d48f3a5
Co-authored-by: Amp <amp@ampcode.com>
…guard

- Fix JWT validation (was using validate_permissive)
- Limit proxy scopes to non-admin
- Add identity-bound pubkey guard: bound pubkeys must present JWT
- Cache identity lookups via moka (2-min TTL), invalidate on bind
- Extract shared AppState::is_identity_bound() helper
- Add sprout-admin unbind-identity for key rotation/offboarding
- Add identity-specific JWKS config (SPROUT_IDENTITY_JWKS_URI/ISSUER/AUDIENCE)
- Use EXISTS instead of COUNT(*) for binding check
- Remove existing_pubkey from mismatch logs (PII)
- Add verified_name badge in desktop UI
- Update ARCHITECTURE.md and AGENTS.md

Amp-Thread-ID: https://ampcode.com/threads/T-019d77d2-5136-77ed-880b-928e13d5cb1c
Co-authored-by: Amp <amp@ampcode.com>
@fsola-sq fsola-sq requested a review from wesbillman as a code owner April 10, 2026 20:58
…iedBadge

- Make identity JWT and device CN header names configurable via
  SPROUT_IDENTITY_JWT_HEADER and SPROUT_IDENTITY_DEVICE_CN_HEADER
- Replace all 'cf-doorman' references with generic 'auth proxy'
- Remove hardcoded header names from error messages and docs
- Fix NIP-11 identity_mode doc to include 'hybrid'
- Add missing VerifiedBadge component (was untracked)
- Fix cargo fmt in desktop/src-tauri identity command

Amp-Thread-ID: https://ampcode.com/threads/T-019d791a-849a-746b-ba47-cb9b56ee196d
Co-authored-by: Amp <amp@ampcode.com>
@tlongwell-block
Copy link
Copy Markdown
Collaborator

Follow-up Items (Subsequent PR)

Tracked in #295. These are out of scope for this PR but should be addressed before or alongside the auth simplification work:

Security Hardening

  • E2E bridge mock for initialize_identityIdentityGate wraps the entire app; e2eBridge.ts needs a handler to unblock E2E tests
  • Registration response pubkey validation — client should assert response.pubkey === localKeypair.publicKey after /api/identity/register
  • device_cn silent default — currently falls back to "default" when the header is missing; should log a warning or fail closed in proxy mode
  • verified_name unbind logic — only clear verified_name when the last binding is removed, not on every unbind

Concurrency

  • SELECT FOR UPDATE phantom read — first-bind race is bounded by the UNIQUE(pubkey) constraint (added in this PR), but INSERT ... ON CONFLICT would be cleaner
  • Multi-instance cache stalenessidentity_bound_cache is local-only with a 2-minute TTL; binding changes on one relay node are not immediately visible to others

Client UX

  • Auth rejection UX — WS auth rejection is currently a silent dead connection; should surface a user-visible error
  • handleAuthChallenge gating — not gated on authMode; harmless today but a latent bug if preauthenticated mode activates
  • NIP-98 URL canonicalization — trailing-slash mismatch between relay config and desktop relay_api_base_url()

Documentation

  • ARCHITECTURE.md — says "Four authentication paths" in 3 places (now five); missing ProxyIdentity variant and /api/identity/register endpoint
  • Router doc comment — stale after proxy identity addition
  • is_identity_bound no-op in Proxy mode — safe but undocumented; add a comment explaining why

Cleanup

  • .env.example — duplicate SPROUT_REQUIRE_AUTH_TOKEN=false
  • identity_bound_cache visibilitypubpub(crate)
  • unreachable!() in api/identity.rs:219 — replace with proper error return
  • last_seen_at column — no consumer; wire it up or remove it

- Enforce pubkey uniqueness via UNIQUE index on identity_bindings.pubkey
- Derive scopes from JWT claims instead of granting all_non_admin()
- Fail closed on DB error in is_identity_bound() (deny, don't allow)
- Register proxy-authenticated sessions in connection manager
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants