Skip to content
Open
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
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: rejects addresses not unambiguously assigned as globally reachable public unicast. 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` rejects any address not unambiguously assigned as globally reachable public unicast. 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
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 @@ -104,7 +104,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
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