Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ versioning through the workspace version in the root `Cargo.toml`.
- MarmotKit/UniFFI now exposes the cached Nostr Kind 0 `website` field through a read-only profile accessor so app
profile surfaces can display it without widening the writable profile record.

### Fixed

- Encrypted media uploads now fail over, in order, across a default list of Blossom endpoints that accept opaque
`application/octet-stream` ciphertext, and encrypted group-image uploads use that list's primary endpoint, replacing
the media-only Primal default that returned HTTP 415. Blossom upload failures also retain bounded, privacy-filtered
server rejection reasons for actionable diagnostics. Group images store no endpoint in group state, so clients
compiled with different defaults resolve them against different endpoints until the group image is re-set on a
current build.

## [0.9.3] - 2026-07-07

### Added
Expand Down
13 changes: 9 additions & 4 deletions crates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,19 @@ Media commands:
```sh
wn --account <npub-or-hex> media list <group-hex>
wn --account <npub-or-hex> media upload <group-hex> <file-path> --send --message <caption>
wn --account <npub-or-hex> media upload <group-hex> <file-path> --server https://blossom.primal.net
wn --account <npub-or-hex> media upload <group-hex> <file-path> --server https://blossom.divine.video
wn --account <npub-or-hex> media download <group-hex> <file-hash> --output ./file.jpg
```

`media upload` encrypts the file with the group's current `MLS-Exporter("marmot", "encrypted-media", 32)` media secret,
uploads the ciphertext to Blossom, and optionally sends a kind-9 media message. Without `--server`, the upload targets
the endpoints in the group's `marmot.group.encrypted-media.v1` component (`https://blossom.primal.net` is the endpoint
baked into newly created groups' component, not a hardcoded CLI default).
uploads the ciphertext to Blossom, and optionally sends a kind-9 media message. Because the encrypted bytes are opaque,
the server must accept `application/octet-stream` uploads. Without `--server`, the upload targets the ordered endpoints
in the group's `marmot.group.encrypted-media.v1` component. Newly created groups use MDK's ciphertext-compatible
built-in endpoint list unless the application was compiled with `MARMOT_ENCRYPTED_MEDIA_BLOB_ENDPOINTS`.

