use super::*;
use crate::internal::group_e2ee::provider::GroupMlsProvider;
use crate::internal::transport::AsyncAuthenticatedRpcTransport;
use anp::group_e2ee::operations::{
AbortCommitInput, AbortCommitOutput, AddMemberInput, CreateGroupInput, DecryptInput,
DecryptOutput, EncryptInput, EncryptOutput, FinalizeCommitInput, FinalizeCommitOutput,
GenerateKeyPackageInput, GroupKeyPackageOutput, LeaveGroupInput, PreparedMlsCommitOutput,
ProcessNoticeInput, ProcessNoticeOutput, ProcessWelcomeInput, ProcessWelcomeOutput,
RecoverMemberInput, RemoveMemberInput, StatusInput, StatusOutput, UpdateMemberInput,
};
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
#[tokio::test]
async fn publish_key_package_async_helper_uses_async_transport() {
let fixture = Fixture::new();
let owner_did = fixture.did_bundle.did().unwrap().to_owned();
let client = fixture.client();
let credentials = fixture.credentials();
let provider = RecordingKeyPackageProvider::default();
let generated = Arc::clone(&provider.generated);
let calls = Arc::new(Mutex::new(Vec::new()));
let result = publish_group_key_package_with_components(
&client,
crate::groups::GroupKeyPackagePublishRequest {
purpose: crate::groups::GroupKeyPackagePurpose::Recovery,
group: Some(crate::ids::GroupRef::parse("did:example:groups:secure").unwrap()),
device_id: Some(" device-a ".to_owned()),
key_package_id: Some(" kp-explicit ".to_owned()),
},
credentials,
crate::ids::Did::parse("did:example:service").unwrap(),
provider,
RecordingAsyncTransport {
calls: Arc::clone(&calls),
response: json!({"accepted": true}),
},
)
.await
.unwrap();
assert_eq!(result.owner_did.as_str(), owner_did);
assert_eq!(result.device_id, "device-a");
assert_eq!(result.key_package_id, "kp-explicit");
assert_eq!(
result.purpose,
crate::groups::GroupKeyPackagePurpose::Recovery
);
assert_eq!(
result.group.as_ref().map(|group| group.as_str()),
Some("did:example:groups:secure")
);
assert_eq!(result.raw_response, json!({"accepted": true}));
let generated = generated.lock().unwrap();
assert_eq!(generated.len(), 1);
assert_eq!(generated[0].owner_did, owner_did);
assert_eq!(generated[0].device_id, "device-a");
assert_eq!(generated[0].key_package_id.as_deref(), Some("kp-explicit"));
assert_eq!(generated[0].purpose.as_deref(), Some("recovery"));
assert_eq!(
generated[0].group_did.as_deref(),
Some("did:example:groups:secure")
);
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(
calls[0].endpoint,
crate::internal::message_runtime::group::MESSAGE_RPC_ENDPOINT
);
assert_eq!(calls[0].method, "group.e2ee.publish_key_package");
assert_eq!(
calls[0].params["meta"]["target"],
json!({"kind": "service", "did": "did:example:service"})
);
assert_eq!(
calls[0].params["body"]["group_key_package"]["key_package_id"],
"kp-explicit"
);
assert_eq!(
calls[0].params["body"]["group_key_package"]["device_id"],
"device-a"
);
assert_eq!(
calls[0].params["body"]["group_key_package"]["purpose"],
"recovery"
);
assert_eq!(
calls[0].params["body"]["group_key_package"]["group_did"],
"did:example:groups:secure"
);
assert!(calls[0].params["body"]["group_key_package"]["did_wba_binding"]["proof"].is_object());
assert_group_key_package_binding_uses_base58btc_object_proof(
&calls[0].params["body"]["group_key_package"]["did_wba_binding"],
);
verify_group_key_package_binding_contract(
&calls[0].params["body"]["group_key_package"]["did_wba_binding"],
&fixture.did_bundle.did_document,
)
.unwrap();
assert!(calls[0].params["auth"]["origin_proof"].is_object());
}
fn assert_group_key_package_binding_uses_base58btc_object_proof(binding: &Value) {
let proof_value = binding["proof"]["proofValue"]
.as_str()
.expect("binding proofValue should be a string");
assert!(
proof_value.starts_with('z'),
"binding proofValue should use multibase base58btc"
);
let signature =
decode_base58btc_signature(&proof_value[1..]).expect("proofValue should be base58btc");
assert_eq!(
signature.len(),
64,
"binding proofValue should encode one Ed25519 signature"
);
}
fn decode_base58btc_signature(value: &str) -> Option<Vec<u8>> {
const ALPHABET: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
let mut decoded = Vec::new();
for byte in value.bytes() {
let mut carry = ALPHABET
.as_bytes()
.iter()
.position(|candidate| *candidate == byte)?;
for current in decoded.iter_mut().rev() {
let next = (*current as usize * 58) + carry;
*current = (next & 0xff) as u8;
carry = next >> 8;
}
while carry > 0 {
decoded.insert(0, (carry & 0xff) as u8);
carry >>= 8;
}
}
let leading_zeroes = value
.bytes()
.take_while(|byte| *byte == ALPHABET.as_bytes()[0])
.count();
let first_non_zero = decoded
.iter()
.position(|byte| *byte != 0)
.unwrap_or(decoded.len());
let mut output = vec![0; leading_zeroes];
output.extend_from_slice(&decoded[first_non_zero..]);
Some(output)
}
#[derive(Default)]
struct RecordingKeyPackageProvider {
generated: Arc<Mutex<Vec<GenerateKeyPackageInput>>>,
}
impl GroupMlsProvider for RecordingKeyPackageProvider {
fn generate_key_package(
&self,
input: GenerateKeyPackageInput,
) -> crate::ImResult<GroupKeyPackageOutput> {
self.generated.lock().unwrap().push(input.clone());
let owner_did = input.owner_did.clone();
Ok(GroupKeyPackageOutput {
group_key_package: anp::group_e2ee::GroupKeyPackage {
key_package_id: input
.key_package_id
.clone()
.unwrap_or_else(|| "kp-generated".to_owned()),
owner_did: owner_did.clone(),
device_id: Some(input.device_id),
purpose: input.purpose,
group_did: input.group_did,
suite: anp::group_e2ee::MTI_SUITE.to_owned(),
mls_key_package_b64u: "mls-key-package".to_owned(),
did_wba_binding: json!({
"agent_did": owner_did,
"verification_method": format!("{owner_did}#key-1"),
"leaf_signature_key_b64u": "leaf-key",
"issued_at": "2026-01-01T00:00:00Z",
"expires_at": "2099-01-01T00:00:00Z"
}),
expires_at: None,
non_cryptographic: false,
artifact_mode: None,
},
})
}
fn create_group_prepare(
&self,
_input: CreateGroupInput,
) -> crate::ImResult<PreparedMlsCommitOutput> {
unreachable!("publish key package should not create groups")
}
fn add_member_prepare(
&self,
_input: AddMemberInput,
) -> crate::ImResult<PreparedMlsCommitOutput> {
unreachable!("publish key package should not add members")
}
fn remove_member_prepare(
&self,
_input: RemoveMemberInput,
) -> crate::ImResult<PreparedMlsCommitOutput> {
unreachable!("publish key package should not remove members")
}
fn leave_prepare(&self, _input: LeaveGroupInput) -> crate::ImResult<PreparedMlsCommitOutput> {
unreachable!("publish key package should not leave groups")
}
fn update_member_prepare(
&self,
_input: UpdateMemberInput,
) -> crate::ImResult<PreparedMlsCommitOutput> {
unreachable!("publish key package should not update members")
}
fn recover_member_prepare(
&self,
_input: RecoverMemberInput,
) -> crate::ImResult<PreparedMlsCommitOutput> {
unreachable!("publish key package should not recover members")
}
fn finalize_commit(
&self,
_input: FinalizeCommitInput,
) -> crate::ImResult<FinalizeCommitOutput> {
unreachable!("publish key package should not finalize commits")
}
fn abort_commit(&self, _input: AbortCommitInput) -> crate::ImResult<AbortCommitOutput> {
unreachable!("publish key package should not abort commits")
}
fn process_welcome(
&self,
_input: ProcessWelcomeInput,
) -> crate::ImResult<ProcessWelcomeOutput> {
unreachable!("publish key package should not process welcomes")
}
fn process_notice(&self, _input: ProcessNoticeInput) -> crate::ImResult<ProcessNoticeOutput> {
unreachable!("publish key package should not process notices")
}
fn encrypt(&self, _input: EncryptInput) -> crate::ImResult<EncryptOutput> {
unreachable!("publish key package should not encrypt")
}
fn decrypt(&self, _input: DecryptInput) -> crate::ImResult<DecryptOutput> {
unreachable!("publish key package should not decrypt")
}
fn status(&self, _input: StatusInput) -> crate::ImResult<StatusOutput> {
unreachable!("publish key package should not read status")
}
}
struct RecordingAsyncTransport {
calls: Arc<Mutex<Vec<RecordedCall>>>,
response: Value,
}
impl AsyncAuthenticatedRpcTransport for RecordingAsyncTransport {
async fn authenticated_rpc(
&mut self,
endpoint: &str,
method: &str,
params: Value,
) -> crate::ImResult<Value> {
self.calls.lock().unwrap().push(RecordedCall {
endpoint: endpoint.to_owned(),
method: method.to_owned(),
params,
});
Ok(self.response.clone())
}
}
struct RecordedCall {
endpoint: String,
method: String,
params: Value,
}
struct Fixture {
root: PathBuf,
did_bundle: anp::authentication::DidDocumentBundle,
}
impl Fixture {
fn new() -> Self {
let root = unique_temp_root();
std::fs::create_dir_all(root.join("identities").join("alice")).unwrap();
Self {
root,
did_bundle: test_did_bundle(),
}
}
fn client(&self) -> crate::core::ImClient {
crate::core::ImCore::new(
crate::ImCoreConfig {
service_base_url: crate::ServiceEndpoint::parse("https://example.test").unwrap(),
did_domain: "example.test".to_owned(),
user_service_endpoint: None,
message_service_endpoint: None,
mail_service_endpoint: None,
anp_service_endpoint: None,
anp_service_did: Some(crate::ids::Did::parse("did:example:service").unwrap()),
ca_bundle: None,
transport_policy: crate::MessageTransportPolicy::HttpOnly,
},
crate::ImCorePaths {
identities: crate::paths::IdentityRegistryPaths {
identity_root_dir: self.root.join("identities"),
registry_path: self.root.join("identities").join("registry.json"),
default_identity_path: Some(self.root.join("identities").join("default")),
},
local_state: crate::paths::LocalStatePaths {
sqlite_path: self.root.join("local").join("im.sqlite"),
},
runtime: crate::paths::RuntimePaths {
cache_dir: self.root.join("cache"),
temp_dir: self.root.join("tmp"),
},
},
)
.unwrap()
.client(crate::identity::IdentitySelector::Did(
crate::ids::Did::parse(self.did_bundle.did().unwrap()).unwrap(),
))
.unwrap()
}
fn credentials(&self) -> crate::internal::message_runtime::group::GroupTextCredentials {
let key1_private_pem = self.did_bundle.private_key_pem("key-1").unwrap().to_owned();
crate::internal::message_runtime::group::GroupTextCredentials {
identity_name: "alice".to_owned(),
did_document: Some(self.did_bundle.did_document.clone()),
key1_private_pem,
}
}
}
fn test_did_bundle() -> anp::authentication::DidDocumentBundle {
anp::authentication::create_did_wba_document(
"example.test",
anp::authentication::DidDocumentOptions {
path_segments: vec!["alice".to_owned()],
domain: Some("example.test".to_owned()),
challenge: Some("group-key-package-publish-test".to_owned()),
..anp::authentication::DidDocumentOptions::default()
},
)
.unwrap()
}
fn unique_temp_root() -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"im-core-group-key-package-publish-{}-{nanos}",
std::process::id()
))
}