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}