im-core 0.1.0

Rust IM SDK for Awiki clients built on Agent Network Protocol (ANP)
Documentation
use super::record::{
    SecretMetadata, VaultCipher, VaultKdf, VaultSecretRecord, VAULT_RECORD_SCHEMA_VERSION,
};
use crate::internal::platform_secret::{DeviceVaultRootKey, SecretBytes};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use hkdf::Hkdf;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::Sha256;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;

const VAULT_NONCE_LEN: usize = 12;
const VAULT_RECORD_HKDF_SALT: &[u8] = b"awiki:vault:hkdf-salt:v1";

pub(crate) fn seal_record(
    root_key: &DeviceVaultRootKey,
    metadata: SecretMetadata,
    plaintext: &SecretBytes,
) -> crate::ImResult<VaultSecretRecord> {
    metadata.policy.validate_no_prompt()?;
    validate_metadata(&metadata)?;
    let now = now_rfc3339();
    let aad = aad_for_metadata(
        VAULT_RECORD_SCHEMA_VERSION,
        &metadata,
        &VaultCipher::ChaCha20Poly1305,
        &VaultKdf::HkdfSha256,
    );
    let record_key = derive_record_key(root_key, &aad)?;
    let mut nonce = [0_u8; VAULT_NONCE_LEN];
    OsRng.fill_bytes(&mut nonce);
    let cipher = ChaCha20Poly1305::new(Key::from_slice(&record_key));
    let ciphertext = cipher
        .encrypt(
            Nonce::from_slice(&nonce),
            Payload {
                msg: plaintext.expose_secret(),
                aad: &aad,
            },
        )
        .map_err(|_| crate::ImError::Internal {
            message: "secret vault encryption failed".to_owned(),
        })?;
    Ok(VaultSecretRecord {
        schema_version: VAULT_RECORD_SCHEMA_VERSION,
        workspace_id: metadata.workspace_id,
        device_id: metadata.device_id,
        identity_id: metadata.identity_id,
        did: metadata.did,
        kind: metadata.kind,
        key_id: metadata.key_id,
        key_version: metadata.key_version,
        cipher: VaultCipher::ChaCha20Poly1305,
        kdf: VaultKdf::HkdfSha256,
        nonce_b64u: URL_SAFE_NO_PAD.encode(nonce),
        aad_b64u: URL_SAFE_NO_PAD.encode(aad),
        ciphertext_b64u: URL_SAFE_NO_PAD.encode(ciphertext),
        created_at: now.clone(),
        updated_at: now,
        policy: metadata.policy,
    })
}

pub(crate) fn open_record(
    root_key: &DeviceVaultRootKey,
    record: &VaultSecretRecord,
) -> crate::ImResult<SecretBytes> {
    if record.schema_version != VAULT_RECORD_SCHEMA_VERSION {
        return Err(crate::ImError::Serialization {
            detail: "unsupported secret vault record schema version".to_owned(),
        });
    }
    if record.cipher != VaultCipher::ChaCha20Poly1305 {
        return Err(crate::ImError::unsupported(format!(
            "unsupported vault cipher {}",
            record.cipher.as_str()
        )));
    }
    if record.kdf != VaultKdf::HkdfSha256 {
        return Err(crate::ImError::unsupported(format!(
            "unsupported vault kdf {}",
            record.kdf.as_str()
        )));
    }
    record.policy.validate_no_prompt()?;
    let metadata = record.metadata();
    validate_metadata(&metadata)?;
    let aad = aad_for_metadata(
        record.schema_version,
        &metadata,
        &record.cipher,
        &record.kdf,
    );
    let stored_aad = decode_b64u("vault_record_aad", &record.aad_b64u)?;
    if stored_aad != aad {
        return Err(vault_integrity_error());
    }
    let nonce = decode_fixed_b64u("vault_record_nonce", &record.nonce_b64u, VAULT_NONCE_LEN)?;
    let ciphertext = decode_b64u("vault_record_ciphertext", &record.ciphertext_b64u)?;
    let record_key = derive_record_key(root_key, &aad)?;
    let cipher = ChaCha20Poly1305::new(Key::from_slice(&record_key));
    let plaintext = cipher
        .decrypt(
            Nonce::from_slice(&nonce),
            Payload {
                msg: &ciphertext,
                aad: &aad,
            },
        )
        .map_err(|_| vault_integrity_error())?;
    Ok(SecretBytes::from_vec(plaintext))
}

