Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
nextest-version = "0.9.136"
# The PostgreSQL lane uses a run-scoped desired-state template database and a
# per-test wrapper, both of which require nextest's script support.
experimental = ["setup-scripts", "wrapper-scripts"]

[scripts.setup.postgres-template]
# Bootstrap the desired-state source database once per nextest invocation.
command = { command-line = "scripts/postgres-test-setup.sh", relative-to = "workspace-root" }
slow-timeout = "60s"

[scripts.wrapper.postgres-isolation]
# Clone or create a unique database for each test process, then drop it on exit.
command = { command-line = "scripts/postgres-test-wrapper.sh", relative-to = "workspace-root" }

[profile.postgres-ci]
# This structural convention keeps new PostgreSQL-backed tests discoverable
# without maintaining an exact list of test names.
default-filter = """
(test(/postgres_tests::/) or binary(/^postgres_/))
and not test(/(^|::)external_infra[^:]*::/)
"""
fail-fast = false
# Eight workers was the fastest stable setting in the Blox benchmark while the
# wrapper retained one database per concurrently running test process.
test-threads = 8

[test-groups.postgres-cluster-global]
# These tests inspect cluster-wide activity or create least-privilege sessions,
# so database-per-test isolation alone cannot make them independent.
max-threads = 1

[[profile.postgres-ci.overrides]]
filter = "test(/cluster_global_/)"
test-group = "postgres-cluster-global"

[[profile.postgres-ci.scripts]]
# Script filters are separate from default-filter: they attach the setup and
# isolation wrapper to the same automatically discovered test set.
filter = "(test(/postgres_tests::/) or binary(/^postgres_/)) and not test(/(^|::)external_infra[^:]*::/)"
setup = "postgres-template"
run-wrapper = "postgres-isolation"
277 changes: 114 additions & 163 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

10 changes: 4 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored)
|----------|---------|
| `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. |
| `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. |
| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. |
| `is_not_global_unicast(ip)` | SSRF protection: enumerated-deny policy — blocks a specific set of non-public address classes and accepts everything else (including addresses not covered by an explicit deny rule, e.g. `fe00::1`). Blocked IPv4 classes: loopback, private (RFC 1918), link-local, CGNAT (RFC 6598), benchmarking (RFC 2544), IETF Protocol Assignments (192.0.0.0/24, exceptions: 192.0.0.9 PCP anycast, 192.0.0.10 TURN anycast), documentation (RFC 5737: 192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526), multicast (RFC 5771, 224/4), reserved/class-E (240/4). Blocked IPv6 classes: loopback, unspecified, ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 anycast, 2001:3::/32 AMT, 2001:4:112::/48 AS112-v6, 2001:20::/28 ORCHIDv2, 2001:30::/28 DETs), documentation (2001:db8::/32, 3fff::/20), 6to4 (2002::/16), Discard-Only (100::/64), Dummy prefix (100:0:0:1::/64), SRv6 SIDs (5f00::/16), NAT64 local-use (64:ff9b:1::/48). IPv4 embedded in mapped, compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated (::ffff:0:0:0/96) forms checked recursively. Compat alias: `is_private_ip`. |

**Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime.

Expand Down Expand Up @@ -746,12 +746,10 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic

### SSRF Protection

`is_private_ip()` in `buzz-core` covers:
- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255)
- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32)
- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address
`is_not_global_unicast(ip)` (compat alias `is_private_ip`) in `buzz-core` is an enumerated-deny policy: it blocks a specific set of non-public address classes and accepts everything else, including addresses not covered by an explicit deny rule (e.g. `fe00::1`). Blocked IPv4 classes: loopback (127.0.0.0/8), private RFC 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (0/8), broadcast, CGNAT/RFC 6598 (100.64/10), benchmarking/RFC 2544 (198.18/15), IETF Protocol Assignments (192.0.0.0/24, globally reachable exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155), documentation/RFC 5737 (192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526, global=None/blank → conservative deny), multicast/RFC 5771 (224/4), and reserved class-E (240/4). Blocked IPv6 classes: loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 PCP/TURN/DNS-SD anycast, 2001:3::/32 AMT RFC 7450, 2001:4:112::/48 AS112-v6 RFC 7535, 2001:20::/28 ORCHIDv2 RFC 7343, 2001:30::/28 DETs RFC 9374), documentation (2001:db8::/32 RFC 3849, 3fff::/20 RFC 9637), 6to4 (2002::/16, RFC 3056), Discard-Only (100::/64, RFC 6666), Dummy IPv6 Prefix (100:0:0:1::/64, RFC 9780), SRv6 SIDs (5f00::/16, RFC 9252), and NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known (64:ff9b::/96, RFC 6052) forms is checked recursively against the IPv4 table; SIIT IPv4-translated (::ffff:0:0:0/96) follows the same path.

