1use crate::runtime::host::{
8 abi_arg, fatal_host_error, read_string_arg, register_host_value_async, throw_host_error,
9 write_submilli_string_struct,
10};
11use crate::runtime::{effects, fuel};
12use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
13
14use crate::runtime::StoreData;
15use crate::runtime::intrinsic_types::build_intrinsic_types;
16use crate::stdlib::shared::check_security;
17use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
18
19pub const MODULE_NAME: &str = "submilli:secrets";
20
21pub fn package_declaration() -> PackageDeclaration {
22 let mut defs = PackageDeclaration::with_package(MODULE_NAME);
23 defs.values.insert(
24 "get".to_string(),
25 ValueSymbol {
26 name: "get".to_string(),
27 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "get"),
28 declaration_span: Span::at(crate::FileId::SECRETS),
29 kind: ValueKind::Function {
30 generics: Vec::new(),
31 params: vec![Param::new("secret", Type::String)],
32 ret: Type::union(vec![Type::String, Type::Undefined]),
33 type_predicate: None,
34 doc: crate::doc(
35 crate::FileId::SECRETS,
36 "/**\n * Resolve a Blueprint-declared secret by name. **Packages only** — a call from main-module code is refused whatever the policy says, because secret values must never reach it. From `main`, pass the secret *name* to the Package API that needs the credential; the Package resolves it internally and never returns it. Returns `undefined` when the secret is undeclared or unavailable. Traps if policy denies access or the resolver fails internally.\n * @param secret Secret name from the Blueprint `secrets:` block.\n * @capability secrets.get { name: $secret }\n */",
37 ),
38 },
39 },
40 );
41 defs
42}
43
44pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
45 let engine = linker.engine().clone();
46 let intr = build_intrinsic_types(&engine)?;
47 let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(intr.string)));
48 let nullable_object = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(intr.object)));
50 let ty = FuncType::new(&engine, [string], [nullable_object]);
51 register_host_value_async(
52 linker,
53 MODULE_NAME,
54 crate::mangle::package_symbol(MODULE_NAME, "get"),
55 ty,
56 |caller, params| {
57 Box::pin(async move {
58 let name =
59 read_string_arg(&mut *caller, abi_arg(params, 0)?, "secrets.get (secret)")?;
60 check_security(
61 &mut *caller,
62 "secrets.get",
63 serde_json::json!({ "name": name }),
64 )?;
65
66 let observation = resolve_secret(caller, name).await?;
67 let Some(value) = &observation else {
68 return crate::runtime::prelude::undefined::value(caller);
69 };
70
71 let st = write_submilli_string_struct(caller, value)?;
72 Ok(Val::AnyRef(Some(st.to_anyref())))
73 })
74 },
75 )
76}
77
78async fn resolve_secret(
79 caller: &mut wasmtime::Caller<'_, StoreData>,
80 name: String,
81) -> wasmtime::Result<Option<String>> {
82 let provider = caller.data().secret_provider.clone();
83 if caller.data().effects.is_none() {
84 return provider.get(&name).await.map_err(|message| {
85 throw_host_error(
86 caller,
87 wasmtime::Error::msg(format!("secrets.get {name}: {message}")),
88 )
89 });
90 }
91
92 let encrypted = effects::execute(
93 caller,
94 "secrets.getEncrypted",
95 name.clone(),
96 |caller, _, name| {
97 Box::pin(async move {
98 let secret = caller
99 .data()
100 .secret_provider
101 .get_encrypted(&name)
102 .await
103 .map_err(fatal_host_error)?;
104 if let Some(secret) = &secret {
105 fuel::settle(&mut *caller, fuel::HASH, secret.ciphertext.len() as u64)?;
106 }
107 Ok(secret)
108 })
109 },
110 )
111 .await?;
112 let Some(encrypted) = encrypted else {
113 return Ok(None);
114 };
115 fuel::charge(&mut *caller, fuel::HASH, encrypted.ciphertext.len() as u64)?;
117 provider
118 .decrypt(&name, &encrypted)
119 .map(Some)
120 .map_err(fatal_host_error)
121}
122
123#[cfg(test)]
124mod tests {
125 use std::collections::BTreeMap;
126 use std::future::Future;
127 use std::pin::Pin;
128 use std::sync::{Arc, Mutex};
129
130 use crate::compile_script;
131 use crate::runtime::security::{CheckOutcome, SecurityCheck};
132 use crate::runtime::{
133 RuntimeConfig, SecretProvider, StoreData, Vfs, dispatch_main_async, install_runtime_async,
134 };
135
136 struct MapSecrets {
137 values: BTreeMap<String, String>,
138 lookups: Mutex<Vec<String>>,
139 }
140
141 impl MapSecrets {
142 fn with(values: BTreeMap<String, String>) -> Arc<Self> {
143 Arc::new(Self {
144 values,
145 lookups: Mutex::new(Vec::new()),
146 })
147 }
148
149 fn one(name: &str, value: &str) -> Arc<Self> {
150 Self::with(BTreeMap::from([(name.to_string(), value.to_string())]))
151 }
152
153 fn lookups(&self) -> Vec<String> {
154 self.lookups.lock().expect("lookups mutex").clone()
155 }
156 }
157
158 impl SecretProvider for MapSecrets {
159 fn get<'a>(
160 &'a self,
161 name: &'a str,
162 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, String>> + Send + 'a>> {
163 self.lookups
164 .lock()
165 .expect("lookups mutex")
166 .push(name.to_string());
167 Box::pin(async move { Ok(self.values.get(name).cloned()) })
168 }
169 }
170
171 #[derive(Default)]
172 struct RecordingCheck {
173 seen: Mutex<Vec<(String, String, serde_json::Value)>>,
174 deny: bool,
175 }
176
177 impl SecurityCheck for RecordingCheck {
178 fn check(
179 &self,
180 caller: &str,
181 capability: &str,
182 context: &serde_json::Value,
183 ) -> CheckOutcome {
184 self.seen.lock().unwrap().push((
185 caller.to_string(),
186 capability.to_string(),
187 context.clone(),
188 ));
189 if self.deny {
190 CheckOutcome::Deny {
191 rule: None,
192 reason: "denied in test".to_string(),
193 }
194 } else {
195 CheckOutcome::Allow { rule: None }
196 }
197 }
198 }
199
200 #[tokio::test]
204 async fn main_is_denied_before_the_policy_and_before_any_lookup() {
205 let source = r#"
206 import { get } from "submilli:secrets";
207
208 function main(): void {
209 const _ = get("TOKEN");
210 }
211 "#;
212 let provider = MapSecrets::one("TOKEN", "tok-123");
213 let recording = Arc::new(RecordingCheck::default());
214
215 let err = run_with(source, provider.clone(), recording.clone())
216 .await
217 .expect_err("main must be denied");
218
219 assert!(
220 format!("{err:#}").contains("permission denied: caller=main capability=secrets.get"),
221 "unexpected error: {err:#}"
222 );
223 assert!(
224 recording.seen.lock().expect("recording mutex").is_empty(),
225 "the policy must never have been consulted"
226 );
227 assert!(
228 provider.lookups().is_empty(),
229 "no plaintext value may be resolved for a denied call"
230 );
231 }
232
233 #[tokio::test]
236 async fn main_is_denied_under_the_default_allow_all_check() {
237 let provider = MapSecrets::one("TOKEN", "tok-123");
238 let err = run_as(None, MAIN_READS_TOKEN, provider.clone(), None)
239 .await
240 .expect_err("main must be denied");
241
242 assert!(
243 format!("{err:#}").contains("permission denied: caller=main capability=secrets.get"),
244 "unexpected error: {err:#}"
245 );
246 assert!(provider.lookups().is_empty(), "no lookup for a denied call");
247 }
248
249 #[tokio::test]
253 async fn the_denial_is_identical_for_declared_and_undeclared_names() {
254 let declared = run_as(
255 None,
256 MAIN_READS_TOKEN,
257 MapSecrets::one("TOKEN", "tok-123"),
258 None,
259 )
260 .await
261 .expect_err("denied");
262 let undeclared = run_as(
263 None,
264 MAIN_READS_TOKEN,
265 MapSecrets::with(BTreeMap::new()),
266 None,
267 )
268 .await
269 .expect_err("denied");
270
271 assert_eq!(format!("{declared:#}"), format!("{undeclared:#}"));
272 }
273
274 #[tokio::test]
284 async fn the_denial_matches_the_fixed_wording() {
285 let err = run_as(
286 None,
287 MAIN_READS_TOKEN,
288 MapSecrets::one("TOKEN", "tok-123"),
289 None,
290 )
291 .await
292 .expect_err("denied");
293
294 let message = format!("{err:#}");
295 let (wording, fields) = message.split_once("\n").expect("fields line follows");
296 assert_eq!(
297 wording,
298 "PermissionDeniedError: permission denied: caller=main \
299 capability=secrets.get: secret values are never available to \
300 main-module code, and no policy can grant this. The package that \
301 needs this credential resolves it internally and never returns it \
302 — pass the secret NAME to that package's API instead. Do not work \
303 around this denial: not through another package, not by asking a \
304 package to fetch the value and hand it back, not through raw HTTP. \
305 Report it and stop.",
306 );
307 assert!(fields.contains("capability = \"secrets.get\""), "{fields}");
308 }
309
310 #[tokio::test]
313 async fn a_package_caller_still_reads_secrets_and_gets_undefined_for_absent_ones() {
314 let source = r#"
315 import { get } from "submilli:secrets";
316
317 function main(): void {
318 const token = get("TOKEN");
319 assert(token !== undefined, "TOKEN should resolve");
320 if (token !== undefined) {
321 assert(token === "tok-123", "TOKEN value");
322 }
323
324 const absent = get("ABSENT");
325 assert(absent === undefined, "missing secret is undefined");
326 }
327 "#;
328 let recording = Arc::new(RecordingCheck::default());
329 run_as(
330 Some(PACKAGE),
331 source,
332 MapSecrets::one("TOKEN", "tok-123"),
333 Some(recording.clone()),
334 )
335 .await
336 .expect("program succeeds");
337
338 let seen = recording.seen.lock().expect("recording mutex");
339 assert_eq!(seen.len(), 2);
340 assert_eq!(seen[0].0, PACKAGE);
341 assert_eq!(seen[0].1, "secrets.get");
342 assert_eq!(seen[0].2, serde_json::json!({ "name": "TOKEN" }));
343 assert_eq!(seen[1].2, serde_json::json!({ "name": "ABSENT" }));
344 }
345
346 #[tokio::test]
349 async fn a_package_caller_denied_by_policy_gets_the_unchanged_wording() {
350 let denying = Arc::new(RecordingCheck {
351 seen: Mutex::new(Vec::new()),
352 deny: true,
353 });
354 let err = run_as(
355 Some(PACKAGE),
356 MAIN_READS_TOKEN,
357 MapSecrets::one("TOKEN", "tok-123"),
358 Some(denying),
359 )
360 .await
361 .expect_err("denial should throw");
362
363 let message = format!("{err:#}");
364 let (wording, fields) = message.split_once("\n").expect("fields line follows");
365 assert_eq!(
366 wording,
367 format!(
368 "PermissionDeniedError: permission denied: caller={PACKAGE} \
369 capability=secrets.get: denied in test. \
370 This request is forbidden by the operator's policy — do not work around \
371 the denial (another package, raw HTTP, or other arguments to reach the same \
372 thing); report it and stop."
373 ),
374 );
375 assert!(
376 fields.contains(&format!("caller = \"{PACKAGE}\"")),
377 "{fields}"
378 );
379 }
380
381 const PACKAGE: &str = "@pied-piper/sdk";
382
383 const MAIN_READS_TOKEN: &str = r#"
384 import { get } from "submilli:secrets";
385
386 function main(): void {
387 const _ = get("TOKEN");
388 }
389 "#;
390
391 async fn run_with(
392 source: &str,
393 provider: Arc<dyn SecretProvider>,
394 security_check: Arc<dyn SecurityCheck>,
395 ) -> wasmtime::Result<Option<String>> {
396 run_as(None, source, provider, Some(security_check)).await
397 }
398
399 async fn run_as(
406 caller: Option<&str>,
407 source: &str,
408 provider: Arc<dyn SecretProvider>,
409 security_check: Option<Arc<dyn SecurityCheck>>,
410 ) -> wasmtime::Result<Option<String>> {
411 let compiled = match caller {
412 Some(package) => crate::compile::compile_script_owned_by(
413 package,
414 source,
415 "test.subm",
416 crate::FileId(0),
417 &[],
418 &[],
419 ),
420 None => compile_script(source, "test.subm", crate::FileId(0), &[], &[]),
421 }
422 .expect("compile clean");
423 let cfg = RuntimeConfig::default();
424 let engine = cfg.engine().expect("engine");
425 let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
426 data.secret_provider = provider;
427 if let Some(check) = security_check {
428 data.security_check = check;
429 }
430 let mut store = cfg.store_async(&engine, data).expect("store");
431 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
432 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
433 install_runtime_async(&mut linker, &mut store)
434 .await
435 .expect("install");
436 let inst = linker
437 .instantiate_async(&mut store, &module)
438 .await
439 .expect("instantiate");
440 dispatch_main_async(&mut store, &inst).await
441 }
442}