diff --git a/.claude/hooks/pre-push-spotless.sh b/.claude/hooks/pre-push-spotless.sh new file mode 100755 index 0000000000..ff989f68a6 --- /dev/null +++ b/.claude/hooks/pre-push-spotless.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# PreToolUse gate: make sure Kotlin is spotless-clean BEFORE it leaves the box. +# +# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool. Runs +# `spotlessApply`; if that reformats any tracked .kt/.kts file, the push/PR is +# blocked (exit 2) so the agent commits the formatting fix first. This turns +# CI's `spotlessCheck` failure into an in-session block — no red PR, no round +# trip. `spotlessApply` runs the same formatters CI's `spotlessCheck` verifies, +# so a clean apply means a green check. +set -uo pipefail + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +# --- Parse the tool call off stdin; decide whether this call is a boundary. --- +payload="$(cat)" +should_gate="$( + printf '%s' "$payload" | python3 -c ' +import json, shlex, sys +try: + data = json.load(sys.stdin) +except Exception: + print("no"); sys.exit(0) +tool = data.get("tool_name", "") +if tool.endswith("create_pull_request"): + print("yes"); sys.exit(0) +if tool != "Bash": + print("no"); sys.exit(0) +cmd = (data.get("tool_input") or {}).get("command", "") +# Tokenize like a shell so `push` inside a quoted commit message or heredoc +# stays one token and is NOT mistaken for the push subcommand. +try: + tokens = shlex.split(cmd, comments=True) +except ValueError: + tokens = cmd.split() +GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"} +for i, t in enumerate(tokens): + if t != "git" and not t.endswith("/git"): + continue + j = i + 1 + while j < len(tokens): # skip git global options to reach the subcommand + tok = tokens[j] + if tok in GLOBAL_WITH_ARG: + j += 2; continue + if tok.startswith("-"): + j += 1; continue + break + if j < len(tokens) and tokens[j] == "push": + print("yes"); sys.exit(0) +print("no") +' 2>/dev/null +)" + +[ "$should_gate" = "yes" ] || exit 0 + +# Nothing to format if no Kotlin is tracked/changed at all — cheap early out. +if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then + exit 0 +fi + +# Snapshot Kotlin state (vs HEAD, so staged + unstaged both count) before/after +# formatting; any delta means the committed tree wasn't spotless. +before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" + +log="$(mktemp /tmp/spotless-gate.XXXXXX.log)" +if ! ./gradlew spotlessApply >"$log" 2>&1; then + # Distinguish a formatting failure (block) from Gradle being unable to RUN — + # e.g. deps can't resolve in a restricted sandbox. An infra failure must not + # strand the agent; warn and let CI's spotlessCheck be the backstop. + if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then + echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2 + echo " CI's spotlessCheck still enforces formatting on the PR." >&2 + rm -f "$log" + exit 0 + fi + echo "BLOCKED: spotlessApply failed — fix the build/formatting error before pushing." >&2 + echo "----- gradle output (tail) -----" >&2 + tail -n 40 "$log" >&2 + rm -f "$log" + exit 2 +fi +rm -f "$log" + +after="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" + +if [ "$before" != "$after" ]; then + echo "BLOCKED: spotlessApply reformatted Kotlin files that were about to be pushed." >&2 + echo "The changes below are now in your working tree. Commit them, then retry:" >&2 + echo >&2 + git diff --name-only HEAD -- '*.kt' '*.kts' >&2 + echo >&2 + echo " git add -A && git commit -m 'style: apply spotless' && " >&2 + echo "(CI runs 'spotlessCheck'; pushing now would fail the lint job.)" >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 15b897a70f..53540bdb4d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,17 @@ { "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|mcp__github__create_pull_request", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh", + "timeout": 180 + } + ] + } + ], "SessionStart": [ { "hooks": [ diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index 8333b24a30..8dc31a681a 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: nostr-expert -description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. +description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent), (8) Resolving user input (hex, npub, nprofile, or NIP-05 `name@domain` internet identifiers) to a pubkey. Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. --- # Nostr Protocol Expert (Quartz Implementation) @@ -15,6 +15,7 @@ Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP - Finding NIP implementations in quartz/ codebase - Nostr cryptography (secp256k1 signing, NIP-44 encryption) - Bech32 encoding/decoding (npub, nsec, note formats) +- Resolving user input (hex / npub / nprofile / NIP-05 `name@domain`) to a pubkey - Event validation and verification **For NIP specifications** → Use `nostr-protocol` agent @@ -424,6 +425,25 @@ when (val entity = Nip19Parser.uriToRoute(input)?.entity) { } ``` +## Resolving User Input to a Pubkey (NIP-05 + NIP-19) + +**Before writing any `if (isHex) … else if (npub) … else if ("@" in s) fetchWellKnown()` logic, stop — it already exists.** `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/` accepts every identifier form a user might type and returns a 64-hex pubkey. + +```kotlin +import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull + +// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → HexKey? (null if unrecognized/lookup fails) +val pubkey = resolveUserHexOrNull(userInput, nip05Client) +``` + +- Tries the **synchronous** hex/bech32 path first (`decodePublicKeyAsHexOrNull`) — only NIP-05-shaped input hits the network. +- `suspend`; re-throws only `CancellationException`. Pass `nip05Client = null` for offline contexts. +- Build the client with `Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttp })` (see `cli/Context.kt`). The OkHttp fetcher already runs on IO and disables redirects per the NIP-05 spec — don't re-implement the `.well-known/nostr.json` fetch or JSON parse. +- Need only hex/bech32 (no network)? Use `decodePublicKeyAsHexOrNull(input)` directly. +- Need to *verify* a claimed identifier maps back to a pubkey? `nip05Client.verify(Nip05Id.parse(id)!!, pubkey)`. + +See `references/nip05-identifiers.md` for the full API surface (`Nip05Id`, `Nip05Client`, `Nip05Parser`, `KeyInfoSet`, Namecoin `.bit`) and the hand-rolled anti-pattern to avoid. + ## Event Validation ```kotlin @@ -552,6 +572,7 @@ Or see `references/nip-catalog.md` for complete catalog. - **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types - **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns - **references/nip19-bech32.md** - `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed) +- **references/nip05-identifiers.md** - Resolving any identifier (hex/npub/nprofile/nsec/`name@domain`) to a pubkey via `resolveUserHexOrNull`; `Nip05Client`, `Nip05Id`, `Nip05Parser`, Namecoin `.bit` — and the hand-rolled anti-pattern to avoid - **references/event-factory.md** - `EventFactory` dispatch pattern and how to register a new kind - **references/crypto-and-encryption.md** - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, `SharedKeyCache` - **references/large-cache.md** - `LargeCache` expect/actual + `ICacheOperations` functional API @@ -567,6 +588,9 @@ Or see `references/nip-catalog.md` for complete catalog. | Verify signature | `event.verify()` | nip01Core/core/ | | Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ | | Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ | +| Resolve input → pubkey | `resolveUserHexOrNull(input, nip05Client)` | nip05DnsIdentifiers/ | +| Decode bech32 → pubkey (no net) | `decodePublicKeyAsHexOrNull(input)` | nip19Bech32/ | +| Verify NIP-05 identifier | `nip05Client.verify(Nip05Id.parse(id)!!, hex)` | nip05DnsIdentifiers/ | | Find NIP | `scripts/nip-lookup.sh ` | - | ## Common Event Kinds diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index a18731696f..78ed47ee11 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -13,7 +13,7 @@ under `experimental/`**. The categorized list below may lag behind — | 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) | | 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps | | 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) | -| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification | +| 05 | `nip05DnsIdentifiers/` | UserHexResolver.kt, Nip05Client.kt | Internet identifiers; `resolveUserHexOrNull` resolves hex/npub/nprofile/`name@domain` → pubkey (see references/nip05-identifiers.md) | | 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation | | 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) | | 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata | diff --git a/.claude/skills/nostr-expert/references/nip05-identifiers.md b/.claude/skills/nostr-expert/references/nip05-identifiers.md new file mode 100644 index 0000000000..450f0e86fc --- /dev/null +++ b/.claude/skills/nostr-expert/references/nip05-identifiers.md @@ -0,0 +1,98 @@ +# NIP-05: Identifiers → Pubkey Resolution + +How Quartz turns anything a human might type — a raw hex pubkey, an `npub`/`nprofile`/`nsec`, or a NIP-05 internet identifier (`alice@domain.tld`) — into a 64-hex Nostr pubkey. **Everything below already exists in `quartz/nip05DnsIdentifiers/`. Do not hand-roll it.** + +## TL;DR — the one function you almost always want + +```kotlin +import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull + +// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → 64-hex pubkey (or null) +val pubkey: HexKey? = resolveUserHexOrNull(userInput, nip05Client) +``` + +`resolveUserHexOrNull(input, nip05Client)` (in `UserHexResolver.kt`) is the canonical "accept any identifier form" resolver. It: + +- trims input, tries the **synchronous** bech32/hex path first (`decodePublicKeyAsHexOrNull`), so hex/`npub`/`nprofile`/`nsec` never touch the network; +- only issues an HTTPS fetch for genuinely NIP-05-shaped input (`name@domain.tld`), gated by a cheap `looksLikeNip05()` precheck; +- returns `null` on anything unrecognizable or on a failed NIP-05 lookup (network error / no match); +- re-throws **only** `CancellationException`, so it's safe inside structured concurrency. + +Pass `nip05Client = null` in pure-offline contexts — NIP-05-shaped inputs then fall through to `null` and no HTTP is attempted. + +## ❌ Do not write this (the hand-rolled anti-pattern) + +```kotlin +// DON'T. This re-implements resolveUserHexOrNull badly: +// - no nsec support +// - no input validation (accepts IP-literal / malformed domains → spurious fetches) +// - hand-parses JSON instead of using Nip05Parser +// - bespoke httpGet ignores the "MUST NOT follow redirects" rule +// - swallows CancellationException, breaking structured concurrency +fun resolveObserver(input: String): String? { + if (Hex.isHex64(input)) return input.lowercase() + if (input.startsWith("npub1") || input.startsWith("nprofile1")) { /* … */ } + if ("@" in input) return resolveNip05(input) // bespoke well-known fetch + return null +} +``` + +## ✅ Do this instead + +```kotlin +// CLI already exposes it — commands call Context.requireUserHex(input): +val pubHex = resolveUserHexOrNull(input, nip05Client) + ?: return Output.error("bad_args", "expected npub, nprofile, 64-hex, or name@domain.tld") +``` + +## Building an `Nip05Client` + +`resolveUserHexOrNull` takes an `INip05Client`. On JVM/Android, wire the OkHttp fetcher (mirror what `cli/Context.kt` does): + +```kotlin +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client +import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher + +val nip05Client = Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttpClient }) +``` + +`OkHttpNip05Fetcher` already runs on `Dispatchers.IO` and disables redirects per the NIP-05 spec ("Fetchers MUST ignore any HTTP redirects"). Don't re-implement the fetch. + +For tests / offline code, `EmptyNip05Client` is a no-op stub. + +## The pieces (all in `quartz/…/nip05DnsIdentifiers/`) + +| Type | File | Purpose | +|------|------|---------| +| `resolveUserHexOrNull(input, client?)` | `UserHexResolver.kt` | **Start here.** Any identifier form → 64-hex pubkey, or null. `suspend`. | +| `Nip05Id` | `Nip05Id.kt` | Parsed `name@domain`. `Nip05Id.parse(str)` validates (RFC 5321 local-part + hostname rules, rejects IP literals) and lowercases. `toUserUrl()` / `toDomainUrl()` build the `.well-known/nostr.json` URLs. `toDisplayValue()` collapses the `_` wildcard to just the domain. | +| `INip05Client` / `Nip05Client` | `INip05Client.kt`, `Nip05Client.kt` | Async resolver. `get(id): Nip05KeyInfo?` (pubkey + relays), `verify(id, hex): Boolean`, `load(id): KeyInfoSet?`, `list(domain): KeyInfoSet`, `loadClinkOffer(id): String?`. Auto-routes `.bit` domains to Namecoin. `EmptyNip05Client` = offline no-op. | +| `Nip05Fetcher` / `OkHttpNip05Fetcher` | `Nip05Fetcher.kt`, `OkHttpNip05Fetcher.kt` (jvmAndroid) | Transport SAM. OkHttp actual disables redirects + runs on IO. | +| `Nip05Parser` | `Nip05Parser.kt` | JSON `.well-known/nostr.json` codec: `parseHexKey`, `parseHexKeyAndRelays`, `parse` → `KeyInfoSet`, `parseClinkOffer`. | +| `Nip05KeyInfo` / `KeyInfoSet` | `Nip05KeyInfo.kt`, `KeyInfoSet.kt` | `Nip05KeyInfo(pubkey, relays)`; `KeyInfoSet(names: Map, relays: Map)` = the full domain listing. | +| `NamecoinNameResolver` | `namecoin/NamecoinNameResolver.kt` | `.bit` / `d/…` / `id/…` blockchain identifiers. `isNamecoinIdentifier(str)`, `resolve(str)`. Invoked automatically by `Nip05Client` — you rarely call it directly. | + +## When you only need the pure (synchronous, no-network) part + +If the input can only be hex/bech32 (no NIP-05), skip the client entirely: + +```kotlin +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +// hex | npub1… | nprofile1… | nsec1… → 64-hex pubkey (or null). No suspend, no network. +val pubkey: HexKey? = decodePublicKeyAsHexOrNull(input) +``` + +See `references/nip19-bech32.md` for the full bech32 entity story. `resolveUserHexOrNull` is just this function plus the NIP-05 HTTP fallback. + +## Verifying a claimed identifier + +To confirm a profile's advertised `nip05` actually points back to its pubkey (NIP-05 verification), use `verify`, not `get`: + +```kotlin +val ok: Boolean = nip05Client.verify(Nip05Id.parse("alice@domain.tld")!!, profilePubkeyHex) +``` + +## Tests + +`quartz/src/commonTest/…/nip05DnsIdentifiers/Nip05Test.kt` covers parsing, URL construction, case-normalization, CLINK offers, and the validation rejects (IP literals, malformed domains). diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index e5531f56b8..f32cdf0517 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt index cd7cc65d4f..90bdbf434c 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 53705a0dac..7ba5102c49 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt differ diff --git a/geode/config.example.toml b/geode/config.example.toml index a95b625d59..7c83311b7b 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -62,6 +62,12 @@ verify_signatures = true # Require clients to NIP-42 AUTH before REQ/EVENT/COUNT. require_auth = false +# Advertise NIP-42 AUTH without requiring it: the relay sends the +# challenge and records clients that authenticate (so downstream +# policies can gate on identity), but REQ/EVENT/COUNT still work for +# clients that never AUTH. Ignored when require_auth = true. +# optional_auth = false + # Reject events whose `created_at` is more than this many seconds in # the future. Enforced by RejectFutureEventsPolicy. # reject_future_seconds = 1800 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index e2becaf114..e0cd7f815e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -24,10 +24,12 @@ import com.vitorpamplona.geode.config.BannedEntry import com.vitorpamplona.geode.config.RuntimeConfig import com.vitorpamplona.geode.config.RuntimeConfigData import com.vitorpamplona.geode.config.StaticConfig +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy @@ -64,6 +66,8 @@ import java.io.File * --info NIP-11 doc file (overrides [info] section) * --db sqlite db path (overrides [database].file) * --auth require NIP-42 AUTH (sets options.require_auth = true) + * --optional-auth advertise NIP-42 AUTH but don't require it (sets + * options.optional_auth = true; ignored when --auth is set) * --no-verify DO NOT verify event signatures (off by default * verify is on; use only for trusted-input * scenarios like fixture replay). @@ -84,6 +88,9 @@ fun main(args: Array) { val cliInfoFile = a.opt("--info")?.let { File(it) } val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } val requireAuth = a.flag("--auth") || config.options.require_auth + // Optional AUTH advertises the challenge without gating commands on it. + // Mandatory AUTH already sends the challenge, so it wins when both are set. + val optionalAuth = !requireAuth && (a.flag("--optional-auth") || config.options.optional_auth) // Verify is on by default; only disable when the operator explicitly // opts out (CLI `--no-verify` or `[options].verify_signatures = false` // in the config). @@ -109,7 +116,7 @@ fun main(args: Array) { val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) val policyBuilder: () -> IRelayPolicy = { - composePolicy(config, advertisedUrl, requireAuth, verifySigs, parallelVerify) + composePolicy(config, advertisedUrl, requireAuth, optionalAuth, verifySigs, parallelVerify) } // Static [authorization] feeds the runtime BanStore only when no @@ -182,8 +189,9 @@ fun main(args: Array) { */ private fun composePolicy( config: StaticConfig, - advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + advertisedUrl: NormalizedRelayUrl, requireAuth: Boolean, + optionalAuth: Boolean, verifySigs: Boolean, parallelVerify: Boolean, ): IRelayPolicy { @@ -191,6 +199,8 @@ private fun composePolicy( if (requireAuth) { pieces += FullAuthPolicy(advertisedUrl) + } else if (optionalAuth) { + pieces += OptionalAuthPolicy(advertisedUrl) } config.options.reject_future_seconds?.let { secs -> diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 06f9120311..9b59d9f45e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -107,6 +107,13 @@ data class StaticConfig( data class OptionsSection( val reject_future_seconds: Int? = null, val require_auth: Boolean = false, + /** + * Advertise NIP-42 AUTH without requiring it: the relay sends the + * challenge and records clients that authenticate, but EVENT/REQ/COUNT + * still work for clients that never do. Ignored when [require_auth] is + * true (mandatory AUTH already sends the challenge). + */ + val optional_auth: Boolean = false, /** * Defaults to `true`: any relay accepting real traffic should * verify Schnorr signatures, and verifying-by-default closes diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt new file mode 100644 index 0000000000..dc8124a90c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Runs the full NIP-42 challenge/verify handshake but never *requires* it. + * + * Like [FullAuthPolicy], [onConnect] emits the AUTH challenge and [accept] + * (AuthCmd) validates a returned event (expiration, freshness, challenge match, + * relay match), recording verified pubkeys into the engine-owned connection + * scope. Unlike [FullAuthPolicy], EVENT, REQ, and COUNT are **always accepted** — + * a client that ignores the challenge and never authenticates keeps working. + * + * Use this when you want the relay to *advertise* authentication (so clients that + * do support NIP-42 can identify themselves, and downstream policies can gate or + * rewrite on the caller's identity via [authenticatedUsers]) without locking out + * clients that don't. It is the middle ground between [EmptyPolicy] (no challenge + * at all) and [FullAuthPolicy] (challenge required for every command). + * + * Because it subclasses [FullAuthPolicy], the [authorize] hook and the + * per-connection [authenticatedUsers] set behave identically; only the gating on + * EVENT/REQ/COUNT is relaxed. Subclasses can still tighten specific commands + * (e.g. require auth for kind 4 only) by overriding the relevant [accept] and + * reading [authenticatedUsers]. + */ +open class OptionalAuthPolicy( + relay: NormalizedRelayUrl, +) : FullAuthPolicy(relay) { + override fun accept(cmd: EventCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd): PolicyResult = PolicyResult.Accepted(cmd) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt index 07d9cc0667..13f4f00046 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.store.IEventStore @@ -464,6 +465,100 @@ class NostrServerAuthTest { server.close() } + // -- NIP-42: optional AUTH ------------------------------------------------- + + @Test + fun optionalAuthSendsChallengeOnConnect() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + + server.close() + } + + @Test + fun optionalAuthAllowsCommandsWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // The challenge was sent, but a client that ignores it can still write. + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertFalse((session.policy as OptionalAuthPolicy).isAuthenticated()) + + // REQ works too — no CLOSED with auth-required. + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + assertTrue(collector.rawMessagesContaining("EOSE").isNotEmpty()) + assertTrue(collector.rawMessagesContaining("CLOSED").isEmpty()) + + server.close() + } + + @Test + fun optionalAuthStillRecordsAuthenticatedUsers() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // A client that DOES authenticate is still verified and recorded, so + // downstream policies can gate on identity. + session.receive(authJson(authEvent(challenge = msg.challenge))) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertTrue((session.policy as OptionalAuthPolicy).isAuthenticated()) + assertTrue(session.requestContext.authenticatedUsers.contains(pubkey)) + + server.close() + } + + @Test + fun optionalAuthRejectsInvalidAuthButKeepsWorking() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // A bogus AUTH is rejected (challenge mismatch) but the connection is + // not authenticated and commands still flow. + session.receive(authJson(authEvent(challenge = "wrong-challenge"))) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",false,")) + assertTrue(okMessages[0].contains("challenge")) + assertFalse((session.policy as OptionalAuthPolicy).isAuthenticated()) + + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + val allOk = collector.rawMessagesContaining("OK") + assertEquals(2, allOk.size) + assertTrue(allOk[1].contains(",true,")) + + server.close() + } + // -- Custom AuthPolicy tests ----------------------------------------------- @Test diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt index e65b2ca487..f041043069 100644 Binary files a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt and b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt differ