Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/auths-crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/auths-crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions crates/auths-crypto/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SecureSeed, SeedDecodeError> {
let bytes = hex::decode(hex_str).map_err(SeedDecodeError::InvalidHex)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|v: Vec<u8>| 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;

Expand Down
2 changes: 1 addition & 1 deletion crates/auths-crypto/tests/cases/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#[cfg(feature = "native")]
mod provider;

mod seed_decode;
mod ssh;
58 changes: 58 additions & 0 deletions crates/auths-crypto/tests/cases/seed_decode.rs
Original file line number Diff line number Diff line change
@@ -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(_))));
}
101 changes: 100 additions & 1 deletion crates/auths-sdk/src/domains/signing/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -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<Utc>,
seed: &SecureSeed,
identity_did: &IdentityDID,
data: &[u8],
expires_in: Option<u64>,
note: Option<String>,
) -> Result<ArtifactSigningResult, ArtifactSigningError> {
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<String, SecureSeed> = 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,
})
}
4 changes: 2 additions & 2 deletions crates/auths-sdk/src/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
69 changes: 69 additions & 0 deletions crates/auths-sdk/tests/cases/artifact.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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}"));
}
14 changes: 14 additions & 0 deletions packages/auths-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/auths-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/auths-node/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading