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/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..8d54b0c2f 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,62 @@ //! `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 +139,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..6d08d4cf5 --- /dev/null +++ b/languages/golang/auth/guest/tests/leak.rs @@ -0,0 +1,276 @@ +//! 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, inside the real export +//! lifecycle. Each failure's encoded error, read back through +//! `se_last_error`'s packing, is decoded and searched at every depth: no +//! marker may appear. Each scenario also pins the code it reaches, so one +//! that stops reaching it fails by name. 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::path::Path; + +use stack_auth::AuthError; +use stack_auth_guest::ops; +use stack_auth_guest::status::fail_auth; +use stack_guest_abi::call; +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 through the real export lifecycle +/// ([`call::run`]), read its error the way the host does, through +/// `se_last_error`'s packing ([`call::take_last_error`]), and check it has +/// the codes this scenario is there to reach: its own code, then its +/// causes'. Each scenario pins its own, so one that stops reaching the code +/// that could leak its marker fails by name. +fn failure( + what: &str, + expect: &[&str], + step: impl FnOnce() -> Result, +) -> FfiValue { + let _ = call::run(step).expect_err(what); + let bytes = call::take_last_error().unwrap_or_else(|| panic!("{what}: no error recorded")); + let error = codec::decode_value(&mut codec::Reader::new(&bytes)).expect("an error decodes"); + assert_eq!(codes(&error), expect, "{what}: the codes it reaches"); + error +} + +/// An error's code, then its causes' codes, in order. +fn codes(value: &FfiValue) -> Vec { + let mut out: Vec = text(get(value, "code")).into_iter().collect(); + if let Some(FfiValue::Array(causes)) = get(value, "causes") { + out.extend(causes.iter().filter_map(|cause| text(get(cause, "code")))); + } + out +} + +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", &["stack_guest_abi::malformed_input"], || { + ops::current_workspace(b"") + }), + ), + ( + "no current workspace", + failure( + "no current", + &["stack_profile::no_current_workspace"], + || ops::current_workspace(&store), + ), + ), + ( + "a workspace id that is not one", + failure("bad id", &["stack_profile::invalid_workspace_id"], || { + ops::set_current_workspace(&store, b"short") + }), + ), + ( + "a workspace with no directory", + failure( + "no workspace", + &["stack_profile::workspace_not_found"], + || ops::set_current_workspace(&store, b"BBBBBBBBBBBBBBBB"), + ), + ), + ( + "a filename that names a path", + failure("bad filename", &["stack_profile::invalid_filename"], || { + ops::lock_path(&store, b"../auth.json") + }), + ), + ( + "a profile file that is not there", + failure("missing", &["stack_profile::not_found"], || { + ops::token(&dir(&workspace)) + }), + ), + ( + "an auth.json whose expiry is the token", + failure("json", &["stack_profile::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", &["stack_profile::json"], || { + std::fs::write( + workspace.join("secretkey.json"), + // A JSON string where an object belongs: serde_json's + // message would quote it. (As an object key it would + // not: serde says only "invalid type: map".) + format!(r#""{CLIENT_KEY}""#), + ) + .expect("write secretkey.json"); + ops::secret_key(&dir(&workspace)) + }), + ), + ( + "a profile file that is a directory", + failure("io", &["stack_profile::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", &["stack_auth::invalid_access_key"], || { + ACCESS_KEY + .parse::() + .map_err(|e| fail_auth(&AuthError::from(e))) + }), + ), + ( + "a CRN that is not one", + failure("crn", &["stack_auth::invalid_crn"], || { + "not a crn" + .parse::() + .map_err(|e| fail_auth(&AuthError::from(e))) + }), + ), + // Coverage only: no marker can reach this error. The token parses, + // and the refusal is `NotAuthenticated`, which this test builds and + // which has no field that could hold the token. + ( + "a device token with no region", + failure( + "not authenticated", + &["stack_auth::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 mut found = Vec::new(); + for (what, error) in scenarios() { + leaks(&error, what, &mut found); + } + assert!( + found.is_empty(), + "markers in encoded errors:\n{}", + found.join("\n") + ); +} + +/// 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", &["stack_profile::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..5e5d1c675 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,26 @@ 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 +247,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 +298,7 @@ pub extern "C" fn se_shutdown() { CIPHER.with(|c| { let _ = c.borrow_mut().take(); }); + last_error::clear(); buffers::wipe_all(); })); } @@ -286,20 +314,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 +360,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 +371,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 +393,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 +404,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 +432,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 +443,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 +457,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 +486,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 +498,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 +506,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..9b0a8c9ac 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,15 @@ 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