aethel_core/identity_error.rs
1//! Rust-side mirror of the `identity-error` variant defined in the
2//! `aethel:core` WIT world ([`wit/aethel-core.wit`](../../wit/aethel-core.wit)).
3//!
4//! Kept as one flat, closed set — not a hierarchy — matching the WIT design.
5//!
6//! # Which variants a caller can actually observe
7//!
8//! Five of the eight are reachable through the component today, and are driven
9//! end-to-end by tests in `tests/component_execution.rs`:
10//! `InvalidInputLength`, `SerializationError`, `ThresholdNotMet`,
11//! `RejectionSamplingFailed`, and `InvalidShareSet`.
12//!
13//! Three are **reserved and currently unreachable**: [`IdentityError::NormBoundViolation`],
14//! [`IdentityError::ChallengeMismatch`] and [`IdentityError::InvalidAttributeCommitment`]. They
15//! are named here deliberately rather than removed — see their individual doc
16//! comments and `component_error_variant_reachability` in
17//! `tests/component_execution.rs`, which pins the split so it cannot drift
18//! silently.
19//!
20//! `RejectionSamplingFailed` is reachable but not forceable from outside: it
21//! needs all 16 rejection-sampling iterations to fail, which is negligible for
22//! honest parameters. It is exercised natively instead.
23
24use crate::saap::SaapValidationError;
25use crate::sampling::RejectionError;
26
27/// Closed set of failure reasons across all `aethel:core` WIT operations.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum IdentityError {
30 /// A byte-slice input was too short or malformed for the operation.
31 InvalidInputLength,
32 /// Serialization or deserialization of a proof/projection failed.
33 SerializationError,
34 /// Rejection sampling exhausted its fixed iteration ceiling.
35 RejectionSamplingFailed,
36 /// A response vector's infinity norm exceeded the rejection bound.
37 ///
38 /// **Reserved, no producer today.** Reachable only through the superseded
39 /// `saap` verifier, which is crate-private and not exported by the
40 /// component. `saap-verify-presentation` reports a well-formed proof that
41 /// does not verify as `ok(false)`, never as an error, because "this does
42 /// not verify" is a verdict and not a failure to reach one.
43 ///
44 /// Kept for the predicate relation (RFC §5.6 relation 3), which is
45 /// deliberately deferred and will need to distinguish a range proof whose
46 /// response is out of bounds from one that simply does not hold.
47 NormBoundViolation,
48 /// A recomputed Fiat-Shamir challenge did not match the proof's challenge.
49 ///
50 /// **Reserved, no producer today.** Same reasoning as
51 /// [`Self::NormBoundViolation`].
52 ChallengeMismatch,
53 /// A disclosed attribute did not match its vector commitment.
54 ///
55 /// **Reserved, no producer today.** Same reasoning as
56 /// [`Self::NormBoundViolation`].
57 InvalidAttributeCommitment,
58 /// Fewer than the threshold number of shares were supplied for reconstruction.
59 ThresholdNotMet,
60 /// The supplied shares are not a valid, authenticated share set. Three
61 /// causes, all folded into one variant because they share a consequence —
62 /// none of them may reconstruct:
63 ///
64 /// - an evaluation index appears more than once
65 /// - more shares were supplied than the scheme issues
66 /// - a share's Merkle proof does not check out against the caller-supplied
67 /// root (0X3-105)
68 ///
69 /// Distinct from [`Self::SerializationError`] on purpose. "This share is
70 /// malformed" and "you sent the same share twice" (or "this share was
71 /// never part of the sharing it claims to be") are different failures
72 /// with different fixes, and collapsing them is the same sentinel-flattening
73 /// this crate treats as a defect class elsewhere. Lagrange interpolation
74 /// over a repeated evaluation point is undefined, and interpolation over a
75 /// share nobody ever split is defined but meaningless; a share set
76 /// carrying either does not reconstruct to the shared secret, so it must
77 /// not reconstruct at all.
78 InvalidShareSet,
79
80 // ── A-4: `aethel-plp-1` wire envelope (native-only, no WIT producer) ────
81 //
82 // These four are new in 0.6.0 and reachable only through `wire::{decode_projection,
83 // decode_proof, verify_projection}` and the raw struct codecs
84 // (`EphemeralProjection::from_bytes` / `ZkIdentityProof::from_bytes`) they build
85 // on. None of them has a WIT producer: `wit/aethel-core.wit`'s `identity-error`
86 // variant is unchanged (adding a case there would be a breaking ordinal shift
87 // for every existing importer), so `component.rs`'s `From<IdentityError> for
88 // WitError` collapses all four to `WitError::SerializationError` at the
89 // component boundary — the same fold the RESERVED variants above already use,
90 // just in the other direction (many native causes, one WIT effect, rather than
91 // one native cause with several WIT-side reservations).
92 //
93 // Kept distinct on the native side because "wrong magic", "wrong version",
94 // "declared length disagrees with what's actually there", and "a coefficient
95 // isn't a member of R_q" are different failures with different fixes for a
96 // native caller debugging a bad wire payload, even though a WASM component
97 // caller sees one undifferentiated `serialization-error`.
98 /// An `aethel-plp-1` envelope's magic bytes did not match
99 /// [`crate::EIAB_MAGIC`].
100 WireBadMagic,
101 /// An `aethel-plp-1` envelope declared a version byte this build does not
102 /// recognize. See `wire::WIRE_VERSION_PLP1`.
103 WireBadVersion,
104 /// An `aethel-plp-1` envelope was too short to contain its header, or its
105 /// declared body length did not match the number of bytes actually
106 /// present after the header.
107 WireLengthMismatch,
108 /// A decoded polynomial coefficient was `>= Q`, i.e. not a member of
109 /// `R_q = Z_q[X]/(X^N + 1)`. Raised by [`crate::plp::EphemeralProjection::from_bytes`]
110 /// and [`crate::plp::ZkIdentityProof::from_bytes`] before any arithmetic
111 /// ever touches the value — `add_mod`/`sub_mod` assume reduced inputs, so
112 /// admitting an out-of-range coefficient would be a robustness defect
113 /// even though the Fiat-Shamir challenge recomputation means it is not,
114 /// by itself, a soundness break.
115 CoefficientOutOfRange,
116}
117
118impl From<SaapValidationError> for IdentityError {
119 fn from(e: SaapValidationError) -> Self {
120 match e {
121 SaapValidationError::NormBoundViolation => IdentityError::NormBoundViolation,
122 SaapValidationError::ChallengeMismatch => IdentityError::ChallengeMismatch,
123 SaapValidationError::InvalidAttributeCommitment => {
124 IdentityError::InvalidAttributeCommitment
125 }
126 }
127 }
128}
129
130impl From<RejectionError> for IdentityError {
131 fn from(e: RejectionError) -> Self {
132 match e {
133 RejectionError::AllIterationsRejected => IdentityError::RejectionSamplingFailed,
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::htss::SecretSharer;
142 use crate::plp::{checked_project_at_context, EphemeralProjection};
143
144 #[test]
145 fn every_saap_validation_error_maps() {
146 assert_eq!(
147 IdentityError::from(SaapValidationError::NormBoundViolation),
148 IdentityError::NormBoundViolation
149 );
150 assert_eq!(
151 IdentityError::from(SaapValidationError::ChallengeMismatch),
152 IdentityError::ChallengeMismatch
153 );
154 assert_eq!(
155 IdentityError::from(SaapValidationError::InvalidAttributeCommitment),
156 IdentityError::InvalidAttributeCommitment
157 );
158 }
159
160 #[test]
161 fn rejection_sampling_failure_maps() {
162 assert_eq!(
163 IdentityError::from(RejectionError::AllIterationsRejected),
164 IdentityError::RejectionSamplingFailed
165 );
166 }
167
168 // ── InvalidInputLength: driven through checked_project_at_context ──────────
169
170 // Fresh per-projection randomness for the error term e_τ. In tests a fixed
171 // 32-byte value is fine; in production this MUST be freshly sampled.
172 const RHO: [u8; 32] = [0x5au8; 32];
173
174 #[test]
175 fn invalid_input_length_from_a_short_secret() {
176 // One byte short of the required 32-byte seed (randomness is valid, so
177 // the failure is unambiguously about the secret length).
178 let short_secret = [0u8; 31];
179 let result = checked_project_at_context(&short_secret, b"context", &RHO);
180 assert_eq!(result.err(), Some(IdentityError::InvalidInputLength));
181 }
182
183 #[test]
184 fn invalid_input_length_from_short_randomness() {
185 // A valid secret but under-length randomness must also be rejected:
186 // silently proceeding would seed e_τ from too little entropy.
187 let secret = [0x42u8; 32];
188 let short_rho = [0u8; 31];
189 let result = checked_project_at_context(&secret, b"context", &short_rho);
190 assert_eq!(result.err(), Some(IdentityError::InvalidInputLength));
191 }
192
193 #[test]
194 fn checked_project_at_context_succeeds_with_a_valid_secret() {
195 // The validation isn't just rejecting everything — a correctly-sized
196 // secret and randomness must actually produce a projection.
197 let secret = [0x42u8; 32];
198 assert!(checked_project_at_context(&secret, b"context", &RHO).is_ok());
199 }
200
201 // ── SerializationError: driven through EphemeralProjection::from_bytes ─────
202
203 #[test]
204 fn serialization_error_from_truncated_projection_bytes() {
205 let truncated = [0u8; 10];
206 let result = EphemeralProjection::from_bytes(&truncated);
207 assert_eq!(result.err(), Some(IdentityError::SerializationError));
208 }
209
210 #[test]
211 fn ephemeral_projection_round_trips_through_bytes() {
212 let secret = [0x99u8; 32];
213 let projection = checked_project_at_context(&secret, b"round-trip", &RHO).unwrap();
214 let bytes = projection.to_bytes();
215 let decoded =
216 EphemeralProjection::from_bytes(&bytes).expect("well-formed bytes must decode");
217 assert_eq!(decoded.tau, projection.tau);
218 // Compare the whole rank-k matrix and vector. Checking one cell would
219 // let a codec that dropped the other components round-trip cleanly.
220 let flat_a = |p: &EphemeralProjection| {
221 p.matrix_a
222 .iter()
223 .flat_map(|row| row.iter().flat_map(|q| q.coeffs().to_vec()))
224 .collect::<alloc::vec::Vec<u32>>()
225 };
226 let flat_b = |p: &EphemeralProjection| {
227 p.public_b
228 .iter()
229 .flat_map(|q| q.coeffs().to_vec())
230 .collect::<alloc::vec::Vec<u32>>()
231 };
232 assert_eq!(flat_a(&decoded), flat_a(&projection));
233 assert_eq!(flat_b(&decoded), flat_b(&projection));
234 }
235
236 // ── ThresholdNotMet: driven through SecretSharer::reconstruct_secret_checked ─
237
238 #[test]
239 #[allow(deprecated)] // shares from the deprecated split_secret, on purpose
240 fn threshold_not_met_with_fewer_than_three_shares() {
241 let shares = SecretSharer::split_secret(12_345u64, 3, 5, 0xdead_beef);
242 let result = SecretSharer::reconstruct_secret_checked(&shares[0..2]);
243 assert_eq!(result.err(), Some(IdentityError::ThresholdNotMet));
244 }
245
246 #[test]
247 #[allow(deprecated)] // shares from the deprecated split_secret, on purpose
248 fn reconstruct_secret_checked_succeeds_at_the_threshold() {
249 let secret = 12_345u64;
250 let shares = SecretSharer::split_secret(secret, 3, 5, 0xdead_beef);
251 let result = SecretSharer::reconstruct_secret_checked(&shares[0..3]);
252 assert_eq!(result, Ok(secret));
253 }
254}