myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

services_hardening_nip46_replay.rs (8841B)


      1 #![forbid(unsafe_code)]
      2 
      3 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
      4 use myc::{
      5     MycConfigProfile, MycNip46AdmissionLimits, MycNip46AuthoredTimePolicy,
      6     MycNip46ObservedAtUnixSeconds, MycNip46ReplayDisposition, MycProviderBinding, MycProviderRole,
      7     admit_myc_nip46_event, admit_myc_nip46_request, bind_myc_nip46_replay, parse_myc_config_v1,
      8     verify_myc_nip46_event, verify_myc_nip46_request,
      9 };
     10 use nostr::{Event, EventBuilder, Keys, Kind, Tag, Timestamp};
     11 use serde_json::{Value, json};
     12 
     13 const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
     14 const CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_replay.v1.json");
     15 const OBSERVED_AT: u64 = 1_725_000_000;
     16 
     17 fn limits() -> MycNip46AdmissionLimits {
     18     let config =
     19         parse_myc_config_v1(CONFIG.as_bytes(), MycConfigProfile::RepoLocal).expect("test config");
     20     MycNip46AdmissionLimits::from_config(&config).expect("limits")
     21 }
     22 
     23 fn keys(seed: u8) -> Keys {
     24     Keys::parse(&format!("{seed:02x}{}", "00".repeat(31))).expect("test keys")
     25 }
     26 
     27 fn provider_binding(target: &Keys) -> MycProviderBinding {
     28     let section_start = CONFIG
     29         .find("[identity.transport]")
     30         .expect("transport section");
     31     let original = "4444444444444444444444444444444444444444444444444444444444444444";
     32     let mut source = CONFIG.to_owned();
     33     let relative = source[section_start..]
     34         .find(original)
     35         .expect("configured identity");
     36     let identity_start = section_start + relative;
     37     source.replace_range(
     38         identity_start..identity_start + original.len(),
     39         &target.public_key().to_hex(),
     40     );
     41     parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal)
     42         .expect("test config")
     43         .provider_contract()
     44         .binding(MycProviderRole::Transport)
     45         .expect("transport binding")
     46         .clone()
     47 }
     48 
     49 fn event(sender: &Keys, target: &Keys, created_at: u64) -> Event {
     50     let content = BASE64_STANDARD.encode([vec![2], vec![0; 32], vec![0; 34], vec![0; 32]].concat());
     51     EventBuilder::new(Kind::Custom(24_133), content)
     52         .tag(Tag::public_key(target.public_key()))
     53         .custom_created_at(Timestamp::from_secs(created_at))
     54         .sign_with_keys(sender)
     55         .expect("signed event")
     56 }
     57 
     58 fn candidate(
     59     sender: &Keys,
     60     target: &Keys,
     61     created_at: u64,
     62     request_id: &str,
     63     method: &str,
     64     params: Value,
     65 ) -> myc::MycReplayBoundNip46Request {
     66     let plaintext = serde_json::to_vec(&json!({
     67         "id": request_id,
     68         "method": method,
     69         "params": params
     70     }))
     71     .expect("request wire");
     72     candidate_from_plaintext(sender, target, created_at, &plaintext)
     73 }
     74 
     75 fn candidate_from_plaintext(
     76     sender: &Keys,
     77     target: &Keys,
     78     created_at: u64,
     79     plaintext: &[u8],
     80 ) -> myc::MycReplayBoundNip46Request {
     81     let event = event(sender, target, created_at);
     82     let wire = serde_json::to_vec(&event).expect("event wire");
     83     let event = verify_myc_nip46_event(
     84         admit_myc_nip46_event(limits(), &wire).expect("bounded event"),
     85         &provider_binding(target),
     86         MycNip46ObservedAtUnixSeconds::new(OBSERVED_AT).expect("observed"),
     87         MycNip46AuthoredTimePolicy::new(60, 60).expect("policy"),
     88     )
     89     .expect("verified event");
     90     let request = verify_myc_nip46_request(
     91         admit_myc_nip46_request(limits(), plaintext).expect("bounded request"),
     92     )
     93     .expect("verified request");
     94     bind_myc_nip46_replay(event, request).expect("replay binding")
     95 }
     96 
     97 fn hex(bytes: &[u8]) -> String {
     98     bytes.iter().map(|byte| format!("{byte:02x}")).collect()
     99 }
    100 
    101 #[test]
    102 fn machine_contract_freezes_identity_framing_and_authority_split() {
    103     let contract: Value = serde_json::from_str(CONTRACT).expect("replay contract");
    104     assert_eq!(contract["schema"], "radroots.myc.nip46-replay.v1");
    105     assert_eq!(contract["contract_version"], 1);
    106     assert_eq!(contract["prerequisite"], "nip46_verification.v1");
    107     assert_eq!(
    108         contract["connection_identity"]["domain"],
    109         "radroots.myc.nip46.connection_identity.v1\\0"
    110     );
    111     assert_eq!(
    112         contract["request_identity"]["domain"],
    113         "radroots.myc.nip46.request_identity.v1\\0"
    114     );
    115     assert_eq!(
    116         contract["request_digest"]["domain"],
    117         "radroots.myc.nip46.request.v1\\0"
    118     );
    119     assert_eq!(contract["connection_identity"]["digest"], "sha256");
    120     assert_eq!(contract["request_identity"]["digest"], "sha256");
    121     assert_eq!(contract["request_digest"]["digest"], "sha256");
    122     assert_eq!(
    123         contract["durable_authority"]["new_replay_store"],
    124         "forbidden"
    125     );
    126     assert_eq!(
    127         contract["nonclaims"],
    128         json!([
    129             "ciphertext_decryption",
    130             "plaintext_event_cryptographic_binding",
    131             "supported_method_admission",
    132             "authorization",
    133             "provider_execution",
    134             "database_mutation",
    135             "relay_publication"
    136         ])
    137     );
    138 }
    139 
    140 #[test]
    141 fn stable_connection_request_and_content_vectors_are_exact() {
    142     let bound = candidate(
    143         &keys(1),
    144         &keys(2),
    145         OBSERVED_AT,
    146         "request-01",
    147         "ping",
    148         json!([]),
    149     );
    150     assert_eq!(
    151         hex(bound.connection_identity().as_bytes()),
    152         "93f2c20f3c8043fdae2ec14f08e398ef5012fd813868285d3ed5c2c98ac8e1c1"
    153     );
    154     assert_eq!(
    155         hex(bound.request_identity().as_bytes()),
    156         "68efc9cd254207260f097997205ee3e34228b290315c53c92cba054dce0452d9"
    157     );
    158     assert_eq!(
    159         hex(bound.request_digest().as_bytes()),
    160         "378d4f7906aed41e5af96c7000dfc57d9c4c4c9ad3d94b84e985621c25e727f2"
    161     );
    162 }
    163 
    164 #[test]
    165 fn duplicate_replay_conflict_and_distinct_relations_are_total() {
    166     let sender = keys(3);
    167     let target = keys(4);
    168     let retained = candidate(
    169         &sender,
    170         &target,
    171         OBSERVED_AT,
    172         "request-01",
    173         "ping",
    174         json!([]),
    175     );
    176     let retained_key = retained.replay_key();
    177     assert_eq!(format!("{retained_key:?}"), "MycNip46ReplayKey([redacted])");
    178     drop(retained);
    179     let duplicate = candidate(
    180         &sender,
    181         &target,
    182         OBSERVED_AT,
    183         "request-01",
    184         "ping",
    185         json!([]),
    186     );
    187     assert_eq!(
    188         duplicate.classify_against(retained_key),
    189         MycNip46ReplayDisposition::DuplicateEvent
    190     );
    191 
    192     let replay = candidate(
    193         &sender,
    194         &target,
    195         OBSERVED_AT + 1,
    196         "request-01",
    197         "ping",
    198         json!([]),
    199     );
    200     assert_eq!(
    201         replay.classify_against(retained_key),
    202         MycNip46ReplayDisposition::ExactRequestReplay
    203     );
    204 
    205     let normalized_replay = candidate_from_plaintext(
    206         &sender,
    207         &target,
    208         OBSERVED_AT + 1,
    209         br#"{ "params" : [ ], "method" : "ping", "id" : "request-01" }"#,
    210     );
    211     assert_eq!(
    212         normalized_replay.classify_against(retained_key),
    213         MycNip46ReplayDisposition::ExactRequestReplay
    214     );
    215 
    216     let request_conflict = candidate(
    217         &sender,
    218         &target,
    219         OBSERVED_AT + 2,
    220         "request-01",
    221         "vendor_method",
    222         json!(["changed"]),
    223     );
    224     assert_eq!(
    225         request_conflict.classify_against(retained_key),
    226         MycNip46ReplayDisposition::ConflictingRequestReuse
    227     );
    228 
    229     let event_conflict = candidate(
    230         &sender,
    231         &target,
    232         OBSERVED_AT,
    233         "request-02",
    234         "ping",
    235         json!([]),
    236     );
    237     assert_eq!(
    238         event_conflict.classify_against(retained_key),
    239         MycNip46ReplayDisposition::ConflictingEventReuse
    240     );
    241 
    242     let distinct = candidate(
    243         &keys(5),
    244         &target,
    245         OBSERVED_AT + 3,
    246         "request-01",
    247         "ping",
    248         json!([]),
    249     );
    250     assert_eq!(
    251         distinct.classify_against(retained_key),
    252         MycNip46ReplayDisposition::DistinctRequest
    253     );
    254 
    255     let other_receiver = candidate(
    256         &sender,
    257         &keys(8),
    258         OBSERVED_AT + 4,
    259         "request-01",
    260         "ping",
    261         json!([]),
    262     );
    263     assert_eq!(
    264         other_receiver.classify_against(retained_key),
    265         MycNip46ReplayDisposition::DistinctRequest
    266     );
    267 }
    268 
    269 #[test]
    270 fn binding_is_sealed_redacted_and_not_execution_authority() {
    271     let sender = keys(6);
    272     let target = keys(7);
    273     let bound = candidate(
    274         &sender,
    275         &target,
    276         OBSERVED_AT,
    277         "protected-request-marker",
    278         "vendor_secret_method",
    279         json!(["protected-parameter-marker"]),
    280     );
    281     let rendered = format!("{bound:?}");
    282     for secret in [
    283         "protected-request-marker",
    284         "vendor_secret_method",
    285         "protected-parameter-marker",
    286         &sender.public_key().to_hex(),
    287     ] {
    288         assert!(!rendered.contains(secret));
    289     }
    290     assert_eq!(
    291         rendered,
    292         "MycReplayBoundNip46Request { identity: \"[redacted]\" }"
    293     );
    294 }