Applied in: `buzz-workflow` (CallWebhook action), `buzz-core` (shared utility).
Applied in: `buzz-auth` (JWKS boundary), `buzz-workflow` (CallWebhook action),
desktop `link_preview` (SSRF check).

### Audit Integrity

Expand Down
58 changes: 58 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,64 @@ connections, NIP-42 auth, event ingestion, search indexing, and workflow
execution. `just test` starts Docker services automatically if they're not
already running.

### PostgreSQL-backed tests

PostgreSQL-backed tests run in a dedicated nextest lane. Mark them ignored with
a PostgreSQL reason and place them in a module whose name ends in
`postgres_tests`. Standalone integration-test targets use a `postgres_`
filename prefix instead. Tests that also require infrastructure beyond
PostgreSQL and Redis live under an `external_infra*_tests` module and are
excluded without changing their descriptive function names.

See the [buzz-db testing guide](crates/buzz-db/TESTING.md) for the crate-level
checklist.

`scripts/test-postgres-test-discovery.sh` enforces the convention across every
Rust source file. It fails CI when an ignored PostgreSQL test would be omitted,
or when a Redis-only or hybrid test is accidentally included, so module or file
renames cannot silently change lane membership. The archive and runner derive
their Cargo package set from the same markers, so a database test in a new crate
does not require a separate package-list update.

The `postgres-ci` nextest profile creates one database per test process, so
destructive and concurrent tests must use the database URL supplied through
`BUZZ_TEST_DATABASE_URL`, `TEST_DATABASE_URL`, or `DATABASE_URL`; do not
hard-code the shared development database. Ordinary tests receive the committed
desired-state schema from `schema/schema.sql`. Tests under
`migration::postgres_tests` receive an empty database and own the embedded
migration lifecycle. A test outside that module whose behavior intentionally
depends on migration-created triggers or seed rows uses a
`migration_schema_` function-name prefix and also receives an empty database
with `BUZZ_TEST_SCHEMA_MODE=migration`. Test helpers that normally call the
migrator honor `BUZZ_TEST_SCHEMA_MODE=desired` so the desired-state contract is
not re-migrated.

Tests that inspect cluster-wide PostgreSQL state or open least-privilege
sessions use a `cluster_global_` function-name segment; migration-backed cases
use `migration_schema_cluster_global_`. Nextest serializes this small group
while the database-isolated remainder stays parallel.

The setup process requires a PostgreSQL role that can create and drop databases
and owns the databases it creates; the harness itself does not require
superuser access. The complete inventory includes privilege-boundary tests that
create temporary roles and inspect all sessions, so grant that role
`CREATEROLE` and membership in `pg_read_all_stats` (or use an ephemeral
superuser, as CI does).
Set `BUZZ_POSTGRES_ADMIN_URL` to that role's maintenance database, and set
`PGHOST`, `PGPORT`, `PGUSER`, and `PGPASSWORD` for the desired-state
schema bootstrap. PostgreSQL client tools are resolved from `PATH` unless
`PG_BIN_DIR` is set. Tests that use Redis read `REDIS_URL`.

With native PostgreSQL and Redis running, the complete lane is below. The
runner bounds compilation to the packages discovered from the current source
tree and removes the run-scoped desired-state source database on exit.
Per-test and source-database cleanup retries transient PostgreSQL disconnect
races and emits a warning if all five attempts fail.

```bash
./scripts/postgres-test-run.sh
```

