1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
//! Delegate request handling for `Executor<Runtime>`.
//!
//! This module owns the delegate-facing surface of the runtime executor:
//! registering/unregistering delegates, dispatching `ApplicationMessages`,
//! exporting per-user secrets, and the `MessageOrigin` precedence rules that
//! decide which identity a delegate message is attributed to.
use super::*;
impl Executor<Runtime> {
/// Export this hosted user's per-user delegate secrets into an encrypted
/// bundle, sealed under the user's `token` (hosted-mode export, P3-live of
/// #4381).
///
/// Runs entirely on the executor (which owns the `SecretsStore` via its
/// `Runtime`). This is a READ-ONLY walk: it enumerates the in-memory secret
/// index and reads + AEAD-decrypts each on-disk secret BLOB (one file per
/// `(delegate, secret_hash)` under the shared `secrets_dir`); it opens no
/// redb write transaction and mutates nothing. So it is safe to run on a
/// blocking thread (or concurrently with other read-only walks): a secret
/// file racing a concurrent write is protected by per-file FS semantics and
/// AEAD authentication — a torn read fails authentication and surfaces a
/// clean export error, never silent corruption.
/// The bundle is scoped to `user_context.scope()` — strictly the per-user
/// namespace, never `Local`. `bundle_key_material` is the secret the bundle
/// is encrypted under; it is DELIBERATELY decoupled from the scope. In the
/// self-reimport case (`GET /v1/hosted/export`) it is the user's own token,
/// so they decrypt with the token they already hold; in the magic-link
/// migration case (`hosted_migrate` mint) it is a FRESH EPHEMERAL key, so
/// the durable token never leaves the hosting node. Do NOT re-couple this to
/// `user_context`'s token — the two are independent by design.
///
/// The key material and the plaintext it derives live only in
/// borrowed/`Zeroizing` buffers here and inside `export_secret_bundle`;
/// nothing is logged.
pub fn export_user_secrets(
&self,
user_context: &UserSecretContext,
bundle_key_material: &[u8],
) -> Result<Vec<u8>, ExecutorError> {
use crate::wasm_runtime::secret_export::{BundleKeyMaterial, ExportError};
self.runtime
.export_secret_bundle(
user_context.scope(),
&BundleKeyMaterial::Token(bundle_key_material),
)
.map_err(|e| {
// Preserve the over-limit case as a typed marker so the HTTP
// layer can map it to a 413 rather than a generic 500. The
// Display text is non-secret (sizes only). Everything else stays
// an opaque executor error. See #4381 P5. (An `if let` rather
// than a `match` with a wildcard arm: `ExportError` is large and
// a catch-all trips `clippy::wildcard_enum_match_arm`.)
if let ExportError::TooLarge { .. } = &e {
ExecutorError::other(ExportTooLarge {
message: e.to_string(),
})
} else {
ExecutorError::other(anyhow::anyhow!("secret export failed: {e}"))
}
})
}
/// Import delegate secrets from an encrypted `bundle` into this node's
/// secrets store at `target_scope`, LIVE — the durable counterpart of
/// [`Self::export_user_secrets`] and the mutating mirror of it (P3-live of
/// #4592). Runs on the executor (which owns the `SecretsStore` via its
/// `Runtime`). Unlike the read-only export, this is invoked ON the contract
/// loop by the pool caller (`RuntimePool::import_secrets`) — the import WRITES
/// and the store write path assumes node-wide write serialization, so it must
/// not run off-loop where it could race another writer on the same secret.
///
/// The bundle is decrypted under `material`; `import_bundle` authenticates
/// the WHOLE bundle BEFORE writing anything, so a wrong key / corrupt bundle
/// fails with NOTHING written (all-or-nothing on the key). `overwrite`
/// controls collision handling (skip+report vs overwrite-with-snapshot).
///
/// A client-input failure (wrong key, bad magic, truncated/unsupported
/// bundle, malformed entry) is preserved as the typed [`ImportBadBundle`]
/// marker so the HTTP layer can map it to a 4xx instead of a generic 500;
/// its `Display` text is non-secret (never echoes the key or plaintext).
/// Node-side failures (store/IO) stay an opaque executor error (→ 500). The
/// `material` and the plaintext it decrypts live only in borrowed/`Zeroizing`
/// buffers; nothing is logged.
pub fn import_secrets(
&mut self,
target_scope: &crate::wasm_runtime::secret_export::TargetScope,
bundle: &[u8],
material: &crate::wasm_runtime::secret_export::BundleKeyMaterial<'_>,
overwrite: bool,
) -> Result<crate::wasm_runtime::secret_export::ImportReport, ExecutorError> {
use crate::contract::executor::{ImportBadBundle, is_bad_bundle_input};
self.runtime
.import_secret_bundle(bundle, material, target_scope, overwrite)
.map_err(|e| {
if is_bad_bundle_input(&e) {
ExecutorError::other(ImportBadBundle {
message: e.to_string(),
})
} else {
ExecutorError::other(anyhow::anyhow!("secret import failed: {e}"))
}
})
}
pub fn delegate_request(
&mut self,
req: DelegateRequest<'_>,
origin_contract: Option<&ContractInstanceId>,
caller_delegate: Option<&DelegateKey>,
user_context: Option<&UserSecretContext>,
) -> Response {
// Mutual exclusion invariant: a single inbound delegate request is
// either dispatched on behalf of a contract-backed web app
// (`origin_contract = Some`) or on behalf of another delegate
// (`caller_delegate = Some`), never both. The doc comment on
// `ContractExecutor::execute_delegate_request` states this. The
// `debug_assert!` turns the convention into a tripwire so a future
// call site that violates it fails loudly in debug/test builds; in
// release builds the precedence below silently picks `caller_delegate`
// (fail-safe in the direction of "least surprising attestation").
debug_assert!(
!(origin_contract.is_some() && caller_delegate.is_some()),
"execute_delegate_request: at most one of origin_contract and \
caller_delegate may be Some (got both)"
);
tracing::debug!(
origin_contract = ?origin_contract,
caller_delegate = ?caller_delegate.map(|k| k.to_string()),
"received delegate request"
);
match req {
DelegateRequest::RegisterDelegate {
delegate,
cipher,
nonce,
} => {
use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
let key = delegate.key().clone();
let arr = (&cipher).into();
let cipher = XChaCha20Poly1305::new(arr);
let nonce = nonce.into();
if let Some(contract) = origin_contract {
self.delegate_origin_ids
.entry(key.clone())
.or_default()
.push(*contract);
}
match self.runtime.register_delegate(delegate, cipher, nonce) {
Ok(_) => Ok(DelegateResponse {
key,
values: Vec::new(),
}),
Err(err) => {
tracing::warn!(
delegate_key = %key,
error = %err,
phase = "register_failed",
"Failed to register delegate"
);
Err(ExecutorError::other(StdDelegateError::RegisterError(key)))
}
}
}
DelegateRequest::UnregisterDelegate(key) => {
self.delegate_origin_ids.remove(&key);
// Remove delegate from all contract subscription entries
crate::wasm_runtime::DELEGATE_SUBSCRIPTIONS.retain(|_, subscribers| {
subscribers.remove(&key);
!subscribers.is_empty()
});
// Clean up delegate creation tracking to prevent unbounded growth
crate::wasm_runtime::DELEGATE_INHERITED_ORIGINS.remove(&key);
// Decrement the global created-delegates counter so the slot can be reused.
// Only decrement if count > 0 to avoid underflow for delegates not created
// via the host function (e.g., registered directly by apps).
{
use std::sync::atomic::Ordering;
let count = &crate::wasm_runtime::CREATED_DELEGATES_COUNT;
let prev = count.load(Ordering::Relaxed);
if prev > 0 {
count.fetch_sub(1, Ordering::Relaxed);
}
}
match self.runtime.unregister_delegate(&key) {
Ok(_) => Ok(HostResponse::Ok),
Err(err) => {
tracing::warn!(
delegate_key = %key,
error = %err,
phase = "unregister_failed",
"Failed to unregister delegate"
);
Ok(HostResponse::Ok)
}
}
}
DelegateRequest::ApplicationMessages {
key,
inbound,
params,
} => {
let origin = resolve_message_origin(caller_delegate, origin_contract, &key);
match self.runtime.inbound_app_message(
&key,
¶ms,
origin.as_ref(),
// The per-user secret scope, present only in hosted mode and
// derived solely from the connection token. It is delivered
// here on a SEPARATE channel from `origin`/the request body,
// so neither WASM nor any delegate-message content can set or
// change which user's namespace a secret op touches.
user_context,
inbound
.into_iter()
.map(InboundDelegateMsg::into_owned)
.collect(),
) {
Ok(values) => Ok(DelegateResponse { key, values }),
Err(err) => {
let key_display = key.to_string();
let exec_err =
ExecutorError::execution(err, Some(InnerOpError::Delegate(key)));
// Downgrade "not found" to warn — expected during legacy
// migration probes when old delegate WASM isn't on this node
if exec_err.is_missing_delegate() {
tracing::warn!(
delegate_key = %key_display,
"Delegate not found in store (expected for migration probes)"
);
} else {
tracing::error!(
delegate_key = %key_display,
error = %exec_err,
phase = "execution_failed",
"Failed executing delegate"
);
}
Err(exec_err)
}
}
}
_ => Err(ExecutorError::other(anyhow::anyhow!("not supported"))),
}
}
}
/// Resolve a [`MessageOrigin`] for a delegate `ApplicationMessages` request,
/// in priority order:
///
/// 1. `caller_delegate` — set when another delegate dispatched this request
/// via `OutboundDelegateMsg::SendDelegateMessage` (issue #3860). The
/// runtime attests the caller's identity, so the receiver can authorize
/// on it. This wins unconditionally — an inter-delegate message
/// deliberately replaces (not composes with) any inherited WebApp origin.
/// 2. `origin_contract` — set when a contract-backed web app dispatched
/// this request via the WebSocket API.
/// 3. `DELEGATE_INHERITED_ORIGINS[delegate_key]` — set when a parent
/// delegate created this delegate via `create_delegate`, inheriting its
/// WebApp attestation.
///
/// Extracted as a free function so the precedence rules can be unit-tested
/// directly without standing up a full `Executor`.
fn resolve_message_origin(
caller_delegate: Option<&DelegateKey>,
origin_contract: Option<&ContractInstanceId>,
delegate_key: &DelegateKey,
) -> Option<MessageOrigin> {
if let Some(caller) = caller_delegate {
Some(MessageOrigin::Delegate(caller.clone()))
} else if let Some(contract_id) = origin_contract {
Some(MessageOrigin::WebApp(*contract_id))
} else {
// Plain read, no timestamp update. The "last used" time is refreshed in
// inbound_app_message instead, so a child that only ever gets messages
// from other delegates (those don't reach this branch) still counts as
// active and isn't dropped.
crate::wasm_runtime::DELEGATE_INHERITED_ORIGINS
.get(delegate_key)
.and_then(|entry| entry.origins.first().copied().map(MessageOrigin::WebApp))
}
}
#[cfg(test)]
mod resolve_message_origin_tests {
use super::*;
use freenet_stdlib::prelude::CodeHash;
fn dkey(seed: u8) -> DelegateKey {
DelegateKey::new([seed; 32], CodeHash::new([seed; 32]))
}
/// Caller delegate identity wins over a concurrently-supplied WebApp
/// contract (regression for issue #3860 precedence rule).
#[test]
fn caller_delegate_takes_precedence_over_origin_contract() {
let caller = dkey(0xA1);
let recipient = dkey(0xB2);
let app_contract = ContractInstanceId::new([0xC3; 32]);
let origin = resolve_message_origin(Some(&caller), Some(&app_contract), &recipient);
match origin {
Some(MessageOrigin::Delegate(k)) => assert_eq!(k, caller),
other => panic!("Expected Delegate(caller), got {other:?}"),
}
}
/// With only `origin_contract` set, the receiver sees `WebApp(..)` —
/// the historical behavior for web-app-driven dispatch must be
/// preserved.
#[test]
fn origin_contract_alone_yields_webapp() {
let recipient = dkey(0xB2);
let app_contract = ContractInstanceId::new([0xC3; 32]);
let origin = resolve_message_origin(None, Some(&app_contract), &recipient);
match origin {
Some(MessageOrigin::WebApp(id)) => assert_eq!(id, app_contract),
other => panic!("Expected WebApp(app_contract), got {other:?}"),
}
}
/// With neither argument set and no inherited origin in the static
/// map, the receiver sees `None` (matches pre-#3860 behavior for
/// orphaned dispatches and the fall-through case for unrelated
/// recipients in tests).
#[test]
fn no_arguments_and_no_inherited_yields_none() {
// Pick a recipient key with no entry in DELEGATE_INHERITED_ORIGINS.
// Using a randomized seed avoids collision with anything another
// test populated in the same process.
let recipient = dkey(0xEE);
crate::wasm_runtime::DELEGATE_INHERITED_ORIGINS.remove(&recipient);
let origin = resolve_message_origin(None, None, &recipient);
assert!(origin.is_none(), "Expected None, got {origin:?}");
}
/// Caller delegate identity also wins over an inherited WebApp origin
/// in `DELEGATE_INHERITED_ORIGINS`. This documents the deliberate
/// "inter-delegate calls revoke inherited contract access" semantics
/// from the `MessageOrigin::Delegate` rustdoc.
#[test]
fn caller_delegate_overrides_inherited_origin() {
let caller = dkey(0xA1);
let recipient = dkey(0xB3);
let inherited_contract = ContractInstanceId::new([0xDD; 32]);
// Plant an inherited WebApp origin for the recipient so the
// fallback branch would have something to return.
crate::wasm_runtime::DELEGATE_INHERITED_ORIGINS.insert(
recipient.clone(),
crate::wasm_runtime::InheritedOriginsEntry::new(vec![inherited_contract]),
);
let origin = resolve_message_origin(Some(&caller), None, &recipient);
// Cleanup before assertions so a panic doesn't leak state into
// sibling tests sharing the same process.
crate::wasm_runtime::DELEGATE_INHERITED_ORIGINS.remove(&recipient);
match origin {
Some(MessageOrigin::Delegate(k)) => assert_eq!(k, caller),
other => panic!("Expected Delegate(caller), got {other:?}"),
}
}
/// Fallback branch (no live caller/origin) yields the child's inherited
/// WebApp origin via a pure read — it does not refresh `last_access`
/// (liveness lives in `inbound_app_message`). Pairs with
/// `no_arguments_and_no_inherited_yields_none`.
#[test]
fn inherited_origin_fallback_yields_webapp() {
use crate::wasm_runtime::{DELEGATE_INHERITED_ORIGINS, InheritedOriginsEntry};
let recipient = dkey(0xC5);
let contract = ContractInstanceId::new([0xC6; 32]);
DELEGATE_INHERITED_ORIGINS.insert(
recipient.clone(),
InheritedOriginsEntry::new(vec![contract]),
);
let origin = resolve_message_origin(None, None, &recipient);
DELEGATE_INHERITED_ORIGINS.remove(&recipient);
assert!(
matches!(origin, Some(MessageOrigin::WebApp(c)) if c == contract),
"fallback must yield the inherited WebApp origin, got {origin:?}"
);
}
}