Skip to content

Commit 5f75a53

Browse files
authored
Merge pull request #146 from auths-dev/dev-signRawNonAuthArtifacts
feat: add raw-key artifact signing to python and node sdks
2 parents 743493a + 6dd5411 commit 5f75a53

18 files changed

Lines changed: 423 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/auths-crypto/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ test-utils = ["dep:ring"]
2020
async-trait = "0.1"
2121
base64.workspace = true
2222
bs58 = "0.5.1"
23+
hex = "0.4"
2324
js-sys = { version = "0.3", optional = true }
2425
ssh-key = { version = "0.6", features = ["ed25519"] }
2526
thiserror.workspace = true

crates/auths-crypto/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub use key_material::{build_ed25519_pkcs8_v2, parse_ed25519_key_material, parse
2828
pub use pkcs8::Pkcs8Der;
2929
pub use provider::{
3030
CryptoError, CryptoProvider, ED25519_PUBLIC_KEY_LEN, ED25519_SIGNATURE_LEN, SecureSeed,
31+
SeedDecodeError, decode_seed_hex,
3132
};
3233
#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
3334
pub use ring_provider::RingCryptoProvider;

crates/auths-crypto/src/provider.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,52 @@ pub trait CryptoProvider: Send + Sync {
164164
) -> Result<[u8; 32], CryptoError>;
165165
}
166166

167+
/// Errors from hex seed decoding.
168+
///
169+
/// Usage:
170+
/// ```ignore
171+
/// match decode_seed_hex("bad") {
172+
/// Err(SeedDecodeError::InvalidHex(_)) => { /* not valid hex */ }
173+
/// Err(SeedDecodeError::WrongLength { .. }) => { /* not 32 bytes */ }
174+
/// Ok(seed) => { /* use seed */ }
175+
/// }
176+
/// ```
177+
#[derive(Debug, thiserror::Error)]
178+
pub enum SeedDecodeError {
179+
/// The input string is not valid hexadecimal.
180+
#[error("invalid hex encoding: {0}")]
181+
InvalidHex(hex::FromHexError),
182+
183+
/// The decoded bytes are not exactly 32 bytes.
184+
#[error("expected {expected} bytes, got {got}")]
185+
WrongLength {
186+
/// Expected byte count (always 32).
187+
expected: usize,
188+
/// Actual byte count after decoding.
189+
got: usize,
190+
},
191+
}
192+
193+
/// Decodes a hex-encoded Ed25519 seed (64 hex chars = 32 bytes) into a [`SecureSeed`].
194+
///
195+
/// Args:
196+
/// * `hex_str`: Hex-encoded seed string (must be exactly 64 characters).
197+
///
198+
/// Usage:
199+
/// ```ignore
200+
/// let seed = decode_seed_hex("abcdef01...")?;
201+
/// ```
202+
pub fn decode_seed_hex(hex_str: &str) -> Result<SecureSeed, SeedDecodeError> {
203+
let bytes = hex::decode(hex_str).map_err(SeedDecodeError::InvalidHex)?;
204+
let arr: [u8; 32] = bytes
205+
.try_into()
206+
.map_err(|v: Vec<u8>| SeedDecodeError::WrongLength {
207+
expected: 32,
208+
got: v.len(),
209+
})?;
210+
Ok(SecureSeed::new(arr))
211+
}
212+
167213
/// Ed25519 public key length in bytes.
168214
pub const ED25519_PUBLIC_KEY_LEN: usize = 32;
169215

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
#[cfg(feature = "native")]
22
mod provider;
3-
3+
mod seed_decode;
44
mod ssh;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use auths_crypto::{SeedDecodeError, decode_seed_hex};
2+
3+
#[test]
4+
fn decode_valid_64_hex_chars() {
5+
let hex = "aa".repeat(32);
6+
let seed = decode_seed_hex(&hex).unwrap();
7+
assert_eq!(seed.as_bytes(), &[0xaa; 32]);
8+
}
9+
10+
#[test]
11+
fn decode_rejects_invalid_hex() {
12+
let result = decode_seed_hex("zzzz");
13+
assert!(matches!(result, Err(SeedDecodeError::InvalidHex(_))));
14+
}
15+
16+
#[test]
17+
fn decode_rejects_too_short() {
18+
let hex = "aa".repeat(16);
19+
let result = decode_seed_hex(&hex);
20+
match result {
21+
Err(SeedDecodeError::WrongLength {
22+
expected: 32,
23+
got: 16,
24+
}) => {}
25+
other => panic!("expected WrongLength(32, 16), got {other:?}"),
26+
}
27+
}
28+
29+
#[test]
30+
fn decode_rejects_too_long() {
31+
let hex = "aa".repeat(64);
32+
let result = decode_seed_hex(&hex);
33+
match result {
34+
Err(SeedDecodeError::WrongLength {
35+
expected: 32,
36+
got: 64,
37+
}) => {}
38+
other => panic!("expected WrongLength(32, 64), got {other:?}"),
39+
}
40+
}
41+
42+
#[test]
43+
fn decode_rejects_empty_string() {
44+
let result = decode_seed_hex("");
45+
match result {
46+
Err(SeedDecodeError::WrongLength {
47+
expected: 32,
48+
got: 0,
49+
}) => {}
50+
other => panic!("expected WrongLength(32, 0), got {other:?}"),
51+
}
52+
}
53+
54+
#[test]
55+
fn decode_rejects_odd_length_hex() {
56+
let result = decode_seed_hex("abc");
57+
assert!(matches!(result, Err(SeedDecodeError::InvalidHex(_))));
58+
}