pub(crate) fn aad_for_record(record: &VaultSecretRecord) -> Vec<u8> {
    aad_for_metadata(
        record.schema_version,
        &record.metadata(),
        &record.cipher,
        &record.kdf,
    )
}

fn aad_for_metadata(
    schema_version: u32,
    metadata: &SecretMetadata,
    cipher: &VaultCipher,
    kdf: &VaultKdf,
) -> Vec<u8> {
    let mut aad = Vec::new();
    push_field(&mut aad, "domain", "awiki:vault:v1");
    push_field(&mut aad, "schema_version", &schema_version.to_string());
    push_field(&mut aad, "workspace_id", &metadata.workspace_id);
    push_field(&mut aad, "device_id", &metadata.device_id);
    push_optional_field(&mut aad, "identity_id", metadata.identity_id.as_deref());
    push_optional_field(&mut aad, "did", metadata.did.as_deref());
    push_field(&mut aad, "kind", metadata.kind.as_str());
    push_field(&mut aad, "key_id", &metadata.key_id);
    push_field(&mut aad, "key_version", &metadata.key_version.to_string());
    push_field(&mut aad, "cipher", cipher.as_str());
    push_field(&mut aad, "kdf", kdf.as_str());
    push_field(
        &mut aad,
        "policy_no_prompt",
        if metadata.policy.no_prompt {
            "true"
        } else {
            "false"
        },
    );
    push_field(
        &mut aad,
        "policy_user_presence_required",
        if metadata.policy.user_presence_required {
            "true"
        } else {
            "false"
        },
    );
    push_field(
        &mut aad,
        "policy_exportable",
        if metadata.policy.exportable {
            "true"
        } else {
            "false"
        },
    );
    push_optional_field(
        &mut aad,
        "policy_cache_ttl_seconds",
        metadata
            .policy
            .cache_ttl_seconds
            .map(|ttl| ttl.to_string())
            .as_deref(),
    );
    aad
}

fn push_optional_field(out: &mut Vec<u8>, name: &str, value: Option<&str>) {
    match value {
        Some(value) => push_field(out, name, value),
        None => push_field(out, name, "<none>"),
    }
}

fn push_field(out: &mut Vec<u8>, name: &str, value: &str) {
    out.extend_from_slice(name.as_bytes());
    out.push(0);
    out.extend_from_slice(value.len().to_string().as_bytes());
    out.push(0);
    out.extend_from_slice(value.as_bytes());
    out.push(0xff);
}

fn derive_record_key(root_key: &DeviceVaultRootKey, aad: &[u8]) -> crate::ImResult<[u8; 32]> {
    let hkdf = Hkdf::<Sha256>::new(Some(VAULT_RECORD_HKDF_SALT), root_key.expose_secret());
    let mut key = [0_u8; 32];
    hkdf.expand(aad, &mut key)
        .map_err(|_| crate::ImError::Internal {
            message: "derive secret vault record key failed".to_owned(),
        })?;
    Ok(key)
}

fn validate_metadata(metadata: &SecretMetadata) -> crate::ImResult<()> {
    validate_required("workspace_id", &metadata.workspace_id)?;
    validate_required("device_id", &metadata.device_id)?;
    validate_required("key_id", &metadata.key_id)?;
    if metadata.key_version == 0 {
        return Err(crate::ImError::invalid_input(
            Some("key_version".to_owned()),
            "key_version must be greater than zero",
        ));
    }
    Ok(())
}

fn validate_required(field: &str, value: &str) -> crate::ImResult<()> {
    if value.trim().is_empty() {
        return Err(crate::ImError::invalid_input(
            Some(field.to_owned()),
            format!("{field} is required"),
        ));
    }
    Ok(())
}

