quantica/secret.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4// The key / signature / ciphertext types are fixed-size byte containers;
5// `len()` returns a compile-time constant (`Params::*_LEN`), so `is_empty()`
6// (always `false`) carries no meaning. Documented allow per CLAUDE.md §6.
7#![allow(clippy::len_without_is_empty)]
8
9//! Zeroize-on-Drop containers for secret key material.
10//!
11//! `quantica` exposes secret keys, signing keys and shared secrets
12//! through wrapper types that automatically wipe their backing memory
13//! when dropped, using the constant-time zeroization primitive from
14//! the [`silentops`] crate.
15//!
16//! Two building blocks live here:
17//!
18//! - `SecretBytes` — heap-allocated, variable-length zeroizing
19//! container, used as the storage for `DecapsulationKey<P>`,
20//! `SigningKey<P>`, and similar types whose length depends on the
21//! parameter set chosen at runtime.
22//! - `SecretArray` — stack-allocated, fixed-size zeroizing
23//! container, used for the 32-byte ML-KEM shared secret.
24//!
25//! Both types implement [`Deref<Target = [u8]>`](core::ops::Deref) so
26//! callers can pass them transparently to any function expecting a
27//! `&[u8]`.
28
29use alloc::vec::Vec;
30use core::fmt;
31use core::ops::{Deref, DerefMut};
32
33/// Heap-allocated, variable-length container that wipes its contents
34/// on [`Drop`] using `silentops::ct_zeroize`.
35///
36/// Used as the storage for the secret-half of every key pair in the
37/// crate. The wipe is performed via `write_volatile` + a compiler
38/// fence so the optimizer is not allowed to elide it.
39#[derive(Clone)]
40pub struct SecretBytes {
41 bytes: Vec<u8>,
42}
43
44impl SecretBytes {
45 /// Build a [`SecretBytes`] from an existing `Vec<u8>`.
46 ///
47 /// The original vector is moved into the wrapper; no copy occurs.
48 pub fn from_vec(bytes: Vec<u8>) -> Self {
49 Self { bytes }
50 }
51
52 /// Build a [`SecretBytes`] by copying the contents of `data`.
53 pub fn from_slice(data: &[u8]) -> Self {
54 Self { bytes: data.to_vec() }
55 }
56
57 /// Borrow the secret as a byte slice.
58 pub fn as_bytes(&self) -> &[u8] {
59 &self.bytes
60 }
61
62 /// Length in bytes.
63 pub fn len(&self) -> usize {
64 self.bytes.len()
65 }
66
67 /// Whether the container is empty.
68 pub fn is_empty(&self) -> bool {
69 self.bytes.is_empty()
70 }
71}
72
73impl Drop for SecretBytes {
74 fn drop(&mut self) {
75 silentops::ct_zeroize(&mut self.bytes);
76 }
77}
78
79impl Deref for SecretBytes {
80 type Target = [u8];
81 fn deref(&self) -> &[u8] {
82 &self.bytes
83 }
84}
85
86impl DerefMut for SecretBytes {
87 fn deref_mut(&mut self) -> &mut [u8] {
88 &mut self.bytes
89 }
90}
91
92impl AsRef<[u8]> for SecretBytes {
93 fn as_ref(&self) -> &[u8] {
94 &self.bytes
95 }
96}
97
98/// Redacted [`Debug`] impl: prints the type and length but never the
99/// secret bytes themselves, so accidentally logging a key won't leak
100/// it.
101impl fmt::Debug for SecretBytes {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 write!(f, "SecretBytes(<redacted; len={}>)", self.bytes.len())
104 }
105}
106
107/// Stack-allocated, fixed-size byte array that wipes itself on
108/// [`Drop`].
109///
110/// Used for shared secrets and other small secret values whose
111/// length is known at compile time. The wipe goes through
112/// `silentops::ct_zeroize`, which is `write_volatile` + a compiler
113/// fence.
114#[derive(Clone)]
115pub struct SecretArray<const N: usize> {
116 bytes: [u8; N],
117}
118
119impl<const N: usize> SecretArray<N> {
120 /// Wrap a raw `[u8; N]` array.
121 pub fn new(bytes: [u8; N]) -> Self {
122 Self { bytes }
123 }
124
125 /// Borrow the secret as a byte slice.
126 pub fn as_bytes(&self) -> &[u8] {
127 &self.bytes
128 }
129
130 /// Borrow the secret as a fixed-size array reference.
131 pub fn as_array(&self) -> &[u8; N] {
132 &self.bytes
133 }
134
135 /// Length in bytes (always `N`).
136 pub fn len(&self) -> usize {
137 N
138 }
139
140 /// Constant-time equality with another secret of the same length.
141 ///
142 /// Wraps `silentops::ct_eq` so the comparison itself does not leak
143 /// timing information about which byte first differed.
144 pub fn ct_eq(&self, other: &Self) -> bool {
145 silentops::ct_eq(&self.bytes, &other.bytes) == 1
146 }
147}
148
149impl<const N: usize> Drop for SecretArray<N> {
150 fn drop(&mut self) {
151 silentops::ct_zeroize(&mut self.bytes);
152 }
153}
154
155impl<const N: usize> Deref for SecretArray<N> {
156 type Target = [u8];
157 fn deref(&self) -> &[u8] {
158 &self.bytes
159 }
160}
161
162impl<const N: usize> AsRef<[u8]> for SecretArray<N> {
163 fn as_ref(&self) -> &[u8] {
164 &self.bytes
165 }
166}
167
168impl<const N: usize> PartialEq for SecretArray<N> {
169 /// Constant-time equality (delegates to [`SecretArray::ct_eq`]).
170 fn eq(&self, other: &Self) -> bool {
171 self.ct_eq(other)
172 }
173}
174impl<const N: usize> Eq for SecretArray<N> {}
175
176/// Redacted [`Debug`] impl: prints the type and length but never the
177/// secret bytes, so accidentally logging a shared secret won't leak it.
178impl<const N: usize> fmt::Debug for SecretArray<N> {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 write!(f, "SecretArray<{}>(<redacted>)", N)
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn secret_bytes_zeroes_on_drop() {
190 // Use the inner Vec's pointer to peek at the heap region after
191 // the wrapper is dropped. We can't dereference it (UAF in
192 // practice) but we can construct a fresh allocation likely to
193 // land on the same spot, and confirm new contents differ. The
194 // real guarantee is that ct_zeroize ran, which we test more
195 // directly via the public API on the wrapper before Drop.
196 let mut s = SecretBytes::from_slice(&[0xAA; 64]);
197 for &b in s.as_bytes() {
198 assert_eq!(b, 0xAA);
199 }
200 // Mutate via DerefMut, then re-read.
201 s.fill(0x55);
202 for &b in s.as_bytes() {
203 assert_eq!(b, 0x55);
204 }
205 // Drop happens at end of scope; the test simply confirms
206 // the API surface compiles and behaves linearly.
207 }
208
209 #[test]
210 fn secret_array_ct_eq() {
211 let a = SecretArray::<8>::new([1, 2, 3, 4, 5, 6, 7, 8]);
212 let b = SecretArray::<8>::new([1, 2, 3, 4, 5, 6, 7, 8]);
213 let c = SecretArray::<8>::new([1, 2, 3, 4, 5, 6, 7, 9]);
214 assert!(a == b);
215 assert!(a != c);
216 assert!(a.ct_eq(&b));
217 assert!(!a.ct_eq(&c));
218 }
219}