crates/auths-sdk/src/domains/signing/service.rs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! Agent communication and passphrase prompting remain in the CLI.
55
66
use crate::context::AuthsContext;
7-
use crate::ports::artifact::ArtifactSource;
7+
use crate::ports::artifact::{ArtifactDigest, ArtifactMetadata, ArtifactSource};
88
use auths_core::crypto::ssh::{self, SecureSeed};
99
use auths_core::crypto::{provider_bridge, signer as core_signer};
1010
use auths_core::signing::{PassphraseProvider, SecureSigner};
@@ -14,6 +14,8 @@ use auths_id::attestation::create::create_signed_attestation;
1414
use auths_id::storage::git_refs::AttestationMetadata;
1515
use auths_verifier::core::{Capability, ResourceId};
1616
use auths_verifier::types::DeviceDID;
17+
use chrono::{DateTime, Utc};
18+
use sha2::{Digest, Sha256};
1719
use std::collections::HashMap;
1820
use std::path::Path;
1921
use std::sync::Arc;
@@ -512,3 +514,100 @@ pub fn sign_artifact(
512514
digest: artifact_meta.digest.hex,
513515
})
514516
}
517+
518+
/// Signs artifact bytes with a raw Ed25519 seed, bypassing keychain and identity storage.
519+
///
520+
/// This is the raw-key equivalent of [`sign_artifact`]. It does not require an
521+
/// [`AuthsContext`] or any filesystem/keychain access. The same seed is used for
522+
/// both identity and device signing roles.
523+
///
524+
/// Args:
525+
/// * `now` - Current UTC time (injected per clock pattern).
526+
/// * `seed` - Ed25519 32-byte seed.
527+
/// * `identity_did` - Parsed identity DID (must be `did:keri:` — caller validates via `IdentityDID::parse()`).
528+
/// * `data` - Raw artifact bytes to sign.
529+
/// * `expires_in` - Optional TTL in seconds.
530+
/// * `note` - Optional attestation note.
531+
///
532+
/// Usage:
533+
/// ```ignore
534+
/// let did = IdentityDID::parse("did:keri:E...")?;
535+
/// let result = sign_artifact_raw(Utc::now(), &seed, &did, b"payload", None, None)?;
536+
/// ```
537+
pub fn sign_artifact_raw(
538+
now: DateTime<Utc>,
539+
seed: &SecureSeed,
540+
identity_did: &IdentityDID,
541+
data: &[u8],
542+
expires_in: Option<u64>,
543+
note: Option<String>,
544+
) -> Result<ArtifactSigningResult, ArtifactSigningError> {
545+
let pubkey = provider_bridge::ed25519_public_key_from_seed_sync(seed)
546+
.map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
547+
548+
let device_did = DeviceDID::from_ed25519(&pubkey);
549+
550+
let digest_hex = hex::encode(Sha256::digest(data));
551+
let artifact_meta = ArtifactMetadata {
552+
artifact_type: "bytes".to_string(),
553+
digest: ArtifactDigest {
554+
algorithm: "sha256".to_string(),
555+
hex: digest_hex,
556+
},
557+
name: None,
558+
size: Some(data.len() as u64),
559+
};
560+
561+
let rid = ResourceId::new(format!("sha256:{}", artifact_meta.digest.hex));
562+
let meta = AttestationMetadata {
563+
timestamp: Some(now),
564+
expires_at: expires_in.map(|s| now + chrono::Duration::seconds(s as i64)),
565+
note,
566+
};
567+
568+
let payload = serde_json::to_value(&artifact_meta)
569+
.map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
570+
571+
let identity_alias = KeyAlias::new_unchecked("__raw_identity__");
572+
let device_alias = KeyAlias::new_unchecked("__raw_device__");
573+
574+
let mut seeds: HashMap<String, SecureSeed> = HashMap::new();
575+
seeds.insert(
576+
identity_alias.as_str().to_string(),
577+
SecureSeed::new(*seed.as_bytes()),
578+
);
579+
seeds.insert(
580+
device_alias.as_str().to_string(),
581+
SecureSeed::new(*seed.as_bytes()),
582+
);
583+
let signer = SeedMapSigner { seeds };
584+
// Seeds are already resolved — passphrase provider will not be called.
585+
let noop_provider = auths_core::PrefilledPassphraseProvider::new("");
586+
587+
let attestation = create_signed_attestation(
588+
now,
589+
&rid,
590+
identity_did,
591+
&device_did,
592+
&pubkey,
593+
Some(payload),
594+
&meta,
595+
&signer,
596+
&noop_provider,
597+
Some(&identity_alias),
598+
Some(&device_alias),
599+
vec![Capability::sign_release()],
600+
None,
601+
None,
602+
)
603+
.map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
604+
605+
let attestation_json = serde_json::to_string_pretty(&attestation)
606+
.map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?;
607+
608+
Ok(ArtifactSigningResult {
609+
attestation_json,
610+
rid,
611+
digest: artifact_meta.digest.hex,
612+
})
613+
}

