auths-sdk 0.1.2

Application services layer for Auths identity operations
Documentation
use std::sync::Arc;

use auths_core::ports::clock::ClockProvider;
use auths_core::signing::{PassphraseProvider, SecureSigner, StorageSigner};
use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage};
use auths_id::attestation::create::create_signed_attestation;
use auths_id::attestation::group::AttestationGroup;
use auths_id::attestation::revoke::create_signed_revocation;
use auths_id::keri::{anchor_and_persist_via_backend, parse_did_keri};
use auths_id::storage::attestation::AttestationSource;
use auths_id::storage::git_refs::AttestationMetadata;
use auths_id::storage::identity::IdentityStorage;
use auths_verifier::core::ResourceId;
use auths_verifier::types::CanonicalDid;
use chrono::{DateTime, Utc};

use crate::context::AuthsContext;
use crate::domains::device::error::{DeviceError, DeviceExtensionError};
use crate::domains::device::types::{
    DeviceExtensionConfig, DeviceExtensionResult, DeviceLinkConfig, DeviceLinkResult,
};

struct AttestationParams {
    identity_did: IdentityDID,
    device_did: CanonicalDid,
    device_public_key: auths_verifier::DevicePublicKey,
    payload: Option<serde_json::Value>,
    meta: AttestationMetadata,
    identity_alias: KeyAlias,
    device_alias: Option<KeyAlias>,
}

fn build_attestation_params(
    config: &DeviceLinkConfig,
    identity_did: IdentityDID,
    device_did: CanonicalDid,
    device_public_key: auths_verifier::DevicePublicKey,
    now: DateTime<Utc>,
) -> AttestationParams {
    AttestationParams {
        identity_did,
        device_did,
        device_public_key,
        payload: config.payload.clone(),
        meta: AttestationMetadata {
            timestamp: Some(now),
            expires_at: config
                .expires_in
                .map(|s| now + chrono::Duration::seconds(s as i64)),
            note: config.note.clone(),
        },
        identity_alias: config.identity_key_alias.clone(),
        device_alias: config.device_key_alias.clone(),
    }
}

/// Links a new device to an existing identity by creating a signed attestation.
///
/// Args:
/// * `config`: Device link parameters (identity alias, capabilities, etc.).
/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider.
/// * `clock`: Clock provider for timestamp generation.
///
/// Usage:
/// ```ignore
/// let result = link_device(config, &ctx, &SystemClock)?;
/// ```
pub fn link_device(
    config: DeviceLinkConfig,
    ctx: &AuthsContext,
    clock: &dyn ClockProvider,
) -> Result<DeviceLinkResult, DeviceError> {
    let now = clock.now();
    let identity = load_identity(ctx.identity_storage.as_ref())?;
    let prefix = parse_did_keri(identity.controller_did.as_str()).map_err(|e| {
        DeviceError::AnchorError(auths_id::keri::AnchorError::InvalidDid(e.to_string()))
    })?;
    let signer = StorageSigner::new(Arc::clone(&ctx.key_storage));
    let (device_did, device_pk) = extract_device_key(
        &config,
        ctx.key_storage.as_ref(),
        ctx.passphrase_provider.as_ref(),
    )?;
    let params = build_attestation_params(
        &config,
        identity.controller_did,
        device_did.clone(),
        device_pk,
        now,
    );
    let attestation = sign_attestation(
        now,
        &params,
        &identity.storage_id,
        &signer,
        ctx.passphrase_provider.as_ref(),
    )?;
    let attestation_rid = attestation.rid.to_string();

    let mut batch = auths_id::storage::registry::backend::AtomicWriteBatch::new();
    batch.stage_attestation(attestation.clone());
    anchor_and_persist_via_backend(
        ctx.registry.as_ref(),
        &signer,
        &config.identity_key_alias,
        ctx.passphrase_provider.as_ref(),
        &prefix,
        &attestation,
        &mut batch,
        &ctx.witness_params(),
        now,
    )?;

    Ok(DeviceLinkResult {
        device_did,
        attestation_id: ResourceId::new(attestation_rid),
    })
}

