Skip to content
Merged
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
78 changes: 78 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: CI

on:
pull_request:
push:
branches:
- master

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
rust:
name: Rust
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt

- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2

- name: Generate test master key
run: |
set -euo pipefail
openssl rand 32 | base64 > master.key
chmod 600 master.key

- name: Check Rust formatting
run: cargo fmt --all --check

- name: Check Rust workspace
run: cargo check --workspace --locked

- name: Build Rust workspace
run: cargo build --workspace --locked

- name: Test Rust workspace
run: cargo test --workspace --locked

web:
name: Web
runs-on: ubuntu-latest

defaults:
run:
working-directory: web

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9

- name: Install web dependencies
run: bun install --frozen-lockfile

- name: Check Svelte package
run: bun run check

- name: Test web package
run: bun test

- name: Build web package
run: bun run build
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,11 @@ Socket organization policy.

## Runtime Flow

1. The web app asks the raw NIP-07 browser extension API for the active Nostr pubkey.
1. The web app signs in through a selected external signer: NIP-07 browser extension, Amber/NIP-55
clipboard signing on Android, or a `bunker://` NIP-46 remote signer.
2. API calls include a NIP-98 HTTP auth event in the `Authorization` header.
3. The API verifies the event, extracts the pubkey, and checks team admin rights for most routes.
3. The API verifies the event, extracts the pubkey, and checks team membership for team reads and
admin rights for mutations or authorization-secret-bearing routes.
4. Stored keys and per-authorization bunker keys are encrypted with the root `master.key`.
5. The signer manager watches authorization rows and starts a `signer_daemon` for each one.
6. Each signer daemon decrypts the stored key and bunker key, listens on configured relays, and calls
Expand Down
2 changes: 1 addition & 1 deletion api/src/api/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ mod tests {
#[test]
fn rejects_future_timestamps() {
let req = request(Method::GET, "/teams");
let future = chrono::Utc::now().timestamp() + AUTH_EVENT_MAX_FUTURE_SKEW_SECONDS + 1;
let future = chrono::Utc::now().timestamp() + AUTH_EVENT_MAX_FUTURE_SKEW_SECONDS + 60;
let event = auth_event(
standard_tags("GET", "https://example.com/api/teams"),
future,
Expand Down
62 changes: 61 additions & 1 deletion api/src/api/http/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ pub async fn get_team(
AuthEvent(event): AuthEvent,
Path(team_id): Path<u32>,
) -> ApiResult<Json<TeamWithRelations>> {
verify_admin(&pool, &event.pubkey, team_id).await?;
verify_teammate(&pool, &event.pubkey, team_id).await?;

let team_with_relations = Team::find_with_relations(&pool, team_id).await?;

Expand Down Expand Up @@ -715,6 +715,20 @@ pub async fn verify_admin<'a>(
}
}

pub async fn verify_teammate<'a>(
pool: &'a SqlitePool,
pubkey: &'a PublicKey,
team_id: u32,
) -> ApiResult<()> {
match User::is_team_teammate(pool, pubkey, team_id).await {
Ok(true) => Ok(()),
Ok(false) => Err(ApiError::forbidden(
"You are not authorized to access this team",
)),
Err(_) => Err(ApiError::auth("Failed to verify team membership")),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -877,6 +891,52 @@ mod tests {
assert_eq!(policy.permissions.len(), 2);
}

#[tokio::test]
async fn team_members_can_read_team_but_not_admin_routes() {
let pool = setup_test_db().await;
let admin = Keys::generate();
let member = Keys::generate();
let team = create_team_for(&pool, &admin, "Ops").await;

let _ = add_user(
State(pool.clone()),
AuthEvent(auth_event(&admin)),
Path(team.team.id),
Json(AddTeammateRequest {
user_public_key: member.public_key().to_hex(),
role: TeamUserRole::Member,
}),
)
.await
.unwrap();

let team_response = get_team(
State(pool.clone()),
AuthEvent(auth_event(&member)),
Path(team.team.id),
)
.await
.unwrap()
.0;

assert_eq!(team_response.team.id, team.team.id);
assert_eq!(team_response.team_users.len(), 2);

let err = add_key(
State(pool.clone()),
AuthEvent(auth_event(&member)),
Path(team.team.id),
Json(AddKeyRequest {
name: "member key".to_string(),
secret_key: Keys::generate().secret_key().to_secret_hex(),
}),
)
.await
.unwrap_err();

assert!(matches!(err, ApiError::Forbidden(_)));
}

#[tokio::test]
async fn add_authorization_rejects_policy_from_another_team() {
let pool = setup_test_db().await;
Expand Down
6 changes: 4 additions & 2 deletions web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ This file inherits the root `AGENTS.md` guidance. It applies to `web/`.
- Keep NIP-98 body hashes in exact sync with the JSON sent to the API.
- Permission form data must match the Rust structs exactly.
- Do not store private keys in localStorage or sessionStorage.
- Do not restore `nostr-login`; sign-in should stay on the raw NIP-07 browser extension API unless
there is a fresh security review.
- Do not restore `nostr-login`; sign-in should stay on the explicit signer-session paths in
`src/lib/nostr.ts` unless there is a fresh security review.
- Remote signer session storage may contain NIP-46 client material, but never the user's Nostr
private key.
- Submit handlers should prevent default browser form submission before doing async signing.
- For security-sensitive UI changes, run both typecheck and build.

Expand Down
10 changes: 5 additions & 5 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ The repo-level dev command runs this app through `bun run dev:web`.

## Auth Flow

Sign-in uses the raw NIP-07 browser extension API to fetch the active pubkey. The app then uses
Applesauce's extension signer for NIP-98 event signing. `nostr-login` and NDK are no longer part of
the auth path.
Sign-in uses an explicit signer session selected by the user: raw NIP-07 browser extension,
Amber/NIP-55 clipboard signing on Android, or a `bunker://` NIP-46 remote signer. `nostr-login` and
NDK are no longer part of the auth path.

`src/lib/keycast_api.svelte.ts` builds NIP-98 events with `u`, `method`, and optional `payload` tags,
signs them with NIP-07 through Applesauce, and sends the base64-encoded event in the
`Authorization` header. `src/lib/nostr.ts` owns browser signer access and public profile/contact
signs them through the active signer, and sends the base64-encoded event in the `Authorization`
header. `src/lib/nostr.ts` owns signer access, signer-session storage, and public profile/contact
reads through Applesauce's event store and relay pool.

The cookie named `keycastUserPubkey` is only a UI route hint. It is not proof of authorization. API
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/components/Header.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from "$app/stores";
import { getCurrentUser } from "$lib/current_user.svelte";
import SignInMenu from "$lib/components/SignInMenu.svelte";
import { getCurrentUser } from "$lib/current_user.svelte";
import { signout } from "$lib/utils/auth";
import { Key, SignOut } from "phosphor-svelte";

Expand Down
Loading
Loading