diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index d796c3748d..8333b24a30 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -371,6 +371,29 @@ Hex.isEqual(hex, bytes) // compare hex to bytes without decoding Constants `PUBKEY_LENGTH` / `EVENT_ID_LENGTH` (both 64) live in `nip01Core.core`. +## Core Utilities (time, random, event id) + +Reuse these instead of hand-rolling — each avoids a common mistake: + +```kotlin +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher + +TimeUtils.now() // Unix SECONDS for created_at — not currentTimeMillis()/1000 +TimeUtils.oneHourAgo() // relative filter bounds (…Ago / …FromNow); all in seconds +RandomInstance.bytes(32) // secure random (SecureRandom) — for nonces/keys, not kotlin.random.Random +RandomInstance.randomChars() // 16-char subscription id + +sha256(bytes) // raw hash primitive +EventHasher.hashId(pubKey, createdAt, kind, tags, content) // canonical event id +EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) // verify untrusted events +``` + +`EventHasher` serializes `[0, pubkey, created_at, kind, tags, content]` in the +exact form NIP-01 requires — prefer it over calling `sha256` on your own JSON. + ## Bech32 Encoding (NIP-19) Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`); diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index c3ebbe6de3..6b88cc6f01 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -200,6 +200,77 @@ Constants `PUBKEY_LENGTH` and `EVENT_ID_LENGTH` (both `64`) live in the same --- +### 3.2 Everyday utilities (time, random, hashing, bech32, base64) + +These small helpers exist so you don't reinvent them — and several have a +footgun the built-in avoids. **Prefer them over stdlib/hand-rolled equivalents.** + +**Time — `TimeUtils` (`com.vitorpamplona.quartz.utils`).** Everything is in Unix +**seconds** (what `created_at` and filter `since`/`until` use), *not* millis. + +```kotlin +import com.vitorpamplona.quartz.utils.TimeUtils + +val createdAt = TimeUtils.now() // seconds — for created_at. NOT currentTimeMillis()/1000 +val since = TimeUtils.oneDayAgo() // relative filter bounds: oneHourAgo(), fiveMinutesAgo()… +val fresh = TimeUtils.withinTenMinutes(event.createdAt) // NIP-42/NIP-98 freshness +// TimeUtils.nowMillis() is the only millisecond helper — non-protocol use only. +``` + +**Secure random — `RandomInstance` (`utils`).** Backed by `SecureRandom`; use it +for anything security-sensitive instead of `kotlin.random.Random`. + +```kotlin +import com.vitorpamplona.quartz.utils.RandomInstance + +val nonce = RandomInstance.bytes(32) // nonces, salts, keys +val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] subscription id +``` + +**Hashing — `sha256(...)` + `EventHasher`.** `sha256` is the raw primitive; to +compute/verify an **event id** use `EventHasher`, which canonically serializes +`[0, pubkey, created_at, kind, tags, content]` before hashing (getting this wrong +is what makes relays reject an event). Typed builders already do this for you. + +```kotlin +import com.vitorpamplona.quartz.utils.sha256.sha256 +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher + +val digest = sha256(bytes) // raw 32-byte hash +val id = EventHasher.hashId(pubKey, createdAt, kind, tags, content) +val valid = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content) +``` + +**Bech32.** For `npub`/`nsec`/`note`/… prefer the NIP-19 layer (`ByteArray.toNpub()`, +`Nip19Parser.uriToRoute(...)` — see §10). Drop to the low-level +`Bech32` object (`nip19Bech32.bech32`) only for a custom prefix: + +```kotlin +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes + +val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32) +val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix +``` + +**Base64.** Quartz has no wrapper — use the Kotlin stdlib `kotlin.io.encoding.Base64` +directly, and match the variant the spec wants: NIP-44/NIP-04 payloads use +`Base64.Default` (standard, padded); url-safe contexts use `Base64.UrlSafe` +(configure padding via `.withPadding(...)`). + +| Need | Call | +|------|------| +| Now (event `created_at`) | `TimeUtils.now()` (seconds) | +| Relative filter bound | `TimeUtils.oneDayAgo()` / `oneHourAgo()` / … | +| Secure random bytes | `RandomInstance.bytes(n)` | +| Subscription id | `RandomInstance.randomChars()` | +| Raw hash | `sha256(bytes)` | +| Event id / verify | `EventHasher.hashId(...)` / `hashIdCheck(...)` | +| Bech32 custom prefix | `Bech32.encodeBytes(hrp, bytes, enc)` / `s.bechToBytes(hrp)` | +| Base64 | `kotlin.io.encoding.Base64` (`.Default` / `.UrlSafe`) | + +--- + ## 4. Signing Events ### `NostrSignerInternal` (local key, JVM + Android) @@ -697,6 +768,10 @@ val results = store.query(Filter(search = "bitcoin")) | ByteArray → hex | `bytes.toHexKey()` | `nip01Core.core` | | hex → ByteArray | `hex.hexToByteArray()` / `hex.hexToByteArrayOrNull()` | `nip01Core.core` | | Validate hex | `Hex.isHex(s)` / `Hex.isHex64(s)` / `hex.isValid()` | `utils`, `nip01Core.core` | +| Now (seconds) | `TimeUtils.now()` | `utils` | +| Relative time | `TimeUtils.oneDayAgo()` / `oneHourAgo()` | `utils` | +| Secure random | `RandomInstance.bytes(n)` / `randomChars()` | `utils` | +| Hash / event id | `sha256(bytes)` / `EventHasher.hashId(...)` | `utils.sha256`, `nip01Core.crypto` | | Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` | | Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` | | Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt index 1c9ad080bc..99b4450cea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt @@ -24,7 +24,23 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 +/** + * Computes and verifies Nostr **event ids** (NIP-01). + * + * An id is the SHA-256 of the canonically serialized array + * `[0, pubkey, created_at, kind, tags, content]` — UTF-8, no whitespace, in that + * exact order. Getting that serialization right by hand is easy to botch (it is + * what makes relays reject an event), so route through here rather than calling + * `sha256` on your own JSON. Signing/building via the typed event builders already + * does this for you; reach for `EventHasher` when validating events from an + * untrusted source or hashing a not-yet-wrapped template. + * + * ```kotlin + * val ok = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content) + * ``` + */ object EventHasher { + /** Raw 32-byte id digest for the given event fields. See [hashId] for the hex form. */ fun hashIdBytes( pubKey: HexKey, createdAt: Long, @@ -33,6 +49,7 @@ object EventHasher { content: String, ): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content)) + /** The event id as a lower-case 64-char hex string. */ fun hashId( pubKey: HexKey, createdAt: Long, @@ -41,6 +58,7 @@ object EventHasher { content: String, ): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey() + /** True when [id] matches the id computed from the other fields — use to validate untrusted events. */ fun hashIdCheck( id: HexKey, pubKey: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt index 3b664718aa..2cc561e561 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt @@ -43,8 +43,21 @@ package com.vitorpamplona.quartz.nip19Bech32.bech32 private typealias Int5 = Byte /** - * Bech32 and Bech32m address formats. See - * https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and + * Low-level Bech32 / Bech32m codec (BIP-173 / BIP-350). + * + * For the common Nostr entities (`npub`, `nsec`, `note`, `nevent`, `nprofile`, + * `naddr`) you usually **don't** need this directly — prefer the NIP-19 helpers: + * the `ByteArray.toNpub()/toNsec()/toNote()` extensions to encode and + * `Nip19Parser.uriToRoute(...)` to decode. Reach for `Bech32` when you need a + * custom human-readable prefix or raw 5-bit/8-bit access: + * + * ```kotlin + * val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32) + * val (hrp, data, _) = Bech32.decodeBytes(addr) // hrp = "npub", data = 32 bytes + * val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix + * ``` + * + * See https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and * https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki. */ object Bech32 { @@ -284,6 +297,11 @@ object Bech32 { } } +/** + * Decodes this Bech32 string to its raw data bytes. If [hrp] is given, throws + * [IllegalArgumentException] when the decoded human-readable prefix doesn't match + * (e.g. pass `"npub"` to reject anything that isn't a public key). + */ fun String.bechToBytes(hrp: String? = null): ByteArray { val decodedForm = Bech32.decodeBytes(this) hrp?.also { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/RandomInstance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/RandomInstance.kt index 39419811fe..7ae9134994 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/RandomInstance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/RandomInstance.kt @@ -20,22 +20,41 @@ */ package com.vitorpamplona.quartz.utils +/** + * Cryptographically secure random source shared across Quartz, backed by the + * platform [SecureRandom]. **Use this — not `kotlin.random.Random`** — for + * anything security-sensitive: NIP-44 nonces, private keys, gift-wrap timestamps, + * random `d` tags, subscription ids. + * + * ```kotlin + * val nonce = RandomInstance.bytes(32) // 32 secure random bytes + * val dTag = RandomInstance.bytes(16).toHexKey() + * val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] id + * ``` + */ object RandomInstance { val randomizer = SecureRandom() + /** A secure random [Int] across the full 32-bit range (may be negative). */ fun int() = randomizer.nextInt() + /** A secure random [Long] across the full 64-bit range (may be negative). */ fun long() = randomizer.nextLong() + /** A secure random [Int] in `0 until bound`. */ fun int(bound: Int) = randomizer.nextInt(bound) + /** A secure random [Long] in `0 until bound`. */ fun long(bound: Long) = randomizer.nextLong(bound) + /** [size] secure random bytes — the go-to for nonces, keys and salts. */ fun bytes(size: Int) = ByteArray(size).also { randomizer.nextBytes(it) } val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + /** A single secure random alphanumeric char from [charPool]. */ fun randomChar() = charPool[randomizer.nextInt(charPool.size)] + /** A secure random alphanumeric string of [size] chars — handy for subscription ids. */ fun randomChars(size: Int = 16) = CharArray(size) { randomChar() }.concatToString() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt index e02e2227d6..3971722890 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt @@ -32,6 +32,13 @@ fun Int.bytesUsedInMemory(): Int = 4 fun Boolean.bytesUsedInMemory(): Int = 8 +/** + * Case-insensitive [contains] that does **not** allocate a lowercased copy of the + * receiver — it scans in place comparing each char against both cases of [term]. + * Preferred over `this.lowercase().contains(term.lowercase())` on hot paths (feed + * search, tag matching). When testing the same term against many strings, precompute + * a [DualCase] and use the two-arg overload / [containsAny] to hoist the casing work. + */ fun String.containsIgnoreCase(term: String): Boolean { if (term.isEmpty()) return true // Empty string is contained @@ -41,6 +48,7 @@ fun String.containsIgnoreCase(term: String): Boolean { return containsIgnoreCase(whatLowercase, whatUppercase) } +/** [containsIgnoreCase] variant taking the term's cases precomputed — see [DualCase]. */ fun String.containsIgnoreCase( whatLowercase: String, whatUppercase: String, @@ -68,6 +76,7 @@ fun String.containsIgnoreCase( return false } +/** Case-insensitive [startsWith], allocation-free, taking the prefix's cases precomputed. */ fun String.startsWithIgnoreCase( whatLowercase: String, whatUppercase: String, @@ -89,6 +98,7 @@ fun String.startsWithIgnoreCase( return true } +/** True when this contains any of [terms] (case-insensitive). Empty list ⇒ true. */ fun String.containsAny(terms: List): Boolean { if (terms.isEmpty()) return true // Empty string is contained @@ -99,6 +109,7 @@ fun String.containsAny(terms: List): Boolean { return terms.any { containsIgnoreCase(it.lowercase, it.uppercase) } } +/** True when this starts with any of [terms] (case-insensitive). Empty list ⇒ true. */ fun String.startsWithAny(terms: List): Boolean { if (terms.isEmpty()) return true // Empty string is contained @@ -111,6 +122,12 @@ fun String.startsWithAny(terms: List): Boolean { fun String.startsWith(prefix: DualCase): Boolean = startsWithIgnoreCase(prefix.lowercase, prefix.uppercase) +/** + * A search term with its lower- and upper-case forms computed once, so the + * allocation-free case-insensitive matchers ([containsIgnoreCase], [startsWithIgnoreCase], + * [containsAny], [startsWithAny]) can be run against many strings without re-casing + * the term each time. Build once, reuse across a feed scan. + */ class DualCase( val lowercase: String, val uppercase: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index b8f1a09ec8..19849b5b65 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -20,6 +20,25 @@ */ package com.vitorpamplona.quartz.utils +/** + * Clock and duration helpers for Nostr timestamps. + * + * **Everything here is in Unix _seconds_, not milliseconds** — that is the unit + * Nostr uses for an event's `created_at` and for filter `since`/`until` bounds. + * Use [now] to stamp an event and the `...Ago` / `...FromNow` helpers to build + * relative filter windows; do **not** hand-roll `currentTimeMillis() / 1000`. + * The `const val` durations ([ONE_MINUTE], [ONE_HOUR], [ONE_DAY], …) are also in + * seconds and can be added/subtracted directly. + * + * ```kotlin + * val createdAt = TimeUtils.now() // seconds, for created_at + * val since = TimeUtils.oneDayAgo() // filter: last 24h + * val fresh = TimeUtils.withinTenMinutes(event.createdAt) + * ``` + * + * [nowMillis] is the one exception: it returns milliseconds, for the rare + * non-protocol case (UI timers, latency measurements) that needs finer detail. + */ object TimeUtils { const val TEN_SECONDS = 10 const val ONE_MINUTE = 60 @@ -34,8 +53,10 @@ object TimeUtils { const val ONE_MONTH = 30 * ONE_DAY const val ONE_YEAR = 365 * ONE_DAY + /** Current time in Unix **seconds** — the value for an event's `created_at`. */ fun now() = currentTimeSeconds() + /** Current time in Unix **milliseconds**. For non-protocol use only; `created_at` wants [now]. */ fun nowMillis() = currentTimeMillis() fun tenSecondsFromNow() = now() + TEN_SECONDS @@ -74,6 +95,7 @@ object TimeUtils { fun oneYearAgo() = now() - ONE_YEAR + /** True when [time] (Unix seconds) is within ±10 minutes of [now] — e.g. a fresh NIP-98/NIP-42 stamp. */ fun withinTenMinutes(time: Long): Boolean { val now = now() return time > now - TEN_MINUTES && time < now + TEN_MINUTES diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt index b9bbf9bf48..53225a1d3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt @@ -20,6 +20,15 @@ */ package com.vitorpamplona.quartz.utils.sha256 +/** + * SHA-256 of [data], returning a fresh 32-byte digest. + * + * This is the raw primitive. To compute or verify a Nostr **event id** don't hash + * by hand — use `EventHasher.hashId(...)` / `hashIdCheck(...)`, which serialize the + * `[0, pubkey, created_at, kind, tags, content]` array in the canonical form the + * protocol requires before hashing. Use [sha256Into] on hot paths to avoid + * allocating a new array per call. + */ expect fun sha256(data: ByteArray): ByteArray /**