/// Revokes a device's attestation by creating a signed revocation record.
///
/// Args:
/// * `device_did`: The DID of the device to revoke.
/// * `identity_key_alias`: Keychain alias for the identity key that will sign the revocation.
/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider.
/// * `note`: Optional reason for revocation.
/// * `clock`: Clock provider for timestamp generation.
///
/// Usage:
/// ```ignore
/// revoke_device("did:key:z6Mk...", "my-identity", &ctx, Some("Lost laptop"), &clock)?;
/// ```
pub fn revoke_device(
    subject: &str,
    identity_key_alias: &KeyAlias,
    ctx: &AuthsContext,
    note: Option<String>,
    clock: &dyn ClockProvider,
) -> Result<(), DeviceError> {
    let now = clock.now();
    let identity = load_identity(ctx.identity_storage.as_ref())?;
    let prefix = parse_did_keri(identity.controller_did.as_str()).map_err(|e| {
        DeviceError::AnchorError(auths_id::keri::AnchorError::InvalidDid(e.to_string()))
    })?;
    let device_pk = find_device_public_key(ctx.attestation_source.as_ref(), subject)?;
    let signer = StorageSigner::new(Arc::clone(&ctx.key_storage));

    let target_did = CanonicalDid::from_public_key_did_key(device_pk.as_bytes(), device_pk.curve());

    let revocation = create_signed_revocation(
        auths_id::attestation::revoke::RevocationInput {
            rid: &identity.storage_id,
            identity_did: &identity.controller_did,
            subject: &target_did,
            device_public_key: device_pk.as_bytes(),
            device_curve: device_pk.curve(),
            note,
            payload: None,
            timestamp: now,
            identity_alias: identity_key_alias,
        },
        &signer,
        ctx.passphrase_provider.as_ref(),
    )
    .map_err(DeviceError::AttestationError)?;

    let mut batch = auths_id::storage::registry::backend::AtomicWriteBatch::new();
    batch.stage_attestation(revocation.clone());
    anchor_and_persist_via_backend(
        ctx.registry.as_ref(),
        &signer,
        identity_key_alias,
        ctx.passphrase_provider.as_ref(),
        &prefix,
        &revocation,
        &mut batch,
        &ctx.witness_params(),
        now,
    )?;

    Ok(())
}

/// Extends the expiration of an existing device authorization by creating a new attestation.
///
/// Loads the latest attestation for the given device DID, verifies it is not revoked,
/// then creates a new signed attestation with the extended expiry and persists it.
/// Capabilities are preserved as empty (`vec![]`) — the extension renews the grant
/// duration only, it does not change what the device is permitted to do.
///
/// Args:
/// * `config`: Extension parameters (device DID, seconds until expiration, key aliases, registry path).
/// * `ctx`: Runtime context providing storage adapters, key material, and passphrase provider.
/// * `clock`: Clock provider for timestamp generation.
///
/// Usage:
/// ```ignore
/// let result = extend_device(config, &ctx, &SystemClock)?;
/// ```
pub fn extend_device(
    config: DeviceExtensionConfig,
    ctx: &AuthsContext,
    clock: &dyn ClockProvider,
) -> Result<DeviceExtensionResult, DeviceExtensionError> {
    let signer = StorageSigner::new(Arc::clone(&ctx.key_storage));

    let identity = load_identity(ctx.identity_storage.as_ref())
        .map_err(|_| DeviceExtensionError::IdentityNotFound)?;

    let group = AttestationGroup::from_list(
        ctx.attestation_source
            .load_all_attestations()
            .map_err(|e| DeviceExtensionError::StorageError(e.into()))?,
    );

    #[allow(clippy::disallowed_methods)]
    // INVARIANT: config.device_did is a did:key string supplied by the CLI from an existing attestation
    let device_did_obj = CanonicalDid::new_unchecked(config.device_did.clone());
    let latest =
        group
            .latest(&device_did_obj)
            .ok_or_else(|| DeviceExtensionError::NoAttestationFound {
                device_did: config.device_did.clone(),
            })?;

    if latest.is_revoked() {
        return Err(DeviceExtensionError::AlreadyRevoked {
            device_did: config.device_did.clone(),
        });
    }

    let previous_expires_at = latest.expires_at;
    let now = clock.now();
    let new_expires_at = now + chrono::Duration::seconds(config.expires_in as i64);

    let meta = AttestationMetadata {
        note: latest.note.clone(),
        timestamp: Some(now),
        expires_at: Some(new_expires_at),
    };

    let extended = create_signed_attestation(
        now,
        auths_id::attestation::create::AttestationInput {
            rid: &identity.storage_id,
            identity_did: &identity.controller_did,
            subject: &device_did_obj,
            device_public_key: latest.device_public_key.as_bytes(),
            device_curve: latest.device_public_key.curve(),
            payload: latest.payload.clone(),
            meta: &meta,
            identity_alias: Some(&config.identity_key_alias),
            device_alias: config.device_key_alias.as_ref(),
            delegated_by: None,
            commit_sha: None,
            signer_type: None,
        },
        &signer,
        ctx.passphrase_provider.as_ref(),
    )
    .map_err(DeviceExtensionError::AttestationFailed)?;

    if let Ok(prefix) = parse_did_keri(identity.controller_did.as_str()) {
        let signer = StorageSigner::new(Arc::clone(&ctx.key_storage));
        let mut batch = auths_id::storage::registry::backend::AtomicWriteBatch::new();
        batch.stage_attestation(extended.clone());
        anchor_and_persist_via_backend(
            ctx.registry.as_ref(),
            &signer,
            &config.identity_key_alias,
            ctx.passphrase_provider.as_ref(),
            &prefix,
            &extended,
            &mut batch,
            &ctx.witness_params(),
            now,
        )?;
    }

    Ok(DeviceExtensionResult {
        #[allow(clippy::disallowed_methods)] // INVARIANT: config.device_did was already validated above when constructing device_did_obj
        device_did: CanonicalDid::new_unchecked(config.device_did),
        new_expires_at,
        previous_expires_at,
    })
}

