Skip to main content

structured_cms/personal/
process.rs

1//! Scoped import orchestration; no temporary plaintext or model can escape.
2
3use super::{
4    Bag, BagValue, DATA, FIELD, Integrity, Pfx, PrivateKey, Safe, ShroudedKey, encoding, end,
5    equal_oid, invalid, sequence,
6};
7use crate::{Error, ErrorCode, Octets, Result, content_source::SourceMap, signer::required};
8use structured_asn1::memory::{OwnedVec, Work};
9use structured_asn1::{Encoding, Reader, Span};
10use zeroize::Zeroize;
11
12/// Accounted plaintext which wipes initialized storage before deallocation.
13/// It owns no backend and never implements Debug/Clone. Growth and copies must
14/// use the supplied current Reader execution before wrapping the resulting owner.
15pub struct Plaintext(OwnedVec<u8>);
16impl Plaintext {
17    /// Copies bytes into current accounted storage after funding the eventual
18    /// wipe. Copy/refusal paths keep a wiping guard from the first stored byte.
19    pub fn copy_from(bytes: &[u8], query: &Reader<'_>) -> Result<Self> {
20        let execution = query.execution();
21        execution
22            .charge_work(bytes.len() as u64)
23            .map_err(|e| Error::resource(e, FIELD, query.offset()))?;
24        let mut output = Wiping(Some(execution.vector()));
25        output
26            .bytes()
27            .try_extend_from_slice(bytes)
28            .map_err(|e| Error::resource(e, FIELD, query.offset()))?;
29        Ok(output.into_owned())
30    }
31    /// Copies exact logical octets once for in-place decryption. Metadata,
32    /// capacity, copies and the eventual wipe are paid by the producing query.
33    pub fn copy_octets(octets: &Octets<'_>, query: &Reader<'_>) -> Result<Self> {
34        if !core::ptr::eq(octets.parser(), query.parser()) {
35            return Err(Error::pkix(structured_pkix::Error::new(
36                structured_pkix::ErrorCode::ForeignReader,
37                FIELD,
38                octets.span().start,
39            )));
40        }
41        let execution = query.execution();
42        let fail = |e| Error::resource(e, FIELD, octets.span().start);
43        execution.charge_work(octets.len() as u64).map_err(fail)?;
44        execution
45            .charge_work(octets.pieces().len() as u64)
46            .map_err(fail)?;
47        let mut output = Wiping(Some(execution.vector()));
48        output
49            .bytes()
50            .try_reserve_exact(octets.len())
51            .map_err(fail)?;
52        for piece in octets.pieces() {
53            output
54                .bytes()
55                .try_extend_from_slice(piece.as_bytes())
56                .map_err(fail)?;
57        }
58        Ok(output.into_owned())
59    }
60    /// Immutable plaintext; no allocating conversion is performed.
61    #[must_use]
62    pub fn as_bytes(&self) -> &[u8] {
63        &self.0
64    }
65    /// Mutable existing bytes for in-place cipher operations, without growth.
66    pub fn as_mut_bytes(&mut self) -> &mut [u8] {
67        &mut self.0
68    }
69    /// Wipes and discards padding/trailing initialized bytes, retaining the
70    /// original allocation/account. A length beyond the current length is a
71    /// no-op; this cannot grow or reveal spare capacity.
72    pub fn truncate(&mut self, length: usize) {
73        if length < self.0.len() {
74            self.0[length..].zeroize();
75            while self.0.len() > length {
76                self.0.pop();
77            }
78        }
79    }
80}
81
82/// Encode a legacy PKCS#12 password as big-endian BMP characters plus a single
83/// zero terminator (RFC7292 Appendix B.1). Supplementary Unicode scalars are
84/// refused, never encoded as surrogate pairs. PBMAC1 (RFC9879 ยง6) and PBES2
85/// use the original UTF-8 bytes instead; callers must not apply this conversion
86/// merely because a different protection layer in the same PFX uses legacy PBE.
87/// <https://www.rfc-editor.org/rfc/rfc7292#appendix-B.1>
88pub fn legacy_password(password: &str, query: &Reader<'_>) -> Result<Plaintext> {
89    let execution = query.execution();
90    let fail = |e| Error::resource(e, FIELD, query.offset());
91    // Both Unicode passes are funded before the first scan.
92    execution.charge_work(password.len() as u64).map_err(fail)?;
93    execution.charge_work(password.len() as u64).map_err(fail)?;
94    let mut count = 1_usize;
95    for ch in password.chars() {
96        if u32::from(ch) > u32::from(u16::MAX) {
97            return Err(Error::schema(
98                ErrorCode::MalformedValue,
99                FIELD,
100                query.offset(),
101            ));
102        }
103        count = count
104            .checked_add(1)
105            .ok_or_else(|| Error::schema(ErrorCode::LengthOverflow, FIELD, query.offset()))?;
106    }
107    let size = count
108        .checked_mul(2)
109        .ok_or_else(|| Error::schema(ErrorCode::LengthOverflow, FIELD, query.offset()))?;
110    execution.charge_work(size as u64).map_err(fail)?;
111    let mut output = Wiping(Some(execution.vector()));
112    output.bytes().try_reserve_exact(size).map_err(fail)?;
113    for ch in password.chars() {
114        debug_assert!(u16::try_from(u32::from(ch)).is_ok());
115        let encoded = u32::from(ch).to_be_bytes();
116        output
117            .bytes()
118            .try_extend_from_slice(&encoded[2..])
119            .map_err(fail)?;
120    }
121    output
122        .bytes()
123        .try_extend_from_slice(&[0, 0])
124        .map_err(fail)?;
125    Ok(output.into_owned())
126}
127impl Drop for Plaintext {
128    fn drop(&mut self) {
129        self.0.zeroize();
130    }
131}
132struct Wiping<W: Work>(Option<OwnedVec<u8, W>>);
133impl<W: Work> Wiping<W> {
134    fn bytes(&mut self) -> &mut OwnedVec<u8, W> {
135        self.0
136            .as_mut()
137            .expect("buffer exists until consuming transfer")
138    }
139    fn into_owned(mut self) -> Plaintext {
140        Plaintext(self.0.take().expect("consuming transfer once").into_owned())
141    }
142}
143impl<W: Work> Drop for Wiping<W> {
144    fn drop(&mut self) {
145        if let Some(bytes) = self.0.as_mut() {
146            bytes.zeroize();
147        }
148    }
149}
150
151/// A provider's explicit acceptance/capability result, not an application verdict.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum Decision {
154    /// Authentication succeeded (or the caller explicitly accepted no MAC).
155    Accept,
156    /// The original integrity protection failed or is required but absent.
157    Reject,
158    /// The selected provider does not implement the written mechanism.
159    Unsupported,
160}
161
162/// Scoped coordinates for callback views. Joins map to their enclosing source;
163/// decrypted bytes have their own coordinate space, never invented physical
164/// offsets. A caller can retain/project known locations while the scope is live.
165pub enum Source<'a> {
166    /// Original PFX document coordinates.
167    Document,
168    /// Logical carried bytes with their exact original fragment mapping.
169    Carried {
170        /// Mapping into the parent source coordinates.
171        map: &'a Fragments,
172        /// The source enclosing those fragments.
173        parent: &'a Source<'a>,
174    },
175    /// New plaintext coordinates starting at zero, without a bytewise map to
176    /// ciphertext (encryption is not a source-offset-preserving transformation).
177    Plaintext {
178        /// Ciphertext enclosure in the parent's coordinates.
179        ciphertext: Span,
180        /// Original source of that encrypted container.
181        parent: &'a Source<'a>,
182    },
183}
184/// One independently retained source transformation, innermost first.
185#[derive(Debug)]
186pub enum Origin {
187    /// Map into the next enclosing source, retained without plaintext bytes.
188    Carried(Fragments),
189    /// Offsets name a decrypted source; only its ciphertext enclosure is physical
190    /// in the next layer. This is not a contiguous plaintext-to-ciphertext map.
191    Plaintext(Span),
192}
193
194/// A carried-byte map into its immediate enclosing source. Unlike a standalone
195/// document map, its parent can itself be carried or decrypted; the [`Source`]
196/// / [`Failure::origins`] chain identifies that domain explicitly. No method
197/// claims that a parent-relative offset is already a physical PFX coordinate.
198#[derive(Debug)]
199pub struct Fragments(SourceMap);
200impl Fragments {
201    /// Parent-relative enclosure of the complete carrying OCTET STRING TLV.
202    #[must_use]
203    pub fn enclosure(&self) -> Span {
204        self.0.carrier()
205    }
206    /// Maps one current source point into the immediately enclosing source.
207    /// Repeat through the retained chain until Document, or stop at a Plaintext
208    /// boundary, where only the ciphertext enclosure can be mapped further.
209    pub fn offset_in_parent(&self, at: u64, work: &impl Work) -> Result<u64> {
210        self.0.project_offset(at, work)
211    }
212    /// Maps a current range into its actual parent fragments, without claiming
213    /// a single contiguous physical range across headers or encryption.
214    pub fn ranges_in_parent(&self, span: Span, work: &impl Work) -> Result<OwnedVec<Span>> {
215        self.0.project_span(span, work)
216    }
217    /// Retains just this layer's metadata for a caller-owned diagnostic/result.
218    pub fn try_copy_in(&self, work: &impl Work) -> Result<Self> {
219        self.0.try_copy_in(work).map(Self)
220    }
221}
222/// Terminal failure, preserving the exact cause and its coordinate chain.
223/// At most two carried wrappers and two decryptions occur in this pipeline;
224/// nested `SafeContents` are walked in place and add no transformation layer.
225#[derive(Debug)]
226pub struct Failure<E> {
227    /// Processing or callback cause, without loss of its original type.
228    pub cause: Cause<E>,
229    /// Retained source transformations, innermost to outermost. Empty means
230    /// the cause refers directly to the original PFX document.
231    pub origins: [Option<Origin>; 4],
232}
233/// Processing failures are distinct from provider/consumer callback refusal.
234#[derive(Debug)]
235pub enum Cause<E> {
236    /// A source/parameter/ownership/resource processing failure.
237    Processing(Error),
238    /// The unchanged callback error, with no conversion to invalid/mismatch.
239    Callback(E),
240}
241impl<E> Failure<E> {
242    fn processing(error: Error) -> Self {
243        Self {
244            cause: Cause::Processing(error),
245            origins: core::array::from_fn(|_| None),
246        }
247    }
248    fn callback(error: E) -> Self {
249        Self {
250            cause: Cause::Callback(error),
251            origins: core::array::from_fn(|_| None),
252        }
253    }
254    fn enclosed(mut self, origin: Origin) -> Self {
255        let slot = self
256            .origins
257            .iter_mut()
258            .find(|item| item.is_none())
259            .expect("four pipeline transformations maximum");
260        *slot = Some(origin);
261        self
262    }
263}
264
265/// A selected backend and extraction visitor. It must fund all backend work,
266/// temporary/output capacity and key/password handling through the supplied
267/// Reader before work. There is no default implementation or implicit engine.
268pub trait Backend {
269    /// Owned error type, independent of temporary decrypted input lifetimes.
270    type Error;
271    /// Check the entire original PFX integrity. For Password mode, MAC input is
272    /// exactly `safe` logical octets. Signed mode uses the existing CMS verifier
273    /// over the actual carried data. A missing MAC must be explicitly accepted
274    /// or rejected here; it never gains an authentication proof from parsing.
275    fn authenticate(
276        &mut self,
277        integrity: &Integrity<'_>,
278        query: &Reader<'_>,
279    ) -> core::result::Result<Decision, Self::Error>;
280    /// Decrypt one complete password-protected or enveloped safe. The model
281    /// retains the original algorithms/recipients; `Safe::Data` is never passed.
282    /// Return a wiping buffer on this current account. A failure terminates
283    /// processing; ciphertext is not interpreted as plaintext on refusal.
284    fn decrypt_safe(
285        &mut self,
286        safe: &Safe<'_>,
287        query: &Reader<'_>,
288        source: &Source<'_>,
289    ) -> core::result::Result<Plaintext, Self::Error>;
290    /// Decrypt one original PKCS#8 shrouded key, including a key inside an
291    /// already encrypted safe. Its KDF is a separate paid operation.
292    fn decrypt_key(
293        &mut self,
294        key: &ShroudedKey<'_>,
295        query: &Reader<'_>,
296        source: &Source<'_>,
297    ) -> core::result::Result<Plaintext, Self::Error>;
298    /// Consume each original bag once in direct sibling/preorder nesting order.
299    /// The parsed bag/source are scoped; owned extraction must fund its own
300    /// retained storage. Unknown bags remain explicit and are not discarded.
301    fn bag(
302        &mut self,
303        bag: &Bag<'_>,
304        query: &Reader<'_>,
305        source: &Source<'_>,
306    ) -> core::result::Result<(), Self::Error>;
307    /// Consume the clear key obtained from a shrouded bag, under its new honest
308    /// plaintext coordinates. The enclosing bag is not replayed to `bag`.
309    fn key(
310        &mut self,
311        key: &PrivateKey<'_>,
312        query: &Reader<'_>,
313        source: &Source<'_>,
314    ) -> core::result::Result<(), Self::Error>;
315}
316
317/// Authenticate, then process every original safe and nested bag once.
318///
319/// Reject/Unsupported returns before any privacy or extraction callback. A
320/// processing/callback error returns no successful completion; external visitor
321/// effects cannot be rolled back by this library and belong in caller staging.
322/// The backend's `Accept` is a trusted callback fact, not a core trust verdict.
323#[expect(
324    clippy::result_large_err,
325    reason = "retain opaque cause and four source maps without allocating on the refusal path"
326)]
327pub fn process<B: Backend>(
328    pfx: &Pfx<'_>,
329    query: &Reader<'_>,
330    backend: &mut B,
331) -> core::result::Result<Decision, Failure<B::Error>> {
332    let original = pfx
333        .original()
334        .reader_in(query)
335        .map_err(Failure::processing)?;
336    original
337        .charge(1)
338        .map_err(encoding)
339        .map_err(Failure::processing)?;
340    let decision = backend
341        .authenticate(&pfx.integrity, &original)
342        .map_err(Failure::callback)?;
343    if decision != Decision::Accept {
344        return Ok(decision);
345    }
346    nested(
347        pfx.safe(),
348        &original,
349        &Source::Document,
350        |reader, source| {
351            let root = required(reader, FIELD).map_err(Failure::processing)?;
352            let mut safes = sequence(reader, &root).map_err(Failure::processing)?;
353            end(reader).map_err(Failure::processing)?;
354            while let Some(element) = safes
355                .next_element()
356                .map_err(encoding)
357                .map_err(Failure::processing)?
358            {
359                let safe = Safe::read(&safes, &element).map_err(Failure::processing)?;
360                safes
361                    .charge(1)
362                    .map_err(encoding)
363                    .map_err(Failure::processing)?;
364                match &safe {
365                    Safe::Data(bytes) => nested(bytes, &safes, source, |reader, source| {
366                        bags(reader, source, backend)
367                    })?,
368                    Safe::Encrypted(encrypted) => {
369                        check_data(&safes, encrypted.content.kind, element.span())
370                            .map_err(Failure::processing)?;
371                        let ciphertext =
372                            encrypted.content.ciphertext.as_ref().ok_or_else(|| {
373                                Failure::processing(invalid(ErrorCode::DetachedContent, &element))
374                            })?;
375                        let plain = backend
376                            .decrypt_safe(&safe, &safes, source)
377                            .map_err(Failure::callback)?;
378                        decrypted(&plain, &safes, ciphertext, source, |reader, source| {
379                            bags(reader, source, backend)
380                        })?;
381                    }
382                    Safe::Enveloped(enveloped) => {
383                        check_data(&safes, enveloped.content.kind, element.span())
384                            .map_err(Failure::processing)?;
385                        let ciphertext =
386                            enveloped.content.ciphertext.as_ref().ok_or_else(|| {
387                                Failure::processing(invalid(ErrorCode::DetachedContent, &element))
388                            })?;
389                        let plain = backend
390                            .decrypt_safe(&safe, &safes, source)
391                            .map_err(Failure::callback)?;
392                        decrypted(&plain, &safes, ciphertext, source, |reader, source| {
393                            bags(reader, source, backend)
394                        })?;
395                    }
396                    Safe::Other { .. } => {
397                        return Err(Failure::processing(invalid(
398                            ErrorCode::UnexpectedContentType,
399                            &element,
400                        )));
401                    }
402                }
403            }
404            Ok(())
405        },
406    )?;
407    Ok(decision)
408}
409
410fn check_data(parent: &Reader<'_>, kind: structured_asn1::Oid<'_>, span: Span) -> Result<()> {
411    if !equal_oid(parent, kind, DATA)? {
412        return Err(Error::schema(
413            ErrorCode::UnexpectedContentType,
414            FIELD,
415            span.start,
416        ));
417    }
418    Ok(())
419}
420
421#[expect(
422    clippy::result_large_err,
423    reason = "owned source maps preserve error provenance without refusal-path allocation"
424)]
425fn bags<B: Backend>(
426    parent: &mut Reader<'_>,
427    source: &Source<'_>,
428    backend: &mut B,
429) -> core::result::Result<(), Failure<B::Error>> {
430    let root = required(parent, FIELD).map_err(Failure::processing)?;
431    let reader = sequence(parent, &root).map_err(Failure::processing)?;
432    end(parent).map_err(Failure::processing)?;
433    let execution = parent.execution();
434    let mut frames = execution.vector();
435    frames
436        .try_push(reader)
437        .map_err(|e| Failure::processing(Error::resource(e, FIELD, root.span().start)))?;
438    while let Some(frame) = frames.last_mut() {
439        frame
440            .charge(1)
441            .map_err(encoding)
442            .map_err(Failure::processing)?;
443        let Some(element) = frame
444            .next_element()
445            .map_err(encoding)
446            .map_err(Failure::processing)?
447        else {
448            frames.pop();
449            continue;
450        };
451        let bag = Bag::read(frame, &element).map_err(Failure::processing)?;
452        backend
453            .bag(&bag, frame, source)
454            .map_err(Failure::callback)?;
455        if let BagValue::Contents(value) = &bag.value {
456            // The original value sits inside SafeBag.[0], two levels below
457            // this loop cursor. Reopen its saved context before entering it.
458            let original = value.reader_in(frame).map_err(Failure::processing)?;
459            let reader = sequence(&original, value.element()).map_err(Failure::processing)?;
460            frames.try_push(reader).map_err(|e| {
461                Failure::processing(Error::resource(e, FIELD, element.span().start))
462            })?;
463        } else if let BagValue::Shrouded(key) = &bag.value {
464            let plain = backend
465                .decrypt_key(key, frame, source)
466                .map_err(Failure::callback)?;
467            decrypted(&plain, frame, &key.encrypted, source, |reader, source| {
468                let value = required(reader, FIELD).map_err(Failure::processing)?;
469                let key = PrivateKey::read(reader, &value).map_err(Failure::processing)?;
470                end(reader).map_err(Failure::processing)?;
471                reader
472                    .charge(1)
473                    .map_err(encoding)
474                    .map_err(Failure::processing)?;
475                backend.key(&key, reader, source).map_err(Failure::callback)
476            })?;
477        }
478    }
479    Ok(())
480}
481
482#[expect(
483    clippy::result_large_err,
484    reason = "preserve independently owned source chain with the original callback cause"
485)]
486fn nested<R, E>(
487    octets: &Octets<'_>,
488    parent: &Reader<'_>,
489    origin: &Source<'_>,
490    callback: impl for<'scope> FnOnce(
491        &mut Reader<'scope>,
492        &Source<'_>,
493    ) -> core::result::Result<R, Failure<E>>,
494) -> core::result::Result<R, Failure<E>> {
495    if !core::ptr::eq(octets.parser(), parent.parser()) {
496        return Err(Failure::processing(Error::pkix(
497            structured_pkix::Error::new(
498                structured_pkix::ErrorCode::ForeignReader,
499                FIELD,
500                octets.span().start,
501            ),
502        )));
503    }
504    let map = Fragments(SourceMap::of(octets, parent, FIELD).map_err(Failure::processing)?);
505    let joined;
506    let (bytes, base) = if let [piece] = octets.pieces() {
507        (piece.as_bytes(), piece.span().start)
508    } else {
509        joined = Plaintext::copy_octets(octets, parent).map_err(Failure::processing)?;
510        (&*joined.0, 0)
511    };
512    let reader = parent.parser().read_nested_query_in(
513        parent,
514        bytes,
515        base,
516        Encoding::Ber,
517        octets.outer_depth(),
518    );
519    let result = match reader {
520        Ok(mut reader) => callback(
521            &mut reader,
522            &Source::Carried {
523                map: &map,
524                parent: origin,
525            },
526        ),
527        Err(error) => Err(Failure::processing(encoding(error))),
528    };
529    result.map_err(|e| e.enclosed(Origin::Carried(map)))
530}
531
532#[expect(
533    clippy::result_large_err,
534    reason = "preserve source/opaque cause without another allocation on refusal"
535)]
536fn decrypted<R, E>(
537    plain: &Plaintext,
538    parent: &Reader<'_>,
539    encrypted: &Octets<'_>,
540    origin: &Source<'_>,
541    callback: impl for<'scope> FnOnce(
542        &mut Reader<'scope>,
543        &Source<'_>,
544    ) -> core::result::Result<R, Failure<E>>,
545) -> core::result::Result<R, Failure<E>> {
546    let ciphertext = encrypted.span();
547    if !plain.0.memory().same_account(parent.memory()) {
548        return Err(Failure::processing(Error::schema(
549            ErrorCode::InvalidLocation,
550            FIELD,
551            ciphertext.start,
552        )));
553    }
554    let reader = parent.parser().read_nested_query_in(
555        parent,
556        &plain.0,
557        0,
558        Encoding::Ber,
559        encrypted.outer_depth(),
560    );
561    let result = match reader {
562        Ok(mut reader) => callback(
563            &mut reader,
564            &Source::Plaintext {
565                ciphertext,
566                parent: origin,
567            },
568        ),
569        Err(error) => Err(Failure::processing(encoding(error))),
570    };
571    result.map_err(|e| e.enclosed(Origin::Plaintext(ciphertext)))
572}