use pqc_sig::{SigAlgorithm, SigPublicKey, Signature};
use crate::purpose::Purpose;
use subtle::ConstantTimeEq;
use zeroize::Zeroize;
use crate::component::exports::aethel::core::identity::EphemeralProjection;
use crate::component::{
self, aethel::core::types::IdentityError, exports::aethel::core::secret_sharing::HtssShare,
AethelCore, LoadError,
};
use wasmtime::component::ResourceAny;
use wasmtime::Store;
pub const MIN_ENTROPY_BYTES: usize = 32;
pub const MIN_PROJECTION_RANDOMNESS_BYTES: usize = 32;
pub const PROJECTION_COEFFICIENTS: usize = 1024;
pub const ML_DSA_65_MULTICODEC: u32 = 0x1211;
pub struct Projection {
inner: EphemeralProjection,
}
impl Projection {
pub(crate) fn from_component(inner: EphemeralProjection) -> Result<Self, Error> {
if inner.public_b.len() != PROJECTION_COEFFICIENTS {
return Err(Error::UnexpectedProjectionRank(inner.public_b.len()));
}
Ok(Self { inner })
}
#[cfg_attr(not(feature = "experimental-credentials"), allow(dead_code))]
pub(crate) fn as_component(&self) -> &EphemeralProjection {
&self.inner
}
pub fn tau(&self) -> &[u8] {
&self.inner.tau
}
pub fn salt(&self) -> &[u8] {
&self.inner.salt
}
pub fn public_b(&self) -> &[u32] {
&self.inner.public_b
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(64 + self.inner.public_b.len() * 4);
bytes.extend_from_slice(&self.inner.tau);
bytes.extend_from_slice(&self.inner.salt);
for coefficient in &self.inner.public_b {
bytes.extend_from_slice(&coefficient.to_le_bytes());
}
bytes
}
}
impl core::fmt::Debug for Projection {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Projection")
.field("tau", &self.tau())
.field("salt", &self.salt())
.field("public_b_coefficients", &self.public_b().len())
.finish()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Load(LoadError),
Component(IdentityError),
Host(wasmtime::Error),
Entropy(getrandom::Error),
UnknownAttribute(String),
TooManyAttributes(usize),
InvalidRecoveryMaterial(&'static str),
UnexpectedProjectionRank(usize),
PqcSig(pqc_sig::SigError),
InvalidTypedPublicKey(&'static str),
InvalidTypedSignature(&'static str),
UnknownPurpose(String),
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Error::Load(e) => write!(f, "{e}"),
Error::Component(e) => write!(f, "aethel-core rejected the call: {e:?}"),
Error::Host(e) => write!(f, "the call into the component failed: {e}"),
Error::Entropy(e) => write!(f, "could not source entropy: {e}"),
Error::UnknownAttribute(name) => write!(
f,
"this credential has no attribute named {name:?}, so it cannot be disclosed"
),
Error::TooManyAttributes(n) => write!(
f,
"a credential carries at most 8 attributes, {n} were supplied"
),
Error::InvalidRecoveryMaterial(reason) => {
write!(f, "invalid recovery material: {reason}")
}
Error::UnexpectedProjectionRank(n) => write!(
f,
"the component returned a projection with {n} coefficients, expected {PROJECTION_COEFFICIENTS}: this is not the parameter set this crate vendors"
),
Error::PqcSig(e) => write!(f, "invalid pqc-sig encoding: {e}"),
Error::InvalidTypedPublicKey(reason) => {
write!(f, "invalid ML-DSA-65 public key: {reason}")
}
Error::InvalidTypedSignature(reason) => {
write!(f, "invalid ML-DSA-65 signature: {reason}")
}
Error::UnknownPurpose(name) => write!(f, "{name:?} is not a registered purpose"),
}
}
}
impl std::error::Error for Error {}
impl From<LoadError> for Error {
fn from(e: LoadError) -> Self {
Error::Load(e)
}
}
impl From<IdentityError> for Error {
fn from(e: IdentityError) -> Self {
Error::Component(e)
}
}
impl From<wasmtime::Error> for Error {
fn from(e: wasmtime::Error) -> Self {
Error::Host(e)
}
}
impl From<pqc_sig::SigError> for Error {
fn from(e: pqc_sig::SigError) -> Self {
Error::PqcSig(e)
}
}
fn validate_ml_dsa_65_public_key(public_key: &SigPublicKey) -> Result<(), Error> {
if public_key.algorithm != SigAlgorithm::MlDsa65 {
return Err(Error::InvalidTypedPublicKey("algorithm must be ML-DSA-65"));
}
if public_key.as_bytes().len() != SigAlgorithm::MlDsa65.public_key_size() {
return Err(Error::InvalidTypedPublicKey("wrong public-key length"));
}
Ok(())
}
fn validate_ml_dsa_65_signature(signature: &Signature) -> Result<(), Error> {
if signature.algorithm != SigAlgorithm::MlDsa65 {
return Err(Error::InvalidTypedSignature("algorithm must be ML-DSA-65"));
}
if signature.as_bytes().len() != SigAlgorithm::MlDsa65.signature_size() {
return Err(Error::InvalidTypedSignature("wrong signature length"));
}
Ok(())
}
pub struct Identity {
pub(crate) store: Store<()>,
pub(crate) bindings: AethelCore,
pub(crate) handle: ResourceAny,
public_key: Vec<u8>,
}
const HTSS_TOTAL_SHARES: usize = 5;
pub struct RecoveryShare {
index: u8,
value: Vec<u8>,
path: Vec<u8>,
merkle_root: [u8; 32],
}
impl RecoveryShare {
pub fn to_bytes(&self) -> Vec<u8> {
RecoveryShareSet {
shares: vec![self.clone()],
merkle_root: self.merkle_root,
}
.to_bytes()
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
let mut set = RecoveryShareSet::from_bytes(bytes)?;
if set.shares.len() != 1 {
return Err(Error::InvalidRecoveryMaterial(
"a single-share encoding must contain exactly one share",
));
}
Ok(set.shares.remove(0))
}
}
impl Clone for RecoveryShare {
fn clone(&self) -> Self {
Self {
index: self.index,
value: self.value.clone(),
path: self.path.clone(),
merkle_root: self.merkle_root,
}
}
}
impl core::fmt::Debug for RecoveryShare {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("RecoveryShare")
.field("index", &self.index)
.field("value", &"<redacted>")
.field("path", &"<redacted>")
.field("merkle_root", &"<redacted>")
.finish()
}
}
pub struct RecoveryShareSet {
shares: Vec<RecoveryShare>,
merkle_root: [u8; 32],
}
impl RecoveryShareSet {
pub fn shares(&self) -> &[RecoveryShare] {
&self.shares
}
pub fn merkle_root(&self) -> &[u8; 32] {
&self.merkle_root
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(
4 + 1
+ 1
+ 32
+ self
.shares
.iter()
.map(|share| 1 + 4 + share.value.len() + 4 + share.path.len())
.sum::<usize>(),
);
bytes.extend_from_slice(b"ATHR");
bytes.push(1);
bytes.push(self.shares.len() as u8);
bytes.extend_from_slice(&self.merkle_root);
for share in &self.shares {
bytes.push(share.index);
write_recovery_bytes(&mut bytes, &share.value);
write_recovery_bytes(&mut bytes, &share.path);
}
bytes
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
const HEADER_BYTES: usize = 4 + 1 + 1 + 32;
if bytes.len() < HEADER_BYTES || &bytes[..4] != b"ATHR" || bytes[4] != 1 {
return Err(Error::InvalidRecoveryMaterial("unknown encoding"));
}
let count = bytes[5] as usize;
if count == 0 || count > 5 {
return Err(Error::InvalidRecoveryMaterial("invalid share count"));
}
let mut merkle_root = [0u8; 32];
merkle_root.copy_from_slice(&bytes[6..HEADER_BYTES]);
let mut offset = HEADER_BYTES;
let mut shares = Vec::with_capacity(count);
for _ in 0..count {
let index = *bytes
.get(offset)
.ok_or(Error::InvalidRecoveryMaterial("truncated share index"))?;
offset += 1;
let value = read_recovery_bytes(bytes, &mut offset)?;
let path = read_recovery_bytes(bytes, &mut offset)?;
shares.push(RecoveryShare {
index,
value,
path,
merkle_root,
});
}
if offset != bytes.len() {
return Err(Error::InvalidRecoveryMaterial("trailing bytes"));
}
Ok(Self {
shares,
merkle_root,
})
}
}
impl core::fmt::Debug for RecoveryShareSet {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("RecoveryShareSet")
.field("share_count", &self.shares.len())
.field("shares", &"<redacted>")
.field("merkle_root", &"<redacted>")
.finish()
}
}
fn write_recovery_bytes(output: &mut Vec<u8>, value: &[u8]) {
output.extend_from_slice(&(value.len() as u32).to_le_bytes());
output.extend_from_slice(value);
}
fn read_recovery_bytes(input: &[u8], offset: &mut usize) -> Result<Vec<u8>, Error> {
let length_bytes = input
.get(*offset..*offset + 4)
.ok_or(Error::InvalidRecoveryMaterial("truncated length"))?;
let length = u32::from_le_bytes(
length_bytes
.try_into()
.map_err(|_| Error::InvalidRecoveryMaterial("invalid length"))?,
) as usize;
*offset += 4;
let end = offset
.checked_add(length)
.ok_or(Error::InvalidRecoveryMaterial("invalid share length"))?;
let value = input
.get(*offset..end)
.ok_or(Error::InvalidRecoveryMaterial("truncated share data"))?
.to_vec();
*offset = end;
Ok(value)
}
impl core::fmt::Debug for Identity {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let fingerprint: String = self
.public_key
.iter()
.take(8)
.map(|b| format!("{b:02x}"))
.collect();
f.debug_struct("Identity")
.field("public_key", &format_args!("{fingerprint}..."))
.field(
"secret",
&format_args!("<held in the component, never here>"),
)
.finish()
}
}
impl Identity {
pub fn generate() -> Result<Self, Error> {
let mut entropy = [0u8; MIN_ENTROPY_BYTES];
getrandom::getrandom(&mut entropy).map_err(Error::Entropy)?;
let identity = Self::from_entropy(&entropy);
entropy.zeroize();
identity
}
pub fn from_entropy(entropy: &[u8]) -> Result<Self, Error> {
let (mut store, bindings) = component::load()?;
let handle = bindings
.aethel_core_identity()
.master_identity()
.call_generate(&mut store, entropy)??;
let public_key = bindings
.aethel_core_identity()
.master_identity()
.call_public_key(&mut store, handle)?;
Ok(Self {
store,
bindings,
handle,
public_key,
})
}
pub fn public_key(&self) -> &[u8] {
&self.public_key
}
pub fn public_key_multibase(&self) -> String {
SigPublicKey::new(SigAlgorithm::MlDsa65, self.public_key.clone())
.to_multibase()
.expect("ML-DSA-65 has a canonical Multikey encoding")
}
pub fn sign(&mut self, message: &[u8]) -> Result<Vec<u8>, Error> {
let signature = self
.bindings
.aethel_core_identity()
.master_identity()
.call_sign(&mut self.store, self.handle, message)??;
Ok(signature)
}
pub fn sign_typed(&mut self, message: &[u8]) -> Result<Signature, Error> {
let signature = Signature::new(SigAlgorithm::MlDsa65, self.sign(message)?);
validate_ml_dsa_65_signature(&signature)?;
Ok(signature)
}
pub fn sign_with_purpose(
&mut self,
purpose: &Purpose,
message: &[u8],
) -> Result<Vec<u8>, Error> {
let signature = self
.bindings
.aethel_core_identity()
.master_identity()
.call_sign_with_purpose(&mut self.store, self.handle, purpose.as_bytes(), message)??;
Ok(signature)
}
pub fn sign_typed_with_purpose(
&mut self,
purpose: &Purpose,
message: &[u8],
) -> Result<Signature, Error> {
let signature = Signature::new(
SigAlgorithm::MlDsa65,
self.sign_with_purpose(purpose, message)?,
);
validate_ml_dsa_65_signature(&signature)?;
Ok(signature)
}
pub fn split_for_recovery(&mut self, sealing_key: &[u8]) -> Result<RecoveryShareSet, Error> {
let mut sealed = self.export_sealed(sealing_key)?;
let split = {
let sharing = self.bindings.aethel_core_secret_sharing();
sharing.call_htss_split(&mut self.store, &sealed)?
};
sealed.zeroize();
let (shares, merkle_root) = split?;
let merkle_root: [u8; 32] = merkle_root
.try_into()
.map_err(|_| Error::InvalidRecoveryMaterial("component returned non-32-byte root"))?;
let shares = shares
.into_iter()
.map(|share| RecoveryShare {
index: share.index,
value: share.value,
path: share.path,
merkle_root,
})
.collect();
Ok(RecoveryShareSet {
shares,
merkle_root,
})
}
pub fn recover_from_shares(
shares: &[RecoveryShare],
expected_root: &[u8; 32],
sealing_key: &[u8],
) -> Result<Self, Error> {
if shares.is_empty() {
return Err(Error::InvalidRecoveryMaterial("no shares supplied"));
}
if shares.len() > HTSS_TOTAL_SHARES {
return Err(Error::Component(IdentityError::InvalidShareSet));
}
if shares
.iter()
.any(|share| !bool::from(share.merkle_root.ct_eq(expected_root)))
{
return Err(Error::InvalidRecoveryMaterial(
"a share does not authenticate against the supplied root: either the root is not the one for this share set, or the shares are from more than one set",
));
}
let component_shares: Vec<HtssShare> = shares
.iter()
.map(|share| HtssShare {
index: share.index,
value: share.value.clone(),
path: share.path.clone(),
})
.collect();
let (mut store, bindings) = component::load()?;
let mut sealed = bindings
.aethel_core_secret_sharing()
.call_htss_reconstruct(&mut store, &component_shares, expected_root)??;
let identity = Self::open_sealed(&sealed, sealing_key);
sealed.zeroize();
identity
}
pub fn project_at(&mut self, context: &[u8]) -> Result<Projection, Error> {
let mut randomness = [0u8; MIN_PROJECTION_RANDOMNESS_BYTES];
getrandom::getrandom(&mut randomness).map_err(Error::Entropy)?;
let projection = self.project_at_with_randomness(context, &randomness);
randomness.zeroize();
projection
}
pub fn project_at_with_randomness(
&mut self,
context: &[u8],
randomness: &[u8],
) -> Result<Projection, Error> {
let projection = self
.bindings
.aethel_core_identity()
.master_identity()
.call_project_at_context(&mut self.store, self.handle, context, randomness)??;
Projection::from_component(projection)
}
}
pub const MIN_SEAL_KEY_BYTES: usize = 32;
impl Identity {
pub fn export_sealed(&mut self, key: &[u8]) -> Result<Vec<u8>, Error> {
let sealed = self
.bindings
.aethel_core_identity()
.master_identity()
.call_export_sealed(&mut self.store, self.handle, key)??;
Ok(sealed)
}
pub fn open_sealed(sealed: &[u8], key: &[u8]) -> Result<Self, Error> {
let (mut store, bindings) = component::load()?;
let handle = bindings
.aethel_core_identity()
.master_identity()
.call_import_sealed(&mut store, sealed, key)??;
let public_key = bindings
.aethel_core_identity()
.master_identity()
.call_public_key(&mut store, handle)?;
Ok(Self {
store,
bindings,
handle,
public_key,
})
}
}
pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<bool, Error> {
let (mut store, bindings) = component::load()?;
let verified = bindings
.aethel_core_identity()
.call_verify_signature(&mut store, public_key, message, signature)??;
Ok(verified)
}
pub fn verify_with_purpose(
public_key: &[u8],
purpose: &Purpose,
message: &[u8],
signature: &[u8],
) -> Result<bool, Error> {
let (mut store, bindings) = component::load()?;
let verified = bindings
.aethel_core_identity()
.call_verify_signature_with_purpose(
&mut store,
public_key,
purpose.as_bytes(),
message,
signature,
)??;
Ok(verified)
}
pub fn verify_typed_with_purpose(
public_key: &SigPublicKey,
purpose: &Purpose,
message: &[u8],
signature: &Signature,
) -> Result<bool, Error> {
validate_ml_dsa_65_public_key(public_key)?;
validate_ml_dsa_65_signature(signature)?;
verify_with_purpose(
public_key.as_bytes(),
purpose,
message,
signature.as_bytes(),
)
}
pub fn public_key_from_multibase(multibase: &str) -> Result<SigPublicKey, Error> {
let public_key = SigPublicKey::from_multibase(SigAlgorithm::MlDsa65, multibase)?;
validate_ml_dsa_65_public_key(&public_key)?;
Ok(public_key)
}
pub fn verify_typed(
public_key: &SigPublicKey,
message: &[u8],
signature: &Signature,
) -> Result<bool, Error> {
validate_ml_dsa_65_public_key(public_key)?;
validate_ml_dsa_65_signature(signature)?;
verify(public_key.as_bytes(), message, signature.as_bytes())
}