struct LoadedIdentity {
    controller_did: IdentityDID,
    storage_id: String,
}

fn load_identity(identity_storage: &dyn IdentityStorage) -> Result<LoadedIdentity, DeviceError> {
    let managed = identity_storage
        .load_identity()
        .map_err(|e| DeviceError::IdentityNotFound {
            did: format!("identity load failed: {e}"),
        })?;
    Ok(LoadedIdentity {
        controller_did: managed.controller_did,
        storage_id: managed.storage_id,
    })
}

fn extract_device_key(
    config: &DeviceLinkConfig,
    keychain: &(dyn KeyStorage + Send + Sync),
    passphrase_provider: &dyn PassphraseProvider,
) -> Result<(CanonicalDid, auths_verifier::DevicePublicKey), DeviceError> {
    let alias = config
        .device_key_alias
        .as_ref()
        .unwrap_or(&config.identity_key_alias);

    let (pk_bytes, curve) = auths_core::storage::keychain::extract_public_key_bytes(
        keychain,
        alias,
        passphrase_provider,
    )
    .map_err(DeviceError::CryptoError)?;

    let device_pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes).map_err(|e| {
        DeviceError::CryptoError(auths_core::AgentError::InvalidInput(e.to_string()))
    })?;
    let device_did = CanonicalDid::from_public_key_did_key(&pk_bytes, curve);

    if let Some(ref expected) = config.device_did
        && expected != &device_did.to_string()
    {
        return Err(DeviceError::DeviceDidMismatch {
            expected: expected.clone(),
            actual: device_did.to_string(),
        });
    }

    Ok((device_did, device_pk))
}

fn sign_attestation(
    now: DateTime<Utc>,
    params: &AttestationParams,
    rid: &str,
    signer: &dyn SecureSigner,
    passphrase_provider: &dyn PassphraseProvider,
) -> Result<auths_verifier::core::Attestation, DeviceError> {
    create_signed_attestation(
        now,
        auths_id::attestation::create::AttestationInput {
            rid,
            identity_did: &params.identity_did,
            subject: &params.device_did,
            device_public_key: params.device_public_key.as_bytes(),
            device_curve: params.device_public_key.curve(),
            payload: params.payload.clone(),
            meta: &params.meta,
            identity_alias: Some(&params.identity_alias),
            device_alias: params.device_alias.as_ref(),
            delegated_by: None,
            commit_sha: None,
            signer_type: None,
        },
        signer,
        passphrase_provider,
    )
    .map_err(DeviceError::AttestationError)
}

fn find_device_public_key(
    attestation_source: &dyn AttestationSource,
    subject: &str,
) -> Result<auths_verifier::DevicePublicKey, DeviceError> {
    let attestations = attestation_source
        .load_all_attestations()
        .map_err(|e| DeviceError::StorageError(e.into()))?;

    for att in &attestations {
        if att.subject.as_str() == subject {
            return Ok(att.device_public_key.clone());
        }
    }

    Err(DeviceError::DeviceNotFound {
        did: subject.to_string(),
    })
}