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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
Mirror of block/buzz, pinned at upstream commit
02f640bc4559c48ac0c2ec595ef34dd2c294b0db.
License: Apache-2.0 (LICENSE carried; upstream has no NOTICE,
so Apache §4(d) is N/A).


<h1 align="center">Buzz 🐝</h1>

<p align="center">
Expand Down
69 changes: 69 additions & 0 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,20 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
return;
}

// NEVER SEND A SECRET (2026-09-04): a channel message is a broadcast.
// A bech32 secret key pasted into it (nsec1...) IS the identity, and a
// broadcast cannot be unsent -- refuse it outright, in plain words.
// Defense in depth beside the client-side composer guard.
if content_leaks_secret(kind_u32, &event.content) {
reject("invalid");
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"invalid: that looks like a private key (nsec1...) -- never send a secret into a room",
));
return;
}

if kind_u32 == KIND_AGENT_OBSERVER_FRAME {
if !scopes.is_empty() && !scopes.contains(&buzz_auth::Scope::MessagesWrite) {
reject("scope");
Expand Down Expand Up @@ -1163,8 +1177,63 @@ fn single_tag_content<'a>(event: &'a Event, tag_name: &str) -> Result<&'a str, S
Ok(value)
}

/// The relay-side half of NEVER SEND A SECRET: kind 9 (channel message)
/// content carrying a bech32 SECRET KEY is refused before ingest. The match
/// is the token shape (the prefix followed by at least 15 bech32 digits), not
/// a bare substring -- ordinary words that merely contain "nsec1" inside them
/// must pass; a real nsec is ~63 chars and never shorter than ~20.
pub(crate) fn content_leaks_secret(kind: u32, content: &str) -> bool {
if kind != buzz_core::kind::KIND_STREAM_MESSAGE {
return false;
}
const BECH32: &str = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let mut from = 0;
while let Some(at) = content[from..].find("nsec1") {
let after = &content[from + at + 5..];
let digits = after.chars().take_while(|c| BECH32.contains(*c)).count();
if digits >= 15 {
return true;
}
from += at + 5;
}
false
}

#[cfg(test)]
mod tests {
use super::content_leaks_secret;
#[test]
fn secret_guard_refuses_nsec_in_channel_message() {
assert!(content_leaks_secret(
buzz_core::kind::KIND_STREAM_MESSAGE,
"here is my key nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq pasted by accident",
));
}

#[test]
fn secret_guard_lets_normal_messages_through() {
assert!(!content_leaks_secret(
buzz_core::kind::KIND_STREAM_MESSAGE,
"hello everyone, welcome to the hive!",
));
assert!(!content_leaks_secret(
buzz_core::kind::KIND_STREAM_MESSAGE,
"the word inseparable contains nsec1 inside it, tricky",
));
assert!(!content_leaks_secret(
buzz_core::kind::KIND_STREAM_MESSAGE,
"nsec1 is the prefix, but this is far too short to be a key",
));
assert!(content_leaks_secret(
buzz_core::kind::KIND_STREAM_MESSAGE,
"oops: nsec1qpzry9x8gf2tvdw0s3jn54khce6mua7lexamplepastefiftycharacterslong",
));
assert!(!content_leaks_secret(
1,
"nsec1 on another kind is not a channel broadcast",
));
}

use std::collections::HashMap;
use std::sync::atomic::AtomicU8;
use std::sync::Arc;
Expand Down
21 changes: 21 additions & 0 deletions web/src/app/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { Route as rootRouteImport } from "./routes/root";
import { Route as reposRouteImport } from "./routes/repos";
import { Route as joinRouteImport } from "./routes/join";
import { Route as indexRouteImport } from "./routes/index";
import { Route as reposDotrepoIdRouteImport } from "./routes/repos.$repoId";
import { Route as inviteDotcodeRouteImport } from "./routes/invite.$code";
Expand All @@ -16,6 +17,11 @@ const reposRoute = reposRouteImport.update({
path: "/repos",
getParentRoute: () => rootRouteImport,
} as any);
const joinRoute = joinRouteImport.update({
id: "/join",
path: "/join",
getParentRoute: () => rootRouteImport,
} as any);
const indexRoute = indexRouteImport.update({
id: "/",
path: "/",
Expand All @@ -40,13 +46,15 @@ const reposDotrepoIdDotblobDotsplatRoute =

export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/join": typeof joinRoute;
"/repos": typeof reposRoute;
"/invite/$code": typeof inviteDotcodeRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
}
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/join": typeof joinRoute;
"/repos": typeof reposRoute;
"/invite/$code": typeof inviteDotcodeRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
Expand All @@ -55,6 +63,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/join": typeof joinRoute;
"/repos": typeof reposRoute;
"/invite/$code": typeof inviteDotcodeRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
Expand All @@ -64,20 +73,23 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths:
| "/"
| "/join"
| "/repos"
| "/invite/$code"
| "/repos/$repoId"
| "/repos/$repoId/blob/$";
fileRoutesByTo: FileRoutesByTo;
to:
| "/"
| "/join"
| "/repos"
| "/invite/$code"
| "/repos/$repoId"
| "/repos/$repoId/blob/$";
id:
| "__root__"
| "/"
| "/join"
| "/repos"
| "/invite/$code"
| "/repos/$repoId"
Expand All @@ -86,6 +98,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
joinRoute: typeof joinRoute;
reposRoute: typeof reposRoute;
inviteDotcodeRoute: typeof inviteDotcodeRoute;
reposDotrepoIdRoute: typeof reposDotrepoIdRoute;
Expand All @@ -101,6 +114,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof reposRouteImport;
parentRoute: typeof rootRouteImport;
};
"/join": {
id: "/join";
path: "/join";
fullPath: "/join";
preLoaderRoute: typeof joinRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
Expand Down Expand Up @@ -134,6 +154,7 @@ declare module "@tanstack/react-router" {

const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
joinRoute: joinRoute,
reposRoute: reposRoute,
inviteDotcodeRoute: inviteDotcodeRoute,
reposDotrepoIdRoute: reposDotrepoIdRoute,
Expand Down
1 change: 1 addition & 0 deletions web/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { index, route, rootRoute } from "@tanstack/virtual-file-routes";

export const routes = rootRoute("root.tsx", [
index("index.tsx"),
route("/join", "join.tsx"),
route("/invite/$code", "invite.$code.tsx"),
route("/repos", "repos.tsx"),
route("/repos/$repoId", "repos.$repoId.tsx"),
Expand Down
6 changes: 6 additions & 0 deletions web/src/app/routes/join.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { JoinPage } from "@/features/join/JoinPage";

export const Route = createFileRoute("/join")({
component: JoinPage,
});
Loading