diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index c8c87c0cd7..d796c3748d 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -345,6 +345,32 @@ object Nip04 { **Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues. +## Hex Encoding (HexKey ↔ ByteArray) + +Pubkeys, event ids and signatures are lower-case hex. Quartz uses the `HexKey` +typealias (`= String`) plus extensions in `nip01Core/core/HexKey.kt`, backed by +the `Hex` object in `utils/Hex.kt`. **Use these — never hand-roll a byte loop or +import a third-party hex codec.** + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.isValid +import com.vitorpamplona.quartz.utils.Hex + +val hex: HexKey = bytes.toHexKey() // ByteArray -> lower-case hex +val back: ByteArray = hex.hexToByteArray() // hex -> ByteArray (throws on odd length) +val safe: ByteArray? = input.hexToByteArrayOrNull() // null on invalid hex + +Hex.isHex(input) // valid hex, any length +Hex.isHex64(input) // ~30% faster fast-path for a 32-byte key/id +hex.isValid() // 64 chars + valid hex (pubkey / event-id shape) +Hex.isEqual(hex, bytes) // compare hex to bytes without decoding +``` + +Constants `PUBKEY_LENGTH` / `EVENT_ID_LENGTH` (both 64) live in `nip01Core.core`. + ## 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 29b41f2d65..c3ebbe6de3 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -134,13 +134,14 @@ val privKeyHex: String? = keyPair.privKey?.toHexKey() ```kotlin import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser // ByteArray → hex val hex = byteArray.toHexKey() // hex → ByteArray -val bytes = HexKey.decodeHex(hex) +val bytes = hex.hexToByteArray() // Bech32 import (npub, nsec) val parsed = Nip19Parser.uriToRoute("npub1abc...") @@ -148,6 +149,55 @@ val parsed = Nip19Parser.uriToRoute("npub1abc...") val parsed = Nip19Parser.uriToRoute("nsec1abc...") ``` +> Hex ↔ ByteArray is a first-class utility in Quartz — see **§3.1 Hex utilities** below. + +--- + +### 3.1 Hex utilities (HexKey ↔ ByteArray) + +Nostr keys, event ids and signatures travel as lower-case hex strings. Quartz +models this with the `HexKey` typealias (just a `String`) plus extension +functions — **do not** write your own byte loop or pull in a third-party codec. + +**Packages:** `com.vitorpamplona.quartz.nip01Core.core` (the extensions) and +`com.vitorpamplona.quartz.utils` (the underlying `Hex` object). + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.HexKey // typealias = String +import com.vitorpamplona.quartz.nip01Core.core.toHexKey // ByteArray → hex +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray // hex → ByteArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.isValid +import com.vitorpamplona.quartz.utils.Hex + +// Encode / decode +val hex: HexKey = pubKeyBytes.toHexKey() // lower-case, 2 chars per byte +val bytes: ByteArray = hex.hexToByteArray() // throws on odd length + +// Untrusted input → decode safely +val maybe: ByteArray? = userInput.hexToByteArrayOrNull() // null if not valid hex + +// Validate without decoding (no allocation) +Hex.isHex(userInput) // even-length, all hex digits (any length) +Hex.isHex64(userInput) // fast path for a 32-byte key/id (checks first 64 chars) +hex.isValid() // 64 chars AND valid hex (pubkey / event-id shape) + +// Compare a hex string to raw bytes without decoding +Hex.isEqual(incomingHexId, myIdBytes) +``` + +| Need | Call | Notes | +|------|------|-------| +| ByteArray → hex | `bytes.toHexKey()` | lower-case output | +| hex → ByteArray (strict) | `hex.hexToByteArray()` | throws on odd length | +| hex → ByteArray (safe) | `hex.hexToByteArrayOrNull()` | `null` on invalid hex | +| is this valid hex? | `Hex.isHex(s)` / `Hex.isHex64(s)` | `isHex64` ~30% faster for keys/ids | +| is this a pubkey/id shape? | `hex.isValid()` | 64 chars + valid hex | +| hex == bytes? | `Hex.isEqual(hex, bytes)` | no decode allocation | + +Constants `PUBKEY_LENGTH` and `EVENT_ID_LENGTH` (both `64`) live in the same +`nip01Core.core` package. + --- ## 4. Signing Events @@ -644,6 +694,9 @@ val results = store.query(Filter(search = "bitcoin")) | Sign event | `signer.sign(template)` | `nip01Core.signers` | | Serialize | `event.toJson()` | `nip01Core.core` | | Parse | `Event.fromJson(json)` | `nip01Core.core` | +| 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` | | 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/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index e7fefe331b..a682753914 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -22,17 +22,49 @@ package com.vitorpamplona.quartz.nip01Core.core import com.vitorpamplona.quartz.utils.Hex -/** Makes the distinction between String and Hex * */ +/** + * A lower-case hexadecimal string. This is the canonical wire format for the + * 32-byte values Nostr deals with everywhere: public keys, event ids, and the + * 64-byte Schnorr signature. It is only a [String] alias — it documents intent + * and does no validation on its own — so pair it with [isValid] (or [Hex.isHex]) + * whenever the value comes from an untrusted source. + * + * Convert with the extension functions below rather than reaching for a byte + * loop or a third-party codec: + * + * ```kotlin + * import com.vitorpamplona.quartz.nip01Core.core.toHexKey + * import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + * + * val hex: HexKey = pubKeyBytes.toHexKey() // ByteArray -> hex + * val bytes: ByteArray = hex.hexToByteArray() // hex -> ByteArray + * ``` + * + * For byte-array-free, allocation-light checks use [Hex.isHex64] (32-byte keys + * and ids) or [Hex.isEqual] (compare a hex string to raw bytes without decoding). + */ typealias HexKey = String +/** Encodes these bytes as a lower-case [HexKey]. Inverse of [hexToByteArray]. */ fun ByteArray.toHexKey(): HexKey = Hex.encode(this) +/** + * Decodes this hex string into its bytes. Inverse of [toHexKey]. + * + * Accepts upper- or lower-case input but requires an even length; throws + * [IllegalArgumentException] on odd-length input. Use [hexToByteArrayOrNull] + * when the string may contain non-hex characters. + */ fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this) +/** Like [hexToByteArray] but returns null instead of throwing when this is not valid hex. */ fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null +/** True when this is a 64-char (32-byte) hex string — the shape of a pubkey or event id. */ fun HexKey.isValid(): Boolean = length == PUBKEY_LENGTH && Hex.isHex(this) +/** Length in hex chars of a 32-byte public key. */ const val PUBKEY_LENGTH = 64 +/** Length in hex chars of a 32-byte event id. */ const val EVENT_ID_LENGTH = 64 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 9ce1389ce0..724c252da0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -20,6 +20,24 @@ */ package com.vitorpamplona.quartz.utils +/** + * Fast, allocation-conscious hex codec used throughout Quartz for keys, event + * ids and signatures. Backed by pre-computed lookup tables and benchmarked + * against the secp256k1 codec and Kotlin's stdlib `HexFormat` (see + * `benchmark/.../HexBenchmark.kt`). + * + * Most call sites should prefer the extension functions in + * [com.vitorpamplona.quartz.nip01Core.core] — `ByteArray.toHexKey()` and + * `HexKey.hexToByteArray()` — which delegate here. Reach for this object + * directly when you want to validate without decoding ([isHex] / [isHex64]) or + * compare a hex string to raw bytes without allocating ([isEqual]). + * + * ```kotlin + * val hex = Hex.encode(bytes) // ByteArray -> lower-case hex + * val bytes = Hex.decode(hex) // hex (any case) -> ByteArray + * if (Hex.isHex64(id)) { ... } // is this a valid 32-byte hex id? + * ``` + */ object Hex { private const val LOWER_CASE_HEX = "0123456789abcdef" private const val UPPER_CASE_HEX = "0123456789ABCDEF" @@ -36,6 +54,12 @@ object Hex { (LOWER_CASE_HEX[(it shr 4)].code shl 8) or LOWER_CASE_HEX[(it and 0xF)].code } + /** + * True when [hex] is a non-null, even-length string of only hex digits + * (upper or lower case). Rejects odd lengths and stray non-hex chars (e.g. + * emoji in `p` tags) instead of throwing. ~47ns in debug on the Emulator; + * use [isHex64] when the length is known to be 64. + */ // 47ns in debug on the Emulator fun isHex(hex: String?): Boolean { if (hex == null) return false @@ -63,6 +87,12 @@ object Hex { return true } + /** + * Validates the first 64 chars of [hex] as hex digits — the fast path for + * checking a 32-byte pubkey or event id. ~30% faster than [isHex] because + * the length is fixed and the checks are unrolled. Assumes [hex] is at least + * 64 chars long; it does not verify the total length. + */ // 30% faster than isHex fun isHex64(hex: String): Boolean = try { @@ -144,6 +174,12 @@ object Hex { false } + /** + * Decodes [hex] (upper or lower case) into bytes. Requires an even length — + * throws [IllegalArgumentException] otherwise. Does not itself validate the + * characters, so guard untrusted input with [isHex] first (or use + * `HexKey.hexToByteArrayOrNull()`). + */ fun decode(hex: String): ByteArray { // faster version of hex decoder require(hex.length and 1 == 0) { @@ -154,6 +190,7 @@ object Hex { } } + /** Encodes [input] as a lower-case hex string (two chars per byte). */ fun encode(input: ByteArray): String { val out = CharArray(input.size * 2) var outIdx = 0 @@ -165,6 +202,12 @@ object Hex { return out.concatToString() } + /** + * True when the hex string [id] encodes exactly the bytes [ourId], compared + * without allocating a decode buffer. Handy for matching an incoming hex id + * against bytes you already hold. Assumes [id] is at least `2 * ourId.size` + * chars and lower-case (as produced by [encode]). + */ fun isEqual( id: String, ourId: ByteArray,