nip46_wave_080_a.rs (23545B)
1 //! Native test-only integration gate for RCLD-RSHR-080 wave 080-a. 2 3 use std::{fs, os::unix::fs::PermissionsExt, path::Path}; 4 5 use nostr::{ 6 EventBuilder, JsonUtil as _, Keys, Kind, Tag, Timestamp, UnsignedEvent as NostrUnsignedEvent, 7 nips::{nip04, nip44}, 8 }; 9 use radroots_nostr_connect::{ 10 Method, 11 message::{Request, RequestMessage, UnsignedEvent as ConnectUnsignedEvent}, 12 }; 13 use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; 14 use radroots_storage::event::SourceGeneration; 15 use serde_json::{Value, json}; 16 17 use crate::{ 18 MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION, MYC_STATE_SCHEMA_VERSION, MycAuditCorrelationId, 19 MycConfigDocumentV1, MycConfigProfile, MycConnectionAdmission, MycConnectionAdmissionPolicy, 20 MycConnectionNonce, MycConnectionOperatorDecision, MycConnectionPermission, 21 MycConnectionPermissionSet, MycConnectionPolicyGeneration, MycConnectionStatus, 22 MycConnectionTimeUnixMs, MycLocalSignerUntrustedResponse, MycNip46AdmissionLimits, 23 MycNip46AuthoredTimePolicy, MycNip46ObservedAtUnixSeconds, MycNip46WorkErrorKind, 24 MycNip46WorkKind, MycPreparedNip46Request, MycProviderCapability, MycProviderDeadlineUnixMs, 25 MycProviderResponseObservedAtUnixMs, MycProviderRole, MycRateRelayId, 26 MycRequestReceivedAtUnixMs, MycSignerOperationNonce, MycSignerRequestAdmission, 27 MycStateMetadata, MycStateRepository, RadrootsHostEnvironment, RadrootsPathResolver, 28 RadrootsPlatform, admit_myc_nip46_event, initialize_myc_state, open_myc_state_read_write, 29 parse_myc_cli_v1_from, parse_myc_config_v1, prepare_myc_nip46_decrypt_work, 30 prepare_myc_nip46_request, prepare_myc_nip46_work, 31 provider_local_signer::{ 32 LocalSignerResponse, LocalSignerUntrustedParts, WireCapability, WireProviderInstance, 33 WireProviderResult, WireRole, 34 }, 35 provider_verification::verify_protected_response_for_test, 36 resolve_myc_runtime_context, verify_myc_nip46_event, 37 }; 38 39 const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 40 const CORPUS: &str = include_str!("../contracts/services_hardening/nip46_wave_080_a.v1.json"); 41 pub(crate) const OBSERVED_AT_SECONDS: u64 = 1_725_000_000; 42 pub(crate) const RECEIVED_AT_MS: u64 = 1_725_000_000_000; 43 pub(crate) const PROVIDER_DEADLINE_MS: u64 = RECEIVED_AT_MS + 120_000; 44 45 #[derive(Clone, Copy)] 46 pub(crate) enum Encryption { 47 Nip04, 48 Nip44V2, 49 } 50 51 pub(crate) fn keys(seed: u8) -> Keys { 52 Keys::parse(&format!("{seed:02x}{}", "00".repeat(31))).expect("test keys") 53 } 54 55 fn configuration_source() -> String { 56 CONFIG 57 .replacen( 58 "4444444444444444444444444444444444444444444444444444444444444444", 59 &keys(2).public_key().to_hex(), 60 1, 61 ) 62 .replacen( 63 "2222222222222222222222222222222222222222222222222222222222222222", 64 &keys(3).public_key().to_hex(), 65 1, 66 ) 67 .replacen("max_attempts = 10", "max_attempts = 2", 1) 68 } 69 70 pub(crate) fn configuration() -> MycConfigDocumentV1 { 71 parse_myc_config_v1( 72 configuration_source().as_bytes(), 73 MycConfigProfile::RepoLocal, 74 ) 75 .expect("wave configuration") 76 } 77 78 pub(crate) fn runtime(root: &Path) -> crate::MycRuntimeContext { 79 let invocation = parse_myc_cli_v1_from([ 80 "myc", 81 "--profile", 82 "repo-local", 83 "--instance", 84 "primary", 85 "--repo-local-root", 86 root.to_str().expect("UTF-8 temporary root"), 87 "run", 88 ]) 89 .expect("test invocation"); 90 resolve_myc_runtime_context( 91 &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), 92 &invocation, 93 ) 94 .expect("runtime context") 95 } 96 97 pub(crate) fn metadata(runtime: &crate::MycRuntimeContext) -> MycStateMetadata { 98 MycStateMetadata::new( 99 runtime, 100 &configuration(), 101 SourceGeneration::new([0x5a; 32]).expect("generation"), 102 RECEIVED_AT_MS, 103 ) 104 .expect("state metadata") 105 } 106 107 pub(crate) fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { 108 let applied_at = 109 MigrationAppliedAtUnixSeconds::new(OBSERVED_AT_SECONDS).expect("migration time"); 110 let build = MigrationBuildIdentity::new( 111 env!("CARGO_PKG_VERSION"), 112 "1111111111111111111111111111111111111111", 113 "053d0c750bf9cd683c6ea37cefe7e79617ba629f", 114 "rustc-test", 115 "test-target", 116 "service-host", 117 1, 118 MYC_STATE_SCHEMA_VERSION, 119 1, 120 1, 121 1, 122 ) 123 .expect("build identity"); 124 (applied_at, build) 125 } 126 127 fn decrypt_work( 128 config: &MycConfigDocumentV1, 129 client: &Keys, 130 request_id: &str, 131 request: Request, 132 encryption: Encryption, 133 created_at: u64, 134 ) -> (crate::MycNip46DecryptWork, Vec<u8>) { 135 let transport_keys = keys(2); 136 let message = RequestMessage::try_new(request_id.to_owned(), request).expect("request message"); 137 let plaintext = serde_json::to_vec(&message).expect("canonical request"); 138 let content = match encryption { 139 Encryption::Nip04 => nip04::encrypt( 140 client.secret_key(), 141 &transport_keys.public_key(), 142 &plaintext, 143 ) 144 .expect("NIP-04 encryption"), 145 Encryption::Nip44V2 => nip44::encrypt( 146 client.secret_key(), 147 &transport_keys.public_key(), 148 &plaintext, 149 nip44::Version::V2, 150 ) 151 .expect("NIP-44 encryption"), 152 }; 153 let event = EventBuilder::new(Kind::Custom(24_133), content) 154 .tag(Tag::public_key(transport_keys.public_key())) 155 .custom_created_at(Timestamp::from_secs(created_at)) 156 .sign_with_keys(client) 157 .expect("signed event"); 158 let wire = serde_json::to_vec(&event).expect("event wire"); 159 let limits = MycNip46AdmissionLimits::from_config(config).expect("admission limits"); 160 let verified = verify_myc_nip46_event( 161 admit_myc_nip46_event(limits, &wire).expect("bounded event"), 162 config 163 .provider_contract() 164 .binding(MycProviderRole::Transport) 165 .expect("transport binding"), 166 MycNip46ObservedAtUnixSeconds::new(OBSERVED_AT_SECONDS).expect("observation"), 167 MycNip46AuthoredTimePolicy::new(120, 120).expect("authored-time policy"), 168 ) 169 .expect("verified event"); 170 let work = prepare_myc_nip46_decrypt_work( 171 verified, 172 config 173 .provider_contract() 174 .binding(MycProviderRole::Transport) 175 .expect("transport binding"), 176 MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("provider deadline"), 177 ) 178 .expect("decrypt work"); 179 (work, plaintext) 180 } 181 182 #[allow(clippy::too_many_arguments)] 183 pub(crate) fn prepared_request( 184 config: &MycConfigDocumentV1, 185 client: &Keys, 186 request_id: &str, 187 request: Request, 188 encryption: Encryption, 189 created_at: u64, 190 nonce: u8, 191 received_at: u64, 192 ) -> MycPreparedNip46Request { 193 let (work, plaintext) = 194 decrypt_work(config, client, request_id, request, encryption, created_at); 195 let transport = config 196 .provider_contract() 197 .binding(MycProviderRole::Transport) 198 .expect("transport binding"); 199 let response = verify_protected_response_for_test(transport, work.operation(), &plaintext) 200 .expect("verified provider response"); 201 let decrypted = work 202 .complete( 203 response, 204 MycNip46AdmissionLimits::from_config(config).expect("admission limits"), 205 ) 206 .expect("decrypted request"); 207 prepare_myc_nip46_request( 208 decrypted, 209 MycSignerOperationNonce::from_injected_entropy([nonce; 32]), 210 MycRequestReceivedAtUnixMs::new(received_at).expect("request time"), 211 ) 212 .expect("prepared request") 213 } 214 215 pub(crate) fn connect_request() -> Request { 216 Request::from_parts( 217 Method::Connect, 218 vec![ 219 keys(2).public_key().to_hex(), 220 String::new(), 221 "sign_event:kind:1".to_owned(), 222 ], 223 ) 224 .expect("connect request") 225 } 226 227 pub(crate) fn unsigned_sign_event() -> ConnectUnsignedEvent { 228 let event = NostrUnsignedEvent::new( 229 keys(3).public_key(), 230 Timestamp::from_secs(OBSERVED_AT_SECONDS), 231 Kind::TextNote, 232 Vec::<Tag>::new(), 233 "wave-080-a", 234 ); 235 ConnectUnsignedEvent::from_json(&event.as_json()).expect("unsigned event") 236 } 237 238 pub(crate) fn permissions() -> MycConnectionPermissionSet { 239 MycConnectionPermissionSet::new(&[MycConnectionPermission::SignEvent(1)]).expect("permissions") 240 } 241 242 pub(crate) fn connection_time(value: u64) -> MycConnectionTimeUnixMs { 243 MycConnectionTimeUnixMs::new(value).expect("connection time") 244 } 245 246 pub(crate) fn untrusted_response( 247 operation: &crate::MycProviderOperation, 248 outer_correlation_id: String, 249 result: WireProviderResult, 250 ) -> MycLocalSignerUntrustedResponse { 251 MycLocalSignerUntrustedResponse::from_parts(LocalSignerUntrustedParts { 252 outer_correlation_id: outer_correlation_id.into_boxed_str(), 253 response: LocalSignerResponse { 254 contract_version: MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION, 255 provider_instance: WireProviderInstance::from(operation.instance()), 256 role: WireRole::from(operation.role()), 257 operation_id: hex::encode(operation.operation_id().as_bytes()), 258 correlation_id: hex::encode(operation.correlation_id().as_bytes()), 259 absolute_deadline_unix_ms: operation.deadline().get(), 260 expected_identity: operation.expected_identity().as_hex().to_owned(), 261 capability: WireCapability::from(operation.input().capability()), 262 result, 263 }, 264 }) 265 } 266 267 #[allow(clippy::too_many_arguments)] 268 pub(crate) async fn admit_connect( 269 repository: &MycStateRepository<'_>, 270 config: &MycConfigDocumentV1, 271 client_seed: u8, 272 request_id: &str, 273 created_at: u64, 274 received_at: u64, 275 nonce: u8, 276 observed_at: u64, 277 ) -> MycConnectionAdmission { 278 let prepared = prepared_request( 279 config, 280 &keys(client_seed), 281 request_id, 282 connect_request(), 283 Encryption::Nip44V2, 284 created_at, 285 nonce, 286 received_at, 287 ); 288 let admitted = repository 289 .admit_signer_request(prepared.signer_request()) 290 .await 291 .expect("durable request admission"); 292 let work = prepare_myc_nip46_work( 293 prepared, 294 admitted.record().clone(), 295 None, 296 config.provider_contract(), 297 connection_time(observed_at), 298 None, 299 ) 300 .expect("connect work"); 301 let request = work 302 .connection_admission_request( 303 MycConnectionPolicyGeneration::new(1).expect("policy generation"), 304 MycConnectionNonce::from_injected_entropy([nonce.wrapping_add(1); 32]), 305 connection_time(observed_at), 306 None, 307 MycConnectionAdmissionPolicy::ExplicitApproval, 308 MycRateRelayId::new("primary").expect("relay ID"), 309 ) 310 .expect("connection admission request"); 311 repository 312 .admit_connection(&request) 313 .await 314 .expect("connection admission") 315 } 316 317 #[test] 318 fn machine_corpus_freezes_the_complete_first_wave_gate() { 319 let actual: Value = serde_json::from_str(CORPUS).expect("wave corpus"); 320 assert_eq!( 321 actual, 322 json!({ 323 "schema": "radroots.myc.nip46-wave-080-a.v1", 324 "contract_version": 1, 325 "steps": [141, 142, 143, 144, 145, 146], 326 "positive_corpus": [ 327 "nip04_ping_full_pipeline", 328 "nip44_ping_full_pipeline", 329 "nip44_connect_durable_approval", 330 "nip44_sign_event_provider_work" 331 ], 332 "negative_corpus": [ 333 "wrong_event_provider_response", 334 "wrong_outer_provider_correlation", 335 "late_provider_response", 336 "wrong_provider_result_shape", 337 "conflicting_request_id_reuse" 338 ], 339 "replay": { 340 "exact_request": "idempotent_durable_record", 341 "conflicting_request_id": "durable_conflicting_reuse", 342 "cross_event_provider_response": "rejected_before_plaintext_admission" 343 }, 344 "saturation": { 345 "authority": "configured_connection_admission_rate_window", 346 "configured_max_attempts": 2, 347 "exact_boundary": "admitted", 348 "just_over_boundary": "rate_limited_without_connection" 349 }, 350 "integration_order": [ 351 "bounded_event", 352 "cryptographic_event_verification", 353 "provider_decrypt_work", 354 "independent_provider_result_verification", 355 "bounded_plaintext_request", 356 "replay_binding", 357 "durable_request_admission", 358 "connection_authorization", 359 "provider_work_construction" 360 ], 361 "authority": { 362 "state": "existing_myc_state_repository", 363 "sqlite": "existing_service_sqlite_transaction_runner", 364 "time": "injected", 365 "entropy": "injected", 366 "provider_execution": "not_performed", 367 "relay_io": "not_performed" 368 }, 369 "gate": { 370 "wave": "080-a", 371 "complete_after_step": 146, 372 "rcld_promotion_owner": 151 373 }, 374 "nonclaims": [ 375 "response_commit", 376 "outbox_creation", 377 "relay_publication", 378 "rcld_promotion", 379 "nix", 380 "oci" 381 ] 382 }) 383 ); 384 } 385 386 #[test] 387 fn canonical_nip04_and_nip44_reach_the_same_typed_boundary() { 388 let config = configuration(); 389 for (index, encryption) in [Encryption::Nip04, Encryption::Nip44V2] 390 .into_iter() 391 .enumerate() 392 { 393 let prepared = prepared_request( 394 &config, 395 &keys(1), 396 &format!("positive-{index}"), 397 Request::Ping, 398 encryption, 399 OBSERVED_AT_SECONDS + u64::try_from(index).expect("index"), 400 u8::try_from(index + 1).expect("nonce"), 401 RECEIVED_AT_MS + u64::try_from(index).expect("index"), 402 ); 403 assert_eq!(prepared.method(), crate::MycSignerRequestMethod::Ping); 404 } 405 } 406 407 #[test] 408 fn cross_event_provider_result_is_rejected_before_plaintext_admission() { 409 let config = configuration(); 410 let client = keys(1); 411 let (first, plaintext) = decrypt_work( 412 &config, 413 &client, 414 "malicious-1", 415 Request::Ping, 416 Encryption::Nip44V2, 417 OBSERVED_AT_SECONDS + 1, 418 ); 419 let (second, _) = decrypt_work( 420 &config, 421 &client, 422 "malicious-2", 423 Request::Ping, 424 Encryption::Nip44V2, 425 OBSERVED_AT_SECONDS + 2, 426 ); 427 let response = verify_protected_response_for_test( 428 config 429 .provider_contract() 430 .binding(MycProviderRole::Transport) 431 .expect("transport binding"), 432 first.operation(), 433 &plaintext, 434 ) 435 .expect("verified first response"); 436 assert_eq!( 437 second 438 .complete( 439 response, 440 MycNip46AdmissionLimits::from_config(&config).expect("limits"), 441 ) 442 .expect_err("cross-event response") 443 .kind(), 444 MycNip46WorkErrorKind::InvalidBinding 445 ); 446 } 447 448 #[tokio::test] 449 async fn durable_replay_authorization_provider_and_saturation_gate_is_atomic() { 450 let directory = tempfile::tempdir().expect("temporary root"); 451 let runtime = runtime(directory.path()); 452 fs::create_dir_all(runtime.context().paths().state()).expect("state directory"); 453 fs::set_permissions( 454 runtime.context().paths().state(), 455 fs::Permissions::from_mode(0o700), 456 ) 457 .expect("state mode"); 458 let metadata = metadata(&runtime); 459 let config = configuration(); 460 let (applied_at, build) = migration_evidence(); 461 initialize_myc_state(&runtime, &metadata, applied_at, &build) 462 .await 463 .expect("state initialization"); 464 let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) 465 .await 466 .expect("writable state"); 467 let repository = host.repository(); 468 469 let prepared = prepared_request( 470 &config, 471 &keys(10), 472 "connect-primary", 473 connect_request(), 474 Encryption::Nip44V2, 475 OBSERVED_AT_SECONDS + 10, 476 10, 477 RECEIVED_AT_MS + 10, 478 ); 479 let admitted = repository 480 .admit_signer_request(prepared.signer_request()) 481 .await 482 .expect("new durable request"); 483 assert!(matches!(admitted, MycSignerRequestAdmission::Admitted(_))); 484 let replay = repository 485 .admit_signer_request(prepared.signer_request()) 486 .await 487 .expect("exact replay"); 488 assert!(matches!(replay, MycSignerRequestAdmission::ExactReplay(_))); 489 assert_eq!( 490 replay.record().operation_id(), 491 admitted.record().operation_id() 492 ); 493 494 let connect_work = prepare_myc_nip46_work( 495 prepared, 496 admitted.record().clone(), 497 None, 498 config.provider_contract(), 499 connection_time(RECEIVED_AT_MS + 1_000), 500 None, 501 ) 502 .expect("connect work"); 503 assert_eq!(connect_work.kind(), MycNip46WorkKind::Connect); 504 let connection_request = connect_work 505 .connection_admission_request( 506 MycConnectionPolicyGeneration::new(1).expect("policy generation"), 507 MycConnectionNonce::from_injected_entropy([0x41; 32]), 508 connection_time(RECEIVED_AT_MS + 1_000), 509 None, 510 MycConnectionAdmissionPolicy::ExplicitApproval, 511 MycRateRelayId::new("primary").expect("relay ID"), 512 ) 513 .expect("connection request"); 514 let pending = repository 515 .admit_connection(&connection_request) 516 .await 517 .expect("pending connection"); 518 let pending_record = pending.record().expect("pending record"); 519 let pending_connection = pending_record.connection().expect("pending connection"); 520 assert_eq!(pending_connection.status(), MycConnectionStatus::Pending); 521 let active = repository 522 .decide_pending_connection( 523 pending_record.operation_id(), 524 pending_connection.id(), 525 MycConnectionPolicyGeneration::new(1).expect("policy generation"), 526 connection_time(RECEIVED_AT_MS + 2_000), 527 MycAuditCorrelationId::new([0x42; 32]), 528 MycConnectionOperatorDecision::Approve { 529 granted_permissions: permissions(), 530 authorized_until: Some(connection_time(RECEIVED_AT_MS + 100_000)), 531 }, 532 ) 533 .await 534 .expect("approved connection"); 535 assert_eq!(active.status(), MycConnectionStatus::Active); 536 537 let sign_request = prepared_request( 538 &config, 539 &keys(10), 540 "sign-primary", 541 Request::SignEvent(unsigned_sign_event()), 542 Encryption::Nip44V2, 543 OBSERVED_AT_SECONDS + 11, 544 11, 545 RECEIVED_AT_MS + 11, 546 ); 547 let sign_admission = repository 548 .admit_signer_request(sign_request.signer_request()) 549 .await 550 .expect("sign request admission"); 551 let sign_work = prepare_myc_nip46_work( 552 sign_request, 553 sign_admission.record().clone(), 554 Some(active), 555 config.provider_contract(), 556 connection_time(RECEIVED_AT_MS + 3_000), 557 Some(MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("provider deadline")), 558 ) 559 .expect("sign provider work"); 560 assert_eq!(sign_work.kind(), MycNip46WorkKind::Provider); 561 let operation = sign_work.provider_operation().expect("provider operation"); 562 assert_eq!( 563 operation.input().capability(), 564 MycProviderCapability::SignEvent 565 ); 566 567 let wrong_outer = untrusted_response( 568 operation, 569 "00".repeat(32), 570 WireProviderResult::PublicIdentity { 571 public_identity: operation.expected_identity().as_hex().to_owned(), 572 }, 573 ); 574 assert_eq!( 575 wrong_outer 576 .verify( 577 config 578 .provider_contract() 579 .binding(MycProviderRole::User) 580 .expect("user binding"), 581 operation, 582 MycProviderResponseObservedAtUnixMs::new(RECEIVED_AT_MS + 3_001) 583 .expect("response time"), 584 ) 585 .expect_err("wrong outer correlation") 586 .kind(), 587 crate::MycProviderVerificationErrorKind::ResponseBinding 588 ); 589 let late = untrusted_response( 590 operation, 591 hex::encode(operation.correlation_id().as_bytes()), 592 WireProviderResult::PublicIdentity { 593 public_identity: operation.expected_identity().as_hex().to_owned(), 594 }, 595 ); 596 assert_eq!( 597 late.verify( 598 config 599 .provider_contract() 600 .binding(MycProviderRole::User) 601 .expect("user binding"), 602 operation, 603 MycProviderResponseObservedAtUnixMs::new(PROVIDER_DEADLINE_MS + 1) 604 .expect("late response time"), 605 ) 606 .expect_err("late provider response") 607 .kind(), 608 crate::MycProviderVerificationErrorKind::LateResponse 609 ); 610 let wrong_shape = untrusted_response( 611 operation, 612 hex::encode(operation.correlation_id().as_bytes()), 613 WireProviderResult::PublicIdentity { 614 public_identity: operation.expected_identity().as_hex().to_owned(), 615 }, 616 ); 617 assert_eq!( 618 wrong_shape 619 .verify( 620 config 621 .provider_contract() 622 .binding(MycProviderRole::User) 623 .expect("user binding"), 624 operation, 625 MycProviderResponseObservedAtUnixMs::new(RECEIVED_AT_MS + 3_001) 626 .expect("response time"), 627 ) 628 .expect_err("wrong provider result shape") 629 .kind(), 630 crate::MycProviderVerificationErrorKind::ResultShape 631 ); 632 633 let conflicting = prepared_request( 634 &config, 635 &keys(10), 636 "sign-primary", 637 Request::Ping, 638 Encryption::Nip44V2, 639 OBSERVED_AT_SECONDS + 12, 640 12, 641 RECEIVED_AT_MS + 12, 642 ); 643 assert!(matches!( 644 repository 645 .admit_signer_request(conflicting.signer_request()) 646 .await 647 .expect("conflict admission"), 648 MycSignerRequestAdmission::ConflictingReuse(_) 649 )); 650 651 let exact_boundary = admit_connect( 652 &repository, 653 &config, 654 11, 655 "connect-boundary", 656 OBSERVED_AT_SECONDS + 20, 657 RECEIVED_AT_MS + 20, 658 20, 659 RECEIVED_AT_MS + 1_100, 660 ) 661 .await; 662 assert!(matches!( 663 exact_boundary, 664 MycConnectionAdmission::Admitted(_) 665 )); 666 let saturated = admit_connect( 667 &repository, 668 &config, 669 12, 670 "connect-saturated", 671 OBSERVED_AT_SECONDS + 21, 672 RECEIVED_AT_MS + 21, 673 21, 674 RECEIVED_AT_MS + 1_200, 675 ) 676 .await; 677 assert!(matches!(saturated, MycConnectionAdmission::RateLimited)); 678 assert!(saturated.record().is_none()); 679 680 host.close().await.expect("host close"); 681 }