diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs
index e64ab54..cd755da 100644
--- a/crates/persona-state/src/lib.rs
+++ b/crates/persona-state/src/lib.rs
@@ -311,6 +311,32 @@ pub fn validate_asset(
#[cfg(test)]
mod tests {
+ use super::*;
+
+ /// `sign_local` has to be the exact inverse of `verify_sig_in`, and that
+ /// includes WHAT is signed: every family here signs blake3(body), and the
+ /// verifier hashes before it checks. Signing the raw body produces a
+ /// signature that validates nowhere — the board rejected a room-key
+ /// proposal for exactly this on 2026-09-03, and only a second instance
+ /// on the live network surfaced it.
+ #[test]
+ fn sign_local_is_the_inverse_of_verify_and_the_payload_is_hashed() {
+ const D: &str = "soulstream.playlist/v1";
+ let secret = [3u8; 32];
+ let body = b"a proposal body";
+ let payload = blake3::hash(body).as_bytes().to_vec();
+ let sig = sign_local(D, &secret, &payload);
+ assert!(verify_sig_in(D, &public_of(&secret), &payload, &sig));
+ assert!(
+ !verify_sig_in(D, &public_of(&secret), body, &sig),
+ "the raw body must not validate — callers hash first"
+ );
+ assert!(
+ !verify_sig_in("soulstream.friend/v1", &public_of(&secret), &payload, &sig),
+ "a signature must not cross domains"
+ );
+ }
+
use super::*;
use ed25519_dalek::{Signer, SigningKey};
diff --git a/src-tauri/src/proposals.rs b/src-tauri/src/proposals.rs
index 9617cab..b896013 100644
--- a/src-tauri/src/proposals.rs
+++ b/src-tauri/src/proposals.rs
@@ -523,7 +523,12 @@ pub async fn propose(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &
};
pr::apply_proposal(&manifest, &hash, &p).map_err(|e| e.to_string())?;
p.sig = match room {
- Some(secret) => persona_state::sign_local(pr::SIGN_DOMAIN, &secret, &pr::proposal_sig_body(&p)),
+ // sign_payload() first: everything here signs blake3(body), and the
+ // verifier hashes before it checks. Handing over the raw body makes a
+ // signature that validates nowhere.
+ Some(secret) => {
+ persona_state::sign_local(pr::SIGN_DOMAIN, &secret, &pr::sign_payload(&pr::proposal_sig_body(&p)))
+ }
None => sign(app, pr::sign_payload(&pr::proposal_sig_body(&p))).await?,
};
if !pr::proposal_valid(&p) {
diff --git a/src/components/ProposalToast.svelte b/src/components/ProposalToast.svelte
index 4c1df91..a483f32 100644
--- a/src/components/ProposalToast.svelte
+++ b/src/components/ProposalToast.svelte
@@ -7,6 +7,13 @@
import Icon from "./Icon.svelte";
import { proposalToast } from "../lib/state.svelte.js";
+ // Without a linked Soulseek name the backend can only give us the raw
+ // SoulStream ID, and a 44-character key runs the pill off the screen.
+ const who = $derived.by(() => {
+ const f = proposalToast.from ?? "";
+ return f.length > 24 ? `${f.slice(0, 4)}…${f.slice(-4)}` : f;
+ });
+
const HOLD_MS = 4200;
const LEAVE_MS = 280;
@@ -46,7 +53,7 @@
>
{:else}
{proposalToast.from} proposed a change to {proposalToast.playlist}{who} proposed a change to {proposalToast.playlist}
{/if}