From a01eedfdff71724209be2e25b398e07cbaa1b8ff Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 22:10:13 +0000 Subject: [PATCH 1/7] feat(stack-guest-abi): se_last_error hands the host the full error behind a status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failing guest export returns one number from the shared status table, and every Rust error behind it is folded away there: the code, message, help and fields #1099 gave each error never reach Go (#1100). The status number stays exactly as it is, the fast path a host acts on, byte-for-byte the vitaminc guest's. Beside it, the full error is now kept for the host to ask for. `last_error` encodes an error as one value in the transport codec every input and output already uses — `code`, `message`, `help`, `url`, `severity`, `fields` (the error's `payload()`) and `causes`, a list of `{code?, message}` — so there is no second encoding. The encoder takes any miette diagnostic and its fields, so this crate still names no library a guest is built over. A cause from another library contributes only what a describer vouches for (an I/O error's kind, a JSON error's kind and position), never its message. `abi::export` is the one wrapper every guest export runs through: it clears the last error when the export starts, makes sure one is recorded when it fails (a `GuestError` for the status when the export recorded nothing), and packs the result. `se_last_error` returns the error as a buffer in the usual packed format, or zero. The error lives in the buffer registry from the moment it is stored, so it is wiped like every other buffer, by `se_dealloc` once the host has it, and by `wipe_all` at shutdown; handing it over empties the slot. `input()` records which pointer/length check failed. Refs #1100 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- Cargo.lock | 3 + packages/stack-guest-abi/Cargo.toml | 8 +- packages/stack-guest-abi/src/abi.rs | 61 ++- packages/stack-guest-abi/src/buffers.rs | 3 + packages/stack-guest-abi/src/last_error.rs | 556 +++++++++++++++++++++ packages/stack-guest-abi/src/lib.rs | 23 +- 6 files changed, 641 insertions(+), 13 deletions(-) create mode 100644 packages/stack-guest-abi/src/last_error.rs diff --git a/Cargo.lock b/Cargo.lock index b5e0a12bb..5af242d0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3315,8 +3315,11 @@ dependencies = [ name = "stack-guest-abi" version = "0.0.0" dependencies = [ + "miette", "proptest", + "serde_json", "thiserror 1.0.69", + "vitaminc-aead-value", "vitaminc-protected", "zeroize", ] diff --git a/packages/stack-guest-abi/Cargo.toml b/packages/stack-guest-abi/Cargo.toml index b9c5ecc1b..3d3179922 100644 --- a/packages/stack-guest-abi/Cargo.toml +++ b/packages/stack-guest-abi/Cargo.toml @@ -15,13 +15,19 @@ publish = false [dependencies] zeroize = { workspace = true } +# `last_error`: an error is any miette diagnostic plus its structured fields +# (a serde_json map, as `ErrorPayload::payload` gives them), encoded in the +# one transport codec every input and output already uses. +miette = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +vitaminc-aead-value = { workspace = true } # Only `transport` (wasm32-only) needs these: the import's error type and # `OpaqueDebug` for its response, whose body can carry wrapped key material # or a credential and so never prints. Declared for that target alone, so a # native build carries nothing it does not use and `cargo udeps` sees none. [target.'cfg(target_arch = "wasm32")'.dependencies] -thiserror = { workspace = true } vitaminc-protected = { workspace = true } [dev-dependencies] diff --git a/packages/stack-guest-abi/src/abi.rs b/packages/stack-guest-abi/src/abi.rs index 5637b4a7f..bcc20dba6 100644 --- a/packages/stack-guest-abi/src/abi.rs +++ b/packages/stack-guest-abi/src/abi.rs @@ -39,7 +39,10 @@ //! `catch_unwind`, belt-and-braces for a hypothetical unwind build — //! wasm32-wasip1 aborts on panic; the two exports here need none, since //! neither has a panic path (allocation goes through `try_reserve_exact` -//! and release through the registry). Statuses are the only detail leaked. +//! and release through the registry). A failure is a status number, and +//! `se_last_error` has the error behind it, encoded under the rule on +//! `stack-profile`'s `ErrorPayload` ([`last_error`]): no plaintext, key, +//! token, ciphertext or context value is ever in it. //! //! Wasm modules are single-threaded; the host must serialize calls into one //! instance. @@ -48,10 +51,57 @@ //! this crate exports them, so every guest gets `se_alloc` and `se_dealloc` //! by depending on it and defines only the exports that are its own. +use std::panic::{catch_unwind, AssertUnwindSafe}; + use zeroize::{Zeroize, Zeroizing}; use crate::buffers; -use crate::status::STATUS_ENCODING; +use crate::last_error; + +/// Run an export's body: clear the last error, run `f` (a panic is an +/// internal failure), make sure a failure leaves an error recorded, and pack +/// the result. Every guest export that returns a packed result goes through +/// here, so "every export clears the last error when it starts and sets it +/// when it fails" is written once. +/// +/// wasm32-wasip1 aborts on panic, so the catch is belt-and-braces for an +/// unwinding build, as each guest's own catch was. +pub fn export(f: impl FnOnce() -> Result, u32>) -> u64 { + last_error::clear(); + let result = catch_unwind(AssertUnwindSafe(f)) + .unwrap_or_else(|_| Err(last_error::internal("a panic was caught"))); + match result { + Ok(out) => { + // An error recorded on the way to a success describes nothing + // the host will ask about. + last_error::clear(); + ok_buffer(out) + } + Err(status) => { + last_error::ensure(status); + err_status(status) + } + } +} + +/// The full error behind the most recent failed export, as a packed buffer +/// result: a transport-codec object of `code`, `message`, `help`, `url` +/// (when set), `severity`, `fields` and `causes` (see +/// [`last_error::encode`]). Zero when there is none — no export has failed +/// since the last success, or the error was already handed over. +/// +/// The host calls it only after a non-zero status, copies the buffer out +/// and releases it with [`se_dealloc`], like any output. Handing the buffer +/// over empties the slot, so a second call returns zero. It reads the last +/// error and does not clear it first, so it is the one export that is not +/// run through [`export`]. +#[no_mangle] +pub extern "C" fn se_last_error() -> u64 { + match last_error::take_registered() { + Some((ptr, len)) => ((ptr as usize as u64) << 32) | len as u64, + None => 0, + } +} /// Allocate `len` bytes of guest memory for the host to write into. Returns /// null if the allocation fails (recoverable host-side; never a trap). @@ -114,16 +164,17 @@ fn linear_memory_bytes() -> u64 { /// is about to free would read freed memory: that is the promise, not a /// property this function can check. pub unsafe fn input<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], u32> { + let refuse = || last_error::malformed("a pointer/length pair is outside guest memory"); let len = len as usize; if len == 0 { return Ok(&[]); } if ptr.is_null() || len > isize::MAX as usize { - return Err(STATUS_ENCODING); + return Err(refuse()); } - let end = (ptr as usize).checked_add(len).ok_or(STATUS_ENCODING)?; + let end = (ptr as usize).checked_add(len).ok_or_else(refuse)?; if end as u64 > linear_memory_bytes() { - return Err(STATUS_ENCODING); + return Err(refuse()); } // SAFETY: non-null, in-bounds of linear memory, and under `isize::MAX`; // wasm linear memory is fully initialized (fresh pages are zero), so diff --git a/packages/stack-guest-abi/src/buffers.rs b/packages/stack-guest-abi/src/buffers.rs index 62aa430d4..c8fad4646 100644 --- a/packages/stack-guest-abi/src/buffers.rs +++ b/packages/stack-guest-abi/src/buffers.rs @@ -132,6 +132,9 @@ pub unsafe fn take(ptr: *mut u8, len: usize) -> Option> { /// linear-memory pressure cannot fail before the wipe on an allocation the /// wipe itself made. pub fn wipe_all() { + // The recorded last error is one of these buffers; its slot must not + // outlive the wipe and name freed memory. + crate::last_error::forget(); let live = BUFFERS.with(|b| core::mem::take(&mut *b.borrow_mut())); for (ptr, len) in live { // SAFETY: every entry was registered by `register`, which leaked a diff --git a/packages/stack-guest-abi/src/last_error.rs b/packages/stack-guest-abi/src/last_error.rs new file mode 100644 index 000000000..01fc751e3 --- /dev/null +++ b/packages/stack-guest-abi/src/last_error.rs @@ -0,0 +1,556 @@ +//! The full error behind the most recent failed export, for +//! [`se_last_error`](crate::abi::se_last_error). +//! +//! A failing export returns a status number in its packed result: that stays +//! the fast path a host acts on, byte-compatible with the vitaminc guest. +//! Beside it, the export records the error that produced the number — its +//! miette code, message, help, URL, severity, structured fields and causes — +//! as one value in the transport codec every input and output already uses +//! ([`encode`]). A host that wants the detail calls `se_last_error` after a +//! non-zero status; a call that succeeds pays nothing. +//! +//! # Lifecycle +//! +//! Every export runs through [`abi::export`](crate::abi::export), which +//! [`clear`]s the stored error when the export starts and makes sure one is +//! stored when it fails ([`ensure`]): the error the export recorded itself, +//! or a [`GuestError`] for its status if it recorded none. Guests are +//! single-threaded and the host serialises calls into one instance, so "the +//! most recent failed call" is well defined. +//! +//! The encoded error is a buffer in the [registry](crate::buffers) from the +//! moment it is stored: [`clear`] wipes and frees it, `se_last_error` hands +//! it to the host (who releases it with `se_dealloc`, like any output), and +//! a shutdown's [`buffers::wipe_all`] wipes it +//! with everything else. Handing it over empties the slot, so a second +//! `se_last_error` returns zero. +//! +//! # What may be in it +//! +//! Everything the encoder writes obeys the rule on `stack-profile`'s +//! `ErrorPayload` trait: the guests only record errors from the four crates +//! that implement it, and [`GuestError`]s, whose text is fixed. A cause +//! from another library contributes a description a guest vouches for +//! ([`describe_std`], or the guest's own describer), and otherwise only that +//! it was one; its message is never copied. + +use std::borrow::Cow; +use std::cell::Cell; +use std::error::Error; + +use miette::{Diagnostic, Severity}; +use vitaminc_aead_value::{transport as codec, FfiValue}; + +use crate::buffers; +use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_STATE}; + +thread_local! { + /// The registered buffer holding the encoded last error, if any. Keyed + /// by the pointer the registry handed out, provenance intact, as the + /// registry keys it. + static LAST: Cell> = const { Cell::new(None) }; +} + +/// How deep the cause chain is followed. A chain this long is a loop or a +/// bug, and a bounded walk cannot be made to spin. +const MAX_CAUSES: usize = 16; + +/// What a cause from another library is reported as when nothing vouches +/// for its message. +pub const UNDESCRIBED_CAUSE: &str = "an error from another library"; + +/// A failure with no library error behind it: input the guest's own +/// boundary refused, a call out of order, or an export that failed without +/// recording why. +/// +/// Its text is fixed or names a field of the guest's own input (a config +/// key), never the value: what [`ensure`] and the guests record where they +/// have only a status number. +#[derive(Debug, thiserror::Error, Diagnostic)] +#[non_exhaustive] +pub enum GuestError { + /// The input failed the export's own validation before any library + /// saw it: bytes that are not the transport codec, a pointer/length + /// pair outside linear memory, a config or option of the wrong shape. + #[error("malformed input: {0}")] + #[diagnostic(code(stack_guest_abi::malformed_input))] + Malformed(Cow<'static, str>), + /// The call is out of order: an operation before the guest's init + /// export, or after its shutdown, or init twice. + #[error("call out of order: {0}")] + #[diagnostic( + code(stack_guest_abi::out_of_order), + help("Initialise the instance once before any operation, and make no call after it is shut down.") + )] + OutOfOrder(Cow<'static, str>), + /// An unexpected failure inside the guest: a caught panic, a response + /// that did not match its request. Never the caller's input. + #[error("internal failure: {0}")] + #[diagnostic(code(stack_guest_abi::internal))] + Internal(Cow<'static, str>), + /// An export failed with a status and recorded nothing more. The status + /// is all there is to say. + #[error("the call failed with status {0}")] + #[diagnostic(code(stack_guest_abi::status))] + Status(u32), +} + +impl GuestError { + /// The error's structured fields. + pub fn fields(&self) -> serde_json::Map { + match self { + Self::Status(status) => [("status".to_owned(), (*status).into())] + .into_iter() + .collect(), + Self::Malformed(_) | Self::OutOfOrder(_) | Self::Internal(_) => serde_json::Map::new(), + } + } + + /// The status this error stands for. + pub fn status(&self) -> u32 { + match self { + Self::Malformed(_) => STATUS_ENCODING, + Self::OutOfOrder(_) => STATUS_STATE, + Self::Internal(_) => STATUS_INTERNAL, + Self::Status(status) => *status, + } + } + + /// Record this error as the last one and return its status: the one + /// call a guest makes where it would otherwise return a bare number. + pub fn fail(self) -> u32 { + record(&self, self.fields()); + self.status() + } +} + +/// Every miette code this crate's errors carry. A test builds every +/// [`GuestError`] and checks its code is here. +pub const ERROR_CODES: &[&str] = &[ + "stack_guest_abi::malformed_input", + "stack_guest_abi::out_of_order", + "stack_guest_abi::internal", + "stack_guest_abi::status", +]; + +/// A [`GuestError::Malformed`], recorded; returns `STATUS_ENCODING`. +pub fn malformed(detail: impl Into>) -> u32 { + GuestError::Malformed(detail.into()).fail() +} + +/// A [`GuestError::OutOfOrder`], recorded; returns `STATUS_STATE`. +pub fn out_of_order(detail: impl Into>) -> u32 { + GuestError::OutOfOrder(detail.into()).fail() +} + +/// A [`GuestError::Internal`], recorded; returns `STATUS_INTERNAL`. +pub fn internal(detail: impl Into>) -> u32 { + GuestError::Internal(detail.into()).fail() +} + +/// Describe a cause from the standard library or serde_json under the rule: +/// an I/O error by its kind, a JSON error by its kind and position, never +/// either's own message. `None` for anything else. +pub fn describe_std(cause: &(dyn Error + 'static)) -> Option { + if let Some(io) = cause.downcast_ref::() { + return Some(format!("I/O error: {}", io.kind())); + } + if let Some(json) = cause.downcast_ref::() { + let kind = match json.classify() { + serde_json::error::Category::Io => "read error", + serde_json::error::Category::Syntax => "syntax error", + serde_json::error::Category::Data => "unexpected data", + serde_json::error::Category::Eof => "unexpected end of input", + }; + return Some(format!( + "JSON error: {kind} at line {} column {}", + json.line(), + json.column() + )); + } + None +} + +/// Encode an error as the value [`se_last_error`](crate::abi::se_last_error) +/// returns: an object of +/// +/// - `code`: the miette code, such as `stack_encrypt::foreign_keyset` +/// - `message`: the error's `Display` +/// - `help`, `url`: when the error has them +/// - `severity`: `"error"`, `"warning"` or `"advice"` +/// - `fields`: the error's structured fields (its `ErrorPayload`) +/// - `causes`: a list of `{code?, message}`, following the error's +/// diagnostic sources while they last and its plain sources after +/// +/// A cause that is a miette diagnostic gives its code and message: it is +/// one of the four crates' errors, which obey the rule. One that is not +/// gives what `describe` vouches for, or [`UNDESCRIBED_CAUSE`]. +pub fn encode( + error: &dyn Diagnostic, + fields: serde_json::Map, + describe: &dyn Fn(&(dyn Error + 'static)) -> Option, +) -> FfiValue { + let mut entries = Vec::with_capacity(7); + if let Some(code) = error.code() { + entries.push(("code".to_owned(), text(code.to_string()))); + } + entries.push(("message".to_owned(), text(error.to_string()))); + if let Some(help) = error.help() { + entries.push(("help".to_owned(), text(help.to_string()))); + } + if let Some(url) = error.url() { + entries.push(("url".to_owned(), text(url.to_string()))); + } + let severity = match error.severity().unwrap_or(Severity::Error) { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Advice => "advice", + }; + entries.push(("severity".to_owned(), text(severity))); + entries.push(( + "fields".to_owned(), + FfiValue::Object( + fields + .into_iter() + .map(|(key, value)| (key, json_value(value))) + .collect(), + ), + )); + entries.push(( + "causes".to_owned(), + FfiValue::Array(causes(error, describe)), + )); + FfiValue::Object(entries) +} + +/// One link of the cause chain. +enum Cause<'a> { + /// One of the four crates' errors, reached as a diagnostic source. + Ours(&'a dyn Diagnostic), + /// Anything reached as a plain source. + Foreign(&'a (dyn Error + 'static)), +} + +/// The error's causes, in order. A diagnostic source is preferred over a +/// plain source, since it is the same error with its code; once the chain +/// leaves the diagnostics it follows plain sources to the end. +fn causes( + error: &dyn Diagnostic, + describe: &dyn Fn(&(dyn Error + 'static)) -> Option, +) -> Vec { + let mut out = Vec::new(); + let mut next = next_of(error); + while let Some(cause) = next { + if out.len() == MAX_CAUSES { + break; + } + let (code, message, after) = match cause { + Cause::Ours(diagnostic) => ( + diagnostic.code().map(|code| code.to_string()), + diagnostic.to_string(), + next_of(diagnostic), + ), + Cause::Foreign(foreign) => ( + None, + describe(foreign).unwrap_or_else(|| UNDESCRIBED_CAUSE.to_owned()), + foreign.source().map(Cause::Foreign), + ), + }; + let mut entry = Vec::with_capacity(2); + if let Some(code) = code { + entry.push(("code".to_owned(), text(code))); + } + entry.push(("message".to_owned(), text(message))); + out.push(FfiValue::Object(entry)); + next = after; + } + out +} + +/// The cause after a diagnostic: its diagnostic source, or else its plain +/// source. +fn next_of(diagnostic: &dyn Diagnostic) -> Option> { + diagnostic + .diagnostic_source() + .map(Cause::Ours) + .or_else(|| diagnostic.source().map(Cause::Foreign)) +} + +/// A string leaf. +fn text(value: impl Into) -> FfiValue { + FfiValue::String(value.into().into()) +} + +/// A structured field as a transport value. +fn json_value(value: serde_json::Value) -> FfiValue { + match value { + serde_json::Value::Null => FfiValue::Null, + serde_json::Value::Bool(value) => FfiValue::Bool(value), + serde_json::Value::Number(number) => { + if let Some(value) = number.as_u64() { + FfiValue::UInt64(value) + } else if let Some(value) = number.as_i64() { + FfiValue::Int64(value) + } else { + FfiValue::Float64(number.as_f64().unwrap_or(f64::NAN)) + } + } + serde_json::Value::String(value) => text(value), + serde_json::Value::Array(items) => { + FfiValue::Array(items.into_iter().map(json_value).collect()) + } + serde_json::Value::Object(entries) => FfiValue::Object( + entries + .into_iter() + .map(|(key, value)| (key, json_value(value))) + .collect(), + ), + } +} + +/// Record `error` as the last error, with its structured fields and +/// [`describe_std`] for causes from other libraries. Replaces any error +/// already recorded in this call. +pub fn record(error: &dyn Diagnostic, fields: serde_json::Map) { + record_with(error, fields, &describe_std); +} + +/// [`record`], with a guest's own describer for causes from the libraries +/// it is built over. The describer returns `None` for a cause it does not +/// vouch for. +pub fn record_with( + error: &dyn Diagnostic, + fields: serde_json::Map, + describe: &dyn Fn(&(dyn Error + 'static)) -> Option, +) { + let mut bytes = Vec::new(); + // An error that does not encode (it cannot: every leaf is a string, a + // number or a container) leaves the last error empty rather than half + // written; the status still says what happened. + if codec::encode_value(encode(error, fields, describe), &mut bytes).is_err() { + clear(); + return; + } + store(bytes); +} + +/// Store encoded bytes as the last error, wiping any before them. +fn store(bytes: Vec) { + clear(); + let len = bytes.len(); + let ptr = buffers::register(bytes); + LAST.with(|last| last.set(Some((ptr, len)))); +} + +/// Make sure an error is recorded for a failed call: if the call recorded +/// none, a [`GuestError`] for its status. +pub fn ensure(status: u32) { + if is_set() { + return; + } + let error = match status { + STATUS_ENCODING => GuestError::Malformed("the input failed validation".into()), + STATUS_STATE => GuestError::OutOfOrder("no operation can run in this state".into()), + STATUS_INTERNAL => GuestError::Internal("an unexpected failure".into()), + other => GuestError::Status(other), + }; + record(&error, error.fields()); +} + +/// Whether an error is recorded. +pub fn is_set() -> bool { + LAST.with(|last| last.get().is_some()) +} + +/// Wipe and free the recorded error, if any. +pub fn clear() { + if let Some((ptr, len)) = LAST.with(Cell::take) { + // SAFETY: the pair is the one `buffers::register` returned for the + // bytes `store` registered, and nothing else holds it: the host is + // only given it by `take_registered`, which empties the slot first. + unsafe { buffers::dealloc(ptr, len) }; + } +} + +/// Drop the slot without freeing: for a shutdown that is about to wipe the +/// whole registry, where freeing first would be a double wipe and keeping +/// the slot would leave it naming freed memory. +pub(crate) fn forget() { + LAST.with(|last| last.set(None)); +} + +/// Hand the recorded error's registered buffer over, emptying the slot: +/// what `se_last_error` returns to the host, which releases it with +/// `se_dealloc`. +pub fn take_registered() -> Option<(*mut u8, usize)> { + LAST.with(Cell::take) +} + +/// Take the recorded error's bytes out of the registry: for a native +/// caller, a test, that has no host to release them. +pub fn take() -> Option> { + let (ptr, len) = take_registered()?; + // SAFETY: as in `clear`; the slot was emptied above. + unsafe { buffers::take(ptr, len) } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode(bytes: &[u8]) -> FfiValue { + codec::decode_value(&mut codec::Reader::new(bytes)).expect("an encoded error decodes") + } + + fn get<'a>(value: &'a FfiValue, key: &str) -> Option<&'a FfiValue> { + match value { + FfiValue::Object(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + fn string(value: Option<&FfiValue>) -> Option { + match value? { + FfiValue::String(s) => String::from_utf8(s.risky_ref().to_vec()).ok(), + _ => None, + } + } + + #[derive(Debug, thiserror::Error, Diagnostic)] + #[error("outer")] + #[diagnostic(code(test::outer), help("do the thing"))] + struct Outer { + #[source] + #[diagnostic_source] + inner: Inner, + } + + #[derive(Debug, thiserror::Error, Diagnostic)] + #[error("inner")] + #[diagnostic(code(test::inner))] + struct Inner { + #[source] + io: std::io::Error, + } + + #[derive(Debug, thiserror::Error)] + #[error("marker-foreign")] + struct Foreign; + + #[test] + fn an_error_encodes_with_its_code_help_fields_and_causes() { + let error = Outer { + inner: Inner { + io: std::io::Error::new(std::io::ErrorKind::NotFound, "marker-io"), + }, + }; + let fields = [("count".to_owned(), 2.into())].into_iter().collect(); + record(&error, fields); + let value = decode(&take().expect("recorded")); + assert_eq!(string(get(&value, "code")).as_deref(), Some("test::outer")); + assert_eq!(string(get(&value, "message")).as_deref(), Some("outer")); + assert_eq!(string(get(&value, "help")).as_deref(), Some("do the thing")); + assert!(get(&value, "url").is_none()); + assert_eq!(string(get(&value, "severity")).as_deref(), Some("error")); + assert!(matches!( + get(get(&value, "fields").expect("fields"), "count"), + Some(FfiValue::UInt64(2)) + )); + let Some(FfiValue::Array(causes)) = get(&value, "causes") else { + panic!("causes is a list"); + }; + assert_eq!(causes.len(), 2); + assert_eq!( + string(get(&causes[0], "code")).as_deref(), + Some("test::inner") + ); + assert_eq!(string(get(&causes[0], "message")).as_deref(), Some("inner")); + assert!( + get(&causes[1], "code").is_none(), + "an I/O error has no code" + ); + assert_eq!( + string(get(&causes[1], "message")).as_deref(), + Some("I/O error: entity not found"), + "an I/O error is described by its kind, never its message" + ); + assert!(!is_set(), "taking the error empties the slot"); + } + + #[test] + fn a_foreign_cause_nobody_vouches_for_gives_no_message() { + #[derive(Debug, thiserror::Error, Diagnostic)] + #[error("wrapper")] + #[diagnostic(code(test::wrapper))] + struct Wrapper(#[source] Foreign); + record(&Wrapper(Foreign), serde_json::Map::new()); + let value = decode(&take().expect("recorded")); + let Some(FfiValue::Array(causes)) = get(&value, "causes") else { + panic!("causes is a list"); + }; + assert_eq!( + string(get(&causes[0], "message")).as_deref(), + Some(UNDESCRIBED_CAUSE) + ); + } + + #[test] + fn ensure_records_a_guest_error_only_when_nothing_was_recorded() { + clear(); + ensure(STATUS_STATE); + let value = decode(&take().expect("recorded")); + assert_eq!( + string(get(&value, "code")).as_deref(), + Some("stack_guest_abi::out_of_order") + ); + + let _ = malformed("the selector is not an object"); + ensure(STATUS_INTERNAL); + let value = decode(&take().expect("recorded")); + assert_eq!( + string(get(&value, "message")).as_deref(), + Some("malformed input: the selector is not an object"), + "the error the call recorded wins over the status fallback" + ); + + ensure(42); + let value = decode(&take().expect("recorded")); + assert_eq!( + string(get(&value, "code")).as_deref(), + Some("stack_guest_abi::status") + ); + assert!(matches!( + get(get(&value, "fields").expect("fields"), "status"), + Some(FfiValue::UInt64(42)) + )); + } + + #[test] + fn clearing_wipes_and_frees_the_registered_buffer() { + let _ = internal("boom"); + assert!(is_set()); + clear(); + assert!(!is_set()); + assert!(take().is_none()); + } + + #[test] + fn every_guest_error_has_a_listed_code() { + let errors = [ + GuestError::Malformed("x".into()), + GuestError::OutOfOrder("x".into()), + GuestError::Internal("x".into()), + GuestError::Status(9), + ]; + let codes: Vec = errors + .iter() + .map(|error| { + error + .code() + .map(|code| code.to_string()) + .unwrap_or_default() + }) + .collect(); + assert_eq!(codes, ERROR_CODES); + } +} diff --git a/packages/stack-guest-abi/src/lib.rs b/packages/stack-guest-abi/src/lib.rs index 48f9b9966..ae9ff84a8 100644 --- a/packages/stack-guest-abi/src/lib.rs +++ b/packages/stack-guest-abi/src/lib.rs @@ -45,10 +45,15 @@ //! - [`buffers`] — the guest-owned buffer registry: every buffer handed to //! the host is recorded with its true length, released through the //! registry (never on the host's say-so), and zeroized on the way out. -//! - [`abi`] (wasm32 only) — the `se_alloc` / `se_dealloc` exports every -//! guest has, the packed `u64` result encoding, and the hostile-input -//! helpers that validate a host `(ptr, len)` pair against linear memory -//! before any slice exists. +//! - [`abi`] (wasm32 only) — the `se_alloc` / `se_dealloc` / +//! `se_last_error` exports every guest has, the packed `u64` result +//! encoding, the `export` wrapper every guest export runs through, and the +//! hostile-input helpers that validate a host `(ptr, len)` pair against +//! linear memory before any slice exists. +//! - [`last_error`] — the full error behind the most recent failed export: +//! its code, message, help, fields and causes, encoded once in the +//! transport codec for `se_last_error`. The status number stays the fast +//! path; this is the detail a host asks for after it. //! - [`status`] — the status table. **One numbering for every guest**: the //! codes below keep their values for good, and a guest that needs more //! appends after them. The Go side decodes the table once. @@ -61,8 +66,10 @@ //! What is deliberately *not* here: anything that names a crate a guest is //! built over. The ZeroKMS connection over the transport, the token import //! the crypto guest uses for its phase-1 auth, the mapping from a library's -//! error type onto the status table, and the `user-agent` a guest sends are -//! each guest's own. +//! error type onto the status table (and the recording of that error), and +//! the `user-agent` a guest sends are each guest's own. The error encoder +//! takes any miette diagnostic and its fields, so it names none of them +//! either. //! //! # Conventions //! @@ -71,7 +78,8 @@ //! the guest's outputs — with `se_dealloc`, which zeroizes before freeing. //! Every fallible export returns one `u64`: a non-zero high half is an //! output pointer with the length in the low half; a zero high half carries -//! a [`status`] code in the low half. Wasm modules are single-threaded; the +//! a [`status`] code in the low half, and `se_last_error` then has the +//! error behind it. Wasm modules are single-threaded; the //! host serializes calls into one instance, and during an export the host's //! imports may re-enter the guest only through `se_alloc`. //! @@ -87,6 +95,7 @@ pub mod buffers; pub mod headers; +pub mod last_error; pub mod status; #[cfg(target_arch = "wasm32")] From f93382dcafa22b597fa1738378d9f68ee5dc73a6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 22:10:26 +0000 Subject: [PATCH 2/7] feat(golang): both guests record the full error behind every failing export, with a leak test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crypto and credential guests now run every export through `stack_guest_abi::abi::export`, so each exports `se_last_error` and every failing export leaves an error for it. Every place that maps an error to a status also records it: `status::fail_error`, `fail_dynamic`, `fail_auth` and `fail_profile` record the stack-encrypt, stack-kms, stack-auth or stack-profile error with its fields and return the status the old mapping gave. Where a guest refuses input before any library sees it (bytes that are not the codec, a malformed options object or config, a call out of order) it records a `GuestError` naming what it refused, and the config error names the key, never its value. The status numbers are unchanged and so are their tests. The credential guest's `status_for_auth` matches stack-auth's variants instead of hand-typed `error_code()` strings, so a renamed code can no longer fall through to "other auth failure"; a test pins that every variant gets the status the string table gave it. The leak tests (`tests/leak.rs` in each guest) drive every error path a native test can reach with marker values where a caller's data would be — plaintext, contexts, ciphertext, a client key, an access token or access key, a ZeroKMS response body — and assert no marker appears in any encoded error, at any depth, keys included. Each pins the codes it reached, so a path that stops being driven fails there. Reverting either the context-descriptor rule or the JSON-message rule makes them fail. The credential guest's lockfile moves `vitaminc-aead-value` from 0.5.0 to 0.5.1, the version stack-guest-abi builds against and the crypto guest already uses. Refs #1100 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- languages/golang/auth/guest/Cargo.lock | 7 +- languages/golang/auth/guest/src/abi.rs | 22 +- languages/golang/auth/guest/src/auth.rs | 68 +- languages/golang/auth/guest/src/ops.rs | 39 +- languages/golang/auth/guest/src/status.rs | 138 ++- languages/golang/auth/guest/tests/leak.rs | 258 ++++++ languages/golang/encrypt/guest/Cargo.lock | 3 + languages/golang/encrypt/guest/src/abi.rs | 103 ++- languages/golang/encrypt/guest/src/config.rs | 24 +- languages/golang/encrypt/guest/src/ops.rs | 78 +- languages/golang/encrypt/guest/src/options.rs | 58 +- languages/golang/encrypt/guest/src/status.rs | 48 +- languages/golang/encrypt/guest/tests/leak.rs | 793 ++++++++++++++++++ 13 files changed, 1466 insertions(+), 173 deletions(-) create mode 100644 languages/golang/auth/guest/tests/leak.rs create mode 100644 languages/golang/encrypt/guest/tests/leak.rs diff --git a/languages/golang/auth/guest/Cargo.lock b/languages/golang/auth/guest/Cargo.lock index ec6a57b95..1616b6d77 100644 --- a/languages/golang/auth/guest/Cargo.lock +++ b/languages/golang/auth/guest/Cargo.lock @@ -1867,7 +1867,10 @@ dependencies = [ name = "stack-guest-abi" version = "0.0.0" dependencies = [ + "miette", + "serde_json", "thiserror 1.0.69", + "vitaminc-aead-value", "vitaminc-protected", "zeroize", ] @@ -2283,9 +2286,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", "vitaminc-protected", diff --git a/languages/golang/auth/guest/src/abi.rs b/languages/golang/auth/guest/src/abi.rs index 72a58f2d8..93c54bc5d 100644 --- a/languages/golang/auth/guest/src/abi.rs +++ b/languages/golang/auth/guest/src/abi.rs @@ -25,9 +25,8 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; -use stack_guest_abi::abi::{err_status, input, ok_buffer}; -use stack_guest_abi::buffers; -use stack_guest_abi::status::STATUS_INTERNAL; +use stack_guest_abi::abi::{export, input}; +use stack_guest_abi::{buffers, last_error}; use crate::{auth, ops}; @@ -42,27 +41,25 @@ type Op1 = fn(&[u8]) -> Result, u32>; type Op2 = fn(&[u8], &[u8]) -> Result, u32>; /// Run a two-input operation as an export: validate both pairs, run, -/// pack. A panic is `STATUS_INTERNAL` (wasm32-wasip1 aborts on panic; the -/// catch is belt-and-braces for an unwinding build). +/// pack, through the shared [`export`] wrapper — which clears the last +/// error first, records one for any failure, and makes a panic +/// `STATUS_INTERNAL` (wasm32-wasip1 aborts on panic; the catch is +/// belt-and-braces for an unwinding build). fn export2(a_ptr: *const u8, a_len: u32, b_ptr: *const u8, b_len: u32, op: Op2) -> u64 { - catch_unwind(AssertUnwindSafe(|| { + export(|| { // SAFETY: host-owned ranges the export was handed; the borrows end // when `op` returns, inside the call, and nothing here writes to // linear memory while they are live. let a = unsafe { input(a_ptr, a_len)? }; let b = unsafe { input(b_ptr, b_len)? }; op(a, b) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + }) } /// [`export2`] for a one-input operation. fn export1(a_ptr: *const u8, a_len: u32, op: Op1) -> u64 { // SAFETY: as in `export2`. - catch_unwind(AssertUnwindSafe(|| op(unsafe { input(a_ptr, a_len)? }))) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + export(|| op(unsafe { input(a_ptr, a_len)? })) } /// The current workspace id of the store at `dir`. See @@ -241,5 +238,6 @@ pub unsafe extern "C" fn sa_auth_free(ptr: *const u8, len: u32) -> u64 { #[no_mangle] pub extern "C" fn sa_shutdown() { let _ = catch_unwind(AssertUnwindSafe(auth::clear)); + let _ = catch_unwind(AssertUnwindSafe(last_error::clear)); let _ = catch_unwind(AssertUnwindSafe(buffers::wipe_all)); } diff --git a/languages/golang/auth/guest/src/auth.rs b/languages/golang/auth/guest/src/auth.rs index cbdbef6a8..e1f0154cf 100644 --- a/languages/golang/auth/guest/src/auth.rs +++ b/languages/golang/auth/guest/src/auth.rs @@ -16,10 +16,10 @@ use stack_profile::ProfileStore; use zeroize::Zeroizing; use crate::host::{HostOidcProvider, WasiAuthTransport}; -use crate::status::{ - status_for_auth, status_for_profile, STATUS_AUTH_CONFIG, STATUS_AUTH_REFRESH_REQUIRED, - STATUS_ENCODING, STATUS_STATE, -}; +use stack_auth::AuthError; +use stack_guest_abi::last_error::{malformed, out_of_order}; + +use crate::status::{fail_auth, fail_profile, STATUS_AUTH_REFRESH_REQUIRED}; #[derive(Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] @@ -62,13 +62,16 @@ thread_local! { fn parse_base_url(value: Option) -> Result, u32> { value - .map(|v| v.parse::().map_err(|_| STATUS_ENCODING)) + .map(|v| { + v.parse::() + .map_err(|_| malformed("the strategy config's base_url is not a URL")) + }) .transpose() } pub fn validate_crn(bytes: &[u8]) -> Result, u32> { - let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?; - let _: Crn = text.parse().map_err(|_| STATUS_AUTH_CONFIG)?; + let text = std::str::from_utf8(bytes).map_err(|_| malformed("the CRN is not UTF-8"))?; + let _: Crn = text.parse().map_err(|e| fail_auth(&AuthError::from(e)))?; Ok(Vec::new()) } @@ -78,7 +81,10 @@ pub fn validate_crn(bytes: &[u8]) -> Result, u32> { // JSON envelope itself. pub fn create(config: &[u8]) -> Result, u32> { - let config: Config = serde_json::from_slice(config).map_err(|_| STATUS_ENCODING)?; + // Never serde_json's message: it can quote the config, which carries + // the access key. + let config: Config = serde_json::from_slice(config) + .map_err(|_| malformed("the strategy config is not a JSON object of the expected shape"))?; let strategy = match config { Config::AccessKey { crn, @@ -86,13 +92,15 @@ pub fn create(config: &[u8]) -> Result, u32> { base_url, } => { let access_key = Zeroizing::new(access_key); - let crn: Crn = crn.parse().map_err(|_| STATUS_AUTH_CONFIG)?; - let key: AccessKey = access_key.parse().map_err(|_| STATUS_AUTH_CONFIG)?; + let crn: Crn = crn.parse().map_err(|e| fail_auth(&AuthError::from(e)))?; + let key: AccessKey = access_key + .parse() + .map_err(|e| fail_auth(&AuthError::from(e)))?; let mut builder = AccessKeyStrategy::builder(crn, key).transport(WasiAuthTransport); if let Some(url) = parse_base_url(base_url)? { builder = builder.base_url(url); } - Strategy::AccessKey(builder.build().map_err(|e| status_for_auth(&e))?) + Strategy::AccessKey(builder.build().map_err(|e| fail_auth(&e))?) } Config::Oidc { crn, @@ -100,7 +108,7 @@ pub fn create(config: &[u8]) -> Result, u32> { base_url, cache_capacity, } => { - let crn: Crn = crn.parse().map_err(|_| STATUS_AUTH_CONFIG)?; + let crn: Crn = crn.parse().map_err(|e| fail_auth(&AuthError::from(e)))?; let mut builder = OidcFederationStrategy::builder(crn, HostOidcProvider(provider)) .transport(WasiAuthTransport); if let Some(url) = parse_base_url(base_url)? { @@ -109,14 +117,14 @@ pub fn create(config: &[u8]) -> Result, u32> { if let Some(capacity) = cache_capacity { builder = builder.cache_capacity(capacity); } - Strategy::Oidc(builder.build().map_err(|e| status_for_auth(&e))?) + Strategy::Oidc(builder.build().map_err(|e| fail_auth(&e))?) } Config::DeviceSession { workspace_dir, base_url, } => { if workspace_dir.is_empty() { - return Err(STATUS_ENCODING); + return Err(malformed("the strategy config's workspace_dir is empty")); } Strategy::DeviceSession { workspace_dir, @@ -136,22 +144,23 @@ pub fn create(config: &[u8]) -> Result, u32> { } fn parse_handle(bytes: &[u8]) -> Result { - let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?; - text.parse::().map_err(|_| STATUS_ENCODING) + let refuse = || malformed("a strategy handle is not a decimal number"); + let text = std::str::from_utf8(bytes).map_err(|_| refuse())?; + text.parse::().map_err(|_| refuse()) } pub fn token(handle: &[u8]) -> Result, u32> { let id = parse_handle(handle)?; STRATEGIES.with(|items| { let items = items.borrow(); - let strategy = items.get(&id).ok_or(STATUS_STATE)?; + let strategy = items.get(&id).ok_or_else(no_strategy)?; match strategy { Strategy::AccessKey(strategy) => block_on(strategy.get_token()) .map(|token| token.as_str().as_bytes().to_vec()) - .map_err(|e| status_for_auth(&e)), + .map_err(|e| fail_auth(&e)), Strategy::Oidc(strategy) => block_on(strategy.get_token()) .map(|token| token.as_str().as_bytes().to_vec()) - .map_err(|e| status_for_auth(&e)), + .map_err(|e| fail_auth(&e)), Strategy::DeviceSession { workspace_dir, base_url, @@ -165,12 +174,12 @@ pub fn token(handle: &[u8]) -> Result, u32> { fn cached_device_token(workspace_dir: &str, base_url: &Option) -> Result, u32> { let token: Token = ProfileStore::new(workspace_dir) .load_profile() - .map_err(|e| status_for_profile(&e))?; + .map_err(|e| fail_profile(&e))?; if token.region().is_none() || token.client_id().is_none() { - return Err(stack_guest_abi::status::STATUS_AUTH_NOT_AUTHENTICATED); + return Err(fail_auth(&stack_auth::NotAuthenticated.into())); } if base_url.is_none() { - let _ = token.issuer().map_err(|e| status_for_auth(&e))?; + let _ = token.issuer().map_err(|e| fail_auth(&e))?; } if token.is_expired() { return Err(STATUS_AUTH_REFRESH_REQUIRED); @@ -187,9 +196,9 @@ pub fn refresh(handle: &[u8]) -> Result, u32> { let Strategy::DeviceSession { workspace_dir, base_url, - } = items.get(&id).ok_or(STATUS_STATE)? + } = items.get(&id).ok_or_else(no_strategy)? else { - return Err(STATUS_STATE); + return Err(out_of_order("only a device session strategy refreshes")); }; let store = ProfileStore::new(workspace_dir); let mut builder = @@ -197,8 +206,8 @@ pub fn refresh(handle: &[u8]) -> Result, u32> { if let Some(url) = base_url { builder = builder.base_url(url.clone()); } - let strategy = builder.build().map_err(|e| status_for_auth(&e))?; - let token = block_on(strategy.get_token()).map_err(|e| status_for_auth(&e))?; + let strategy = builder.build().map_err(|e| fail_auth(&e))?; + let token = block_on(strategy.get_token()).map_err(|e| fail_auth(&e))?; Ok(token.as_str().as_bytes().to_vec()) }) } @@ -206,11 +215,16 @@ pub fn refresh(handle: &[u8]) -> Result, u32> { pub fn free(handle: &[u8]) -> Result, u32> { let id = parse_handle(handle)?; STRATEGIES.with(|items| { - let _removed = items.borrow_mut().remove(&id).ok_or(STATUS_STATE)?; + let _removed = items.borrow_mut().remove(&id).ok_or_else(no_strategy)?; Ok(Vec::new()) }) } +/// A handle that names no live strategy: never created, or already freed. +fn no_strategy() -> u32 { + out_of_order("no strategy has this handle: it was never created, or was freed") +} + pub fn clear() { STRATEGIES.with(|items| items.borrow_mut().clear()); } diff --git a/languages/golang/auth/guest/src/ops.rs b/languages/golang/auth/guest/src/ops.rs index 4e684cb82..6a32bfa5d 100644 --- a/languages/golang/auth/guest/src/ops.rs +++ b/languages/golang/auth/guest/src/ops.rs @@ -12,10 +12,11 @@ //! //! # Errors //! -//! Every function reports a [`crate::status`] code, never a message. A -//! directory, id or filename that is not UTF-8, or an empty directory, is -//! [`STATUS_ENCODING`]; everything else is `stack-profile`'s verdict -//! ([`status_for_profile`]). +//! Every function reports a [`crate::status`] code, and records the error +//! behind it for `se_last_error`. A directory, id or filename that is not +//! UTF-8, or an empty directory, is +//! [`STATUS_ENCODING`](crate::status::STATUS_ENCODING); everything else is +//! `stack-profile`'s verdict ([`fail_profile`]). //! //! # Copies //! @@ -33,7 +34,9 @@ use stack_profile::{DeviceIdentity, ProfileStore}; use vitaminc_aead_value::{transport as codec, FfiValue}; use zeroize::{Zeroize, ZeroizeOnDrop}; -use crate::status::{status_for_profile, STATUS_ENCODING, STATUS_INTERNAL}; +use stack_guest_abi::last_error::{internal, malformed}; + +use crate::status::fail_profile; /// The file `secretkey.json`, as `stack-auth`'s device client writes it /// and `stack-kms`'s `SecretKey` reads it: the ZeroKMS client id and the @@ -64,13 +67,13 @@ const AUTH_FILENAME: &str = "auth.json"; pub fn store(dir: &[u8]) -> Result { let dir = text(dir)?; if dir.is_empty() { - return Err(STATUS_ENCODING); + return Err(malformed("the store directory is empty")); } Ok(ProfileStore::new(dir)) } fn text(bytes: &[u8]) -> Result<&str, u32> { - std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING) + std::str::from_utf8(bytes).map_err(|_| malformed("an input is not UTF-8")) } fn string(value: impl Into) -> FfiValue { @@ -83,7 +86,7 @@ fn optional(value: Option<&str>) -> FfiValue { fn encode(value: FfiValue) -> Result, u32> { let mut out = Vec::new(); - codec::encode_value(value, &mut out).map_err(|_| STATUS_INTERNAL)?; + codec::encode_value(value, &mut out).map_err(|_| internal("an output did not encode"))?; Ok(out) } @@ -92,7 +95,7 @@ pub fn current_workspace(dir: &[u8]) -> Result, u32> { store(dir)? .current_workspace() .map(String::into_bytes) - .map_err(|e| status_for_profile(&e)) + .map_err(|e| fail_profile(&e)) } /// Set the current workspace. The workspace must already have a directory; @@ -101,7 +104,7 @@ pub fn set_current_workspace(dir: &[u8], id: &[u8]) -> Result, u32> { store(dir)? .set_current_workspace(text(id)?) .map(|()| Vec::new()) - .map_err(|e| status_for_profile(&e)) + .map_err(|e| fail_profile(&e)) } /// Remove the current workspace selection. Empty output; nothing to remove @@ -110,7 +113,7 @@ pub fn clear_current_workspace(dir: &[u8]) -> Result, u32> { store(dir)? .clear_current_workspace() .map(|()| Vec::new()) - .map_err(|e| status_for_profile(&e)) + .map_err(|e| fail_profile(&e)) } /// The workspace ids with profile data on disk, sorted, as a codec array @@ -118,7 +121,7 @@ pub fn clear_current_workspace(dir: &[u8]) -> Result, u32> { pub fn list_workspaces(dir: &[u8]) -> Result, u32> { let ids = store(dir)? .list_workspaces() - .map_err(|e| status_for_profile(&e))?; + .map_err(|e| fail_profile(&e))?; encode(FfiValue::Array(ids.into_iter().map(string).collect())) } @@ -129,7 +132,7 @@ pub fn list_workspaces(dir: &[u8]) -> Result, u32> { pub fn workspace_dir(dir: &[u8], id: &[u8]) -> Result, u32> { let scoped = store(dir)? .workspace_store(text(id)?) - .map_err(|e| status_for_profile(&e))?; + .map_err(|e| fail_profile(&e))?; path_bytes(scoped.dir()) } @@ -139,7 +142,7 @@ pub fn workspace_dir(dir: &[u8], id: &[u8]) -> Result, u32> { pub fn lock_path(dir: &[u8], filename: &[u8]) -> Result, u32> { let path = store(dir)? .lock_path(text(filename)?) - .map_err(|e| status_for_profile(&e))?; + .map_err(|e| fail_profile(&e))?; path_bytes(&path) } @@ -148,7 +151,7 @@ fn path_bytes(path: &std::path::Path) -> Result, u32> { // this module's bug, not the caller's. path.to_str() .map(|s| s.as_bytes().to_vec()) - .ok_or(STATUS_INTERNAL) + .ok_or_else(|| internal("a store path is not UTF-8")) } /// `secretkey.json` in this store, as a codec object `{client_id, @@ -157,7 +160,7 @@ fn path_bytes(path: &std::path::Path) -> Result, u32> { pub fn secret_key(dir: &[u8]) -> Result, u32> { let mut file: SecretKeyFile = store(dir)? .load(SECRET_KEY_FILENAME) - .map_err(|e| status_for_profile(&e))?; + .map_err(|e| fail_profile(&e))?; // Moved out rather than copied: `SecretKeyFile` wipes on drop, so its // fields cannot be moved out of it directly. let client_id = std::mem::take(&mut file.client_id); @@ -180,7 +183,7 @@ pub fn secret_key(dir: &[u8]) -> Result, u32> { pub fn token(dir: &[u8]) -> Result, u32> { let token: Token = store(dir)? .load(AUTH_FILENAME) - .map_err(|e| status_for_profile(&e))?; + .map_err(|e| fail_profile(&e))?; encode(FfiValue::Object(vec![ ( "access_token".to_string(), @@ -210,7 +213,7 @@ pub fn has_token(dir: &[u8]) -> Result, u32> { /// `device.json` in this store, read-only, as a codec object /// `{device_instance_id, device_name}`. Creating one is the CLI's. pub fn device_identity(dir: &[u8]) -> Result, u32> { - let identity = DeviceIdentity::load(&store(dir)?).map_err(|e| status_for_profile(&e))?; + let identity = DeviceIdentity::load(&store(dir)?).map_err(|e| fail_profile(&e))?; encode(FfiValue::Object(vec![ ( "device_instance_id".to_string(), diff --git a/languages/golang/auth/guest/src/status.rs b/languages/golang/auth/guest/src/status.rs index 0ac77b52b..bfb95c8ce 100644 --- a/languages/golang/auth/guest/src/status.rs +++ b/languages/golang/auth/guest/src/status.rs @@ -1,4 +1,6 @@ -//! Map profile and auth errors onto the shared guest status table. +//! Map profile and auth errors onto the shared guest status table, and +//! record each as the last error ([`fail_auth`], [`fail_profile`]) so +//! `se_last_error` has the full error behind the number. //! //! The numbers are [`stack_guest_abi::status`]'s — one table for every //! guest, decoded once by the Go host — re-exported here so this crate's @@ -7,37 +9,64 @@ //! `stack-profile` conditions a Go caller can act on each have one, and //! the two it cannot act on are internal. -use stack_auth::AuthError; +use stack_auth::{AuthError, ErrorPayload}; +use stack_guest_abi::last_error; use stack_profile::ProfileError; pub use stack_guest_abi::status::{ - STATUS_AUTH_CONFIG, STATUS_AUTH_REFRESH_REQUIRED, STATUS_ENCODING, STATUS_INTERNAL, + STATUS_AUTH_CONFIG, STATUS_AUTH_INVALID_CLIENT, STATUS_AUTH_INVALID_GRANT, + STATUS_AUTH_NOT_AUTHENTICATED, STATUS_AUTH_OTHER, STATUS_AUTH_REFRESH_REQUIRED, + STATUS_AUTH_TRANSPORT, STATUS_AUTH_USAGE_LIMIT, STATUS_ENCODING, STATUS_INTERNAL, STATUS_PROFILE_INVALID_FILENAME, STATUS_PROFILE_INVALID_WORKSPACE_ID, STATUS_PROFILE_IO, STATUS_PROFILE_JSON, STATUS_PROFILE_NOT_FOUND, STATUS_PROFILE_NO_CURRENT_WORKSPACE, STATUS_PROFILE_WORKSPACE_NOT_FOUND, STATUS_STATE, }; +/// Record an auth error as the last error and return its status: what +/// every export's `map_err` calls, so the status a host acts on and the +/// error it can ask for come from the one site. +pub fn fail_auth(error: &AuthError) -> u32 { + last_error::record(error, error.payload()); + status_for_auth(error) +} + +/// [`fail_auth`] for a profile error. +pub fn fail_profile(error: &ProfileError) -> u32 { + last_error::record(error, error.payload()); + status_for_profile(error) +} + /// Preserve the auth decisions callers can act on without exposing token /// bytes or parsing a server message across the ABI. +/// +/// Matched on the variants, not on `error_code()`'s strings: a renamed code +/// cannot silently fall through to [`STATUS_AUTH_OTHER`] here. `AuthError` +/// is `#[non_exhaustive]`, so a variant added upstream still lands on the +/// catch-all until someone reads it and gives it a number; every variant +/// there is today is named. pub fn status_for_auth(error: &AuthError) -> u32 { - use stack_guest_abi::status::*; - if let AuthError::Store(store_error) = error { - return status_for_profile(&store_error.0); - } - match error.error_code() { - "INVALID_GRANT" => STATUS_AUTH_INVALID_GRANT, - "INVALID_CLIENT" => STATUS_AUTH_INVALID_CLIENT, - "USAGE_LIMIT_EXCEEDED" => STATUS_AUTH_USAGE_LIMIT, - "NOT_AUTHENTICATED" | "EXPIRED_TOKEN" => STATUS_AUTH_NOT_AUTHENTICATED, - "REQUEST_ERROR" | "SERVER_ERROR" => STATUS_AUTH_TRANSPORT, - "INVALID_URL" - | "INVALID_REGION" - | "INVALID_CRN" - | "WORKSPACE_MISMATCH" - | "INVALID_WORKSPACE_ID" - | "MISSING_WORKSPACE_CRN" - | "INVALID_ACCESS_KEY" - | "INVALID_TOKEN" => STATUS_AUTH_CONFIG, + match error { + AuthError::Store(store_error) => status_for_profile(&store_error.0), + AuthError::InvalidGrant(_) => STATUS_AUTH_INVALID_GRANT, + AuthError::InvalidClient(_) => STATUS_AUTH_INVALID_CLIENT, + AuthError::UsageLimitExceeded(_) => STATUS_AUTH_USAGE_LIMIT, + AuthError::NotAuthenticated(_) | AuthError::TokenExpired(_) => { + STATUS_AUTH_NOT_AUTHENTICATED + } + AuthError::Request(_) | AuthError::Server(_) => STATUS_AUTH_TRANSPORT, + AuthError::InvalidUrl(_) + | AuthError::Region(_) + | AuthError::InvalidCrn(_) + | AuthError::WorkspaceMismatch(_) + | AuthError::InvalidWorkspaceId(_) + | AuthError::MissingWorkspaceCrn(_) + | AuthError::InvalidAccessKey(_) + | AuthError::InvalidToken(_) => STATUS_AUTH_CONFIG, + AuthError::AccessDenied(_) + | AuthError::OrgNotProvisioned(_) + | AuthError::AlreadyConsumed(_) + | AuthError::Internal(_) + | AuthError::Custom(_) => STATUS_AUTH_OTHER, _ => STATUS_AUTH_OTHER, } } @@ -112,6 +141,73 @@ mod tests { } } + /// The match on variants gives every error the status the match on + /// `error_code()` strings it replaced gave, so the switch changed no + /// number. The string table is kept here, frozen, as the reference. + #[test] + fn the_variant_match_gives_every_code_its_old_status() { + fn by_code(code: &str) -> u32 { + match code { + "INVALID_GRANT" => STATUS_AUTH_INVALID_GRANT, + "INVALID_CLIENT" => STATUS_AUTH_INVALID_CLIENT, + "USAGE_LIMIT_EXCEEDED" => STATUS_AUTH_USAGE_LIMIT, + "NOT_AUTHENTICATED" | "EXPIRED_TOKEN" => STATUS_AUTH_NOT_AUTHENTICATED, + "REQUEST_ERROR" | "SERVER_ERROR" => STATUS_AUTH_TRANSPORT, + "INVALID_URL" + | "INVALID_REGION" + | "INVALID_CRN" + | "WORKSPACE_MISMATCH" + | "INVALID_WORKSPACE_ID" + | "MISSING_WORKSPACE_CRN" + | "INVALID_ACCESS_KEY" + | "INVALID_TOKEN" => STATUS_AUTH_CONFIG, + _ => STATUS_AUTH_OTHER, + } + } + let workspace = |id: &str| id.parse::().unwrap(); + let errors: Vec = vec![ + stack_auth::RequestError(Box::new(std::io::Error::other("refused"))).into(), + stack_auth::AccessDenied.into(), + stack_auth::InvalidGrant.into(), + stack_auth::InvalidClient.into(), + "not a url".parse::().unwrap_err().into(), + "nowhere".parse::().unwrap_err().into(), + "not a crn".parse::().unwrap_err().into(), + stack_auth::WorkspaceMismatch { + expected_workspace: workspace("ZVATKW3VHMFG27DY"), + token_workspace: workspace("AAAAAAAAAAAAAAAA"), + } + .into(), + "short" + .parse::() + .unwrap_err() + .into(), + stack_auth::MissingWorkspaceCrn.into(), + stack_auth::NotAuthenticated.into(), + stack_auth::TokenExpired.into(), + "".parse::().unwrap_err().into(), + stack_auth::InvalidToken("malformed".into()).into(), + stack_auth::UsageLimitExceeded("over".into()).into(), + stack_auth::OrgNotProvisioned("unknown".into()).into(), + stack_auth::ServerError("boom".into()).into(), + stack_auth::AlreadyConsumed.into(), + stack_auth::InternalError("poisoned".into()).into(), + stack_auth::CustomError("custom".into()).into(), + ]; + let mut codes = std::collections::BTreeSet::new(); + for error in &errors { + assert_eq!( + status_for_auth(error), + by_code(error.error_code()), + "{error:?}" + ); + codes.insert(error.error_code()); + } + // Every frozen code but the store's, which defers to the profile + // status and has its own test below. + assert_eq!(codes.len(), AuthError::ERROR_CODES.len() - 1); + } + #[test] fn auth_store_errors_keep_their_profile_status() { let missing = AuthError::from(ProfileError::NotFound { diff --git a/languages/golang/auth/guest/tests/leak.rs b/languages/golang/auth/guest/tests/leak.rs new file mode 100644 index 000000000..9fdb4e031 --- /dev/null +++ b/languages/golang/auth/guest/tests/leak.rs @@ -0,0 +1,258 @@ +//! The leak test for the credential guest: no error it records carries a +//! secret. +//! +//! Every error path a native test can reach is driven with marker values +//! where a caller's secrets would be — an access token in `auth.json`, an +//! access key in a strategy config, a client key in `secretkey.json` — +//! through the same functions the wasm exports run. Each failure's encoded +//! error, the bytes `se_last_error` would hand the host, is decoded and +//! searched at every depth: no marker may appear. The rule it enforces is +//! written on `ErrorPayload` in `stack-profile`. +//! +//! The profile exports (`ops`) run natively, so they are driven directly. +//! The strategy exports (`auth`) exist only on wasm32, since they reach the +//! host's imports, so their refusals are recorded here through the same +//! `status::fail_auth` they call, from errors built the way they build them +//! (a parsed access key, a parsed CRN, a token with no region). The token +//! exchanges themselves need the host's HTTP import; their errors are +//! stack-auth's, which the crypto guest's leak test also encodes. + +use std::collections::BTreeSet; +use std::path::Path; + +use stack_auth::AuthError; +use stack_auth_guest::ops; +use stack_auth_guest::status::fail_auth; +use stack_guest_abi::last_error; +use vitaminc_aead_value::{transport as codec, FfiValue}; + +const ACCESS_TOKEN: &str = "leak-marker-access-token"; +const ACCESS_KEY: &str = "leak-marker-access-key"; +const CLIENT_KEY: &str = "leak-marker-client-key"; + +const MARKERS: &[&str] = &[ACCESS_TOKEN, ACCESS_KEY, CLIENT_KEY]; + +/// Run one failing step the way an export does — clear, run, make sure a +/// failure is recorded — and return the decoded error. +fn failure(what: &str, step: impl FnOnce() -> Result) -> FfiValue { + last_error::clear(); + let status = step().expect_err(what); + last_error::ensure(status); + let bytes = last_error::take().unwrap_or_else(|| panic!("{what}: no error recorded")); + codec::decode_value(&mut codec::Reader::new(&bytes)).expect("an error decodes") +} + +fn check(text: &[u8], at: &str, found: &mut Vec) { + for marker in MARKERS { + if text + .windows(marker.len()) + .any(|window| window == marker.as_bytes()) + { + found.push(format!("{at}: {marker}")); + } + } +} + +fn leaks(value: &FfiValue, path: &str, found: &mut Vec) { + match value { + FfiValue::String(text) => check(text.risky_ref(), path, found), + FfiValue::Bytes(bytes) => { + use vitaminc_protected::Controlled; + check(bytes.risky_ref(), path, found); + } + FfiValue::Array(items) => { + for (at, item) in items.iter().enumerate() { + leaks(item, &format!("{path}[{at}]"), found); + } + } + FfiValue::Object(entries) => { + for (key, item) in entries { + check(key.as_bytes(), &format!("{path} key"), found); + leaks(item, &format!("{path}.{key}"), found); + } + } + _ => {} + } +} + +fn get<'a>(value: &'a FfiValue, key: &str) -> Option<&'a FfiValue> { + match value { + FfiValue::Object(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } +} + +fn text(value: Option<&FfiValue>) -> Option { + match value? { + FfiValue::String(s) => String::from_utf8(s.risky_ref().to_vec()).ok(), + _ => None, + } +} + +fn dir(path: &Path) -> Vec { + path.to_str().expect("utf8 temp dir").as_bytes().to_vec() +} + +fn scenarios() -> Vec<(&'static str, FfiValue)> { + let root = tempfile::tempdir().expect("temp dir"); + let store = dir(root.path()); + let workspace = root.path().join("workspaces").join("AAAAAAAAAAAAAAAA"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + + vec![ + // -- the profile store --------------------------------------------- + ( + "an empty store directory", + failure("empty dir", || ops::current_workspace(b"")), + ), + ( + "no current workspace", + failure("no current", || ops::current_workspace(&store)), + ), + ( + "a workspace id that is not one", + failure("bad id", || ops::set_current_workspace(&store, b"short")), + ), + ( + "a workspace with no directory", + failure("no workspace", || { + ops::set_current_workspace(&store, b"BBBBBBBBBBBBBBBB") + }), + ), + ( + "a filename that names a path", + failure("bad filename", || ops::lock_path(&store, b"../auth.json")), + ), + ( + "a profile file that is not there", + failure("missing", || ops::token(&dir(&workspace))), + ), + ( + "an auth.json whose expiry is the token", + failure("json", || { + std::fs::write( + workspace.join("auth.json"), + format!( + r#"{{"access_token":"x","token_type":"Bearer","expires_at":"{ACCESS_TOKEN}"}}"# + ), + ) + .expect("write auth.json"); + ops::token(&dir(&workspace)) + }), + ), + ( + "a secretkey.json whose client id is the key", + failure("secret key json", || { + std::fs::write( + workspace.join("secretkey.json"), + format!(r#"{{"client_id":{{"{CLIENT_KEY}":1}}}}"#), + ) + .expect("write secretkey.json"); + ops::secret_key(&dir(&workspace)) + }), + ), + ( + "a profile file that is a directory", + failure("io", || { + std::fs::create_dir_all(workspace.join("device.json")).expect("dir"); + ops::device_identity(&dir(&workspace)) + }), + ), + // -- strategies, as `auth` records their refusals ----------------- + ( + "an access key that is not one", + failure("access key", || { + ACCESS_KEY + .parse::() + .map_err(|e| fail_auth(&AuthError::from(e))) + }), + ), + ( + "a CRN that is not one", + failure("crn", || { + "not a crn" + .parse::() + .map_err(|e| fail_auth(&AuthError::from(e))) + }), + ), + ( + "a device token with no region", + failure("not authenticated", || { + let session = root.path().join("session"); + std::fs::create_dir_all(&session).expect("dir"); + std::fs::write( + session.join("auth.json"), + format!( + r#"{{"access_token":"{ACCESS_TOKEN}","token_type":"Bearer","expires_at":99999999999}}"# + ), + ) + .expect("write auth.json"); + let token: stack_auth::Token = stack_profile::ProfileStore::new(&session) + .load_profile() + .expect("a token that parses"); + if token.region().is_none() { + return Err(fail_auth(&stack_auth::NotAuthenticated.into())); + } + Ok(()) + }), + ), + ] +} + +#[test] +fn no_encoded_error_carries_a_marker() { + let expected: BTreeSet<&str> = [ + "stack_guest_abi::malformed_input", + "stack_profile::no_current_workspace", + "stack_profile::invalid_workspace_id", + "stack_profile::workspace_not_found", + "stack_profile::invalid_filename", + "stack_profile::not_found", + "stack_profile::json", + "stack_profile::io", + "stack_auth::invalid_access_key", + "stack_auth::invalid_crn", + "stack_auth::not_authenticated", + ] + .into_iter() + .collect(); + let mut reached = BTreeSet::new(); + let mut found = Vec::new(); + for (what, error) in scenarios() { + leaks(&error, what, &mut found); + let code = text(get(&error, "code")).unwrap_or_else(|| panic!("{what}: no code")); + reached.insert(code); + } + assert!( + found.is_empty(), + "markers in encoded errors:\n{}", + found.join("\n") + ); + let missing: Vec<&str> = expected + .into_iter() + .filter(|code| !reached.contains(*code)) + .collect(); + assert!( + missing.is_empty(), + "codes no scenario reached: {missing:?}\nreached: {reached:?}" + ); +} + +/// A profile JSON error gives its kind and position, and its payload names +/// where in the file, so a caller can say which line to fix. +#[test] +fn a_profile_json_error_points_at_the_line() { + let root = tempfile::tempdir().expect("temp dir"); + std::fs::write(root.path().join("auth.json"), "{\n \"access_token\": 7\n}").expect("write"); + let error = failure("json", || ops::token(&dir(root.path()))); + assert_eq!( + text(get(&error, "code")).as_deref(), + Some("stack_profile::json") + ); + let fields = get(&error, "fields").expect("fields"); + assert!(matches!(get(fields, "line"), Some(FfiValue::UInt64(2)))); + assert!( + text(get(&error, "help")).is_some(), + "the help says how to fix it" + ); +} diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 841e36ff0..09815fb6a 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -2119,7 +2119,10 @@ dependencies = [ name = "stack-guest-abi" version = "0.0.0" dependencies = [ + "miette", + "serde_json", "thiserror 1.0.69", + "vitaminc-aead-value", "vitaminc-protected", "zeroize", ] diff --git a/languages/golang/encrypt/guest/src/abi.rs b/languages/golang/encrypt/guest/src/abi.rs index 95a314b24..f94e3745b 100644 --- a/languages/golang/encrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -39,6 +39,13 @@ //! the guest **only** through `se_alloc` (to place the transport response //! / token); calling any other export from inside a host import is //! undefined behaviour of the embedding, not of this module. +//! - **Every export runs through `stack_guest_abi::abi::export`**, so a +//! failure leaves its full error for `se_last_error`: the code, message, +//! help and fields of the `stack_encrypt`, `stack_kms` or `stack_auth` +//! error behind the status, recorded where the status is decided +//! ([`crate::status::fail_error`] and [`crate::status::fail_dynamic`]), +//! or a `GuestError` naming what this boundary refused. [`se_shutdown`] +//! wipes it with every other buffer. //! //! There are no whole-value exports: every value crosses as a record under //! a declaration (ADR-0007, amended). The record and term exports bind @@ -66,15 +73,19 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use futures::executor::block_on; use stack_encrypt::{KeysetCipher, StackCipher}; -use stack_guest_abi::abi::{err_status, input, ok_buffer, take_plaintext, wipe_input}; +use stack_guest_abi::abi::{export, input, take_plaintext, wipe_input}; use stack_guest_abi::buffers; +use stack_guest_abi::last_error::{self, malformed, out_of_order}; use vitaminc_aead_value::transport as codec; use vitaminc_aead_value::FfiValue; use crate::ops; use crate::options::{parse_options, parse_selector, scope_for, KeysetSelector, Side}; -use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_STATE}; +#[cfg(not(feature = "deterministic-kms"))] +use crate::status::STATUS_INTERNAL; use stack_encrypt::dynamic::Scope; +#[cfg(not(feature = "deterministic-kms"))] +use stack_encrypt::ErrorPayload; /// The key source the instance's cipher runs over: the host-transport /// ZeroKMS client with host-supplied tokens — or, in the `deterministic-kms` @@ -100,7 +111,8 @@ thread_local! { /// Decode one codec-encoded input. fn decode(bytes: &[u8]) -> Result { - codec::decode_value(&mut codec::Reader::new(bytes)).map_err(|_| STATUS_ENCODING) + codec::decode_value(&mut codec::Reader::new(bytes)) + .map_err(|_| malformed("an input is not a value in the transport codec")) } /// Run `f` with the instance's cipher, or report `STATUS_STATE` when there @@ -108,7 +120,11 @@ fn decode(bytes: &[u8]) -> Result { fn with_cipher(f: impl FnOnce(&GuestCipher) -> Result) -> Result { CIPHER.with(|c| { let c = c.borrow(); - let cipher = c.as_ref().ok_or(STATUS_STATE)?; + let cipher = c.as_ref().ok_or_else(|| { + out_of_order( + "there is no cipher: call se_cipher_init first, and none after se_shutdown", + ) + })?; f(cipher) }) } @@ -161,7 +177,7 @@ fn with_scope( /// the bytes are the ones the host intended. #[no_mangle] pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { - catch_unwind(AssertUnwindSafe(|| { + export(|| { // The pointer/length pair is validated first and on its own: a pair // that fails here returns before anything touches the range, which // is `wipe_input`'s precondition. Only a validated buffer is decoded @@ -175,9 +191,7 @@ pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { // before the init round trip), whatever the decode outcome. unsafe { wipe_input(cfg_ptr, cfg_len) }; cipher_init(decoded?) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + }) } #[cfg(not(feature = "deterministic-kms"))] @@ -186,9 +200,9 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { use crate::status::STATUS_KMS_TRANSPORT; use stack_kms::{ClientOpts, StackKms}; - let config = crate::config::parse_config(decoded).map_err(|_| STATUS_ENCODING)?; + let config = crate::config::parse_config(decoded).map_err(|e| malformed(e.describe()))?; if SHUT_DOWN.with(Cell::get) || CIPHER.with(|c| c.borrow().is_some()) { - return Err(STATUS_STATE); + return Err(init_out_of_order()); } // One request at a time: the host import is synchronous, so concurrency @@ -203,19 +217,25 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { // docs say 500 rather than "one". let opts = ClientOpts::new(config.endpoint) .with_max_concurrent_reqs(1) - .map_err(|_| STATUS_INTERNAL)?; + .map_err(|e| { + last_error::record(&e, e.payload()); + STATUS_INTERNAL + })?; let kms = StackKms::::connect( opts, HostTokenStrategy, config.client_key, ) - .map_err(|_| STATUS_KMS_TRANSPORT)?; + .map_err(|e| { + last_error::record_with(&e, e.payload(), &crate::status::describe); + STATUS_KMS_TRANSPORT + })?; let mut builder = StackCipher::builder().kms(kms); if let Some(size) = config.keyset_cache_size { builder = builder.keyset_cache_size(size); } - install(block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?) + install(block_on(builder.init()).map_err(|e| crate::status::fail_error(&e))?) } /// The `deterministic-kms` build's init: the config buffer is the @@ -226,19 +246,25 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { fn cipher_init(decoded: FfiValue) -> Result, u32> { use vitaminc_protected::Controlled; + let not_a_seed = || malformed("the deterministic build's config is a 32-byte seed"); let FfiValue::Bytes(seed) = decoded else { - return Err(STATUS_ENCODING); + return Err(not_a_seed()); }; let seed: [u8; 32] = seed .risky_ref() .as_slice() .try_into() - .map_err(|_| STATUS_ENCODING)?; + .map_err(|_| not_a_seed())?; if SHUT_DOWN.with(Cell::get) || CIPHER.with(|c| c.borrow().is_some()) { - return Err(STATUS_STATE); + return Err(init_out_of_order()); } let builder = StackCipher::builder().kms(crate::deterministic::DeterministicSource::new(seed)); - install(block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?) + install(block_on(builder.init()).map_err(|e| crate::status::fail_error(&e))?) +} + +/// `se_cipher_init` called a second time, or after `se_shutdown`. +fn init_out_of_order() -> u32 { + out_of_order("the cipher is already initialised, or the instance is shut down") } /// Install the built cipher as the instance's and return its default @@ -271,6 +297,7 @@ pub extern "C" fn se_shutdown() { CIPHER.with(|c| { let _ = c.borrow_mut().take(); }); + last_error::clear(); buffers::wipe_all(); })); } @@ -286,20 +313,18 @@ pub extern "C" fn se_shutdown() { /// As for [`se_term`]. #[no_mangle] pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { - catch_unwind(AssertUnwindSafe(|| { + export(|| { // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns and before any wipe of an overlapping range. let selector = parse_selector(decode(unsafe { input(sel_ptr, sel_len)? })?)?; if selector == KeysetSelector::Any { - return Err(STATUS_ENCODING); + return Err(malformed(r#"{"any"} is not a keyset"#)); } with_cipher(|cipher| { let keyset = block_on(selector.resolve(cipher))?; Ok(keyset.keyset_id().as_bytes().to_vec()) }) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + }) } /// Derive one index term under the keyset `opts` selects: a codec-encoded @@ -334,7 +359,7 @@ pub unsafe extern "C" fn se_term( opt_ptr: *const u8, opt_len: u32, ) -> u64 { - catch_unwind(AssertUnwindSafe(|| { + export(|| { let value = unsafe { take_plaintext(val_ptr, val_len)? }; let value = value.as_slice(); // SAFETY: host-owned ranges the export was handed; the borrows end @@ -345,9 +370,7 @@ pub unsafe extern "C" fn se_term( with_keyset(opts, |keyset| { block_on(ops::term(keyset, value, context, kind)) }) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + }) } /// Encrypt a record (or a batch) per a plan under the keyset `opts` selects @@ -369,7 +392,7 @@ pub unsafe extern "C" fn se_encrypt_record( opt_ptr: *const u8, opt_len: u32, ) -> u64 { - catch_unwind(AssertUnwindSafe(|| { + export(|| { let source = unsafe { take_plaintext(src_ptr, src_len)? }; let source = source.as_slice(); // SAFETY: host-owned ranges the export was handed; the borrows end @@ -380,9 +403,7 @@ pub unsafe extern "C" fn se_encrypt_record( with_keyset(opts, |keyset| { block_on(ops::encrypt_record(keyset, source, plan)) }) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + }) } /// Decrypt a record (or a batch) produced by [`se_encrypt_record`] under @@ -410,7 +431,7 @@ pub unsafe extern "C" fn se_decrypt_record( opt_ptr: *const u8, opt_len: u32, ) -> u64 { - catch_unwind(AssertUnwindSafe(|| { + export(|| { // SAFETY: host-owned ranges the export was handed; the borrows end // before it returns and before any wipe of an overlapping range. let record = unsafe { input(rec_ptr, rec_len)? }; @@ -421,9 +442,7 @@ pub unsafe extern "C" fn se_decrypt_record( with_scope(opts, |scope| { block_on(ops::decrypt_record(scope, record, plan, expected)) }) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + }) } /// Check a codec-encoded plan without a cipher: `STATUS_ENCODING` for a @@ -437,14 +456,12 @@ pub unsafe extern "C" fn se_decrypt_record( /// As for [`se_term`]. #[no_mangle] pub unsafe extern "C" fn se_plan_check(plan_ptr: *const u8, plan_len: u32) -> u64 { - catch_unwind(AssertUnwindSafe(|| { + export(|| { // SAFETY: a host-owned range the export was handed; the borrow ends // before it returns. let plan = unsafe { input(plan_ptr, plan_len)? }; ops::plan_check(plan).map(|()| Vec::new()) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + }) } /// Derive the EQL query value of one target field under the keyset `opts` @@ -468,7 +485,7 @@ pub unsafe extern "C" fn se_query( opt_ptr: *const u8, opt_len: u32, ) -> u64 { - catch_unwind(AssertUnwindSafe(|| { + export(|| { let value = unsafe { take_plaintext(val_ptr, val_len)? }; let value = value.as_slice(); // SAFETY: host-owned ranges the export was handed; the borrows end @@ -480,9 +497,7 @@ pub unsafe extern "C" fn se_query( with_keyset(opts, |keyset| { block_on(ops::query(keyset, value, plan, field)) }) - })) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + }) } /// The EQL types this build knows, as a codec-encoded @@ -490,7 +505,5 @@ pub unsafe extern "C" fn se_query( /// nothing in the other. Needs no cipher. #[no_mangle] pub extern "C" fn se_targets() -> u64 { - catch_unwind(AssertUnwindSafe(ops::targets)) - .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_buffer) + export(ops::targets) } diff --git a/languages/golang/encrypt/guest/src/config.rs b/languages/golang/encrypt/guest/src/config.rs index c12c4a9d8..800461444 100644 --- a/languages/golang/encrypt/guest/src/config.rs +++ b/languages/golang/encrypt/guest/src/config.rs @@ -31,8 +31,9 @@ use vitaminc_aead_value::FfiValue; use zeroize::Zeroizing; /// A parse failure, carrying which key was at fault. Maps to -/// `STATUS_ENCODING` at the ABI; the detail exists for the native tests and -/// is never surfaced across the boundary (statuses leak no config content). +/// `STATUS_ENCODING` at the ABI, and [`describe`](Self::describe) is the +/// message the host reads through `se_last_error`: the name of the key at +/// fault crosses the boundary, its value never does. #[derive(Debug, PartialEq, Eq)] pub enum ConfigError { /// The config was not an object of string values. @@ -52,6 +53,25 @@ pub enum ConfigError { UnknownKey(String), } +impl ConfigError { + /// Says what was wrong with the config, for the host to read. + /// + /// The message names the key at fault but never its value, because the + /// config holds the client key. An unrecognised key is not named + /// either: if a value is pasted where a key should be, the unrecognised + /// key *is* that value. + pub fn describe(&self) -> String { + match self { + Self::NotAnObject => "the config is not an object of string values".to_owned(), + Self::Missing(key) => format!("the config has no {key}"), + Self::NotAString(key) => format!("the config's {key} is not a string"), + Self::Invalid(key) => format!("the config's {key} is not valid"), + Self::Duplicate(key) => format!("the config gives {key} twice"), + Self::UnknownKey(_) => "the config has a key this version does not know".to_owned(), + } + } +} + /// Everything `se_cipher_init` needs to build the cipher. pub struct CipherConfig { pub client_key: ClientKey, diff --git a/languages/golang/encrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs index b4f2bf554..35549c6a9 100644 --- a/languages/golang/encrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -38,6 +38,7 @@ use stack_encrypt::dynamic::{self, Scalar, Scope}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::IndexSpec; +use stack_encrypt::ErrorPayload; use stack_encrypt::{ BoxedPassthrough, CipherText, KeysetCipher, Label, SealedValue, StackCipherText, }; @@ -45,7 +46,9 @@ use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; use vitaminc_protected::Controlled; -use crate::status::{status_for_dynamic, status_for_error, STATUS_ENCODING, STATUS_INTERNAL}; +use stack_guest_abi::last_error; + +use crate::status::{fail_dynamic, fail_error, STATUS_ENCODING, STATUS_INTERNAL}; use crate::targets::resolver; /// Term kinds for `se_term`, part of the guest/host contract (the Go host @@ -94,11 +97,11 @@ where { // The same proof every stack-encrypt leaf demands: an empty context is // `STATUS_ENCODING` here, before any derivation. - let context = dynamic::context(decode_value(context)?).map_err(|e| status_for_dynamic(&e))?; + let context = dynamic::context(decode_value(context)?).map_err(|e| fail_dynamic(&e))?; let (scalar, kind) = parse_term(decode_value(value)?, kind)?; dynamic::term(cipher, scalar, &kind, context) .await - .map_err(|e| status_for_dynamic(&e)) + .map_err(|e| fail_dynamic(&e)) } /// The static half of a term: the kind is one of the ABI's table, the value @@ -116,11 +119,15 @@ fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, IndexSpec), u32> { TERM_MATCH => IndexSpec::Match(MatchOptions::default()), TERM_ORE => IndexSpec::Ore, TERM_OPE => IndexSpec::Ope, - _ => return Err(STATUS_ENCODING), + _ => { + return Err(last_error::malformed( + "the term kind is not one of the four", + )) + } }; - let scalar = Scalar::of(&value, &kind).map_err(|e| status_for_dynamic(&e))?; + let scalar = Scalar::of(&value, &kind).map_err(|e| fail_dynamic(&e))?; if !kind.supports(&scalar) { - return Err(STATUS_ENCODING); + return Err(fail_dynamic(&dynamic::Error::Term { field: None, kind })); } Ok((scalar, kind)) } @@ -158,9 +165,9 @@ where { let plan = parse_plan(plan)?; let tree = dynamic::record::encrypt_with(cipher, decode_value(source)?, &plan, &resolver()) - .map_err(|e| status_for_dynamic(&e))? + .map_err(|e| fail_dynamic(&e))? .await - .map_err(|e| status_for_error(&e))?; + .map_err(|e| fail_error(&e))?; encode_tree(tree) } @@ -193,9 +200,9 @@ where let plan = parse_plan(plan)?; let value = dynamic::record::decrypt_with(scope, decode_tree(record)?, &plan, expected, &resolver()) - .map_err(|e| status_for_dynamic(&e))? + .map_err(|e| fail_dynamic(&e))? .await - .map_err(|e| status_for_error(&e))?; + .map_err(|e| fail_error(&e))?; encode_value(value) } @@ -216,18 +223,19 @@ where K: DataKeySource + Sync + 'static, { let plan = parse_plan(plan)?; - let field = std::str::from_utf8(field).map_err(|_| STATUS_ENCODING)?; + let field = std::str::from_utf8(field) + .map_err(|_| last_error::malformed("the field name is not UTF-8"))?; dynamic::record::query(cipher, &plan, field, decode_value(value)?, &resolver()) - .map_err(|e| status_for_dynamic(&e))? + .map_err(|e| fail_dynamic(&e))? .await - .map_err(|e| status_for_error(&e)) + .map_err(|e| fail_error(&e)) } /// A codec-encoded plan, parsed against this build's resolver: a target /// name this build cannot run is refused here, the same way at every /// export that takes a plan. fn parse_plan(plan: &[u8]) -> Result { - dynamic::record::plan_with(decode_value(plan)?, &resolver()).map_err(|e| status_for_dynamic(&e)) + dynamic::record::plan_with(decode_value(plan)?, &resolver()).map_err(|e| fail_dynamic(&e)) } // ============================================================================= @@ -305,7 +313,7 @@ pub mod validate { pub fn term(value: &[u8], context: &[u8], kind: u32) -> Result<(), u32> { dynamic::context(decode_value(context)?) .map(drop) - .map_err(|e| status_for_dynamic(&e))?; + .map_err(|e| fail_dynamic(&e))?; parse_term(decode_value(value)?, kind).map(drop) } @@ -315,8 +323,7 @@ pub mod validate { /// against its field's outputs). pub fn record(source: &[u8], plan: &[u8]) -> Result<(), u32> { let plan = parse_plan(plan)?; - dynamic::record::check_source(decode_value(source)?, &plan) - .map_err(|e| status_for_dynamic(&e)) + dynamic::record::check_source(decode_value(source)?, &plan).map_err(|e| fail_dynamic(&e)) } /// A target query's inputs, as [`query`] takes them: the plan parses @@ -324,15 +331,28 @@ pub mod validate { /// and the value is of the field's declared kind. pub fn query(value: &[u8], plan: &[u8], field: &[u8]) -> Result<(), u32> { let plan = parse_plan(plan)?; - let field = std::str::from_utf8(field).map_err(|_| STATUS_ENCODING)?; + let field = std::str::from_utf8(field) + .map_err(|_| last_error::malformed("the field name is not UTF-8"))?; + let refuse = |reason| { + fail_dynamic(&dynamic::Error::Plan { + field: Some(field.to_owned()), + reason, + }) + }; let field = plan .fields() .iter() - .find(|candidate| candidate.name() == field && candidate.target().is_some()) - .ok_or(STATUS_ENCODING)?; + .find(|candidate| candidate.name() == field) + .ok_or_else(|| refuse(dynamic::Reason::NoSuchField))?; + if field.target().is_none() { + return Err(refuse(dynamic::Reason::NotATarget)); + } let value = decode_value(value)?; if field.field_type().is_some_and(|kind| !kind.holds(&value)) { - return Err(STATUS_ENCODING); + return Err(fail_dynamic(&dynamic::Error::Source { + field: Some(field.name().to_owned()), + reason: dynamic::Reason::FieldType, + })); } Ok(()) } @@ -345,7 +365,7 @@ pub mod validate { pub fn record_tree(record: &[u8], plan: &[u8], expected: Option<&Label>) -> Result<(), u32> { let plan = parse_plan(plan)?; dynamic::record::check_record(decode_tree(record)?, &plan, expected) - .map_err(|e| status_for_dynamic(&e)) + .map_err(|e| fail_dynamic(&e)) } } @@ -354,7 +374,8 @@ pub mod validate { // ============================================================================= fn decode_value(bytes: &[u8]) -> Result { - codec::decode_value(&mut codec::Reader::new(bytes)).map_err(|_| STATUS_ENCODING) + codec::decode_value(&mut codec::Reader::new(bytes)) + .map_err(|_| last_error::malformed("an input is not a value in the transport codec")) } /// Encode a value tree into a buffer sized **before** the first byte is @@ -374,12 +395,17 @@ fn encode_value(value: FfiValue) -> Result, u32> { } fn decode_tree(bytes: &[u8]) -> Result { - let tree: BytesTree = codec::decode_ciphertext_boxed(&mut codec::Reader::new(bytes)) - .map_err(|_| STATUS_ENCODING)?; + let tree: BytesTree = + codec::decode_ciphertext_boxed(&mut codec::Reader::new(bytes)).map_err(|_| { + last_error::malformed("the record is not a ciphertext tree in the transport codec") + })?; // Structural only — a decoded leaf proves nothing until its AEAD opens // (see the `SealedValue` docs). map_leaves(tree, &mut |l: Vec| { - SealedValue::from_bytes(&l).map_err(|_| STATUS_ENCODING) + SealedValue::from_bytes(&l).map_err(|e| { + last_error::record(&e, e.payload()); + STATUS_ENCODING + }) }) } diff --git a/languages/golang/encrypt/guest/src/options.rs b/languages/golang/encrypt/guest/src/options.rs index 0af23a229..02db276e1 100644 --- a/languages/golang/encrypt/guest/src/options.rs +++ b/languages/golang/encrypt/guest/src/options.rs @@ -49,7 +49,11 @@ use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; use zerokms_protocol::Name; -use crate::status::{status_for_error, STATUS_ENCODING}; +use stack_guest_abi::last_error::malformed; + +use crate::status::fail_error; +#[cfg(any(doc, test))] +use crate::status::STATUS_ENCODING; /// Which keyset a call binds to. See the [module docs](self). #[derive(Debug, Clone, PartialEq, Eq)] @@ -88,7 +92,7 @@ pub struct Options { /// the [module docs](self) is [`STATUS_ENCODING`]. pub fn parse_options(value: FfiValue, side: Side) -> Result { let FfiValue::Object(entries) = value else { - return Err(STATUS_ENCODING); + return Err(malformed("the options are not an object")); }; let mut keyset: Option = None; let mut context: Option