Endpoint policy is signed group state. Upgrading MDK changes the defaults for new groups only; an active group admin must
call `replace_encrypted_media_blob_endpoints` to migrate an existing group whose embedded endpoints reject encrypted
blobs.
Upload JSON returns an `attachments` array with each attachment's `plaintext_sha256`, `ciphertext_sha256`, and locators.
`media download` resolves a projected media reference by plaintext hash, fetches the encrypted blob, verifies it,
decrypts it, and writes the plaintext file.
Expand Down
16 changes: 16 additions & 0 deletions crates/marmot-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ list helpers, and public re-exports. Runtime orchestration lives in the `src/run
live in the `src/client/` module, group DTOs/component projection helpers live in `src/groups.rs`, and encrypted-media
DTOs plus Blossom upload/download helpers live in the `src/media/` module.

## Encrypted media endpoints

Encrypted media and encrypted group images are uploaded as opaque `application/octet-stream` blobs. A compatible
Blossom server must accept arbitrary binary data rather than only recognizable image, audio, or video payloads. New
groups use the ordered built-in ciphertext-compatible endpoint list unless the host build supplies
`MARMOT_ENCRYPTED_MEDIA_BLOB_ENDPOINTS`. Encrypted media uploads try those endpoints in order; encrypted group-image
uploads use only the primary (first) endpoint.

The endpoint list is embedded in the signed `marmot.group.encrypted-media.v1` component. Changing application defaults
does not rewrite existing group state. An active group admin can migrate an existing group with
`replace_encrypted_media_blob_endpoints` through the app runtime or UniFFI API.

Encrypted group images differ: no endpoint is stored in group state, so upload and fetch both resolve against the
build's primary endpoint. Clients compiled with different defaults therefore look for group images in different
places; re-setting the group image on a current build republishes it to the current primary endpoint.

## Run the tests

```sh
Expand Down
7 changes: 5 additions & 2 deletions crates/marmot-app/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use crate::groups::{
};
use crate::ids::{admin_pubkey_from_account_id_hex, admin_pubkey_from_member_id};
use crate::media::{
DEFAULT_BLOSSOM_SERVER_URL, download_encrypted_media, fetch_group_image,
DEFAULT_BLOSSOM_SERVER_URLS, download_encrypted_media, fetch_group_image,
is_loopback_http_endpoint, upload_encrypted_media, upload_group_image,
};
use crate::messages::{AppMessageIntent, build_inner_event, encode_inner_event, tag_value};
Expand Down Expand Up @@ -1951,7 +1951,10 @@ impl AppClient {
.encrypted_media_blob_endpoints
.is_empty()
{
vec![DEFAULT_BLOSSOM_SERVER_URL.to_owned()]
DEFAULT_BLOSSOM_SERVER_URLS
.iter()
.map(|endpoint| (*endpoint).to_owned())
.collect()
} else {
self.app
.service_endpoints()
Expand Down
7 changes: 4 additions & 3 deletions crates/marmot-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,10 @@ pub use ids::{
/// depending on `marmot-forensics` directly.
pub use marmot_forensics::AuditDataMode;
pub use media::{
DEFAULT_BLOSSOM_SERVER_URL, ENCRYPTED_MEDIA_VERSION, MediaAttachmentReference,
MediaDownloadResult, MediaLocator, MediaUploadAttachmentRequest, MediaUploadAttachmentResult,
MediaUploadRequest, MediaUploadResult, media_attachment_from_imeta_tag,
DEFAULT_BLOSSOM_SERVER_URL, DEFAULT_BLOSSOM_SERVER_URLS, ENCRYPTED_MEDIA_VERSION,
MediaAttachmentReference, MediaDownloadResult, MediaLocator, MediaUploadAttachmentRequest,
MediaUploadAttachmentResult, MediaUploadRequest, MediaUploadResult,
media_attachment_from_imeta_tag,
};
pub use messages::{is_stream_final_event, tag_value, tag_values};
pub use notifications::{
Expand Down
68 changes: 64 additions & 4 deletions crates/marmot-app/src/media/blossom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crate::{AppError, unix_now_seconds};

const BLOSSOM_UPLOAD_AUTH_TTL: Duration = Duration::from_secs(10 * 60);
const BLOSSOM_UPLOAD_CONTENT_TYPE: &str = "application/octet-stream";
const MAX_BLOSSOM_ERROR_BODY_BYTES: u64 = 1024;
const MEDIA_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const MEDIA_HTTP_READ_TIMEOUT: Duration = Duration::from_secs(15);
const MEDIA_HTTP_TOTAL_TIMEOUT: Duration = Duration::from_secs(60);
Expand Down Expand Up @@ -45,10 +46,7 @@ pub(crate) async fn upload_blossom_blob(
.await
.map_err(reqwest_blob_error)?;
if !response.status().is_success() {
return Err(AppError::BlobStore(format!(
"upload returned HTTP {}",
response.status().as_u16()
)));
return Err(blossom_upload_status_error(response).await);
}
let descriptor = response
.json::<BlossomBlobDescriptor>()
Expand Down Expand Up @@ -76,6 +74,68 @@ pub(crate) async fn upload_blossom_blob(
Ok(url)
}

async fn blossom_upload_status_error(response: reqwest::Response) -> AppError {
let status = response.status().as_u16();
let header_reason = response
.headers()
.get("X-Reason")
.and_then(|value| value.to_str().ok())
.and_then(privacy_safe_server_reason);
let body = read_limited_blossom_body(response, MAX_BLOSSOM_ERROR_BODY_BYTES)
.await
.ok();
let body_reason = body
.as_deref()
.and_then(blossom_error_body_reason)
.and_then(|reason| privacy_safe_server_reason(&reason));
match header_reason.or(body_reason) {
Some(reason) => AppError::BlobStore(format!("upload returned HTTP {status}: {reason}")),
None => AppError::BlobStore(format!("upload returned HTTP {status}")),
}
}

fn blossom_error_body_reason(body: &[u8]) -> Option<String> {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) {
return ["reason", "message", "error"]
.into_iter()
.find_map(|key| value.get(key).and_then(|value| value.as_str()))
.map(str::to_owned);
}
std::str::from_utf8(body).ok().map(str::to_owned)
}

/// Keep server-provided diagnostics useful without allowing an untrusted
/// response to inject blob hashes, pubkeys, URLs, or unbounded text into app
/// errors that may later be logged.
fn privacy_safe_server_reason(reason: &str) -> Option<String> {
let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
if reason.is_empty()
|| reason.len() > 256
|| !reason
.chars()
.all(|character| character.is_ascii() && !character.is_ascii_control())
{
return None;
}
let lowercase = reason.to_ascii_lowercase();
if ["://", "nostr:", "npub1", "nsec1"]
.iter()
.any(|needle| lowercase.contains(needle))
{
return None;
}
if reason.split_ascii_whitespace().any(|token| {
// Collapse punctuation so hyphenated hashes and UUIDs still register
// as one long identifier.
let token: String = token.chars().filter(char::is_ascii_alphanumeric).collect();
token.len() >= 48
|| (token.len() >= 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit()))
}) {
return None;
}
Some(reason)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +107 to +137

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my agent:

The long-token check only catches a token that is purely hex or purely alphanumeric. Any punctuation embedded
inside the token (hyphen, colon, slash that isn't ://, etc.) defeats it, because trim_matches only strips
matching characters from the two ends, not the middle, so the mixed token fails both is_ascii_hexdigit() and
is_ascii_alphanumeric() "all" checks and is let through untouched.

Verified with the actual function body extracted and run standalone:

"duplicate blob 1111...1111 exists"          => None   (correctly filtered: pure-hex 64-char token)
"duplicate-blob-1111...1111-exists"          => Some("duplicate-blob-1111...1111-exists")   <- hash leaks
"uuid 123e4567-e89b-12d3-a456-426614174000 already used" => Some(...)                        <- UUID leaks

A malicious or compromised Blossom endpoint (which the client is already talking to — either the group's signed
default or a user-supplied --server) can trivially defeat this specific defense-in-depth check by hyphenating or
otherwise punctuating an embedded hash/UUID/identifier, exactly contradicting the function's own doc comment ("without
allowing an untrusted response to inject blob hashes, pubkeys, URLs"). Suggest stripping internal punctuation before
the alnum/hex run-length check (e.g. token.chars().filter(char::is_ascii_alphanumeric).collect::<String>()), or
scanning for any maximal alphanumeric run within the token rather than requiring the whole token to qualify.


pub(crate) async fn fetch_blossom_blob(
url: &str,
allow_loopback_http: bool,
Expand Down
16 changes: 15 additions & 1 deletion crates/marmot-app/src/media/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,21 @@ pub(crate) use crypto::canonical_media_type;
pub(crate) use group_image::{fetch_group_image, upload_group_image};
pub(crate) use host_safety::is_loopback_http_endpoint;

pub const DEFAULT_BLOSSOM_SERVER_URL: &str = "https://blossom.primal.net";
/// Built-in encrypted-media endpoints, in upload fallback order.
///
/// Every endpoint in this list must accept opaque `application/octet-stream`
/// blobs. Encrypted media and encrypted group images are indistinguishable
/// from random bytes, so media-only Blossom servers are not compatible even
/// when the original plaintext was an image or video.
pub const DEFAULT_BLOSSOM_SERVER_URLS: &[&str] = &[
"https://blossom.divine.video",
"https://blossom.ditto.pub",
"https://cdn.hzrd149.com",
];

/// Primary built-in Blossom endpoint used by single-endpoint APIs such as
/// encrypted group-image upload.
pub const DEFAULT_BLOSSOM_SERVER_URL: &str = DEFAULT_BLOSSOM_SERVER_URLS[0];
pub const ENCRYPTED_MEDIA_VERSION: &str = ENCRYPTED_MEDIA_FORMAT_V1;

#[derive(Clone, Debug, PartialEq, Eq)]
Expand Down
Loading