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
8 changes: 4 additions & 4 deletions Cargo.lock

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

22 changes: 8 additions & 14 deletions examples/composition-signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,13 @@ fn sign_component(
key_handle: KeyHandle,
output_path: &str,
) -> Result<String, WSError> {
// For testing, use full keypair
let keypair = provider.export_keypair(key_handle)?;
// Borrow keypair for cert creation (key stays in store for signing)
let device_id = DeviceIdentity::new("device");
let cert_config = CertificateConfig::new("device");

let device_cert = ca.sign_device_certificate_with_keypair(
&keypair,
&device_id,
&cert_config,
)?;
let device_cert = provider.with_keypair(key_handle, |keypair| {
ca.sign_device_certificate_with_keypair(keypair, &device_id, &cert_config)
})??;

let cert_chain = vec![device_cert, ca.certificate().to_vec()];

Expand Down Expand Up @@ -208,16 +205,13 @@ fn sign_composed_component(
key_handle: KeyHandle,
output_path: &str,
) -> Result<(), WSError> {
// For testing, use full keypair
let keypair = provider.export_keypair(key_handle)?;
// Borrow keypair for cert creation (key stays in store for signing)
let device_id = DeviceIdentity::new("integrator-device");
let cert_config = CertificateConfig::new("integrator-device");

let device_cert = ca.sign_device_certificate_with_keypair(
&keypair,
&device_id,
&cert_config,
)?;
let device_cert = provider.with_keypair(key_handle, |keypair| {
ca.sign_device_certificate_with_keypair(keypair, &device_id, &cert_config)
})??;

let cert_chain = vec![device_cert, ca.certificate().to_vec()];

Expand Down
10 changes: 5 additions & 5 deletions src/component/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ impl Guest for Component {

Ok(KeyPair {
public_key: kp.pk.to_bytes(),
secret_key: kp.sk.to_bytes(),
secret_key: kp.sk.to_bytes().to_vec(),
})
}

Expand Down Expand Up @@ -121,16 +121,16 @@ impl Guest for Component {
// Note: OpenSSH format not supported - convert to PEM first
// Try raw WSC bytes first
if let Ok(sk) = SecretKey::from_bytes(&key_bytes) {
return Ok(sk.to_bytes());
return Ok(sk.to_bytes().to_vec());
}
// Try DER
if let Ok(sk) = SecretKey::from_der(&key_bytes) {
return Ok(sk.to_bytes());
return Ok(sk.to_bytes().to_vec());
}
// Try PEM (text format)
if let Ok(s) = std::str::from_utf8(&key_bytes) {
if let Ok(sk) = SecretKey::from_pem(s) {
return Ok(sk.to_bytes());
return Ok(sk.to_bytes().to_vec());
}
}
Err("Failed to parse secret key. Supported formats: WSC bytes, DER, PEM".to_string())
Expand All @@ -147,7 +147,7 @@ impl Guest for Component {
let sk =
SecretKey::from_bytes(&key_bytes).map_err(|e| format!("Invalid secret key: {}", e))?;

Ok(sk.to_pem())
Ok((*sk.to_pem()).clone())
}
}

Expand Down
38 changes: 36 additions & 2 deletions src/lib/src/airgapped/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ use serde::{Deserialize, Serialize};
/// Current trust bundle format version
pub const TRUST_BUNDLE_FORMAT_VERSION: u8 = 1;

/// Maximum grace period: 365 days (in seconds)
///
/// SECURITY: Prevents malicious bundles from setting an excessively large
/// grace period that would effectively disable expiry checking.
pub const MAX_GRACE_PERIOD_SECONDS: u64 = 365 * 86400;

/// Trust bundle containing all trust anchors for offline verification
///
/// This structure contains:
Expand Down Expand Up @@ -83,6 +89,23 @@ impl TrustBundle {
}
}

/// Set the grace period, capping at MAX_GRACE_PERIOD_SECONDS (365 days).
///
/// Returns the actual grace period applied (may be less than requested).
pub fn set_grace_period(&mut self, seconds: u64) -> u64 {
let capped = seconds.min(MAX_GRACE_PERIOD_SECONDS);
if capped < seconds {
log::warn!(
"Grace period capped from {} to {} seconds (max {} days)",
seconds,
capped,
MAX_GRACE_PERIOD_SECONDS / 86400
);
}
self.validity.grace_period_seconds = capped;
capped
}

/// Add a certificate authority
pub fn add_certificate_authority(&mut self, ca: CertificateAuthority) {
self.certificate_authorities.push(ca);
Expand All @@ -106,9 +129,20 @@ impl TrustBundle {
}

/// Check if the bundle is in grace period
///
/// SECURITY: Caps grace period to MAX_GRACE_PERIOD_SECONDS and uses checked
/// arithmetic to prevent overflow in the `not_after + grace` calculation.
pub fn is_in_grace_period(&self, current_time: u64) -> bool {
current_time > self.validity.not_after
&& current_time <= self.validity.not_after + self.validity.grace_period_seconds
let capped_grace = self
.validity
.grace_period_seconds
.min(MAX_GRACE_PERIOD_SECONDS);
let grace_end = self
.validity
.not_after
.checked_add(capped_grace)
.unwrap_or(u64::MAX);
current_time > self.validity.not_after && current_time <= grace_end
}

/// Check if a certificate fingerprint is revoked
Expand Down
24 changes: 17 additions & 7 deletions src/lib/src/airgapped/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,19 @@ impl<T: TimeSource> AirGappedVerifier<T> {
/// Check trust bundle health
///
/// Returns warnings about expiring/expired bundle.
/// If no time source is configured, includes an `UnreliableTimeSource` warning
/// and falls back to build timestamp for health estimation.
pub fn check_bundle_health(&self) -> Vec<VerificationWarning> {
let mut warnings = Vec::new();

// Get current time (use time source if available, otherwise build time)
let current_time = self
.time_source
.as_ref()
.and_then(|ts| ts.now_unix().ok())
.unwrap_or(BUILD_TIMESTAMP);
// Get current time (use time source if available, otherwise build time with warning)
let current_time = match self.time_source.as_ref().and_then(|ts| ts.now_unix().ok()) {
Some(t) => t,
None => {
warnings.push(VerificationWarning::UnreliableTimeSource);
BUILD_TIMESTAMP
}
};

// Check if bundle is expired
if current_time > self.trust_bundle.validity.not_after {
Expand Down Expand Up @@ -199,11 +203,17 @@ impl<T: TimeSource> AirGappedVerifier<T> {
let mut warnings = Vec::new();

// Get current time for bundle validity check
// SECURITY: Fail closed when no time source is available. Without a reliable
// clock, all time-based checks (expiry, freshness, grace period) are meaningless.
let current_time = self
.time_source
.as_ref()
.and_then(|ts| ts.now_unix().ok())
.unwrap_or(BUILD_TIMESTAMP);
.ok_or_else(|| WSError::VerificationError(
"No time source available: air-gapped verification requires a reliable clock \
to enforce bundle expiry and signature freshness. Configure a time source \
via with_time_source() or use BuildTimeSource for development only.".to_string(),
))?;

// 1. Check bundle validity
if !self.trust_bundle.is_valid(current_time) {
Expand Down
10 changes: 9 additions & 1 deletion src/lib/src/dsse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,15 @@ impl DsseEnvelope {

/// Verify the envelope and return the decoded payload
///
/// Verifies at least one signature is valid.
/// Verifies at least one signature is valid (1-of-N).
///
/// # Security Warning
///
/// This method returns `Ok` if **any single** signature is valid. An attacker
/// who can append signatures to an envelope could add a valid signature alongside
/// forged ones. If you need to verify that ALL signatures are valid (e.g., for
/// multi-party signing where every signer must be trusted), use [`verify_all()`]
/// instead.
pub fn verify(&self, verifier: &dyn DsseVerifier) -> Result<Vec<u8>, WSError> {
if self.signatures.is_empty() {
return Err(WSError::VerificationFailed);
Expand Down
45 changes: 34 additions & 11 deletions src/lib/src/platform/software.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,29 @@ impl SoftwareProvider {
Ok(store.insert(keypair))
}

/// Borrow a key pair for a callback
///
/// # Security Warning
///
/// This exposes the private key! Only use for testing or migration.
/// The key remains in the store after the callback completes.
pub fn with_keypair<R>(
&self,
handle: KeyHandle,
f: impl FnOnce(&KeyPair) -> R,
) -> Result<R, WSError> {
let store = self
.store
.lock()
.map_err(|e| WSError::InternalError(format!("Lock poisoned: {}", e)))?;

let keypair = store
.get(handle)
.ok_or_else(|| WSError::InternalError("Invalid key handle".to_string()))?;

Ok(f(keypair))
}

/// Export a key pair
///
/// # Security Warning
Expand All @@ -126,16 +149,16 @@ impl SoftwareProvider {
///
/// # Returns
///
/// The key pair (including private key)
/// The key pair (including private key). This **removes** the key from
/// the store — the caller takes ownership of the key material.
pub fn export_keypair(&self, handle: KeyHandle) -> Result<KeyPair, WSError> {
let store = self
let mut store = self
.store
.lock()
.map_err(|e| WSError::InternalError(format!("Lock poisoned: {}", e)))?;

store
.get(handle)
.cloned()
.remove(handle)
.ok_or_else(|| WSError::InternalError("Invalid key handle".to_string()))
}
}
Expand Down Expand Up @@ -362,22 +385,22 @@ mod tests {
fn test_import_export_keypair() {
let provider = SoftwareProvider::new();

// Generate a key pair the old way
// Generate a key pair and capture the public key before importing
let original_keypair = KeyPair::generate();
let original_pk_bytes = original_keypair.pk.pk.as_ref().to_vec();

// Import it
// Import it (moves ownership — KeyPair intentionally does not impl Clone)
let handle = provider
.import_keypair(original_keypair.clone())
.import_keypair(original_keypair)
.expect("Failed to import keypair");

// Export it
// Export removes the key from the store (takes ownership)
let exported_keypair = provider
.export_keypair(handle)
.expect("Failed to export keypair");

// Verify they match
assert_eq!(original_keypair.pk.pk, exported_keypair.pk.pk);
assert_eq!(original_keypair.sk.sk, exported_keypair.sk.sk);
// Verify the exported public key matches the original
assert_eq!(original_pk_bytes, exported_keypair.pk.pk.as_ref());
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion src/lib/src/provisioning/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,9 @@ impl PrivateCA {
/// Convert Ed25519 keypair to PEM format for rcgen
fn ed25519_to_pem(keypair: &KeyPair) -> Result<String, WSError> {
// Use ed25519-compact's PEM export feature
// to_pem returns Zeroizing<String> — extract the inner value
let pem = keypair.sk.to_pem();
Ok(pem)
Ok((*pem).clone())
}

/// Get CA certificate (DER)
Expand Down
Loading
Loading