crates/auths-sdk/src/signing.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
33
pub use crate::domains::signing::service::{
44
ArtifactSigningError, ArtifactSigningParams, ArtifactSigningResult, SigningConfig,
5-
SigningError, SigningKeyMaterial, construct_signature_payload, sign_artifact, sign_with_seed,
6-
validate_freeze_state,
5+
SigningError, SigningKeyMaterial, construct_signature_payload, sign_artifact,
6+
sign_artifact_raw, sign_with_seed, validate_freeze_state,
77
};

crates/auths-sdk/tests/cases/artifact.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
use auths_core::crypto::ssh::SecureSeed;
2+
use auths_core::storage::keychain::IdentityDID;
23
use auths_sdk::domains::signing::service::{
34
ArtifactSigningError, ArtifactSigningParams, SigningKeyMaterial, sign_artifact,
5+
sign_artifact_raw,
46
};
57
use auths_sdk::ports::artifact::{ArtifactDigest, ArtifactError, ArtifactMetadata, ArtifactSource};
68
use auths_sdk::testing::fakes::FakeArtifactSource;
79
use auths_sdk::workflows::artifact::compute_digest;
10+
use chrono::Utc;
811
use std::sync::Arc;
912

1013
use crate::cases::helpers::{build_empty_test_context, setup_signed_artifact_context};
@@ -189,3 +192,69 @@ fn sign_artifact_identity_not_found_returns_error() {
189192
result.unwrap_err()
190193
);
191194
}
195+
196+
// ---------------------------------------------------------------------------
197+
// sign_artifact_raw tests
198+
// ---------------------------------------------------------------------------
199+
200+
#[test]
201+
fn sign_artifact_raw_produces_valid_attestation_json() {
202+
let seed = SecureSeed::new([42u8; 32]);
203+
let identity_did = IdentityDID::new_unchecked("did:keri:Etest1234");
204+
let data = b"release binary content";
205+
let now = Utc::now();
206+
207+
let result = sign_artifact_raw(
208+
now,
209+
&seed,
210+
&identity_did,
211+
data,
212+
Some(86400),
213+
Some("test note".into()),
214+
)
215+
.unwrap();
216+
217+
assert!(!result.attestation_json.is_empty());
218+
assert!(result.rid.starts_with("sha256:"));
219+
assert!(!result.digest.is_empty());
220+
221+
let parsed: serde_json::Value = serde_json::from_str(&result.attestation_json).unwrap();
222+
assert_eq!(parsed["issuer"].as_str().unwrap(), "did:keri:Etest1234");
223+
assert!(parsed.get("identity_signature").is_some());
224+
assert!(parsed.get("device_signature").is_some());
225+
assert!(parsed.get("payload").is_some());
226+
assert!(parsed.get("expires_at").is_some());
227+
assert_eq!(parsed["note"].as_str().unwrap(), "test note");
228+
229+
let payload = &parsed["payload"];
230+
assert_eq!(payload["artifact_type"].as_str().unwrap(), "bytes");
231+
assert_eq!(payload["digest"]["algorithm"].as_str().unwrap(), "sha256");
232+
assert_eq!(payload["size"].as_u64().unwrap(), data.len() as u64);
233+
}
234+
235+
#[test]
236+
fn sign_artifact_raw_without_optional_fields() {
237+
let seed = SecureSeed::new([7u8; 32]);
238+
let identity_did = IdentityDID::new_unchecked("did:keri:Eminimal");
239+
let now = Utc::now();
240+
241+
let result = sign_artifact_raw(now, &seed, &identity_did, b"data", None, None).unwrap();
242+
243+
let parsed: serde_json::Value = serde_json::from_str(&result.attestation_json).unwrap();
244+
assert!(parsed.get("expires_at").is_none() || parsed["expires_at"].is_null());
245+
assert!(parsed.get("note").is_none() || parsed["note"].is_null());
246+
}
247+
248+
#[test]
249+
fn sign_artifact_raw_digest_matches_sha256_of_data() {
250+
let seed = SecureSeed::new([1u8; 32]);
251+
let identity_did = IdentityDID::new_unchecked("did:keri:Edigest");
252+
let data = b"hello world";
253+
let now = Utc::now();
254+
255+
let result = sign_artifact_raw(now, &seed, &identity_did, data, None, None).unwrap();
256+
257+
let expected_digest = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
258+
assert_eq!(result.digest, expected_digest);
259+
assert_eq!(result.rid.as_str(), format!("sha256:{expected_digest}"));
260+
}

