Skip to main content

aethel_sdk/
identity.rs

1//! Generating an identity, signing with it, and verifying signatures.
2//!
3//! This is the ergonomic surface, and it is deliberately thin. Every
4//! cryptographic operation happens inside the embedded component: this module
5//! sources entropy, converts types, and calls across. If you find yourself
6//! adding an algorithm here, it belongs in `aethel-core` instead. That is the
7//! charter's L1 rule, and it is what makes "one artifact, embedded by every
8//! language" true rather than aspirational.
9//!
10//! # Where the secret lives
11//!
12//! Nowhere in this crate. [`Identity`] holds a resource handle into the
13//! component instance, not key material. The ML-DSA-65 signing key and the PLP
14//! master seed are derived inside the component from entropy this crate
15//! supplies, and the only key material that ever crosses back is the public key.
16//!
17//! That is worth stating precisely, because it is stronger than "we are careful
18//! not to log the secret": there is no secret in this address space to log. The
19//! entropy passed *in* is not the key either, it is stretched and domain
20//! separated inside the component, and this crate zeroizes its copy regardless.
21//!
22//! # One instance per identity
23//!
24//! Each [`Identity`] owns its own component instance. That costs memory but
25//! keeps the model obvious: dropping an identity drops the instance holding its
26//! secret, and two identities cannot observe each other.
27
28use pqc_sig::{SigAlgorithm, SigPublicKey, Signature};
29
30use crate::purpose::Purpose;
31use subtle::ConstantTimeEq;
32use zeroize::Zeroize;
33
34use crate::component::exports::aethel::core::identity::EphemeralProjection;
35use crate::component::{
36    self, aethel::core::types::IdentityError, exports::aethel::core::secret_sharing::HtssShare,
37    AethelCore, LoadError,
38};
39use wasmtime::component::ResourceAny;
40use wasmtime::Store;
41
42/// Minimum entropy accepted by the component's key derivation: 32 bytes.
43///
44/// Anything shorter is refused with `IdentityError::InvalidInputLength`.
45pub const MIN_ENTROPY_BYTES: usize = 32;
46/// Minimum bytes of secret randomness required for one PLP projection: 32.
47pub const MIN_PROJECTION_RANDOMNESS_BYTES: usize = 32;
48
49/// Coefficients in a projection's public vector (b_tau): 1024.
50///
51/// The lattice module rank is part of the artifact's identity, not a tuning
52/// knob. aethel-core moved the identity path from rank 1 to rank 4 in 0.5.0,
53/// which quadrupled this length while leaving the WIT record types unchanged:
54/// `public-b` was `list<u32>` before and after. That is precisely why it is
55/// checked here. A rank-1 component still satisfies the world this crate
56/// generates bindings from, so nothing in the type system or the bindings
57/// would notice the substitution, and a shorter vector would flow through
58/// [`Projection::to_bytes`] into a well-formed encoding of a weaker parameter
59/// set.
60pub const PROJECTION_COEFFICIENTS: usize = 1024;
61
62/// The multicodec code for an ML-DSA-65 public key, registered upstream.
63///
64/// Retained for source compatibility. New code should use
65/// [`SigAlgorithm::multicodec_code`], which is the canonical mapping owned by
66/// `pqc-sig`.
67pub const ML_DSA_65_MULTICODEC: u32 = 0x1211;
68
69/// A public, context-bound PLP projection.
70///
71/// A projection contains only its padded context tag, public per-projection
72/// salt, and public projection coefficients. The identity's PLP master seed
73/// stays inside the component and is not represented here.
74///
75/// Under aethel-core's stated M-LWE security assumptions, the master secret is
76/// not derivable from any number of projections made with fresh, secret
77/// randomness. That randomness derives the public salt, which in turn derives
78/// this projection's context matrix; fresh salts keep same-context projections
79/// from sharing the matrix needed for the historical averaging attack. This is
80/// a cryptographic property of aethel-core's construction, not one SDK unit
81/// tests can prove.
82pub struct Projection {
83    inner: EphemeralProjection,
84}
85
86impl Projection {
87    /// Wrap a projection the component produced, refusing one whose module
88    /// rank is not the vendored parameter set's.
89    ///
90    /// Every projection reaching this crate comes through here, so this is the
91    /// one place the rank has to be checked.
92    pub(crate) fn from_component(inner: EphemeralProjection) -> Result<Self, Error> {
93        if inner.public_b.len() != PROJECTION_COEFFICIENTS {
94            return Err(Error::UnexpectedProjectionRank(inner.public_b.len()));
95        }
96        Ok(Self { inner })
97    }
98
99    /// The component's own representation, for passing back across the
100    /// boundary. Crate-internal: this type is not nameable by a consumer.
101    /// Only the credential path uses it today, so it is unused in a default
102    /// build; PLP proof verification (COR-4) is expected to use it too.
103    #[cfg_attr(not(feature = "experimental-credentials"), allow(dead_code))]
104    pub(crate) fn as_component(&self) -> &EphemeralProjection {
105        &self.inner
106    }
107
108    /// The core's padded 32-byte context tag (τ).
109    pub fn tau(&self) -> &[u8] {
110        &self.inner.tau
111    }
112
113    /// The public 32-byte salt that selects this projection's context matrix.
114    pub fn salt(&self) -> &[u8] {
115        &self.inner.salt
116    }
117
118    /// The public projection coefficients (b_τ).
119    pub fn public_b(&self) -> &[u32] {
120        &self.inner.public_b
121    }
122
123    /// Canonical core projection encoding: padded τ, salt, then `public_b`
124    /// coefficients as little-endian `u32`s.
125    ///
126    /// This encoding intentionally excludes the context matrix: core derives
127    /// that matrix from τ and salt during verification.
128    pub fn to_bytes(&self) -> Vec<u8> {
129        let mut bytes = Vec::with_capacity(64 + self.inner.public_b.len() * 4);
130        bytes.extend_from_slice(&self.inner.tau);
131        bytes.extend_from_slice(&self.inner.salt);
132        for coefficient in &self.inner.public_b {
133            bytes.extend_from_slice(&coefficient.to_le_bytes());
134        }
135        bytes
136    }
137}
138
139impl core::fmt::Debug for Projection {
140    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
141        f.debug_struct("Projection")
142            .field("tau", &self.tau())
143            .field("salt", &self.salt())
144            .field("public_b_coefficients", &self.public_b().len())
145            .finish()
146    }
147}
148
149/// Anything that can go wrong generating or using an identity.
150#[derive(Debug)]
151#[non_exhaustive]
152pub enum Error {
153    /// The embedded component could not be loaded. Includes the integrity
154    /// failure case, where the artifact is not the one the package declares.
155    Load(LoadError),
156    /// The component rejected the call, with its declared reason.
157    Component(IdentityError),
158    /// A call into the component trapped or otherwise failed at the host
159    /// boundary. Distinct from `Component`, which is the operation answering.
160    Host(wasmtime::Error),
161    /// The operating system would not supply entropy.
162    Entropy(getrandom::Error),
163    /// A disclosure named an attribute the credential does not carry.
164    ///
165    /// An error rather than a silent no-op: disclosing nothing when the caller
166    /// asked to disclose something produces a perfectly valid proof of the
167    /// wrong statement.
168    UnknownAttribute(String),
169    /// More attributes than a credential has slots for.
170    TooManyAttributes(usize),
171    /// Recovery material was not a complete, well-formed transport encoding.
172    ///
173    /// This does not reveal which sensitive share bytes were malformed.
174    InvalidRecoveryMaterial(&'static str),
175    /// The component returned a projection of the wrong module rank.
176    ///
177    /// Carries the coefficient count that was returned; the expected count is
178    /// [`PROJECTION_COEFFICIENTS`]. This means the loaded component is not the
179    /// parameter set this crate vendors, so the projection is refused rather
180    /// than encoded.
181    UnexpectedProjectionRank(usize),
182    /// A pqc-sig transport encoding was malformed.
183    PqcSig(pqc_sig::SigError),
184    /// Typed public verification material was not ML-DSA-65 or had the wrong
185    /// raw public-key length.
186    InvalidTypedPublicKey(&'static str),
187    /// Typed signature material was not ML-DSA-65 or had the wrong length.
188    InvalidTypedSignature(&'static str),
189    /// A purpose name that is not in aethel-core's registry.
190    UnknownPurpose(String),
191}
192
193impl core::fmt::Display for Error {
194    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
195        match self {
196            Error::Load(e) => write!(f, "{e}"),
197            Error::Component(e) => write!(f, "aethel-core rejected the call: {e:?}"),
198            Error::Host(e) => write!(f, "the call into the component failed: {e}"),
199            Error::Entropy(e) => write!(f, "could not source entropy: {e}"),
200            Error::UnknownAttribute(name) => write!(
201                f,
202                "this credential has no attribute named {name:?}, so it cannot be disclosed"
203            ),
204            Error::TooManyAttributes(n) => write!(
205                f,
206                "a credential carries at most 8 attributes, {n} were supplied"
207            ),
208            Error::InvalidRecoveryMaterial(reason) => {
209                write!(f, "invalid recovery material: {reason}")
210            }
211            Error::UnexpectedProjectionRank(n) => write!(
212                f,
213                "the component returned a projection with {n} coefficients, expected {PROJECTION_COEFFICIENTS}: this is not the parameter set this crate vendors"
214            ),
215            Error::PqcSig(e) => write!(f, "invalid pqc-sig encoding: {e}"),
216            Error::InvalidTypedPublicKey(reason) => {
217                write!(f, "invalid ML-DSA-65 public key: {reason}")
218            }
219            Error::InvalidTypedSignature(reason) => {
220                write!(f, "invalid ML-DSA-65 signature: {reason}")
221            }
222            Error::UnknownPurpose(name) => write!(f, "{name:?} is not a registered purpose"),
223        }
224    }
225}
226
227impl std::error::Error for Error {}
228
229impl From<LoadError> for Error {
230    fn from(e: LoadError) -> Self {
231        Error::Load(e)
232    }
233}
234
235impl From<IdentityError> for Error {
236    fn from(e: IdentityError) -> Self {
237        Error::Component(e)
238    }
239}
240
241impl From<wasmtime::Error> for Error {
242    fn from(e: wasmtime::Error) -> Self {
243        Error::Host(e)
244    }
245}
246
247impl From<pqc_sig::SigError> for Error {
248    fn from(e: pqc_sig::SigError) -> Self {
249        Error::PqcSig(e)
250    }
251}
252
253fn validate_ml_dsa_65_public_key(public_key: &SigPublicKey) -> Result<(), Error> {
254    if public_key.algorithm != SigAlgorithm::MlDsa65 {
255        return Err(Error::InvalidTypedPublicKey("algorithm must be ML-DSA-65"));
256    }
257    if public_key.as_bytes().len() != SigAlgorithm::MlDsa65.public_key_size() {
258        return Err(Error::InvalidTypedPublicKey("wrong public-key length"));
259    }
260    Ok(())
261}
262
263fn validate_ml_dsa_65_signature(signature: &Signature) -> Result<(), Error> {
264    if signature.algorithm != SigAlgorithm::MlDsa65 {
265        return Err(Error::InvalidTypedSignature("algorithm must be ML-DSA-65"));
266    }
267    if signature.as_bytes().len() != SigAlgorithm::MlDsa65.signature_size() {
268        return Err(Error::InvalidTypedSignature("wrong signature length"));
269    }
270    Ok(())
271}
272
273/// A post-quantum identity.
274///
275/// Created by [`Identity::generate`]. The secret key material lives inside the
276/// component instance this owns, never in this struct.
277pub struct Identity {
278    // pub(crate) so the disclosure module can drive the same component instance.
279    // A credential handle is only meaningful inside the instance that issued it,
280    // so there is one owner of the store and everything else borrows it.
281    pub(crate) store: Store<()>,
282    pub(crate) bindings: AethelCore,
283    pub(crate) handle: ResourceAny,
284    public_key: Vec<u8>,
285}
286
287const HTSS_TOTAL_SHARES: usize = 5;
288
289/// One authenticated HTSS recovery share.
290///
291/// The legacy transport encoding carries a claimed 32-byte Merkle root so a
292/// share can be transported independently. That root is untrusted metadata:
293/// [`Identity::recover_from_shares`] authenticates shares only against its
294/// separate `expected_root` argument. This remains recovery-sensitive material;
295/// transport encryption and access control are still required.
296pub struct RecoveryShare {
297    index: u8,
298    value: Vec<u8>,
299    path: Vec<u8>,
300    merkle_root: [u8; 32],
301}
302
303impl RecoveryShare {
304    /// Encode this share and its claimed Merkle root for transport or storage.
305    ///
306    /// The bytes contain recovery-sensitive material. They are portable, not
307    /// safe to publish.
308    pub fn to_bytes(&self) -> Vec<u8> {
309        RecoveryShareSet {
310            shares: vec![self.clone()],
311            merkle_root: self.merkle_root,
312        }
313        .to_bytes()
314    }
315
316    /// Decode one share previously produced by [`RecoveryShare::to_bytes`].
317    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
318        let mut set = RecoveryShareSet::from_bytes(bytes)?;
319        if set.shares.len() != 1 {
320            return Err(Error::InvalidRecoveryMaterial(
321                "a single-share encoding must contain exactly one share",
322            ));
323        }
324        Ok(set.shares.remove(0))
325    }
326}
327
328impl Clone for RecoveryShare {
329    fn clone(&self) -> Self {
330        Self {
331            index: self.index,
332            value: self.value.clone(),
333            path: self.path.clone(),
334            merkle_root: self.merkle_root,
335        }
336    }
337}
338
339impl core::fmt::Debug for RecoveryShare {
340    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
341        f.debug_struct("RecoveryShare")
342            .field("index", &self.index)
343            .field("value", &"<redacted>")
344            .field("path", &"<redacted>")
345            .field("merkle_root", &"<redacted>")
346            .finish()
347    }
348}
349
350/// A 3-of-5 HTSS recovery set for an identity.
351///
352/// The legacy encoding keeps shares and their Merkle root together for
353/// compatibility. Its root is informational and untrusted at recovery time:
354/// retain [`Self::merkle_root`] separately in trusted storage before accepting
355/// serialized shares. A store that can replace both can substitute an entire
356/// recovery set.
357pub struct RecoveryShareSet {
358    shares: Vec<RecoveryShare>,
359    merkle_root: [u8; 32],
360}
361
362impl RecoveryShareSet {
363    /// The five authenticated shares. Distribute each share separately.
364    pub fn shares(&self) -> &[RecoveryShare] {
365        &self.shares
366    }
367
368    /// The 32-byte Merkle root emitted by the component for this share set.
369    ///
370    /// Save this separately in a trusted location. A root decoded from recovery
371    /// material is an untrusted claim, not a recovery authority.
372    pub fn merkle_root(&self) -> &[u8; 32] {
373        &self.merkle_root
374    }
375
376    /// Encode all shares and a claimed Merkle root in the legacy versioned
377    /// binary format suitable for transport or storage.
378    ///
379    /// These bytes are recovery-sensitive material and must be protected like
380    /// the identity itself. Transport-safe does not mean safe to publish.
381    pub fn to_bytes(&self) -> Vec<u8> {
382        let mut bytes = Vec::with_capacity(
383            4 + 1
384                + 1
385                + 32
386                + self
387                    .shares
388                    .iter()
389                    .map(|share| 1 + 4 + share.value.len() + 4 + share.path.len())
390                    .sum::<usize>(),
391        );
392        bytes.extend_from_slice(b"ATHR");
393        bytes.push(1);
394        bytes.push(self.shares.len() as u8);
395        bytes.extend_from_slice(&self.merkle_root);
396        for share in &self.shares {
397            bytes.push(share.index);
398            write_recovery_bytes(&mut bytes, &share.value);
399            write_recovery_bytes(&mut bytes, &share.path);
400        }
401        bytes
402    }
403
404    /// Decode recovery material produced by [`RecoveryShareSet::to_bytes`].
405    ///
406    /// The decoded root is an untrusted claim. Supply a separately retained
407    /// trusted root to [`Identity::recover_from_shares`] for authentication.
408    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
409        const HEADER_BYTES: usize = 4 + 1 + 1 + 32;
410        if bytes.len() < HEADER_BYTES || &bytes[..4] != b"ATHR" || bytes[4] != 1 {
411            return Err(Error::InvalidRecoveryMaterial("unknown encoding"));
412        }
413        let count = bytes[5] as usize;
414        if count == 0 || count > 5 {
415            return Err(Error::InvalidRecoveryMaterial("invalid share count"));
416        }
417        let mut merkle_root = [0u8; 32];
418        merkle_root.copy_from_slice(&bytes[6..HEADER_BYTES]);
419        let mut offset = HEADER_BYTES;
420        let mut shares = Vec::with_capacity(count);
421        for _ in 0..count {
422            let index = *bytes
423                .get(offset)
424                .ok_or(Error::InvalidRecoveryMaterial("truncated share index"))?;
425            offset += 1;
426            let value = read_recovery_bytes(bytes, &mut offset)?;
427            let path = read_recovery_bytes(bytes, &mut offset)?;
428            shares.push(RecoveryShare {
429                index,
430                value,
431                path,
432                merkle_root,
433            });
434        }
435        if offset != bytes.len() {
436            return Err(Error::InvalidRecoveryMaterial("trailing bytes"));
437        }
438        Ok(Self {
439            shares,
440            merkle_root,
441        })
442    }
443}
444
445impl core::fmt::Debug for RecoveryShareSet {
446    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
447        f.debug_struct("RecoveryShareSet")
448            .field("share_count", &self.shares.len())
449            .field("shares", &"<redacted>")
450            .field("merkle_root", &"<redacted>")
451            .finish()
452    }
453}
454
455fn write_recovery_bytes(output: &mut Vec<u8>, value: &[u8]) {
456    output.extend_from_slice(&(value.len() as u32).to_le_bytes());
457    output.extend_from_slice(value);
458}
459
460fn read_recovery_bytes(input: &[u8], offset: &mut usize) -> Result<Vec<u8>, Error> {
461    let length_bytes = input
462        .get(*offset..*offset + 4)
463        .ok_or(Error::InvalidRecoveryMaterial("truncated length"))?;
464    let length = u32::from_le_bytes(
465        length_bytes
466            .try_into()
467            .map_err(|_| Error::InvalidRecoveryMaterial("invalid length"))?,
468    ) as usize;
469    *offset += 4;
470    let end = offset
471        .checked_add(length)
472        .ok_or(Error::InvalidRecoveryMaterial("invalid share length"))?;
473    let value = input
474        .get(*offset..end)
475        .ok_or(Error::InvalidRecoveryMaterial("truncated share data"))?
476        .to_vec();
477    *offset = end;
478    Ok(value)
479}
480
481/// Prints the public key fingerprint and nothing else.
482///
483/// There is no secret in this struct to redact, but formatting it should still
484/// be explicit about that rather than deriving `Debug` and letting a future
485/// field decide the question by accident.
486impl core::fmt::Debug for Identity {
487    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
488        let fingerprint: String = self
489            .public_key
490            .iter()
491            .take(8)
492            .map(|b| format!("{b:02x}"))
493            .collect();
494        f.debug_struct("Identity")
495            .field("public_key", &format_args!("{fingerprint}..."))
496            .field(
497                "secret",
498                &format_args!("<held in the component, never here>"),
499            )
500            .finish()
501    }
502}
503
504impl Identity {
505    /// Generate a new identity, sourcing entropy from the operating system.
506    ///
507    /// The entropy is passed to the component, which derives the signing key and
508    /// the PLP master seed from it. Reading the OS CSPRNG is not a cryptographic
509    /// operation this crate implements, and it has to happen here: the component
510    /// has no WASI and therefore no ambient randomness of its own.
511    pub fn generate() -> Result<Self, Error> {
512        let mut entropy = [0u8; MIN_ENTROPY_BYTES];
513        getrandom::getrandom(&mut entropy).map_err(Error::Entropy)?;
514        let identity = Self::from_entropy(&entropy);
515        // The component has already stretched this into key material. Wipe our
516        // copy either way: it is the input that determines the identity.
517        entropy.zeroize();
518        identity
519    }
520
521    /// Generate an identity from caller-supplied entropy.
522    ///
523    /// Deterministic: the same entropy always produces the same identity. That
524    /// makes tests reproducible, and it makes this the wrong function to reach
525    /// for in production unless you are certain about where the bytes came from.
526    /// Requires at least [`MIN_ENTROPY_BYTES`].
527    pub fn from_entropy(entropy: &[u8]) -> Result<Self, Error> {
528        let (mut store, bindings) = component::load()?;
529
530        let handle = bindings
531            .aethel_core_identity()
532            .master_identity()
533            .call_generate(&mut store, entropy)??;
534
535        let public_key = bindings
536            .aethel_core_identity()
537            .master_identity()
538            .call_public_key(&mut store, handle)?;
539
540        Ok(Self {
541            store,
542            bindings,
543            handle,
544            public_key,
545        })
546    }
547
548    /// The ML-DSA-65 public key. Safe to publish.
549    pub fn public_key(&self) -> &[u8] {
550        &self.public_key
551    }
552
553    /// The public key as a [W3C Multikey][mk]: base58btc, `z`-prefixed, over
554    /// the multicodec code for ML-DSA-65 followed by the key bytes.
555    ///
556    /// This is the interoperable form. `public_key()` returns raw bytes, which
557    /// say nothing about which algorithm produced them; a Multikey names the
558    /// algorithm in-band, so a verifier that has never seen this SDK can decode
559    /// it and know what it is holding.
560    ///
561    /// The code is `0x1211`, which is registered upstream in the
562    /// [multicodec table][mc] for ML-DSA-65, so nothing here is private-use.
563    ///
564    /// [mk]: https://www.w3.org/TR/controller-document/#multikey
565    /// [mc]: https://github.com/multiformats/multicodec
566    pub fn public_key_multibase(&self) -> String {
567        SigPublicKey::new(SigAlgorithm::MlDsa65, self.public_key.clone())
568            .to_multibase()
569            .expect("ML-DSA-65 has a canonical Multikey encoding")
570    }
571
572    /// Sign a message.
573    ///
574    /// Deterministic, per FIPS 204: signing the same message twice produces the
575    /// same signature.
576    pub fn sign(&mut self, message: &[u8]) -> Result<Vec<u8>, Error> {
577        let signature = self
578            .bindings
579            .aethel_core_identity()
580            .master_identity()
581            .call_sign(&mut self.store, self.handle, message)??;
582        Ok(signature)
583    }
584
585    /// Sign a message and return an algorithm-labelled signature suitable for
586    /// transport via [`Signature::to_json`].
587    ///
588    /// The actual signing remains inside the embedded aethel-core component.
589    /// Use [`verify_typed`] at the receiving end; the existing [`Self::sign`]
590    /// byte-oriented API remains available for compatibility.
591    pub fn sign_typed(&mut self, message: &[u8]) -> Result<Signature, Error> {
592        let signature = Signature::new(SigAlgorithm::MlDsa65, self.sign(message)?);
593        validate_ml_dsa_65_signature(&signature)?;
594        Ok(signature)
595    }
596
597    /// Sign a message under a registered purpose.
598    ///
599    /// The signature is bound to `purpose`: it fails verification under any
600    /// other purpose and under the empty context, which is what [`Self::sign`]
601    /// uses. Use one purpose per kind of statement and never sign under a purpose
602    /// other than the one the caller asked for.
603    pub fn sign_with_purpose(
604        &mut self,
605        purpose: &Purpose,
606        message: &[u8],
607    ) -> Result<Vec<u8>, Error> {
608        let signature = self
609            .bindings
610            .aethel_core_identity()
611            .master_identity()
612            .call_sign_with_purpose(&mut self.store, self.handle, purpose.as_bytes(), message)??;
613        Ok(signature)
614    }
615
616    /// [`Self::sign_with_purpose`], returned as an algorithm-labelled
617    /// [`Signature`]. Verify it with [`verify_typed_with_purpose`].
618    pub fn sign_typed_with_purpose(
619        &mut self,
620        purpose: &Purpose,
621        message: &[u8],
622    ) -> Result<Signature, Error> {
623        let signature = Signature::new(
624            SigAlgorithm::MlDsa65,
625            self.sign_with_purpose(purpose, message)?,
626        );
627        validate_ml_dsa_65_signature(&signature)?;
628        Ok(signature)
629    }
630
631    /// Split this identity's canonical sealed representation into authenticated
632    /// 3-of-5 recovery shares.
633    ///
634    /// The current embedded aethel-core HTSS policy is fixed at 3-of-5. The
635    /// raw signing key and PLP master seed never leave the component; instead,
636    /// HTSS protects the sealed identity blob that [`Identity::open_sealed`]
637    /// canonically imports. `sealing_key` must meet the same requirements as
638    /// [`Identity::export_sealed`], and is required again during recovery.
639    pub fn split_for_recovery(&mut self, sealing_key: &[u8]) -> Result<RecoveryShareSet, Error> {
640        let mut sealed = self.export_sealed(sealing_key)?;
641        let split = {
642            let sharing = self.bindings.aethel_core_secret_sharing();
643            sharing.call_htss_split(&mut self.store, &sealed)?
644        };
645        sealed.zeroize();
646        let (shares, merkle_root) = split?;
647        let merkle_root: [u8; 32] = merkle_root
648            .try_into()
649            .map_err(|_| Error::InvalidRecoveryMaterial("component returned non-32-byte root"))?;
650        let shares = shares
651            .into_iter()
652            .map(|share| RecoveryShare {
653                index: share.index,
654                value: share.value,
655                path: share.path,
656                merkle_root,
657            })
658            .collect();
659        Ok(RecoveryShareSet {
660            shares,
661            merkle_root,
662        })
663    }
664
665    /// Recover an identity from any three valid shares of one recovery set.
666    ///
667    /// Pass **three** shares, any three of the five, in any order. Passing more
668    /// than the scheme issues is refused; passing fewer than the threshold is
669    /// refused by the component as `ThresholdNotMet`.
670    ///
671    /// `expected_root` comes from [`RecoveryShareSet::merkle_root`] at split
672    /// time, and has to be kept somewhere the holder of the shares does not
673    /// control. That is the whole point of it: see the type's documentation.
674    ///
675    /// A wrong `sealing_key` surfaces as the component's sealed-blob error
676    /// rather than a distinct "wrong key" variant, because [`Identity::open_sealed`]
677    /// makes its failure modes deliberately indistinguishable. That error does
678    /// not mean the shares were bad.
679    ///
680    /// `expected_root` is trusted authentication metadata retained separately
681    /// from the untrusted shares, such as in a keychain or an authenticated
682    /// recovery record. It must not be sourced from the share bundle supplied
683    /// to this method. The component verifies every inclusion path against it
684    /// before reconstructing the sealed identity blob, which is then restored
685    /// through the canonical [`Identity::open_sealed`] path.
686    pub fn recover_from_shares(
687        shares: &[RecoveryShare],
688        expected_root: &[u8; 32],
689        sealing_key: &[u8],
690    ) -> Result<Self, Error> {
691        if shares.is_empty() {
692            return Err(Error::InvalidRecoveryMaterial("no shares supplied"));
693        }
694        if shares.len() > HTSS_TOTAL_SHARES {
695            return Err(Error::Component(IdentityError::InvalidShareSet));
696        }
697        if shares
698            .iter()
699            .any(|share| !bool::from(share.merkle_root.ct_eq(expected_root)))
700        {
701            return Err(Error::InvalidRecoveryMaterial(
702                "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",
703            ));
704        }
705        let component_shares: Vec<HtssShare> = shares
706            .iter()
707            .map(|share| HtssShare {
708                index: share.index,
709                value: share.value.clone(),
710                path: share.path.clone(),
711            })
712            .collect();
713        let (mut store, bindings) = component::load()?;
714        let mut sealed = bindings
715            .aethel_core_secret_sharing()
716            .call_htss_reconstruct(&mut store, &component_shares, expected_root)??;
717        let identity = Self::open_sealed(&sealed, sealing_key);
718        sealed.zeroize();
719        identity
720    }
721
722    /// Project this identity into `context` with fresh OS-supplied randomness.
723    ///
724    /// This is the preferred API. Each call samples new secret randomness, so
725    /// even two projections at the same context have different salts and
726    /// independent context matrices. No master secret leaves the component.
727    pub fn project_at(&mut self, context: &[u8]) -> Result<Projection, Error> {
728        let mut randomness = [0u8; MIN_PROJECTION_RANDOMNESS_BYTES];
729        getrandom::getrandom(&mut randomness).map_err(Error::Entropy)?;
730        let projection = self.project_at_with_randomness(context, &randomness);
731        randomness.zeroize();
732        projection
733    }
734
735    /// Project this identity into `context` using caller-provided randomness.
736    ///
737    /// `randomness` must contain at least
738    /// [`MIN_PROJECTION_RANDOMNESS_BYTES`] bytes. In normal use it must be
739    /// freshly sampled secret entropy for every projection; derive it
740    /// neither from the context nor from identity data. Core derives both the
741    /// public salt and the projection error from it.
742    ///
743    /// Reusing the same randomness at the same context reproduces the same
744    /// projection byte-for-byte. That reuse creates no new independent sample,
745    /// but using fresh randomness through [`Identity::project_at`] is the safe
746    /// default and prevents the shared-matrix averaging attack this construction
747    /// is designed to avoid.
748    ///
749    /// Under aethel-core's stated M-LWE security assumptions, fresh, secret
750    /// randomness makes the master secret non-derivable from any number of
751    /// projections. The salt it derives gives each projection its own context
752    /// matrix, including at a repeated context, so the historical averaging
753    /// attack has no shared matrix to use. This is a property of the underlying
754    /// cryptographic construction, not a claim SDK unit tests can establish.
755    pub fn project_at_with_randomness(
756        &mut self,
757        context: &[u8],
758        randomness: &[u8],
759    ) -> Result<Projection, Error> {
760        let projection = self
761            .bindings
762            .aethel_core_identity()
763            .master_identity()
764            .call_project_at_context(&mut self.store, self.handle, context, randomness)??;
765        Projection::from_component(projection)
766    }
767}
768
769/// Minimum sealing key length: 32 bytes. See [`Identity::export_sealed`].
770///
771/// This crate deliberately offers no helper to generate one. A sealing key
772/// usually needs to outlive the process and be retrievable later, so where it
773/// comes from and where it is stored is an application decision, not something
774/// a library should quietly make. To generate one directly, use the `getrandom`
775/// crate (already in this crate's dependency tree) or `rand`; to derive one from
776/// a password, run Argon2id or scrypt first and pass the output here.
777///
778/// This length is enforced; the *quality* of the bytes is not, and cannot be.
779/// A 32-byte password-derived string satisfies this constant and still produces
780/// a blob that falls to a wordlist. See [`Identity::export_sealed`] for how to
781/// obtain a key that does not.
782pub const MIN_SEAL_KEY_BYTES: usize = 32;
783
784impl Identity {
785    /// Seal this identity so it can be stored and loaded again.
786    ///
787    /// # This takes a key, not a password
788    ///
789    /// `key` must be at least [`MIN_SEAL_KEY_BYTES`] of **high-entropy** key
790    /// material: a key from an OS keychain, an HSM, or a random value you store.
791    /// The component stretches it with SHAKE-256, which is fast by design.
792    ///
793    /// **Do not pass a human-chosen password.** A password needs a deliberately
794    /// slow, memory-hard KDF (Argon2id or scrypt) to survive offline guessing,
795    /// and neither this crate nor the component provides one. A password here
796    /// produces a file that looks encrypted and falls to a wordlist. Run a real
797    /// password KDF first and pass its output.
798    ///
799    /// # What you get
800    ///
801    /// A byte string safe to write to disk, and exactly as sensitive as the
802    /// identity: whoever can open it holds the identity. Sealing is
803    /// deterministic, so the same identity under the same key produces identical
804    /// bytes and a stored file does not churn.
805    pub fn export_sealed(&mut self, key: &[u8]) -> Result<Vec<u8>, Error> {
806        let sealed = self
807            .bindings
808            .aethel_core_identity()
809            .master_identity()
810            .call_export_sealed(&mut self.store, self.handle, key)??;
811        Ok(sealed)
812    }
813
814    /// Open a sealed identity.
815    ///
816    /// Returns an error for a blob that is malformed, truncated, of an unknown
817    /// version, or sealed under a different key. Those are deliberately
818    /// indistinguishable: a decryption failure must not tell an attacker which
819    /// part they got wrong.
820    pub fn open_sealed(sealed: &[u8], key: &[u8]) -> Result<Self, Error> {
821        let (mut store, bindings) = component::load()?;
822
823        let handle = bindings
824            .aethel_core_identity()
825            .master_identity()
826            .call_import_sealed(&mut store, sealed, key)??;
827
828        let public_key = bindings
829            .aethel_core_identity()
830            .master_identity()
831            .call_public_key(&mut store, handle)?;
832
833        Ok(Self {
834            store,
835            bindings,
836            handle,
837            public_key,
838        })
839    }
840}
841
842/// Verify a signature against a public key.
843///
844/// A free function, because verification needs only public material. Returns
845/// `Ok(false)` for a well-formed signature that does not verify, and an error
846/// only when the input could not be processed at all. Those are different
847/// answers and collapsing them is how "invalid signature" and "malformed input"
848/// become indistinguishable.
849///
850/// This is the one place in this crate where the wrong idiom silently accepts
851/// everything. `verify(..).is_ok()` is true for a signature that did not
852/// verify: the `Ok` says the check ran, and the `bool` inside says what it
853/// concluded. Use the `bool`.
854///
855/// ```
856/// use aethel_sdk::{verify, Identity};
857///
858/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
859/// let mut identity = Identity::generate()?;
860/// let signature = identity.sign(b"a message")?;
861///
862/// assert!(verify(identity.public_key(), b"a message", &signature)?);
863///
864/// // A signature that does not verify is Ok(false), not Err.
865/// let wrong = verify(identity.public_key(), b"another message", &signature)?;
866/// assert!(!wrong);
867/// # Ok(())
868/// # }
869/// ```
870pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<bool, Error> {
871    let (mut store, bindings) = component::load()?;
872    let verified = bindings
873        .aethel_core_identity()
874        .call_verify_signature(&mut store, public_key, message, signature)??;
875    Ok(verified)
876}
877
878/// Verify a signature made under a registered purpose.
879///
880/// Returns `Ok(false)` for a well-formed signature that does not verify under
881/// this purpose, including one made under a different purpose or with plain
882/// [`Identity::sign`], and `Err` only for input that cannot be parsed.
883pub fn verify_with_purpose(
884    public_key: &[u8],
885    purpose: &Purpose,
886    message: &[u8],
887    signature: &[u8],
888) -> Result<bool, Error> {
889    let (mut store, bindings) = component::load()?;
890    let verified = bindings
891        .aethel_core_identity()
892        .call_verify_signature_with_purpose(
893            &mut store,
894            public_key,
895            purpose.as_bytes(),
896            message,
897            signature,
898        )??;
899    Ok(verified)
900}
901
902/// [`verify_with_purpose`] for typed public verification material and a typed
903/// signature. Algorithm labels and lengths are checked first, as in
904/// [`verify_typed`].
905pub fn verify_typed_with_purpose(
906    public_key: &SigPublicKey,
907    purpose: &Purpose,
908    message: &[u8],
909    signature: &Signature,
910) -> Result<bool, Error> {
911    validate_ml_dsa_65_public_key(public_key)?;
912    validate_ml_dsa_65_signature(signature)?;
913    verify_with_purpose(
914        public_key.as_bytes(),
915        purpose,
916        message,
917        signature.as_bytes(),
918    )
919}
920
921/// Decode ML-DSA-65 public verification material from a W3C Multikey.
922///
923/// The Multikey's base58btc encoding and embedded multicodec are decoded by
924/// `pqc-sig`. This SDK then requires ML-DSA-65 and its exact public-key
925/// length, so callers cannot pass another algorithm's key to [`verify_typed`].
926pub fn public_key_from_multibase(multibase: &str) -> Result<SigPublicKey, Error> {
927    let public_key = SigPublicKey::from_multibase(SigAlgorithm::MlDsa65, multibase)?;
928    validate_ml_dsa_65_public_key(&public_key)?;
929    Ok(public_key)
930}
931
932/// Verify a typed ML-DSA-65 signature using typed public verification material.
933///
934/// Algorithm labels and lengths are checked before Aethel's embedded component
935/// performs the cryptographic verification. A validly formed signature for a
936/// different message or key returns `Ok(false)`, as [`verify`] does.
937pub fn verify_typed(
938    public_key: &SigPublicKey,
939    message: &[u8],
940    signature: &Signature,
941) -> Result<bool, Error> {
942    validate_ml_dsa_65_public_key(public_key)?;
943    validate_ml_dsa_65_signature(signature)?;
944    verify(public_key.as_bytes(), message, signature.as_bytes())
945}