An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(relay): add admin_token + signing_key_master_key config and V003 migration (MM-92)

authored by malpercio.dev and committed by

Tangled fc9fbce4 022d7fd2

+163 -18
+118 -15
crates/common/src/config.rs
··· 19 19 pub oauth: OAuthConfig, 20 20 pub iroh: IrohConfig, 21 21 pub telemetry: TelemetryConfig, 22 + // Operator authentication for management endpoints (e.g., POST /v1/relay/keys). 23 + pub admin_token: Option<String>, 24 + // AES-256-GCM master key for encrypting signing key private keys at rest. 25 + pub signing_key_master_key: Option<[u8; 32]>, 22 26 } 23 27 24 28 /// Optional privacy/ToS links surfaced by `com.atproto.server.describeServer`. ··· 97 101 pub(crate) iroh: IrohConfig, 98 102 #[serde(default)] 99 103 pub(crate) telemetry: RawTelemetryConfig, 104 + pub(crate) admin_token: Option<String>, 105 + #[serde(skip)] 106 + pub(crate) signing_key_master_key: Option<[u8; 32]>, 100 107 } 101 108 102 109 #[derive(Debug, thiserror::Error)] ··· 115 122 Invalid(String), 116 123 } 117 124 125 + /// Parse a 64-character hex string into a 32-byte array. 126 + /// Returns a human-readable error string on failure. 127 + fn parse_hex_32(var_name: &str, value: &str) -> Result<[u8; 32], ConfigError> { 128 + if value.len() != 64 { 129 + return Err(ConfigError::Invalid(format!( 130 + "{var_name} must be exactly 64 hex characters (32 bytes), got {} characters", 131 + value.len() 132 + ))); 133 + } 134 + let mut bytes = [0u8; 32]; 135 + for (i, pair) in value.as_bytes().chunks(2).enumerate() { 136 + let hi = hex_nibble(var_name, pair[0])?; 137 + let lo = hex_nibble(var_name, pair[1])?; 138 + bytes[i] = (hi << 4) | lo; 139 + } 140 + Ok(bytes) 141 + } 142 + 143 + fn hex_nibble(var_name: &str, b: u8) -> Result<u8, ConfigError> { 144 + match b { 145 + b'0'..=b'9' => Ok(b - b'0'), 146 + b'a'..=b'f' => Ok(b - b'a' + 10), 147 + b'A'..=b'F' => Ok(b - b'A' + 10), 148 + _ => Err(ConfigError::Invalid(format!( 149 + "{var_name} contains invalid hex character: {:?}", 150 + char::from(b) 151 + ))), 152 + } 153 + } 154 + 118 155 /// Apply `EZPDS_*` and selected OTel standard environment variable overrides to a [`RawConfig`], 119 156 /// returning the updated config. 120 157 /// ··· 175 212 } 176 213 if let Some(v) = env.get("OTEL_SERVICE_NAME") { 177 214 raw.telemetry.service_name = Some(v.clone()); 215 + } 216 + if let Some(v) = env.get("EZPDS_ADMIN_TOKEN") { 217 + raw.admin_token = Some(v.clone()); 218 + } 219 + if let Some(v) = env.get("EZPDS_SIGNING_KEY_MASTER_KEY") { 220 + raw.signing_key_master_key = Some(parse_hex_32("EZPDS_SIGNING_KEY_MASTER_KEY", v)?); 178 221 } 179 222 Ok(raw) 180 223 } ··· 262 305 oauth: raw.oauth, 263 306 iroh: raw.iroh, 264 307 telemetry, 308 + admin_token: raw.admin_token, 309 + signing_key_master_key: raw.signing_key_master_key, 265 310 }) 266 311 } 267 312 ··· 607 652 608 653 #[test] 609 654 fn env_override_telemetry_enabled() { 610 - let env = HashMap::from([( 611 - "EZPDS_TELEMETRY_ENABLED".to_string(), 612 - "true".to_string(), 613 - )]); 655 + let env = HashMap::from([("EZPDS_TELEMETRY_ENABLED".to_string(), "true".to_string())]); 614 656 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 615 657 let config = validate_and_build(raw).unwrap(); 616 658 ··· 631 673 632 674 #[test] 633 675 fn env_override_otel_service_name() { 634 - let env = HashMap::from([( 635 - "OTEL_SERVICE_NAME".to_string(), 636 - "my-service".to_string(), 637 - )]); 676 + let env = HashMap::from([("OTEL_SERVICE_NAME".to_string(), "my-service".to_string())]); 638 677 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 639 678 let config = validate_and_build(raw).unwrap(); 640 679 ··· 652 691 service_name = "from-toml" 653 692 "#; 654 693 let raw: RawConfig = toml::from_str(toml).unwrap(); 655 - let env = HashMap::from([( 656 - "OTEL_SERVICE_NAME".to_string(), 657 - "from-env".to_string(), 658 - )]); 694 + let env = HashMap::from([("OTEL_SERVICE_NAME".to_string(), "from-env".to_string())]); 659 695 let raw = apply_env_overrides(raw, &env).unwrap(); 660 696 let config = validate_and_build(raw).unwrap(); 661 697 ··· 664 700 665 701 #[test] 666 702 fn env_override_telemetry_enabled_invalid_returns_error() { 703 + let env = HashMap::from([("EZPDS_TELEMETRY_ENABLED".to_string(), "maybe".to_string())]); 704 + let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 705 + 706 + assert!(matches!(err, ConfigError::Invalid(_))); 707 + assert!(err.to_string().contains("EZPDS_TELEMETRY_ENABLED")); 708 + } 709 + 710 + // --- admin_token and signing_key_master_key config fields --- 711 + 712 + #[test] 713 + fn admin_token_is_optional() { 714 + let config = validate_and_build(minimal_raw()).unwrap(); 715 + // MM-92.AC7.5 716 + assert!(config.admin_token.is_none()); 717 + } 718 + 719 + #[test] 720 + fn signing_key_master_key_is_optional() { 721 + let config = validate_and_build(minimal_raw()).unwrap(); 722 + // MM-92.AC7.5 723 + assert!(config.signing_key_master_key.is_none()); 724 + } 725 + 726 + #[test] 727 + fn env_override_admin_token() { 728 + // MM-92.AC7.1 729 + let env = HashMap::from([("EZPDS_ADMIN_TOKEN".to_string(), "secret-token".to_string())]); 730 + let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 731 + let config = validate_and_build(raw).unwrap(); 732 + assert_eq!(config.admin_token.as_deref(), Some("secret-token")); 733 + } 734 + 735 + #[test] 736 + fn env_override_signing_key_master_key_valid_hex() { 737 + // MM-92.AC7.2: 64 valid hex chars → [u8; 32] 738 + let hex_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; 667 739 let env = HashMap::from([( 668 - "EZPDS_TELEMETRY_ENABLED".to_string(), 669 - "maybe".to_string(), 740 + "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(), 741 + hex_key.to_string(), 742 + )]); 743 + let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 744 + let config = validate_and_build(raw).unwrap(); 745 + 746 + let expected: [u8; 32] = [ 747 + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 748 + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 749 + 0x1d, 0x1e, 0x1f, 0x20, 750 + ]; 751 + assert_eq!(config.signing_key_master_key, Some(expected)); 752 + } 753 + 754 + #[test] 755 + fn env_override_signing_key_master_key_wrong_length_returns_error() { 756 + // MM-92.AC7.3: 62 hex chars (31 bytes) — wrong length 757 + let short_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; 758 + let env = HashMap::from([( 759 + "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(), 760 + short_key.to_string(), 670 761 )]); 671 762 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 763 + assert!(matches!(err, ConfigError::Invalid(_))); 764 + assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY")); 765 + } 672 766 767 + #[test] 768 + fn env_override_signing_key_master_key_non_hex_returns_error() { 769 + // MM-92.AC7.4: contains 'g' which is not a valid hex character 770 + let invalid_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fgg"; 771 + let env = HashMap::from([( 772 + "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(), 773 + invalid_key.to_string(), 774 + )]); 775 + let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 673 776 assert!(matches!(err, ConfigError::Invalid(_))); 674 - assert!(err.to_string().contains("EZPDS_TELEMETRY_ENABLED")); 777 + assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY")); 675 778 } 676 779 }
+2
crates/relay/src/app.rs
··· 125 125 oauth: OAuthConfig::default(), 126 126 iroh: IrohConfig::default(), 127 127 telemetry: TelemetryConfig::default(), 128 + admin_token: None, 129 + signing_key_master_key: None, 128 130 }), 129 131 db: pool, 130 132 }
+17
crates/relay/src/db/migrations/V003__relay_signing_keys.sql
··· 1 + -- V003: Relay-level signing keys 2 + -- Applied in a single transaction by the migration runner. 3 + -- 4 + -- Relay signing keys are operator-level keys used to sign user repo commits. 5 + -- Unlike signing_keys (V002), these are not tied to a specific account DID. 6 + 7 + -- ── Relay Signing Keys ─────────────────────────────────────────────────────── 8 + 9 + -- WITHOUT ROWID: keys are always fetched by their did:key URI (the primary key). 10 + CREATE TABLE relay_signing_keys ( 11 + id TEXT NOT NULL, -- full did:key:z... URI; derived from public key 12 + algorithm TEXT NOT NULL, -- "p256" 13 + public_key TEXT NOT NULL, -- multibase base58btc compressed point 14 + private_key_encrypted TEXT NOT NULL, -- base64(12-byte nonce || 32-byte ciphertext || 16-byte tag) 15 + created_at TEXT NOT NULL, -- ISO 8601 UTC 16 + PRIMARY KEY (id) 17 + ) WITHOUT ROWID;
+26 -3
crates/relay/src/db/mod.rs
··· 36 36 version: 2, 37 37 sql: include_str!("migrations/V002__auth_identity.sql"), 38 38 }, 39 + Migration { 40 + version: 3, 41 + sql: include_str!("migrations/V003__relay_signing_keys.sql"), 42 + }, 39 43 ]; 40 44 41 45 /// Open a WAL-mode SQLite connection pool with a maximum of 1 connection. ··· 324 328 } 325 329 } 326 330 327 - /// schema_migrations must contain exactly 2 rows after applying V001 + V002. 331 + /// schema_migrations must contain one row per migration in MIGRATIONS. 328 332 #[tokio::test] 329 - async fn v002_migration_count_is_two_after_both_migrations() { 333 + async fn all_migrations_recorded_in_schema_migrations() { 330 334 let pool = in_memory_pool().await; 331 335 run_migrations(&pool).await.unwrap(); 332 336 ··· 334 338 .fetch_one(&pool) 335 339 .await 336 340 .unwrap(); 337 - assert_eq!(count, 2, "both V001 and V002 must be recorded"); 341 + assert_eq!( 342 + count, 343 + MIGRATIONS.len() as i64, 344 + "schema_migrations must have one row per migration in MIGRATIONS" 345 + ); 338 346 } 339 347 340 348 /// Running migrations twice must not drop or recreate V002 tables. ··· 724 732 detail.contains("idx_sessions_did"), 725 733 "sessions WHERE did query must use idx_sessions_did; got: {detail}" 726 734 ); 735 + } 736 + 737 + // ── V003 tests ─────────────────────────────────────────────────────────── 738 + 739 + #[tokio::test] 740 + async fn v003_relay_signing_keys_table_exists() { 741 + let pool = in_memory_pool().await; 742 + run_migrations(&pool).await.unwrap(); 743 + 744 + // Verify the table exists by performing a SELECT with no rows expected. 745 + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM relay_signing_keys") 746 + .fetch_one(&pool) 747 + .await 748 + .expect("relay_signing_keys table must exist after V003 migration"); 749 + assert_eq!(count.0, 0, "table must be empty after migration"); 727 750 } 728 751 729 752 /// WAL mode requires a real file — use tempfile here, not :memory:.