packages/auths-node/index.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,20 @@ export declare function signArtifact(filePath: string, identityKeyAlias: string,
250250

251251
export declare function signArtifactBytes(data: Buffer, identityKeyAlias: string, repoPath: string, passphrase?: string | undefined | null, expiresIn?: number | undefined | null, note?: string | undefined | null): NapiArtifactResult
252252

253+
/**
254+
* Sign raw bytes with a raw Ed25519 private key, producing a dual-signed attestation.
255+
*
256+
* No keychain or filesystem access required.
257+
*
258+
* Args:
259+
* * `data`: The raw bytes to sign.
260+
* * `private_key_hex`: Ed25519 seed as hex string (64 chars = 32 bytes).
261+
* * `identity_did`: Identity DID string (must be `did:keri:` format).
262+
* * `expires_in`: Optional duration in seconds until expiration.
263+
* * `note`: Optional human-readable note.
264+
*/
265+
export declare function signArtifactBytesRaw(data: Buffer, privateKeyHex: string, identityDid: string, expiresIn?: number | undefined | null, note?: string | undefined | null): NapiArtifactResult
266+
253267
export declare function signAsAgent(message: Buffer, keyAlias: string, repoPath: string, passphrase?: string | undefined | null): NapiCommitSignResult
254268

255269
export declare function signAsIdentity(message: Buffer, identityDid: string, repoPath: string, passphrase?: string | undefined | null): NapiCommitSignResult

0 commit comments

Comments
 (0)