fn decode_b64u(field: &str, value: &str) -> crate::ImResult<Vec<u8>> {
    URL_SAFE_NO_PAD
        .decode(value.trim())
        .map_err(|_| crate::ImError::Serialization {
            detail: format!("{field} must be base64url without padding"),
        })
}

fn decode_fixed_b64u(field: &str, value: &str, expected_len: usize) -> crate::ImResult<Vec<u8>> {
    let decoded = decode_b64u(field, value)?;
    if decoded.len() != expected_len {
        return Err(crate::ImError::Serialization {
            detail: format!("{field} must decode to {expected_len} bytes"),
        });
    }
    Ok(decoded)
}

fn now_rfc3339() -> String {
    OffsetDateTime::now_utc()
        .format(&Rfc3339)
        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
}

fn vault_integrity_error() -> crate::ImError {
    crate::ImError::PermissionDenied
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::internal::secret_vault::policy::SecretAccessPolicy;
    use crate::internal::secret_vault::record::SecretKind;

    #[test]
    fn secret_vault_crypto_roundtrips_and_uses_bound_aad() {
        let root_key = DeviceVaultRootKey::from_bytes([1_u8; 32]);
        let metadata = test_metadata("workspace-a", "device-a");
        let plaintext = SecretBytes::from_vec(b"identity-private-key".to_vec());

        let record = seal_record(&root_key, metadata, &plaintext).unwrap();
        let opened = open_record(&root_key, &record).unwrap();

        assert_eq!(opened.expose_secret(), b"identity-private-key");
        assert_eq!(record.schema_version, VAULT_RECORD_SCHEMA_VERSION);
        assert_ne!(record.ciphertext_b64u, "identity-private-key");
        assert_eq!(
            record.aad_b64u,
            URL_SAFE_NO_PAD.encode(aad_for_record(&record))
        );
    }

    #[test]
    fn secret_vault_crypto_rejects_aad_tamper() {
        let root_key = DeviceVaultRootKey::from_bytes([1_u8; 32]);
        let metadata = test_metadata("workspace-a", "device-a");
        let plaintext = SecretBytes::from_vec(b"identity-private-key".to_vec());
        let mut record = seal_record(&root_key, metadata, &plaintext).unwrap();

        record.workspace_id = "workspace-b".to_owned();
        let err = open_record(&root_key, &record).unwrap_err();

        assert_eq!(err, crate::ImError::PermissionDenied);
    }

    #[test]
    fn secret_vault_crypto_rejects_wrong_root_key() {
        let root_key = DeviceVaultRootKey::from_bytes([1_u8; 32]);
        let wrong_root_key = DeviceVaultRootKey::from_bytes([2_u8; 32]);
        let metadata = test_metadata("workspace-a", "device-a");
        let plaintext = SecretBytes::from_vec(b"identity-private-key".to_vec());
        let record = seal_record(&root_key, metadata, &plaintext).unwrap();

        let err = open_record(&wrong_root_key, &record).unwrap_err();

        assert_eq!(err, crate::ImError::PermissionDenied);
    }

    #[test]
    fn secret_vault_crypto_rejects_user_presence_policy() {
        let root_key = DeviceVaultRootKey::from_bytes([1_u8; 32]);
        let mut metadata = test_metadata("workspace-a", "device-a");
        metadata.policy.user_presence_required = true;
        let plaintext = SecretBytes::from_vec(b"identity-private-key".to_vec());

        let err = seal_record(&root_key, metadata, &plaintext).unwrap_err();

        assert!(matches!(err, crate::ImError::UnsupportedCapability { .. }));
    }

    fn test_metadata(workspace_id: &str, device_id: &str) -> SecretMetadata {
        SecretMetadata {
            workspace_id: workspace_id.to_owned(),
            device_id: device_id.to_owned(),
            identity_id: Some("identity-a".to_owned()),
            did: Some("did:wba:alice@example.com".to_owned()),
            kind: SecretKind::IdentityRootPrivate,
            key_id: "key-1".to_owned(),
            key_version: 1,
            policy: SecretAccessPolicy::no_prompt_local_secret(),
        }
    }
}