diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f38ad3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 99ef273..0a8cc9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/src/api/http/mod.rs b/api/src/api/http/mod.rs index d0fe356..c18fac4 100644 --- a/api/src/api/http/mod.rs +++ b/api/src/api/http/mod.rs @@ -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, diff --git a/api/src/api/http/teams.rs b/api/src/api/http/teams.rs index 213d324..cc5b8c2 100644 --- a/api/src/api/http/teams.rs +++ b/api/src/api/http/teams.rs @@ -140,7 +140,7 @@ pub async fn get_team( AuthEvent(event): AuthEvent, Path(team_id): Path, ) -> ApiResult> { - 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?; @@ -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::*; @@ -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; diff --git a/web/AGENTS.md b/web/AGENTS.md index e25abde..caab339 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -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. diff --git a/web/README.md b/web/README.md index 2ace18b..1b4a579 100644 --- a/web/README.md +++ b/web/README.md @@ -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 diff --git a/web/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte index 395ed4a..cb7a089 100644 --- a/web/src/lib/components/Header.svelte +++ b/web/src/lib/components/Header.svelte @@ -1,7 +1,7 @@