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}