### End-to-End Tests

End-to-end tests live in `crates/buzz-test-client/tests/`:
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ chrono = { version = "0.4", features = ["serde"] }
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] }

# HTTP client (webhook delivery)
reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false }
reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false }

# Cryptography
sha2 = "0.11"
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-audit/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result<AuditEntry, AuditEr
}

#[cfg(test)]
mod tests {
mod postgres_tests {
use super::*;
use crate::action::AuditAction;
use crate::entry::NewAuditEntry;
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ dev = []
[dev-dependencies]
# `use_pem` enables EncodingKey::from_ec_pem for minting ES256 test assertions.
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs", "use_pem"] }
tokio = { workspace = true, features = ["test-util"] }

[dependencies]
buzz-core = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true }
jsonwebtoken = { workspace = true }
nostr = { workspace = true }
futures-util = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
Expand Down
14 changes: 8 additions & 6 deletions crates/buzz-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,14 @@ pub use rate_limit::{
pub use scope::{parse_scopes, Scope};

pub use nip_fi::{
AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, ClientSubjectPosture,
ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, FederatedIdentity,
FreshnessClass, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry,
RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId,
VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM,
OAUTH_CLIENT_ID_CLAIM,
validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities,
ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier,
FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher,
IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry,
JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError,
ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass,
TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER,
NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM,
};

#[cfg(any(test, feature = "test-utils"))]
Expand Down
43 changes: 43 additions & 0 deletions crates/buzz-auth/src/nip_fi/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fmt;

use super::jwks::JwksSourceContract;

/// Maximum accepted length of an `iss` or `aud` string.
const MAX_URI_LEN: usize = 2_048;
/// Maximum accepted length of a claim name.
Expand Down Expand Up @@ -349,6 +351,11 @@ pub struct IssuerPolicy {
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
/// The authenticated key-source contract: validated JWKS URI, refresh
/// interval, and hard deadline. Included in `derive_assertion_policy_id`
/// so that a change to the endpoint, refresh schedule, or hard-deadline
/// rule changes the policy ID and invalidates all prepared evidence.
jwks_source_contract: JwksSourceContract,
id: AssertionPolicyId,
}

Expand Down Expand Up @@ -382,6 +389,10 @@ pub enum IssuerPolicyError {
/// so subject classification could not be total and mutually exclusive.
#[error("subject class contract is not exclusive")]
NonExclusiveSubjectClass,
/// The [`JwksSourceContract`] was not valid — invalid URI, zero or
/// out-of-range timing, or `refresh_interval >= hard_deadline`.
#[error("invalid JWKS source contract")]
InvalidJwksSourceContract,
}

impl IssuerPolicy {
Expand All @@ -397,6 +408,7 @@ impl IssuerPolicy {
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
jwks_source_contract: JwksSourceContract,
) -> Result<Self, IssuerPolicyError> {
// Identity-bearing strings are validated for bounds but never mutated:
// exact `iss`/`aud`/`sub` bytes select policies and form the identity
Expand Down Expand Up @@ -459,6 +471,7 @@ impl IssuerPolicy {
skew_seconds,
maximum_assertion_age_seconds,
maximum_status_age_seconds,
&jwks_source_contract,
);

Ok(Self {
Expand All @@ -471,6 +484,7 @@ impl IssuerPolicy {
skew_seconds,
maximum_assertion_age_seconds,
maximum_status_age_seconds,
jwks_source_contract,
id,
})
}
Expand Down Expand Up @@ -524,6 +538,11 @@ impl IssuerPolicy {
pub const fn id(&self) -> AssertionPolicyId {
self.id
}

/// The authenticated key-source contract for this policy's JWKS endpoint.
pub fn jwks_source_contract(&self) -> &JwksSourceContract {
&self.jwks_source_contract
}
}

/// A closed set of issuer policies keyed by exact `iss`. Selection preserves
Expand Down Expand Up @@ -560,6 +579,12 @@ impl IssuerRegistry {
pub fn is_empty(&self) -> bool {
self.policies.is_empty()
}

/// Iteration order is deliberately unspecified; callers must not depend on
/// registration order.
pub fn all_policies(&self) -> impl Iterator<Item = &IssuerPolicy> {
self.policies.values()
}
}

/// Sort and deduplicate a set-valued list of strings into its canonical form.
Expand Down Expand Up @@ -625,6 +650,7 @@ fn derive_assertion_policy_id(
skew_seconds: u64,
maximum_assertion_age_seconds: u64,
maximum_status_age_seconds: Option<u64>,
jwks_source_contract: &JwksSourceContract,
) -> AssertionPolicyId {
let mut hasher = Sha256::new();
hasher.update(b"buzz:nip-fi:assertion-policy:v1\0");
Expand Down Expand Up @@ -680,6 +706,23 @@ fn derive_assertion_policy_id(
hasher.update(skew_seconds.to_be_bytes());
hasher.update(maximum_assertion_age_seconds.to_be_bytes());
hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes());
// Authenticated key-source contract (NIP-FI.md, "Policy identity and
// snapshots"): URI selects the authenticated source; interval defines
// bounded refresh; hard deadline defines the accepted time rule. These are
// contract, not mutable state — key rotation (JWKS content change) leaves
// all three unchanged and must not move the ID.
hasher.update(b"jwks-source-contract\0");
hash_field(&mut hasher, jwks_source_contract.jwks_uri().as_bytes());
hasher.update(
jwks_source_contract
.refresh_interval_seconds()
.to_be_bytes(),
);
hasher.update(
jwks_source_contract
.key_snapshot_hard_deadline_seconds()
.to_be_bytes(),
);
AssertionPolicyId(hasher.finalize().into())
}

Expand Down
66 changes: 66 additions & 0 deletions crates/buzz-auth/src/nip_fi/discovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//! NIP-11 federated-identity discovery output.
//!
//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity`
//! object required by the NIP-FI.md "Discovery" section of the NIP-11 relay
//! information document.
//!
//! ## Privacy invariants
//!
//! The discovery object MUST NOT contain: enrollment mode, TOFU posture,
//! issuer URLs, audiences, claim names, tenant IDs, or deployment-local
//! identifiers. For a fixed set of claimed profiles the complete output is
//! byte-identical across every enrollment policy and lifecycle state.
//! [FI-TRACE-DISCOVERY-PRIVATE]
//!
//! ## Offline-jwt residual bound
//!
//! `maximum_residual_upstream_revocation_seconds` is `null` for `offline-jwt`
//! deployments. An offline-jwt deployment MUST NOT advertise a finite value
//! here (NIP-FI.md:259-266).

use serde::{Deserialize, Serialize};

/// The `assertion_freshness` sub-object in the `federated_identity` discovery
/// document. Describes the claimed freshness posture without exposing any
/// issuer or deployment-private state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssertionFreshnessDiscovery {
/// The wire string identifying the freshness class.
pub class: FreshnessClassDiscovery,
/// `null` for `offline-jwt`; advertising a finite bound here requires a
/// live status witness that is not yet implemented.
pub maximum_residual_upstream_revocation_seconds: Option<u64>,
}

/// The freshness class as a stable NIP-FI wire string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FreshnessClassDiscovery {
/// No revocation bound is claimed; JWKS snapshot validation only.
OfflineJwt,
}

/// The `federated_identity` NIP-11 discovery object. Fields never expose
/// enrollment mode, issuer, audience, or private state.
/// [FI-TRACE-DISCOVERY-PRIVATE]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FederatedIdentityDiscovery {
/// Fixed value `"client-attached"` for the core NIP-FI transport mode.
pub core: String,
/// The freshness contract claimed by this deployment.
pub assertion_freshness: AssertionFreshnessDiscovery,
}

impl FederatedIdentityDiscovery {
/// The only supported posture: claims no residual revocation bound, which
/// is the honest description of JWKS-only assertion verification.
pub fn offline_jwt() -> Self {
Self {
core: "client-attached".to_owned(),
assertion_freshness: AssertionFreshnessDiscovery {
class: FreshnessClassDiscovery::OfflineJwt,
maximum_residual_upstream_revocation_seconds: None,
},
}
}
}
Loading
Loading