nostr 1.3.0.0 → 1.3.1.0
raw patch · 5 files changed
+297/−61 lines, 5 filesdep ~QuickCheckdep ~aesondep ~async
Dependency ranges changed: QuickCheck, aeson, async, base16-bytestring, base64-bytestring, bech32, bytestring, containers, cryptonite, entropy, hspec, http-conduit, memory, mtl, nonempty-wrapper-text, ppad-fixed, ppad-secp256k1, stm, text, time, unix-time, vector, websockets, wuss
Files
- README.md +224/−0
- lib/Nostr/Crypto.hs +25/−20
- lib/Nostr/Event.hs +8/−0
- lib/Nostr/Nip04.hs +2/−3
- nostr.cabal +38/−38
+ README.md view
@@ -0,0 +1,224 @@+# nostr.hs++A NIP-01 compliant Nostr library and client implementation in Haskell.++## Features++- **NIP-01 Compliant**: Full implementation of event structure, rules, and tags.+- **Schnorr Signatures**: Pure Haskell BIP-340 Schnorr signatures using `ppad-secp256k1`.+- **Relay Communication**: WebSocket-based communication with Nostr relays.+- **Type Safety**: Strong types for Event IDs, Public Keys, and Signatures.++## Implemented NIPs++- [x] NIP-01: Basic protocol flow+- [x] NIP-02: Contact Lists+- [x] NIP-05: DNS-based verification+- [x] NIP-09: Event Deletion Request+- [x] NIP-10: Text Notes and Threads+- [x] NIP-19: bech32-encoded entities+- [x] NIP-21: nostr: URI scheme+- [x] NIP-42: Relay Authentication+++## Installation++This project uses **Nix** for reproducible builds.++1. **Clone the repository:**+ ```bash+ git clone https://github.com/delirehberi/nostr.hs.git+ cd nostr.hs+ ```++2. **Enter the development shell:**+ ```bash+ nix develop+ ```++3. **Build the project:**+ ```bash+ cabal build all+ ```++## Development++### Building and Testing++```bash+make build # Build the project+make test # Run tests+make clean # Clean build artifacts+make docs # Generate documentation+make sdist # Generate source distribution tar.gz+```++All `make` commands automatically use the Nix development environment, so you don't need to run `nix develop` first.++### Uploading to Hackage++To package and upload the library to Hackage:++```bash+make upload-hackage+```++This will:+1. Generate a source distribution tar.gz file+2. Generate Haddock documentation for Hackage+3. Upload the package source to Hackage+4. Upload the documentation to Hackage++You'll need to be authenticated with Hackage and have upload permissions. The script assumes you have `cabal` configured with your Hackage credentials.++## Usage Examples++### High-Level API (Recommended)++The `Nostr.Client` module provides a monadic interface for managing connections, signing, and publishing. It handles robust reconnections (exponential backoff) and TLS usage automatically.++```haskell+{-# LANGUAGE OverloadedStrings #-}++import Nostr.Client+import Nostr.Event+import Nostr.Crypto+import Data.Function ((&))+import Control.Monad.IO.Class (liftIO)++main :: IO ()+main = do+ -- 1. Generate Keys+ -- Note: In a real app, you'd load these from safe storage+ (secKey, pubKey) <- generateKeyPair+ let keys = Keys secKey pubKey Nothing++ -- 2. Connect to Relays+ -- connectRelays establishes background threads for each relay and manages reconnection+ env <- connectRelays ["wss://relay.damus.io", "wss://nos.lol"]++ runNostrApp env $ do+ -- A. Publish a simple short text note (Kind 1)+ publishShortNote keys "Hello Nostr from Haskell!"++ -- B. Publish a complex event using the Builder Pattern+ -- Use the (&) operator to chain combinators for tags and kinds+ liftIO $ putStrLn "Publishing a reply..."+ publish keys $ shortNote "Replying to an event..."+ & withKind 1+ & withReply "event-id-hex"+ & withMention "pubkey-hex"+ & withTag ["t", "haskell"]+ + -- C. Query Events+ -- Queries all connected relays concurrently and aggregates unique results+ let filter = defaultFilter+ { filterKinds = Just [1]+ , filterAuthors = Just [pubKey]+ , filterLimit = Just 10+ }+ + events <- queryEvents filter+ liftIO $ print events++ -- D. Follow Users (NIP-02)+ -- This fetches your existing contact list (Kind 3), adds the user, and republishes.+ liftIO $ putStrLn "Following Jack..."+ follow keys "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbf71d22" (Just "wss://relay.damus.io") (Just "jack")++ -- E. Fetch Contacts+ contacts <- getContacts keys+ liftIO $ print contacts++ -- F. Delete Events (NIP-09)+ -- Deletes a list of event IDs with an optional reason.+ -- let eventIds = [EventId "hex_id_1...", EventId "hex_id_2..."]+ -- deleteEvents keys eventIds (Just "mistake")++ -- 3. Disconnect+ disconnect env+```++### Low-Level Event Creation++If you need manual control over event creation without the `NostrApp` environment:++```haskell+import Nostr.Event+import Nostr.Crypto+import Data.Time.Clock.POSIX (getPOSIXTime)++main :: IO ()+main = do+ (secKey, pubKey) <- generateKeyPair+ now <- round <$> getPOSIXTime+ + -- Create unsigned event+ let unsigned = createUnsignedEvent pubKey now 1 [] "Manual event content"+ + -- Sign event+ signed <- signEvent secKey unsigned+ case signed of+ Right event -> putStrLn $ "Event ID: " ++ show (eventId event)+ Left err -> putStrLn $ "Error: " ++ show err++### Encrypted Direct Messages (NIP-04)++Use the `Nostr.Nip04` module to securely encrypt and decrypt direct messages using AES-256-CBC and ECDH:++```haskell+import Nostr.Crypto+import Nostr.Nip04+import qualified Data.Text as T++main :: IO ()+main = do+ (senderSec, senderPub) <- generateKeyPair+ (receiverSec, receiverPub) <- generateKeyPair+ + let msg = "Super secret message"+ + -- Encrypt (Sender -> Receiver)+ Just encrypted <- encryptNip04 senderSec receiverPub msg+ putStrLn $ "Encrypted: " ++ T.unpack encrypted+ + -- Decrypt (Receiver)+ let decrypted = decryptNip04 receiverSec senderPub encrypted+ print decrypted -- Just "Super secret message"+```++### Fetching Relay Information (NIP-11)++Query a relay's metadata document using the `Nostr.Nip11` module:++```haskell+import Nostr.Nip11++main :: IO ()+main = do+ info <- fetchRelayInfo "wss://relay.damus.io"+ case info of+ Right i -> print (riSoftware i, riSupportedNips i)+ Left err -> putStrLn $ "Failed to fetch info: " ++ err+```++### Parsing Lightning Zaps (NIP-57)++Easily parse Zap Requests (Kind 9734) and Zap Receipts (Kind 9735):++```haskell+import Nostr.Nip57+import Nostr.Event++-- Assuming `event` is a parsed Event of kind 9734+handleEvent :: Event -> IO ()+handleEvent event = do+ case parseZapRequest event of+ Just zr -> putStrLn $ "Zap Request for " ++ T.unpack (zrP zr)+ Nothing -> return ()+```+```++## Contributing++Contributions are welcome! Please ensure all tests pass before submitting a PR.
lib/Nostr/Crypto.hs view
@@ -21,6 +21,7 @@ , generateKeyPair , pubKeyFromSecKey , exportSecKey+ , widerToInteger , exportPubKey , secKeyFromBytes , Keys(..)@@ -77,9 +78,8 @@ -- | Derive public key from secret key (x-only 32-byte for BIP-340) pubKeyFromSecKey :: SecKey -> IO PubKey pubKeyFromSecKey secKey = do- -- ppad-secp256k1 uses derive_pub to create public key from secret key- -- It returns Maybe Pub (Projective point)- case Secp.derive_pub secKey of+ let pkInt = widerToInteger secKey+ case Secp.derive_pub pkInt of Just pubKey -> do -- Serialize to compressed format (33 bytes) and drop the first byte -- to get the 32-byte x-only public key as per BIP-340@@ -203,29 +203,34 @@ -- BIP-340 Schnorr Signatures -- ============================================================================ --- | Sign an event and set its id and sig fields using BIP-340 Schnorr signatures+widerToInteger :: SecKey -> Integer+widerToInteger sec =+ case hexToBytes (exportSecKey sec) of+ Right bs -> BS.foldl' (\acc b -> acc * 256 + toInteger b) 0 bs+ Left _ -> 0++-- | Sign an event with a secret key signEvent :: SecKey -> Event -> IO (Either Text Event) signEvent secKey event = do- -- First compute the event ID- let eventWithId = event { eventId = computeEventId event }+ -- 1. Create the serialized event to get the ID+ let idBytes = computeEventId event - -- Get the event ID bytes for signing (must be 32 bytes for Schnorr)- case hexToBytes (unEventId (eventId eventWithId)) of- Left err -> return $ Left err- Right idBytes -> do- -- Generate 32 bytes of auxiliary randomness (recommended by BIP-340)+ case hexToBytes (unEventId idBytes) of+ Right idBytesRaw -> do+ let eventWithId = event { eventId = idBytes }+ + -- 2. Generate 32 bytes of auxiliary randomness as recommended by BIP-340 auxRand <- E.getEntropy 32 - -- Sign with BIP-340 Schnorr- case Secp.sign_schnorr secKey idBytes auxRand of- Nothing -> return $ Left "Failed to create Schnorr signature"+ -- 3. Sign the 32-byte event ID+ case Secp.sign_schnorr (widerToInteger secKey) idBytesRaw auxRand of Just sigBytes -> do- let sigHex = bytesToHex sigBytes- - -- Create Signature with proper validation (should be 64 bytes = 128 hex chars)- case mkSignature sigHex of- Right signature -> return $ Right $ eventWithId { eventSig = signature }- Left err -> return $ Left err+ let sigHex = bytesToHex sigBytes+ case mkSignature sigHex of+ Right signature -> return $ Right $ eventWithId { eventSig = signature }+ Left err -> return $ Left err+ Nothing -> return $ Left "Failed to create Schnorr signature"+ Left err -> return $ Left err -- | Verify event signature using BIP-340 Schnorr verification verifySignature :: Event -> IO Bool
lib/Nostr/Event.hs view
@@ -113,12 +113,20 @@ validateHex128 t = T.length t == 128 && T.all isValidHex t -- | Smart constructor for EventId with validation+--+-- >>> mkEventId "41ce9bc50daea4225efcd82edcb90e7eb25ce8d2d6cba2dfbb61e15e86979601"+-- Right (EventId "41ce9bc50daea4225efcd82edcb90e7eb25ce8d2d6cba2dfbb61e15e86979601")+-- >>> mkEventId "invalid"+-- Left "Invalid EventId: must be 64 lowercase hex characters, got: invalid" mkEventId :: Text -> Either Text EventId mkEventId t | validateHex64 t = Right (EventId t) | otherwise = Left $ "Invalid EventId: must be 64 lowercase hex characters, got: " <> t -- | Smart constructor for PubKey with validation+--+-- >>> mkPubKey "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"+-- Right (PubKey "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245") mkPubKey :: Text -> Either Text PubKey mkPubKey t | validateHex64 t = Right (PubKey t)
lib/Nostr/Nip04.hs view
@@ -30,7 +30,7 @@ import qualified Data.Text.Encoding as TE import qualified System.Entropy as E -import Nostr.Crypto (SecKey, hexToBytes)+import Nostr.Crypto (SecKey, hexToBytes, widerToInteger) import Nostr.Event (PubKey(..)) -- | Compute shared secret using ECDH@@ -41,10 +41,9 @@ let compressedPubKey = BS.cons 0x02 pubBytes pubPoint <- Secp.parse_point compressedPubKey - -- Use ppad-secp256k1 ecdh. Note: If this computes SHA256 of the point instead of -- just returning the X coordinate, we might need to use `mul` directly. -- Let's try `ecdh` first.- Secp.ecdh pubPoint secKey+ Secp.ecdh pubPoint (widerToInteger secKey) -- | PKCS7 padding padPKCS7 :: Int -> ByteString -> ByteString
nostr.cabal view
@@ -20,7 +20,7 @@ -- PVP summary: +-+------- breaking API changes -- | | +----- non-breaking API additions -- | | | +--- code changes with no API change-version: 1.3.0.0+version: 1.3.1.0 bug-reports: https://github.com/delirehberi/nostr.hs/issues -- A short (one-line) description of the package.@@ -53,7 +53,7 @@ build-type: Simple -- Extra doc files to be distributed with the package, such as a CHANGELOG or a README.-extra-doc-files: CHANGELOG.md+extra-doc-files: CHANGELOG.md README.md -- Extra source files to be distributed with the package, such as examples, or a tutorial module. -- extra-source-files:@@ -81,28 +81,28 @@ -- Other library packages from which modules are imported. build-depends: base >=4.18.0.0 && <4.20- , aeson - , bytestring - , text - , unix-time - , websockets- , ppad-secp256k1 >= 0.4.0- , ppad-fixed- , nonempty-wrapper-text- , cryptonite- , memory- , base16-bytestring- , vector- , entropy- , containers- , async- , stm- , http-conduit- , mtl- , time- , wuss- , bech32- , base64-bytestring+ , aeson < 3+ , bytestring < 0.13+ , text < 3+ , unix-time < 0.5+ , websockets < 0.14+ , ppad-secp256k1 >= 0.4.0 && < 0.5+ , ppad-fixed < 0.2+ , nonempty-wrapper-text < 0.2+ , cryptonite < 0.31+ , memory < 0.19+ , base16-bytestring < 1.1+ , vector < 0.14+ , entropy < 0.5+ , containers < 0.8+ , async < 2.3+ , stm < 2.6+ , http-conduit < 2.4+ , mtl < 2.4+ , time < 1.13+ , wuss < 2.1+ , bech32 < 1.2+ , base64-bytestring < 1.3 -- Directories containing source files. hs-source-dirs: lib@@ -127,10 +127,10 @@ build-depends: base >=4.18.0.0 && <4.20, nostr,- aeson,- bytestring,- text,- time+ aeson < 3,+ bytestring < 0.13,+ text < 3,+ time < 1.13 -- Directories containing source files. hs-source-dirs: app@@ -144,9 +144,9 @@ build-depends: base >=4.18.0.0 && <4.20, nostr,- text,- bytestring,- time+ text < 3,+ bytestring < 0.13,+ time < 1.13 hs-source-dirs: app default-language: GHC2021@@ -172,10 +172,10 @@ build-depends: base >=4.18.0.0 && <4.20, nostr,- hspec,- QuickCheck,- text,- bytestring,- aeson,- containers,- base64-bytestring+ hspec < 3.0,+ QuickCheck < 2.15,+ text < 3,+ bytestring < 0.13,+ aeson < 3,+ containers < 0.8,+ base64-bytestring < 1.3