Skip to content
Draft
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
4 changes: 3 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ const overrides = new Map([
// added to RawAcpRuntimeCatalogEntry + fromRawAcpRuntimeCatalogEntry mapper (+8).
// codex-install-auto-restart: restarted_count + failed_restart_count added to
// RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2).
["src/shared/api/tauri.ts", 1282],
// NIP-37 synced drafts: Tauri bridges for capability gating and opaque
// secret-derived draft addresses. Both preserve the key boundary.
["src/shared/api/tauri.ts", 1298],
// doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line).
// codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line).
// doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
base64 = "0.22"
sha2 = "0.11"
hmac = "0.13"
tar = "0.4"
bzip2 = "0.6"
chrono = { version = "0.4", features = ["serde"] }
Expand Down
68 changes: 68 additions & 0 deletions desktop/src-tauri/src/commands/identity.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use hmac::{Hmac, KeyInit, Mac};
use nostr::{
nips::nip44, Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp, ToBech32,
};
use sha2::Sha256;
use tauri::Manager;
use tauri::State;

Expand Down Expand Up @@ -417,6 +419,45 @@ pub async fn nip44_encrypt_to_self(
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}

const DRAFT_ADDRESS_DOMAIN: &[u8] = b"buzz-draft-address/v1";

type HmacSha256 = Hmac<Sha256>;

fn derive_draft_address_from_keys(
keys: &Keys,
logical_compose_key: &str,
relay_scope: &str,
) -> Result<String, String> {
let conversation_key =
nip44::v2::ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| format!("nip44 conversation key failed: {error}"))?;
let mut mac = HmacSha256::new_from_slice(conversation_key.as_bytes())
.map_err(|error| format!("draft address HMAC initialization failed: {error}"))?;
mac.update(DRAFT_ADDRESS_DOMAIN);
mac.update(&[0]);
mac.update(relay_scope.as_bytes());
mac.update(&[0]);
mac.update(logical_compose_key.as_bytes());
Ok(hex::encode(mac.finalize().into_bytes()))
}

/// Derive an opaque, stable NIP-37 `d` tag from the identity's NIP-44-to-self
/// conversation key. The secret-derived operation stays in Rust so raw key
/// material never enters the webview.
#[tauri::command]
pub async fn derive_draft_address(
logical_compose_key: String,
relay_scope: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let keys = state.signing_keys()?;
tauri::async_runtime::spawn_blocking(move || {
derive_draft_address_from_keys(&keys, &logical_compose_key, &relay_scope)
})
.await
.map_err(|error| format!("spawn_blocking failed: {error}"))?
}

#[tauri::command]
pub async fn nip44_decrypt_from_self(
ciphertext: String,
Expand All @@ -432,6 +473,33 @@ pub async fn nip44_decrypt_from_self(
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}

#[cfg(test)]
mod draft_address_tests {
use super::derive_draft_address_from_keys;
use nostr::Keys;

#[test]
fn derive_draft_address_is_deterministic_and_scoped() {
let keys = Keys::generate();
let first =
derive_draft_address_from_keys(&keys, "thread:root-a", "wss://relay.example").unwrap();
let repeated =
derive_draft_address_from_keys(&keys, "thread:root-a", "wss://relay.example").unwrap();
let different_key =
derive_draft_address_from_keys(&keys, "thread:root-b", "wss://relay.example").unwrap();
let different_relay =
derive_draft_address_from_keys(&keys, "thread:root-a", "wss://other.example").unwrap();

assert_eq!(first, repeated);
assert_ne!(first, different_key);
assert_ne!(first, different_relay);
assert_eq!(first.len(), 64);
assert!(first
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()));
}
}

#[cfg(test)]
mod nostr_identity_binding_tests {
use super::build_nostr_identity_binding_event;
Expand Down
32 changes: 32 additions & 0 deletions desktop/src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,38 @@ pub async fn fetch_workspace_icon(
Ok(doc.icon.filter(|icon| !icon.is_empty()))
}

#[derive(Deserialize)]
struct RelayInfoDocument {
#[serde(default)]
supported_nips: Vec<u32>,
}

/// Return whether the active relay advertises a requested NIP in its NIP-11
/// document. Network and parse failures deliberately return false so callers
/// can preserve offline/local-only behavior.
#[tauri::command]
pub async fn relay_supports_nip(nip: u32, state: State<'_, AppState>) -> Result<bool, String> {
let relay_url = relay::relay_ws_url_with_override(&state);
let http_url = relay::relay_http_base_url(&relay_url);
let Ok(response) = state
.http_client
.get(http_url)
.header("Accept", "application/nostr+json")
.send()
.await
else {
return Ok(false);
};
if !response.status().is_success() {
return Ok(false);
}
Ok(response
.json::<RelayInfoDocument>()
.await
.map(|document| document.supported_nips.contains(&nip))
.unwrap_or(false))
}

#[derive(Serialize)]
pub struct ActiveWorkspaceInfo {
relay_url: String,
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,8 @@ pub fn run() {
create_auth_event,
nip44_encrypt_to_self,
nip44_decrypt_from_self,
derive_draft_address,
relay_supports_nip,
get_channels,
create_channel,
open_dm,
Expand Down
42 changes: 42 additions & 0 deletions desktop/src/features/messages/lib/draftPayload.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";

import { parseDraftPayload, serializeDraftPayload } from "./draftPayload.ts";

const pubkey = "a".repeat(64);
const draft = {
content: "cross-device draft",
selectionStart: 0,
selectionEnd: 18,
channelId: "550e8400-e29b-41d4-a716-446655440000",
createdAt: "2026-07-13T00:00:00.000Z",
updatedAt: "2026-07-13T00:01:00.000Z",
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
};

test("test_draft_payload_round_trip_recovers_thread_context", () => {
const plaintext = serializeDraftPayload(
"thread:root-event",
draft,
pubkey,
100,
);
const decoded = parseDraftPayload(plaintext, pubkey, 9, draft.updatedAt);

assert.ok(decoded);
assert.equal(decoded.draftKey, "thread:root-event");
assert.equal(decoded.draft.content, draft.content);
assert.equal(decoded.draft.channelId, draft.channelId);
});

test("test_draft_payload_foreign_author_is_rejected", () => {
const plaintext = serializeDraftPayload(
draft.channelId,
draft,
"b".repeat(64),
100,
);
assert.equal(parseDraftPayload(plaintext, pubkey, 9, draft.updatedAt), null);
});
106 changes: 106 additions & 0 deletions desktop/src/features/messages/lib/draftPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
buildImetaTags,
imetaMediaFromTags,
} from "@/features/messages/lib/imetaMediaMarkdown";
import type { DraftState } from "@/features/messages/lib/useDrafts";
import {
getChannelIdFromTags,
getThreadReference,
} from "@/features/messages/lib/threading";
import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds";

export type DraftPayload = {
kind: number;
created_at: number;
tags: string[][];
content: string;
pubkey: string;
};

export type DecodedDraftPayload = {
draftKey: string;
draft: DraftState;
};

function isStringTag(tag: unknown): tag is string[] {
return Array.isArray(tag) && tag.every((value) => typeof value === "string");
}

function isSupportedKind(kind: unknown): kind is number {
return kind === KIND_STREAM_MESSAGE;
}

/** Serialize an interoperable unsigned event; editor-only selection stays local. */
export function serializeDraftPayload(
draftKey: string,
draft: DraftState,
pubkey: string,
createdAt = Math.floor(Date.now() / 1_000),
): string {
const tags = [["h", draft.channelId], ...buildImetaTags(draft.pendingImeta)];
if (draftKey.startsWith("thread:")) {
const rootId = draftKey.slice("thread:".length);
if (rootId) tags.splice(1, 0, ["e", rootId, "", "reply"]);
}
return JSON.stringify({
kind: KIND_STREAM_MESSAGE,
created_at: createdAt,
tags,
content: draft.content,
pubkey,
} satisfies DraftPayload);
}

/**
* Validate a decrypted NIP-37 payload before it reaches the local draft store.
* The compose key is reconstructed solely from encrypted inner tags.
*/
export function parseDraftPayload(
plaintext: string,
expectedPubkey: string,
expectedKind: number,
fallbackUpdatedAt: string,
): DecodedDraftPayload | null {
try {
const value: unknown = JSON.parse(plaintext);
if (typeof value !== "object" || value === null) return null;
const payload = value as Partial<DraftPayload>;
if (
!isSupportedKind(payload.kind) ||
payload.kind !== expectedKind ||
typeof payload.content !== "string" ||
typeof payload.pubkey !== "string" ||
payload.pubkey.toLowerCase() !== expectedPubkey.toLowerCase() ||
!Array.isArray(payload.tags) ||
!payload.tags.every(isStringTag)
) {
return null;
}
const channelId = getChannelIdFromTags(payload.tags);
if (!channelId) return null;
const thread = getThreadReference(payload.tags);
const draftKey = thread.rootId ? `thread:${thread.rootId}` : channelId;
const media = imetaMediaFromTags(payload.tags);
const createdAt =
typeof payload.created_at === "number" &&
Number.isFinite(payload.created_at)
? new Date(payload.created_at * 1_000).toISOString()
: fallbackUpdatedAt;
return {
draftKey,
draft: {
content: payload.content,
selectionStart: payload.content.length,
selectionEnd: payload.content.length,
channelId,
createdAt,
updatedAt: fallbackUpdatedAt,
pendingImeta: media,
spoileredAttachmentUrls: [],
status: "active",
},
};
} catch {
return null;
}
}
Loading