diff --git a/Cargo.lock b/Cargo.lock index 19660cc5..8ee42fc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -462,6 +462,7 @@ dependencies = [ "async-trait", "base64", "bs58", + "hex", "js-sys", "ring", "ssh-key", diff --git a/crates/auths-crypto/Cargo.toml b/crates/auths-crypto/Cargo.toml index 180efb0a..b434a9dc 100644 --- a/crates/auths-crypto/Cargo.toml +++ b/crates/auths-crypto/Cargo.toml @@ -20,6 +20,7 @@ test-utils = ["dep:ring"] async-trait = "0.1" base64.workspace = true bs58 = "0.5.1" +hex = "0.4" js-sys = { version = "0.3", optional = true } ssh-key = { version = "0.6", features = ["ed25519"] } thiserror.workspace = true diff --git a/crates/auths-crypto/src/lib.rs b/crates/auths-crypto/src/lib.rs index 76dbed13..444c223f 100644 --- a/crates/auths-crypto/src/lib.rs +++ b/crates/auths-crypto/src/lib.rs @@ -28,6 +28,7 @@ pub use key_material::{build_ed25519_pkcs8_v2, parse_ed25519_key_material, parse pub use pkcs8::Pkcs8Der; pub use provider::{ CryptoError, CryptoProvider, ED25519_PUBLIC_KEY_LEN, ED25519_SIGNATURE_LEN, SecureSeed, + SeedDecodeError, decode_seed_hex, }; #[cfg(all(feature = "native", not(target_arch = "wasm32")))] pub use ring_provider::RingCryptoProvider; diff --git a/crates/auths-crypto/src/provider.rs b/crates/auths-crypto/src/provider.rs index 1b534835..a65cf324 100644 --- a/crates/auths-crypto/src/provider.rs +++ b/crates/auths-crypto/src/provider.rs @@ -164,6 +164,52 @@ pub trait CryptoProvider: Send + Sync { ) -> Result<[u8; 32], CryptoError>; } +/// Errors from hex seed decoding. +/// +/// Usage: +/// ```ignore +/// match decode_seed_hex("bad") { +/// Err(SeedDecodeError::InvalidHex(_)) => { /* not valid hex */ } +/// Err(SeedDecodeError::WrongLength { .. }) => { /* not 32 bytes */ } +/// Ok(seed) => { /* use seed */ } +/// } +/// ``` +#[derive(Debug, thiserror::Error)] +pub enum SeedDecodeError { + /// The input string is not valid hexadecimal. + #[error("invalid hex encoding: {0}")] + InvalidHex(hex::FromHexError), + + /// The decoded bytes are not exactly 32 bytes. + #[error("expected {expected} bytes, got {got}")] + WrongLength { + /// Expected byte count (always 32). + expected: usize, + /// Actual byte count after decoding. + got: usize, + }, +} + +/// Decodes a hex-encoded Ed25519 seed (64 hex chars = 32 bytes) into a [`SecureSeed`]. +/// +/// Args: +/// * `hex_str`: Hex-encoded seed string (must be exactly 64 characters). +/// +/// Usage: +/// ```ignore +/// let seed = decode_seed_hex("abcdef01...")?; +/// ``` +pub fn decode_seed_hex(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str).map_err(SeedDecodeError::InvalidHex)?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|v: Vec| SeedDecodeError::WrongLength { + expected: 32, + got: v.len(), + })?; + Ok(SecureSeed::new(arr)) +} + /// Ed25519 public key length in bytes. pub const ED25519_PUBLIC_KEY_LEN: usize = 32; diff --git a/crates/auths-crypto/tests/cases/mod.rs b/crates/auths-crypto/tests/cases/mod.rs index 18bc7830..a1f2b3d5 100644 --- a/crates/auths-crypto/tests/cases/mod.rs +++ b/crates/auths-crypto/tests/cases/mod.rs @@ -1,4 +1,4 @@ #[cfg(feature = "native")] mod provider; - +mod seed_decode; mod ssh; diff --git a/crates/auths-crypto/tests/cases/seed_decode.rs b/crates/auths-crypto/tests/cases/seed_decode.rs new file mode 100644 index 00000000..3baf5eb3 --- /dev/null +++ b/crates/auths-crypto/tests/cases/seed_decode.rs @@ -0,0 +1,58 @@ +use auths_crypto::{SeedDecodeError, decode_seed_hex}; + +#[test] +fn decode_valid_64_hex_chars() { + let hex = "aa".repeat(32); + let seed = decode_seed_hex(&hex).unwrap(); + assert_eq!(seed.as_bytes(), &[0xaa; 32]); +} + +#[test] +fn decode_rejects_invalid_hex() { + let result = decode_seed_hex("zzzz"); + assert!(matches!(result, Err(SeedDecodeError::InvalidHex(_)))); +} + +#[test] +fn decode_rejects_too_short() { + let hex = "aa".repeat(16); + let result = decode_seed_hex(&hex); + match result { + Err(SeedDecodeError::WrongLength { + expected: 32, + got: 16, + }) => {} + other => panic!("expected WrongLength(32, 16), got {other:?}"), + } +} + +#[test] +fn decode_rejects_too_long() { + let hex = "aa".repeat(64); + let result = decode_seed_hex(&hex); + match result { + Err(SeedDecodeError::WrongLength { + expected: 32, + got: 64, + }) => {} + other => panic!("expected WrongLength(32, 64), got {other:?}"), + } +} + +#[test] +fn decode_rejects_empty_string() { + let result = decode_seed_hex(""); + match result { + Err(SeedDecodeError::WrongLength { + expected: 32, + got: 0, + }) => {} + other => panic!("expected WrongLength(32, 0), got {other:?}"), + } +} + +#[test] +fn decode_rejects_odd_length_hex() { + let result = decode_seed_hex("abc"); + assert!(matches!(result, Err(SeedDecodeError::InvalidHex(_)))); +} diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs index 370b20c5..475f0a91 100644 --- a/crates/auths-sdk/src/domains/signing/service.rs +++ b/crates/auths-sdk/src/domains/signing/service.rs @@ -4,7 +4,7 @@ //! Agent communication and passphrase prompting remain in the CLI. use crate::context::AuthsContext; -use crate::ports::artifact::ArtifactSource; +use crate::ports::artifact::{ArtifactDigest, ArtifactMetadata, ArtifactSource}; use auths_core::crypto::ssh::{self, SecureSeed}; use auths_core::crypto::{provider_bridge, signer as core_signer}; use auths_core::signing::{PassphraseProvider, SecureSigner}; @@ -14,6 +14,8 @@ use auths_id::attestation::create::create_signed_attestation; use auths_id::storage::git_refs::AttestationMetadata; use auths_verifier::core::{Capability, ResourceId}; use auths_verifier::types::DeviceDID; +use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -512,3 +514,100 @@ pub fn sign_artifact( digest: artifact_meta.digest.hex, }) } + +/// Signs artifact bytes with a raw Ed25519 seed, bypassing keychain and identity storage. +/// +/// This is the raw-key equivalent of [`sign_artifact`]. It does not require an +/// [`AuthsContext`] or any filesystem/keychain access. The same seed is used for +/// both identity and device signing roles. +/// +/// Args: +/// * `now` - Current UTC time (injected per clock pattern). +/// * `seed` - Ed25519 32-byte seed. +/// * `identity_did` - Parsed identity DID (must be `did:keri:` — caller validates via `IdentityDID::parse()`). +/// * `data` - Raw artifact bytes to sign. +/// * `expires_in` - Optional TTL in seconds. +/// * `note` - Optional attestation note. +/// +/// Usage: +/// ```ignore +/// let did = IdentityDID::parse("did:keri:E...")?; +/// let result = sign_artifact_raw(Utc::now(), &seed, &did, b"payload", None, None)?; +/// ``` +pub fn sign_artifact_raw( + now: DateTime, + seed: &SecureSeed, + identity_did: &IdentityDID, + data: &[u8], + expires_in: Option, + note: Option, +) -> Result { + let pubkey = provider_bridge::ed25519_public_key_from_seed_sync(seed) + .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; + + let device_did = DeviceDID::from_ed25519(&pubkey); + + let digest_hex = hex::encode(Sha256::digest(data)); + let artifact_meta = ArtifactMetadata { + artifact_type: "bytes".to_string(), + digest: ArtifactDigest { + algorithm: "sha256".to_string(), + hex: digest_hex, + }, + name: None, + size: Some(data.len() as u64), + }; + + let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex)); + let meta = AttestationMetadata { + timestamp: Some(now), + expires_at: expires_in.map(|s| now + chrono::Duration::seconds(s as i64)), + note, + }; + + let payload = serde_json::to_value(&artifact_meta) + .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; + + let identity_alias = KeyAlias::new_unchecked("__raw_identity__"); + let device_alias = KeyAlias::new_unchecked("__raw_device__"); + + let mut seeds: HashMap = HashMap::new(); + seeds.insert( + identity_alias.as_str().to_string(), + SecureSeed::new(*seed.as_bytes()), + ); + seeds.insert( + device_alias.as_str().to_string(), + SecureSeed::new(*seed.as_bytes()), + ); + let signer = SeedMapSigner { seeds }; + // Seeds are already resolved — passphrase provider will not be called. + let noop_provider = auths_core::PrefilledPassphraseProvider::new(""); + + let attestation = create_signed_attestation( + now, + &rid, + identity_did, + &device_did, + &pubkey, + Some(payload), + &meta, + &signer, + &noop_provider, + Some(&identity_alias), + Some(&device_alias), + vec![Capability::sign_release()], + None, + None, + ) + .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; + + let attestation_json = serde_json::to_string_pretty(&attestation) + .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; + + Ok(ArtifactSigningResult { + attestation_json, + rid, + digest: artifact_meta.digest.hex, + }) +} diff --git a/crates/auths-sdk/src/signing.rs b/crates/auths-sdk/src/signing.rs index 05a737b7..4296e4c3 100644 --- a/crates/auths-sdk/src/signing.rs +++ b/crates/auths-sdk/src/signing.rs @@ -2,6 +2,6 @@ pub use crate::domains::signing::service::{ ArtifactSigningError, ArtifactSigningParams, ArtifactSigningResult, SigningConfig, - SigningError, SigningKeyMaterial, construct_signature_payload, sign_artifact, sign_with_seed, - validate_freeze_state, + SigningError, SigningKeyMaterial, construct_signature_payload, sign_artifact, + sign_artifact_raw, sign_with_seed, validate_freeze_state, }; diff --git a/crates/auths-sdk/tests/cases/artifact.rs b/crates/auths-sdk/tests/cases/artifact.rs index bdd341c4..02cba402 100644 --- a/crates/auths-sdk/tests/cases/artifact.rs +++ b/crates/auths-sdk/tests/cases/artifact.rs @@ -1,10 +1,13 @@ use auths_core::crypto::ssh::SecureSeed; +use auths_core::storage::keychain::IdentityDID; use auths_sdk::domains::signing::service::{ ArtifactSigningError, ArtifactSigningParams, SigningKeyMaterial, sign_artifact, + sign_artifact_raw, }; use auths_sdk::ports::artifact::{ArtifactDigest, ArtifactError, ArtifactMetadata, ArtifactSource}; use auths_sdk::testing::fakes::FakeArtifactSource; use auths_sdk::workflows::artifact::compute_digest; +use chrono::Utc; use std::sync::Arc; use crate::cases::helpers::{build_empty_test_context, setup_signed_artifact_context}; @@ -189,3 +192,69 @@ fn sign_artifact_identity_not_found_returns_error() { result.unwrap_err() ); } + +// --------------------------------------------------------------------------- +// sign_artifact_raw tests +// --------------------------------------------------------------------------- + +#[test] +fn sign_artifact_raw_produces_valid_attestation_json() { + let seed = SecureSeed::new([42u8; 32]); + let identity_did = IdentityDID::new_unchecked("did:keri:Etest1234"); + let data = b"release binary content"; + let now = Utc::now(); + + let result = sign_artifact_raw( + now, + &seed, + &identity_did, + data, + Some(86400), + Some("test note".into()), + ) + .unwrap(); + + assert!(!result.attestation_json.is_empty()); + assert!(result.rid.starts_with("sha256:")); + assert!(!result.digest.is_empty()); + + let parsed: serde_json::Value = serde_json::from_str(&result.attestation_json).unwrap(); + assert_eq!(parsed["issuer"].as_str().unwrap(), "did:keri:Etest1234"); + assert!(parsed.get("identity_signature").is_some()); + assert!(parsed.get("device_signature").is_some()); + assert!(parsed.get("payload").is_some()); + assert!(parsed.get("expires_at").is_some()); + assert_eq!(parsed["note"].as_str().unwrap(), "test note"); + + let payload = &parsed["payload"]; + assert_eq!(payload["artifact_type"].as_str().unwrap(), "bytes"); + assert_eq!(payload["digest"]["algorithm"].as_str().unwrap(), "sha256"); + assert_eq!(payload["size"].as_u64().unwrap(), data.len() as u64); +} + +#[test] +fn sign_artifact_raw_without_optional_fields() { + let seed = SecureSeed::new([7u8; 32]); + let identity_did = IdentityDID::new_unchecked("did:keri:Eminimal"); + let now = Utc::now(); + + let result = sign_artifact_raw(now, &seed, &identity_did, b"data", None, None).unwrap(); + + let parsed: serde_json::Value = serde_json::from_str(&result.attestation_json).unwrap(); + assert!(parsed.get("expires_at").is_none() || parsed["expires_at"].is_null()); + assert!(parsed.get("note").is_none() || parsed["note"].is_null()); +} + +#[test] +fn sign_artifact_raw_digest_matches_sha256_of_data() { + let seed = SecureSeed::new([1u8; 32]); + let identity_did = IdentityDID::new_unchecked("did:keri:Edigest"); + let data = b"hello world"; + let now = Utc::now(); + + let result = sign_artifact_raw(now, &seed, &identity_did, data, None, None).unwrap(); + + let expected_digest = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + assert_eq!(result.digest, expected_digest); + assert_eq!(result.rid.as_str(), format!("sha256:{expected_digest}")); +} diff --git a/packages/auths-node/index.d.ts b/packages/auths-node/index.d.ts index 44dbf586..5f869e76 100644 --- a/packages/auths-node/index.d.ts +++ b/packages/auths-node/index.d.ts @@ -250,6 +250,20 @@ export declare function signArtifact(filePath: string, identityKeyAlias: string, export declare function signArtifactBytes(data: Buffer, identityKeyAlias: string, repoPath: string, passphrase?: string | undefined | null, expiresIn?: number | undefined | null, note?: string | undefined | null): NapiArtifactResult +/** + * Sign raw bytes with a raw Ed25519 private key, producing a dual-signed attestation. + * + * No keychain or filesystem access required. + * + * Args: + * * `data`: The raw bytes to sign. + * * `private_key_hex`: Ed25519 seed as hex string (64 chars = 32 bytes). + * * `identity_did`: Identity DID string (must be `did:keri:` format). + * * `expires_in`: Optional duration in seconds until expiration. + * * `note`: Optional human-readable note. + */ +export declare function signArtifactBytesRaw(data: Buffer, privateKeyHex: string, identityDid: string, expiresIn?: number | undefined | null, note?: string | undefined | null): NapiArtifactResult + export declare function signAsAgent(message: Buffer, keyAlias: string, repoPath: string, passphrase?: string | undefined | null): NapiCommitSignResult export declare function signAsIdentity(message: Buffer, identityDid: string, repoPath: string, passphrase?: string | undefined | null): NapiCommitSignResult diff --git a/packages/auths-node/index.js b/packages/auths-node/index.js index 9a6055f7..2bfbf0d2 100644 --- a/packages/auths-node/index.js +++ b/packages/auths-node/index.js @@ -609,6 +609,7 @@ module.exports.signActionAsIdentity = nativeBinding.signActionAsIdentity module.exports.signActionRaw = nativeBinding.signActionRaw module.exports.signArtifact = nativeBinding.signArtifact module.exports.signArtifactBytes = nativeBinding.signArtifactBytes +module.exports.signArtifactBytesRaw = nativeBinding.signArtifactBytesRaw module.exports.signAsAgent = nativeBinding.signAsAgent module.exports.signAsIdentity = nativeBinding.signAsIdentity module.exports.signBytesRaw = nativeBinding.signBytesRaw diff --git a/packages/auths-node/lib/index.ts b/packages/auths-node/lib/index.ts index 34518d17..ee5bb67b 100644 --- a/packages/auths-node/lib/index.ts +++ b/packages/auths-node/lib/index.ts @@ -122,7 +122,9 @@ export { } from './types' import native from './native' +import type { NapiArtifactResult } from './native' export const version: () => string = native.version export const signBytesRaw: (privateKeyHex: string, message: Buffer) => string = native.signBytesRaw export const signActionRaw: (privateKeyHex: string, actionType: string, payloadJson: string, identityDid: string) => string = native.signActionRaw +export const signArtifactBytesRaw: (data: Buffer, privateKeyHex: string, identityDid: string, expiresIn?: number | null, note?: string | null) => NapiArtifactResult = native.signArtifactBytesRaw export const verifyActionEnvelope: (envelopeJson: string, publicKeyHex: string) => { valid: boolean; error?: string | null; errorCode?: string | null } = native.verifyActionEnvelope diff --git a/packages/auths-node/lib/native.ts b/packages/auths-node/lib/native.ts index e6913b23..2cad9990 100644 --- a/packages/auths-node/lib/native.ts +++ b/packages/auths-node/lib/native.ts @@ -223,6 +223,7 @@ export interface NativeBindings { // Artifact signArtifact(filePath: string, identityKeyAlias: string, repoPath: string, passphrase?: string | null, expiresInDays?: number | null, note?: string | null): NapiArtifactResult signArtifactBytes(data: Buffer, identityKeyAlias: string, repoPath: string, passphrase?: string | null, expiresInDays?: number | null, note?: string | null): NapiArtifactResult + signArtifactBytesRaw(data: Buffer, privateKeyHex: string, identityDid: string, expiresIn?: number | null, note?: string | null): NapiArtifactResult // Audit generateAuditReport(targetRepoPath: string, authsRepoPath: string, since?: string | null, until?: string | null, author?: string | null, limit?: number | null): string diff --git a/packages/auths-node/src/artifact.rs b/packages/auths-node/src/artifact.rs index 44ecd160..a86e266e 100644 --- a/packages/auths-node/src/artifact.rs +++ b/packages/auths-node/src/artifact.rs @@ -3,16 +3,19 @@ use std::path::PathBuf; use std::sync::Arc; use auths_core::signing::PrefilledPassphraseProvider; -use auths_core::storage::keychain::{KeyAlias, get_platform_keychain_with_config}; +use auths_core::storage::keychain::{IdentityDID, KeyAlias, get_platform_keychain_with_config}; +use auths_crypto::decode_seed_hex; use auths_sdk::context::AuthsContext; use auths_sdk::ports::artifact::{ArtifactDigest, ArtifactError, ArtifactMetadata, ArtifactSource}; use auths_sdk::signing::{ ArtifactSigningParams, SigningKeyMaterial, sign_artifact as sdk_sign_artifact, + sign_artifact_raw, }; use auths_storage::git::{ GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage, }; use auths_verifier::clock::SystemClock; +use chrono::Utc; use napi_derive::napi; use sha2::{Digest, Sha256}; @@ -215,3 +218,66 @@ pub fn sign_artifact_bytes( note, ) } + +/// Sign raw bytes with a raw Ed25519 private key, producing a dual-signed attestation. +/// +/// No keychain or filesystem access required. +/// +/// Args: +/// * `data`: The raw bytes to sign. +/// * `private_key_hex`: Ed25519 seed as hex string (64 chars = 32 bytes). +/// * `identity_did`: Identity DID string (must be `did:keri:` format). +/// * `expires_in`: Optional duration in seconds until expiration. +/// * `note`: Optional human-readable note. +/// +/// Usage: +/// ```ignore +/// let result = sign_artifact_bytes_raw(buffer, "abcd...".into(), "did:keri:E...".into(), None, None)?; +/// ``` +#[napi] +pub fn sign_artifact_bytes_raw( + data: napi::bindgen_prelude::Buffer, + private_key_hex: String, + identity_did: String, + expires_in: Option, + note: Option, +) -> napi::Result { + let seed = decode_seed_hex(&private_key_hex).map_err(|e| { + format_error("AUTHS_INVALID_INPUT", format!("Invalid private key: {e}")) + })?; + + let did = IdentityDID::parse(&identity_did).map_err(|e| { + format_error("AUTHS_INVALID_INPUT", format!("Invalid identity DID: {e}")) + })?; + + let expires_in_u64 = expires_in + .map(|v| { + if v < 0 { + Err(format_error( + "AUTHS_INVALID_INPUT", + "expiresIn must be non-negative".to_string(), + )) + } else { + Ok(v as u64) + } + }) + .transpose()?; + + let now = Utc::now(); + let data_len = data.len(); + + let result = sign_artifact_raw(now, &seed, &did, data.as_ref(), expires_in_u64, note) + .map_err(|e| { + format_error( + "AUTHS_SIGNING_FAILED", + format!("Artifact signing failed: {e}"), + ) + })?; + + Ok(NapiArtifactResult { + attestation_json: result.attestation_json, + rid: result.rid.to_string(), + digest: result.digest, + file_size: data_len as i64, + }) +} diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index ebf1c9fa..c9d27b70 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -174,6 +174,7 @@ dependencies = [ "async-trait", "base64", "bs58", + "hex", "ring", "ssh-key", "thiserror 2.0.18", diff --git a/packages/auths-python/python/auths/__init__.py b/packages/auths-python/python/auths/__init__.py index dceade21..3d92c597 100644 --- a/packages/auths-python/python/auths/__init__.py +++ b/packages/auths-python/python/auths/__init__.py @@ -19,6 +19,7 @@ VerificationStatus, get_token, sign_action, + sign_artifact_bytes_raw, sign_bytes, verify_action_envelope, verify_at_time, diff --git a/packages/auths-python/src/artifact_sign.rs b/packages/auths-python/src/artifact_sign.rs index 18f9b9e2..2e1eabe6 100644 --- a/packages/auths-python/src/artifact_sign.rs +++ b/packages/auths-python/src/artifact_sign.rs @@ -3,17 +3,20 @@ use std::path::PathBuf; use std::sync::Arc; use auths_core::signing::PrefilledPassphraseProvider; -use auths_core::storage::keychain::{KeyAlias, get_platform_keychain_with_config}; +use auths_core::storage::keychain::{IdentityDID, KeyAlias, get_platform_keychain_with_config}; +use auths_crypto::decode_seed_hex; use auths_sdk::context::AuthsContext; use auths_sdk::ports::artifact::{ArtifactDigest, ArtifactError, ArtifactMetadata, ArtifactSource}; use auths_sdk::signing::{ ArtifactSigningParams, SigningKeyMaterial, sign_artifact as sdk_sign_artifact, + sign_artifact_raw, }; use auths_storage::git::{ GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage, }; use auths_verifier::clock::SystemClock; -use pyo3::exceptions::{PyFileNotFoundError, PyRuntimeError}; +use chrono::Utc; +use pyo3::exceptions::{PyFileNotFoundError, PyRuntimeError, PyValueError}; use pyo3::prelude::*; use sha2::{Digest, Sha256}; @@ -95,7 +98,7 @@ pub struct PyArtifactResult { #[pyo3(get)] pub digest: String, #[pyo3(get)] - pub file_size: u64, + pub file_size: i64, } #[pymethods] @@ -111,7 +114,7 @@ impl PyArtifactResult { } } -fn human_size(bytes: u64) -> String { +fn human_size(bytes: i64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { @@ -169,7 +172,7 @@ fn build_context_and_sign( let file_size = artifact .metadata() .map(|m| m.size.unwrap_or(0)) - .unwrap_or(0); + .unwrap_or(0) as i64; let params = ArtifactSigningParams { artifact, @@ -269,3 +272,48 @@ pub fn sign_artifact_bytes( build_context_and_sign(artifact, &alias, &rp, passphrase, expires_in, note) } } + +/// Sign raw bytes with a raw Ed25519 private key, producing a dual-signed attestation. +/// +/// No keychain or filesystem access required. +/// +/// Args: +/// * `data`: The raw bytes to sign. +/// * `private_key_hex`: Ed25519 seed as hex string (64 chars = 32 bytes). +/// * `identity_did`: Identity DID string (must be `did:keri:` format). +/// * `expires_in`: Optional duration in seconds until expiration. +/// * `note`: Optional human-readable note. +/// +/// Usage: +/// ```ignore +/// let result = sign_artifact_bytes_raw(py, b"payload", "abcd...", "did:keri:E...", None, None)?; +/// ``` +#[pyfunction] +#[pyo3(signature = (data, private_key_hex, identity_did, expires_in=None, note=None))] +pub fn sign_artifact_bytes_raw( + _py: Python<'_>, + data: &[u8], + private_key_hex: &str, + identity_did: &str, + expires_in: Option, + note: Option, +) -> PyResult { + let seed = decode_seed_hex(private_key_hex) + .map_err(|e| PyValueError::new_err(format!("[AUTHS_INVALID_INPUT] {e}")))?; + + let did = IdentityDID::parse(identity_did) + .map_err(|e| PyValueError::new_err(format!("[AUTHS_INVALID_INPUT] {e}")))?; + + let now = Utc::now(); + + let result = sign_artifact_raw(now, &seed, &did, data, expires_in, note).map_err(|e| { + PyRuntimeError::new_err(format!("[AUTHS_SIGNING_FAILED] Artifact signing failed: {e}")) + })?; + + Ok(PyArtifactResult { + attestation_json: result.attestation_json, + rid: result.rid.to_string(), + digest: result.digest, + file_size: data.len() as i64, + }) +} diff --git a/packages/auths-python/src/lib.rs b/packages/auths-python/src/lib.rs index 729ec2b4..562462e5 100644 --- a/packages/auths-python/src/lib.rs +++ b/packages/auths-python/src/lib.rs @@ -88,6 +88,10 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_function(wrap_pyfunction!(artifact_sign::sign_artifact, m)?)?; m.add_function(wrap_pyfunction!(artifact_sign::sign_artifact_bytes, m)?)?; + m.add_function(wrap_pyfunction!( + artifact_sign::sign_artifact_bytes_raw, + m + )?)?; m.add_class::()?; m.add_function(wrap_pyfunction!(commit_sign::sign_commit, m)?)?;