diff --git a/CHANGELOG.md b/CHANGELOG.md index 62967e7..41eca3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [0.2.7] + +### Added + +- New `nipIC` package (NIP-IC, Identity Connection): binds Web Identity + accounts (Discord, Telegram, ...) to Nostr pubkeys via a signed IA + attestation. `NewAttestation`/`ParseAttestation`/`ValidateAttestation` for + Kind 35522, `ParseIdentityConnection`/`ValidateIdentityConnection` for Kind + 35521, `NewChallenge`/`ChallengeToken.Verify` for the npv1 cross-IA + challenge binding, and `EncodeNConnection`/`DecodeNConnection` for the + `nconnection` bech32 profile-link format. + +### Fixed + +- **Security:** `nipAZ.NewAltZapReceipt` no longer silently re-derives its + `p`/`P` tags by parsing the embedded request's `description` JSON — it now + only ever uses `Identity` values the caller explicitly passed, closing a + gap where a tampered embedded request could redirect a receipt's + attribution. +- `nipIC.NewChallenge`'s session entropy is now 16 bytes (32 hex chars), + matching its real caller (a token posted publicly); it was previously 12 + hex chars, sized for a short human-typeable pre-auth code that belongs to + a different caller entirely. + ## [0.2.6] ### Changed diff --git a/README.md b/README.md index 80b9826..3d57001 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,10 @@ go get github.com/ohstr/nmilat - **[`nip88`](https://github.com/nostr-protocol/nips/blob/master/88.md)** — Polls - **[`nip90`](https://github.com/nostr-protocol/nips/blob/master/90.md)** — Data Vending Machines - **[`nipAA`](https://github.com/block/buzz/blob/main/docs/nips/NIP-AA.md)** — Agent Auth -- **[`nipAZ`](https://docs.zapf.app/protocol/zap-request-5520)** — AltZap: zaps for energy-backed coins +- **[`nipAZ`](https://github.com/ohstr/zapf-nips/blob/main/NIP-AZ.md)** — AltZap: zaps for energy-backed coins - **[`nipB0`](https://github.com/nostr-protocol/nips/blob/master/B0.md)** — Web bookmarks - **[`nipB7`](https://github.com/nostr-protocol/nips/blob/master/B7.md)** — Blossom media +- **[`nipIC`](https://github.com/ohstr/zapf-nips/blob/main/NIP-IC.md)** — Identity Connection: binds Web Identity accounts to Nostr pubkeys - **[`nipOA`](https://github.com/block/buzz/blob/main/docs/nips/NIP-OA.md)** — Owner Attestation ### Relay engine and infrastructure @@ -285,10 +286,13 @@ func main() { } ``` -### Send an AltZap request (NIP-AZ) +### Send an AltZap request to a Web Identity recipient (NIP-AZ + NIP-IC) **AltZap** is the same flow for non-Bitcoin chains — a mandatory `chain` tag -and its own kinds (5520-5523): +and its own kinds (5520-5523). Its most common use isn't zapping a native +Nostr pubkey (`nipAZ.Pubkey(hex)`) — it's zapping a recipient who only has an +account on another platform and no Nostr keypair yet. `nipAZ.Connection` +covers that case by deriving a **NIP-IC** `ConnectionKey` internally: ```go package main @@ -303,14 +307,19 @@ import ( ) func main() { - zapRequest := nipAZ.NewAltZapRequest(nipAZ.AltZapRequestParams{ - Chain: "flokicoin", // prevents cross-chain replay - Recipient: recipientPubKeyHex, + zapRequest, err := nipAZ.NewAltZapRequest(nipAZ.AltZapRequestParams{ + PrivateKey: senderPrivKeyHex, // signs internally + Chain: "flokicoin", // prevents cross-chain replay + // Recipient has no Nostr pubkey yet — identified by their Discord + // account instead. nipAZ.Connection hashes platform+externalID into + // a nipIC.ConnectionKey; use nipAZ.Pubkey(hex) for a native Nostr + // recipient instead. + Recipient: nipAZ.Connection("discord", externalUserID), Lnurl: recipientLnurl, AmountMloki: 21000, Relays: []string{"wss://relay.ohstr.com"}, }) - if err := zapRequest.Sign(senderPrivKeyHex); err != nil { + if err != nil { panic(err) } @@ -329,6 +338,59 @@ func main() { } ``` +### Bind a Web Identity to a Nostr pubkey (NIP-IC) + +**nipIC** implements Identity Connection: an Identity Authority (IA) attests +that a Web Identity account (Discord, Telegram, ...) belongs to a Nostr +pubkey by signing a Kind 35522 event; the user then references it from their +own Kind 35521. + +```go +package main + +import ( + "fmt" + + "github.com/ohstr/nmilat/nipIC" +) + +func main() { + // 1. Mint a challenge + pre-auth code for the user to prove control of + // their Nostr key, e.g. by posting the pre-auth code publicly. + challenge, preAuthCode, err := nipIC.NewChallenge(userPubkeyHex) + if err != nil { + panic(err) + } + + // 2. Once the IA has verified the public post, it signs the attestation. + connectionKey := nipIC.NewConnectionKey("discord", externalUserID) + attestation, err := nipIC.NewAttestation(nipIC.AttestationParams{ + PrivateKey: iaPrivKeyHex, // IA's nsec hex, signs internally + ConnectionKey: connectionKey, + UserPubkey: userPubkeyHex, + Platform: "discord", + ExpirationDays: 90, + Evidence: nipIC.Evidence{ + Platform: "discord", + UserID: externalUserID, + Username: "alice", + EvidenceURL: "https://discord.com/channels/.../123456789", + Challenge: challenge, + PreAuthCode: preAuthCode, + }, + }) + if err != nil { + panic(err) + } + fmt.Println("attestation id:", attestation.ID) +} +``` + +A verifier re-checks cross-IA re-attestation evidence with +`challenge.Verify(userPubkeyHex, preAuthCode)` before trusting it — see +[NIP-IC's Cross-IA Challenge Binding](https://github.com/ohstr/zapf-nips/blob/main/references/identity-connection.md#e--cross-ia-challenge-binding) +for the full security model. + ### Pay an invoice over Nostr Wallet Connect (NIP-47) Parse a `nostr+walletconnect://` pairing URI and construct a `NWCClient` — diff --git a/nipAZ/nipAZ.go b/nipAZ/nipAZ.go index 6212e9e..6c3cbbe 100644 --- a/nipAZ/nipAZ.go +++ b/nipAZ/nipAZ.go @@ -3,6 +3,13 @@ // Bitcoin. It adds a mandatory "chain" tag for cross-chain replay safety // and its own event kinds (5520-5523), so it is not wire-compatible with // vanilla NIP-57 and does not claim to be. +// +// nipAZ depends on nipIC (github.com/ohstr/nmilat/nipIC) for the +// Identity/WebIdentity/ConnectionKey types — NIP-AZ's own spec says NIP-IC +// owns the ConnectionKey concept a p/P tag may carry instead of a raw +// pubkey. WebIdentity and ConnectionKey are re-exported here under nipAZ's +// own names so a caller who only touches AltZap doesn't have to import +// nipIC directly for the common case. package nipAZ import ( @@ -17,6 +24,7 @@ import ( "github.com/ohstr/nmilat/nip01" "github.com/ohstr/nmilat/nip57" + "github.com/ohstr/nmilat/nipIC" "github.com/ohstr/nmilat/utils" ) @@ -43,22 +51,142 @@ const ( KindAltZapOnBehalfRequest = 5523 ) +// DescriptionHash computes SHA256(description), hex-encoded — the value a +// ZSP must request as a BOLT11 invoice's LUD-11 description_hash so it +// cryptographically binds to a specific AltZap request (NIP-AZ.md's +// "Description-hash binding" rule). description is hashed verbatim: for a +// kind 5521 receipt's "description" tag this is the exact wire string being +// stored (never re-marshaled — a receiver later hashes that same stored +// string to verify, so any re-serialization here would break the match); +// for a kind 5522 direct payment it's typically the request event's own +// canonical JSON (json.Marshal(event)) since there's no separate +// description field to bind to. +func DescriptionHash(description string) string { + sum := sha256.Sum256([]byte(description)) + return hex.EncodeToString(sum[:]) +} + +// Chain identifies which Lightning-routable network a request/receipt +// settles on. Open string type, no predefined values — a different consumer +// of this SDK may settle on an entirely different set of chains than any +// particular deployment does today. An application that wants named +// constants for its own known chains defines them itself, on top of this type. +type Chain string + +// WebIdentity and ConnectionKey are re-exported from nipIC — see the +// package doc comment. +type ( + WebIdentity = nipIC.WebIdentity + ConnectionKey = nipIC.ConnectionKey +) + +// Identity is a p/P tag value: either a native Nostr pubkey or a +// ConnectionKey scoped to a WebIdentity platform, with an optional stable +// display handle. Build one with Pubkey or Connection — never construct the +// underlying tag array by hand. +type Identity struct { + value string + webIdentity WebIdentity + handle string +} + +// Pubkey builds an Identity for a native Nostr recipient/sender. +func Pubkey(hex string) Identity { + return Identity{value: hex} +} + +// Connection builds an Identity for a recipient/sender on platform who has +// no Nostr keypair yet, computing its ConnectionKey internally — the caller +// never hashes platform+externalID by hand. +func Connection(platform WebIdentity, externalID string) Identity { + return Identity{ + value: nipIC.NewConnectionKey(platform, externalID).String(), + webIdentity: platform, + } +} + +// ResolvedConnection builds an Identity from a ConnectionKey the caller has +// already computed (e.g. resolved earlier in a request-handling pipeline and +// passed through several layers) — unlike Connection, it does not hash +// anything, so a caller who already has the key avoids computing the same +// SHA256 twice for one logical identity. +func ResolvedConnection(key ConnectionKey, platform WebIdentity) Identity { + return Identity{value: key.String(), webIdentity: platform} +} + +// WithHandle attaches a stable, human-readable display handle (e.g. a +// Discord username) to a receipt's Identity. Informational only — MUST NOT +// be used for identity resolution (NIP-AZ.md). Has no effect on request-side +// tags (5520/5523 never emit a 4th tag element; see AltZapRequestParams). +func (id Identity) WithHandle(handle string) Identity { + id.handle = handle + return id +} + +// IsZero reports whether id is the zero Identity — i.e. absent. Used for +// optional identities (a request's Sender, a receipt's Recipient on an +// anonymous direct payment). +func (id Identity) IsZero() bool { return id.value == "" } + +// WebIdentity returns the platform id is scoped to, or the zero value for a +// native Nostr identity. +func (id Identity) WebIdentity() WebIdentity { return id.webIdentity } + +// Value returns the hex pubkey or ConnectionKey hex. +func (id Identity) Value() string { return id.value } + +// Handle returns the stable display handle, or "" if none was set. +func (id Identity) Handle() string { return id.handle } + +// toSlice renders id as a Nostr tag array under key ("p" or "P"). Requests +// never include a handle even if one is set on id (5520/5523 cap at 3 +// elements, matching NIP-AZ's request format); receipts do, via includeHandle. +func (id Identity) toSlice(key string, includeHandle bool) []string { + if id.webIdentity == "" { + return []string{key, id.value} + } + if includeHandle && id.handle != "" { + return []string{key, id.value, string(id.webIdentity), id.handle} + } + return []string{key, id.value, string(id.webIdentity)} +} + +// identityFromRequestTag builds an Identity from a parsed p/P tag on a +// request (2 or 3 elements — requests never carry a handle). An empty or +// literal "nostr" platform element both mean "native pubkey", matching +// NIP-AZ.md's own rule that an omitted platform element defaults to nostr. +func identityFromRequestTag(tag []string) Identity { + id := Identity{value: tag[1]} + if len(tag) > 2 && tag[2] != "" && tag[2] != "nostr" { + id.webIdentity = WebIdentity(tag[2]) + } + return id +} + +// identityFromReceiptTag is identityFromRequestTag plus an optional 4th +// element handle, which only receipts carry. +func identityFromReceiptTag(tag []string) Identity { + id := identityFromRequestTag(tag) + if len(tag) > 3 && tag[3] != "" { + id.handle = tag[3] + } + return id +} + // AltZapRequest is a parsed and validated AltZap request event (kinds 5520, // 5522, or 5523). type AltZapRequest struct { *nip01.Event - Relays []string - Amount int64 - Lnurl string - Bolt11 string // for kind 5522 direct payments - Chain string // required, to prevent cross-chain replay - EventID string // e tag - ATag string // a tag coordinate - KTag string // k tag kind limit - Author string // p tag (recipient pubkey or hash) - Provider string // p tag (recipient lidp name) - Sender string // P tag (sender pubkey or hash) - SenderProvider string // P tag (sender lidp name) + Relays []string + Amount int64 + Lnurl string + Bolt11 string // for kind 5522 direct payments + Chain Chain // required, to prevent cross-chain replay + EventID string // e tag + ATag string // a tag coordinate + KTag string // k tag kind limit + Recipient Identity // p tag + Sender Identity // P tag } // ParseAltZapRequest parses and validates an AltZap request event (kinds @@ -113,26 +241,16 @@ func ParseAltZapRequest(event *nip01.Event) (*AltZapRequest, error) { case "bolt11": zr.Bolt11 = tag[1] case "chain": - zr.Chain = tag[1] + zr.Chain = Chain(tag[1]) case "a": zr.ATag = tag[1] case "k": zr.KTag = tag[1] case "p": - zr.Author = tag[1] - if len(tag) > 2 && tag[2] != "" { - zr.Provider = tag[2] - } else { - zr.Provider = "nostr" // Default per AltZap convention - } + zr.Recipient = identityFromRequestTag(tag) pTagCount++ case "P": - zr.Sender = tag[1] - if len(tag) > 2 && tag[2] != "" { - zr.SenderProvider = tag[2] - } else { - zr.SenderProvider = "nostr" // Default per AltZap convention - } + zr.Sender = identityFromRequestTag(tag) PTagCount++ case "zap": if len(tag) < 3 { @@ -229,8 +347,7 @@ func ValidateAltZapRequest(event *nip01.Event, expectedAmountMloki int64) error return fmt.Errorf("nip57: failed to marshal 5522 event for hash validation: %w", err) } - descHash := sha256.Sum256(eventJSON) - descHashHex := hex.EncodeToString(descHash[:]) + descHashHex := DescriptionHash(string(eventJSON)) if inv.DescriptionHash != descHashHex { return fmt.Errorf("%w: bolt11 description hash %s does not match event hash %s", ErrHashLockMismatch, inv.DescriptionHash, descHashHex) @@ -243,14 +360,12 @@ func ValidateAltZapRequest(event *nip01.Event, expectedAmountMloki int64) error // AltZapReceipt is a parsed and validated AltZap receipt event (kind 5521). type AltZapReceipt struct { *nip01.Event - Recipient string // p tag - RecipientProvider string - Sender string // P tag - SenderProvider string - ResolvedPubkey string // r tag - ResolvedSenderPubkey string // R tag + Recipient Identity // p tag + Sender Identity // P tag + ResolvedPubkey string // r tag + ResolvedSenderPubkey string // R tag Bolt11 string - Chain string + Chain Chain Preimage string Description string Request *AltZapRequest @@ -270,19 +385,9 @@ func ParseAltZapReceipt(event *nip01.Event) (*AltZapReceipt, error) { } switch tag[0] { case "p": - zr.Recipient = tag[1] - if len(tag) > 2 && tag[2] != "" { - zr.RecipientProvider = tag[2] - } else { - zr.RecipientProvider = "nostr" - } + zr.Recipient = identityFromReceiptTag(tag) case "P": - zr.Sender = tag[1] - if len(tag) > 2 && tag[2] != "" { - zr.SenderProvider = tag[2] - } else { - zr.SenderProvider = "nostr" - } + zr.Sender = identityFromReceiptTag(tag) case "r": zr.ResolvedPubkey = tag[1] case "R": @@ -290,7 +395,7 @@ func ParseAltZapReceipt(event *nip01.Event) (*AltZapReceipt, error) { case "bolt11": zr.Bolt11 = tag[1] case "chain": - zr.Chain = tag[1] + zr.Chain = Chain(tag[1]) case "preimage": zr.Preimage = tag[1] case "description": @@ -323,7 +428,7 @@ func ParseAltZapReceipt(event *nip01.Event) (*AltZapReceipt, error) { if zr.Preimage == "" { return nil, ErrMissingPreimageTag } - if zr.Recipient == "" && zr.Description != "" { + if zr.Recipient.IsZero() && zr.Description != "" { // Only 5522 can have no p tag, and 5522 has no description. // If description exists, it must have a p tag. return nil, nip57.ErrMissingRecipientTag @@ -382,8 +487,7 @@ func ValidateAltZapReceipt(receipt *nip01.Event) error { // A. Verify description hash (CRITICAL) // SHA256(description) == invoice.DescriptionHash - descHash := sha256.Sum256([]byte(zr.Description)) - descHashHex := hex.EncodeToString(descHash[:]) + descHashHex := DescriptionHash(zr.Description) if invoice.DescriptionHash != descHashHex { return fmt.Errorf("%w: have=%s want=%s", nip57.ErrDescriptionHashMismatch, descHashHex, invoice.DescriptionHash) @@ -396,8 +500,8 @@ func ValidateAltZapReceipt(receipt *nip01.Event) error { } // C. Verify Recipients match - if zr.Recipient != zr.Request.Author { - return fmt.Errorf("%w: receipt=%s request_author=%s", nip57.ErrRecipientMismatch, zr.Recipient, zr.Request.Author) + if zr.Recipient.Value() != zr.Request.Recipient.Value() { + return fmt.Errorf("%w: receipt=%s request_author=%s", nip57.ErrRecipientMismatch, zr.Recipient.Value(), zr.Request.Recipient.Value()) } } else { // Direct Payment (no Zap Request description) @@ -410,40 +514,34 @@ func ValidateAltZapReceipt(receipt *nip01.Event) error { } // AltZapRequestParams describes an AltZap request (kind 5520, or 5523 when -// built via NewAltZapOnBehalfRequest). Chain, Recipient, Lnurl, AmountMloki, -// and Relays are required; the rest are optional. +// built via NewAltZapOnBehalfRequest). PrivateKey, Chain, Recipient, Lnurl, +// AmountMloki, and Relays are required; the rest are optional. The event is +// signed with PrivateKey internally — the caller never calls .Sign() +// themselves. type AltZapRequestParams struct { - Chain string // e.g. "flokicoin" — prevents cross-chain replay - Recipient string // recipient pubkey ("p" tag) - RecipientProvider string // optional lidp name for the recipient, e.g. "nostr" - Lnurl string // recipient's LNURL-pay endpoint - AmountMloki int64 // amount in mloki (milli-loki) - Relays []string // relays the zap receipt should be published to - Sender string // optional sender pubkey ("P" tag) - SenderProvider string // optional lidp name for the sender - EventID *string // optional zapped event ID ("e" tag) + PrivateKey string // sender's (or proxy agent's, for on-behalf) nsec hex + Chain Chain // e.g. "flokicoin" — prevents cross-chain replay + Recipient Identity // recipient ("p" tag) — required + Lnurl string // recipient's LNURL-pay endpoint + AmountMloki int64 // amount in mloki (milli-loki) + Relays []string // relays the zap receipt should be published to + Sender Identity // optional sender override ("P" tag) — required in effect for 5523, see NewAltZapOnBehalfRequest + Content string // optional note content + EventID *string // optional zapped event ID ("e" tag) } -// NewAltZapRequest creates a new AltZap request event (kind 5520). -func NewAltZapRequest(p AltZapRequestParams) *nip01.Event { - pTag := []string{"p", p.Recipient} - if p.RecipientProvider != "" { - pTag = append(pTag, p.RecipientProvider) - } - +// NewAltZapRequest creates, signs, and returns a new AltZap request event +// (kind 5520). +func NewAltZapRequest(p AltZapRequestParams) (*nip01.Event, error) { tags := [][]string{ - pTag, + p.Recipient.toSlice("p", false), {"amount", fmt.Sprintf("%d", p.AmountMloki)}, {"lnurl", p.Lnurl}, - {"chain", p.Chain}, + {"chain", string(p.Chain)}, } - if p.Sender != "" { - PTag := []string{"P", p.Sender} - if p.SenderProvider != "" { - PTag = append(PTag, p.SenderProvider) - } - tags = append(tags, PTag) + if !p.Sender.IsZero() { + tags = append(tags, p.Sender.toSlice("P", false)) } if len(p.Relays) > 0 { @@ -456,51 +554,56 @@ func NewAltZapRequest(p AltZapRequestParams) *nip01.Event { tags = append(tags, []string{"e", *p.EventID}) } - return &nip01.Event{ - PubKey: p.Sender, + event := &nip01.Event{ CreatedAt: uint64(time.Now().Unix()), Kind: KindAltZapRequest, Tags: tags, - Content: "", + Content: p.Content, + } + if err := event.Sign(p.PrivateKey); err != nil { + return nil, fmt.Errorf("nipAZ: sign request: %w", err) } + return event, nil } -// NewAltZapOnBehalfRequest creates a new proxy AltZap request event (kind -// 5523) — used when a service is zapping on behalf of another identified -// sender (set via AltZapRequestParams.Sender). -func NewAltZapOnBehalfRequest(p AltZapRequestParams) *nip01.Event { - event := NewAltZapRequest(p) +// NewAltZapOnBehalfRequest creates, signs, and returns a new proxy AltZap +// request event (kind 5523) — used when a Proxy Agent (e.g. a bot) signs on +// behalf of an identified sender who does not hold the signing key. sender +// is required (not an optional params field) so it is impossible to build +// an invalid 5523 at construction time — kind 5523 mandates a P tag. +func NewAltZapOnBehalfRequest(sender Identity, p AltZapRequestParams) (*nip01.Event, error) { + p.Sender = sender + event, err := NewAltZapRequest(p) + if err != nil { + return nil, err + } event.Kind = KindAltZapOnBehalfRequest - return event + return event, nil } // AltZapDirectPaymentParams describes a direct-payment AltZap request (kind // 5522) — a bolt11 invoice paid directly, bypassing the LNURL/zap-request -// flow. Chain, Bolt11, AmountMloki, and Relays are required. +// flow. PrivateKey, Chain, Bolt11, AmountMloki, and Relays are required. type AltZapDirectPaymentParams struct { - Chain string - Bolt11 string - AmountMloki int64 - Relays []string - Sender string // optional sender pubkey ("P" tag) - SenderProvider string // optional lidp name for the sender + PrivateKey string + Chain Chain + Bolt11 string + AmountMloki int64 + Relays []string + Sender Identity // optional sender override ("P" tag) } -// NewAltZapDirectPaymentRequest creates a new direct-payment AltZap request -// event (kind 5522). -func NewAltZapDirectPaymentRequest(p AltZapDirectPaymentParams) *nip01.Event { +// NewAltZapDirectPaymentRequest creates, signs, and returns a new +// direct-payment AltZap request event (kind 5522). +func NewAltZapDirectPaymentRequest(p AltZapDirectPaymentParams) (*nip01.Event, error) { tags := [][]string{ {"amount", fmt.Sprintf("%d", p.AmountMloki)}, {"bolt11", p.Bolt11}, - {"chain", p.Chain}, + {"chain", string(p.Chain)}, } - if p.Sender != "" { - PTag := []string{"P", p.Sender} - if p.SenderProvider != "" { - PTag = append(PTag, p.SenderProvider) - } - tags = append(tags, PTag) + if !p.Sender.IsZero() { + tags = append(tags, p.Sender.toSlice("P", false)) } if len(p.Relays) > 0 { @@ -509,30 +612,36 @@ func NewAltZapDirectPaymentRequest(p AltZapDirectPaymentParams) *nip01.Event { tags = append(tags, relayTag) } - return &nip01.Event{ - PubKey: p.Sender, + event := &nip01.Event{ CreatedAt: uint64(time.Now().Unix()), Kind: KindAltZapDirectPayment, Tags: tags, Content: "", } + if err := event.Sign(p.PrivateKey); err != nil { + return nil, fmt.Errorf("nipAZ: sign direct payment request: %w", err) + } + return event, nil } // AltZapReceiptParams describes an AltZap receipt (kind 5521), issued by the -// LNURL provider once the invoice is paid. ProviderPubkey and Bolt11 are -// required; RecipientPubkey is omitted for anonymous kind-5522 receipts. +// ZSP once the invoice is paid. PrivateKey and Bolt11 are required; +// Recipient is the zero Identity for an anonymous kind-5522 receipt. // // ResolvedRecipientPubkey/ResolvedSenderPubkey/Coordinate/EventID are for -// callers whose p/P tags carry a non-Nostr identity (e.g. a hashed -// ConnectionKey rather than a raw pubkey) and need to mirror the resolved -// native pubkey ("r"/"R" tags) and/or the zapped event/addressable-event -// coordinate ("e"/"a" tags) onto the receipt directly, independent of -// whatever the embedded request's Description happens to carry. +// callers whose p/P tags carry a non-Nostr identity (e.g. a ConnectionKey) +// and need to mirror the resolved native pubkey ("r"/"R" tags) and/or the +// zapped event/addressable-event coordinate ("e"/"a" tags) onto the receipt. +// +// Recipient/Sender (including any handle attached via Identity.WithHandle) +// are always authoritative — never re-derived or overridden from Description, +// even when Description embeds different-looking p/P tags. Description is +// stored verbatim for auditability only. type AltZapReceiptParams struct { - Chain string - ProviderPubkey string - RecipientPubkey string - SenderPubkey string + PrivateKey string // ZSP's nsec hex — required, derives the event pubkey and signs + Chain Chain + Recipient Identity + Sender Identity Bolt11 string Description string // JSON of the embedded AltZap request, if any Preimage *string @@ -542,15 +651,16 @@ type AltZapReceiptParams struct { EventID string // optional "e" tag } -// NewAltZapReceipt creates a new AltZap receipt event (kind 5521). +// NewAltZapReceipt creates, signs, and returns a new AltZap receipt event +// (kind 5521). func NewAltZapReceipt(p AltZapReceiptParams) (*nip01.Event, error) { tags := [][]string{ {"bolt11", p.Bolt11}, - {"chain", p.Chain}, + {"chain", string(p.Chain)}, } - if p.RecipientPubkey != "" { - tags = append(tags, []string{"p", p.RecipientPubkey}) + if !p.Recipient.IsZero() { + tags = append(tags, p.Recipient.toSlice("p", true)) } if p.Description != "" { @@ -564,8 +674,8 @@ func NewAltZapReceipt(p AltZapReceiptParams) (*nip01.Event, error) { } tags = append(tags, []string{"amount", fmt.Sprintf("%d", inv.AmountMloki)}) - if p.SenderPubkey != "" { - tags = append(tags, []string{"P", p.SenderPubkey}) + if !p.Sender.IsZero() { + tags = append(tags, p.Sender.toSlice("P", true)) } if p.Preimage != nil { @@ -588,46 +698,14 @@ func NewAltZapReceipt(p AltZapReceiptParams) (*nip01.Event, error) { tags = append(tags, []string{"a", p.Coordinate}) } - // Extract tags from the description request if possible - var req nip01.Event - if err := json.Unmarshal([]byte(p.Description), &req); err == nil { - for _, tag := range req.Tags { - if len(tag) < 2 { - continue - } - key := tag[0] - if key == "e" || key == "a" || key == "tbd" || key == "r" || key == "p" || key == "P" { - // We overwrite our default basic 'p' and 'P' tags with the detailed ones from the request - if key == "p" || key == "P" { - for i, existingTag := range tags { - if len(existingTag) > 0 && existingTag[0] == key { - tags[i] = tag // Replace basic tag with the fully detailed tag (containing provider) - break - } - } - // If it wasn't there at all, append it - found := false - for _, existingTag := range tags { - if len(existingTag) > 0 && existingTag[0] == key { - found = true - break - } - } - if !found { - tags = append(tags, tag) - } - continue - } - tags = append(tags, tag) - } - } - } - - return &nip01.Event{ - PubKey: p.ProviderPubkey, + event := &nip01.Event{ CreatedAt: uint64(time.Now().Unix()), Kind: KindAltZapReceipt, Tags: tags, Content: "", - }, nil + } + if err := event.Sign(p.PrivateKey); err != nil { + return nil, fmt.Errorf("nipAZ: sign receipt: %w", err) + } + return event, nil } diff --git a/nipAZ/nipAZ_more_test.go b/nipAZ/nipAZ_more_test.go index 93cd427..3ac0add 100644 --- a/nipAZ/nipAZ_more_test.go +++ b/nipAZ/nipAZ_more_test.go @@ -14,6 +14,10 @@ import ( const zapsTestPrivKey = "0acd12cbf0fb87cd13b17bc9b57dffd11b3870b407984cec5a4ce2a69b90268c" +// A real bech32-decodable lnurl (utils.ValidateLNURL requires this, not a +// placeholder string) shared across tests that build+parse a request. +const validTestLnurl = "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns" + func signedZapRequestEvent(t *testing.T, kind int, extraTags [][]string) *nip01.Event { t.Helper() pubkeyPlaceholder := "0000000000000000000000000000000000000000000000000000000000000001" @@ -113,23 +117,25 @@ func TestNewAltZapRequest(t *testing.T) { eventID := strings.Repeat("1", 63) + "a" validLnurl := "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns" - ev := NewAltZapRequest(AltZapRequestParams{ - Chain: "flokicoin", - Recipient: "recipient1", - Lnurl: validLnurl, - AmountMloki: 5000, - Relays: relays, - Sender: "sender1", - SenderProvider: "discord", - RecipientProvider: "nostr", - EventID: &eventID, + ev, err := NewAltZapRequest(AltZapRequestParams{ + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Recipient: Pubkey("recipient1"), + Lnurl: validLnurl, + AmountMloki: 5000, + Relays: relays, + Sender: Connection("discord", "sender-external-id"), + EventID: &eventID, }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if ev.Kind != KindAltZapRequest { t.Errorf("expected kind %d, got %d", KindAltZapRequest, ev.Kind) } - if ev.PubKey != "sender1" { - t.Errorf("expected pubkey sender1, got %s", ev.PubKey) + if err := ev.Verify(); err != nil { + t.Errorf("expected a validly signed event, got: %v", err) } req, err := ParseAltZapRequest(ev) @@ -139,44 +145,128 @@ func TestNewAltZapRequest(t *testing.T) { if req.Amount != 5000 || req.Lnurl != validLnurl || req.Chain != "flokicoin" { t.Errorf("unexpected parsed request: %+v", req) } - if req.Sender != "sender1" || req.SenderProvider != "discord" { - t.Errorf("expected sender tags to round-trip, got %+v", req) + if req.Sender.WebIdentity() != "discord" || req.Sender.Value() != Connection("discord", "sender-external-id").Value() { + t.Errorf("expected sender identity to round-trip, got %+v", req.Sender) } if req.EventID != eventID { t.Errorf("expected event ID %q, got %q", eventID, req.EventID) } } -func TestNewAltZapOnBehalfRequest(t *testing.T) { - relays := []string{"wss://relay.example.com"} - ev := NewAltZapOnBehalfRequest(AltZapRequestParams{ +func TestNewAltZapRequest_ContentRoundTrips(t *testing.T) { + ev, err := NewAltZapRequest(AltZapRequestParams{ + PrivateKey: zapsTestPrivKey, Chain: "flokicoin", - Recipient: "recipient1", - Lnurl: "lnurl1", + Recipient: Pubkey("recipient1"), + Lnurl: validTestLnurl, AmountMloki: 5000, - Relays: relays, - Sender: "sender1", + Relays: []string{"wss://relay.example.com"}, + Content: "Great post!", }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ev.Content != "Great post!" { + t.Errorf("expected content to round-trip, got %q", ev.Content) + } +} + +func TestNewAltZapRequest_InvalidPrivateKeyErrors(t *testing.T) { + _, err := NewAltZapRequest(AltZapRequestParams{ + PrivateKey: "not-a-valid-key", + Chain: "flokicoin", + Recipient: Pubkey("recipient1"), + Lnurl: validTestLnurl, + AmountMloki: 5000, + Relays: []string{"wss://relay.example.com"}, + }) + if err == nil { + t.Error("expected error for invalid private key") + } +} + +func TestNewAltZapOnBehalfRequest(t *testing.T) { + relays := []string{"wss://relay.example.com"} + ev, err := NewAltZapOnBehalfRequest( + Connection("discord", "sender-external-id"), + AltZapRequestParams{ + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Recipient: Connection("discord", "recipient-external-id"), + Lnurl: validTestLnurl, + AmountMloki: 5000, + Relays: relays, + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if ev.Kind != KindAltZapOnBehalfRequest { t.Errorf("expected kind %d, got %d", KindAltZapOnBehalfRequest, ev.Kind) } + + req, err := ParseAltZapRequest(ev) + if err != nil { + t.Fatalf("expected output to be parseable, got: %v", err) + } + if req.Sender.IsZero() { + t.Error("expected sender identity to be present on an on-behalf request") + } + if req.Sender.WebIdentity() != "discord" { + t.Errorf("expected sender platform 'discord', got %q", req.Sender.WebIdentity()) + } +} + +// Kind 5523 must always carry a sender — sender being a required positional +// argument (not an optional params field) makes this a compile-time +// guarantee rather than a runtime check, but confirm the resulting event +// really does parse with a non-zero sender regardless of what's in Params. +func TestNewAltZapOnBehalfRequest_SenderParamIsIgnoredInFavorOfPositionalArg(t *testing.T) { + ev, err := NewAltZapOnBehalfRequest( + Pubkey("real-sender"), + AltZapRequestParams{ + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Recipient: Pubkey("recipient1"), + Lnurl: validTestLnurl, + AmountMloki: 5000, + Relays: []string{"wss://relay.example.com"}, + Sender: Pubkey("this-should-be-overridden"), + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req, err := ParseAltZapRequest(ev) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Sender.Value() != "real-sender" { + t.Errorf("expected the positional sender argument to win, got %q", req.Sender.Value()) + } } func TestNewAltZapDirectPaymentRequest(t *testing.T) { relays := []string{"wss://relay.example.com"} - ev := NewAltZapDirectPaymentRequest(AltZapDirectPaymentParams{ - Chain: "flokicoin", - Bolt11: "lnfc-invoice", - AmountMloki: 5000, - Relays: relays, - Sender: "sender1", - SenderProvider: "nostr", + ev, err := NewAltZapDirectPaymentRequest(AltZapDirectPaymentParams{ + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Bolt11: "lnfc-invoice", + AmountMloki: 5000, + Relays: relays, + Sender: Pubkey("sender1"), }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if ev.Kind != KindAltZapDirectPayment { t.Errorf("expected kind %d, got %d", KindAltZapDirectPayment, ev.Kind) } + if err := ev.Verify(); err != nil { + t.Errorf("expected a validly signed event, got: %v", err) + } var hasBolt11, hasSender bool for _, tag := range ev.Tags { @@ -195,45 +285,155 @@ func TestNewAltZapDirectPaymentRequest(t *testing.T) { } } -func TestNewAltZapReceipt_ExtractsTagsFromDescription(t *testing.T) { +// Replaces TestNewAltZapReceipt_ExtractsTagsFromDescription: the old +// fallback silently copied p/P tags (handle included) back out of the +// embedded request's description JSON, which could reintroduce a value the +// ZSP never verified — see NIPAZ-NIPIC-API-EXAMPLES.md scenario 6. Confirm +// that no longer happens: Recipient/Sender always come from the caller's +// explicit params, never from Description, even when Description embeds a +// conflicting identity. +func TestNewAltZapReceipt_RecipientNeverOverriddenByDescription(t *testing.T) { originalDecode := nip57.DecodeBolt11 defer func() { nip57.DecodeBolt11 = originalDecode }() nip57.DecodeBolt11 = func(bolt11 string) (*nip57.Invoice, error) { return &nip57.Invoice{AmountMloki: 1000}, nil } - reqEvent := signedZapRequestEvent(t, KindAltZapRequest, nil) + // The embedded request's own p tag names a *different* recipient than + // what the ZSP has authoritatively resolved server-side. + reqEvent := &nip01.Event{ + Kind: KindAltZapRequest, + Tags: [][]string{ + {"p", "some-other-unverified-recipient", "discord", "unverified-handle"}, + {"amount", "1000"}, + {"lnurl", validTestLnurl}, + {"chain", "flokicoin"}, + {"relays", "wss://relay.example.com"}, + }, + } + if err := reqEvent.Sign(zapsTestPrivKey); err != nil { + t.Fatalf("failed to sign embedded request: %v", err) + } descBytes, err := json.Marshal(reqEvent) if err != nil { t.Fatalf("failed to marshal embedded request: %v", err) } preimage := "abc123" + verifiedRecipient := Connection("discord", "server-verified-id").WithHandle("server-verified-handle") receipt, err := NewAltZapReceipt(AltZapReceiptParams{ - Chain: "flokicoin", - ProviderPubkey: "provider1", - RecipientPubkey: "recipient1", - SenderPubkey: "sender1", - Bolt11: "lnfc-invoice", - Description: string(descBytes), - Preimage: &preimage, + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Recipient: verifiedRecipient, + Bolt11: "lnfc-invoice", + Description: string(descBytes), + Preimage: &preimage, }) if err != nil { t.Fatalf("unexpected error: %v", err) } - var eTagFound, detailedPTagFound bool - for _, tag := range receipt.Tags { - if len(tag) >= 2 && tag[0] == "e" { - eTagFound = true - } - if len(tag) >= 3 && tag[0] == "p" { - detailedPTagFound = true - } + parsed, err := ParseAltZapReceipt(receipt) + if err != nil { + t.Fatalf("unexpected error parsing constructed receipt: %v", err) + } + if parsed.Recipient.Value() != verifiedRecipient.Value() { + t.Errorf("expected server-verified recipient to win, got value %q", parsed.Recipient.Value()) + } + if parsed.Recipient.Handle() != "server-verified-handle" { + t.Errorf("expected server-verified handle to win, got %q", parsed.Recipient.Handle()) + } +} + +// New coverage: a receipt's Recipient handle (set via WithHandle) is emitted +// as the 4th tag element and round-trips through Parse. +func TestNewAltZapReceipt_HandleRoundTrips(t *testing.T) { + originalDecode := nip57.DecodeBolt11 + defer func() { nip57.DecodeBolt11 = originalDecode }() + nip57.DecodeBolt11 = func(bolt11 string) (*nip57.Invoice, error) { + return &nip57.Invoice{AmountMloki: 1000}, nil + } + + preimage := "abc123" + receipt, err := NewAltZapReceipt(AltZapReceiptParams{ + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Recipient: Connection("discord", "id1").WithHandle("cool_username"), + Bolt11: "lnfc-invoice", + Preimage: &preimage, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + parsed, err := ParseAltZapReceipt(receipt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.Recipient.Handle() != "cool_username" { + t.Errorf("expected handle to round-trip, got %q", parsed.Recipient.Handle()) + } +} + +// Ported from zapf's internal/nostr/events_test.go:TestBuildZapReceipt's +// "Proxy Request (Intent 5523) with Resolved Sender" case — a proxy sender +// (ConnectionKey scoped to a platform, no handle) combined with a resolved +// native pubkey on the R tag. +func TestNewAltZapReceipt_ProxySenderWithResolvedSender(t *testing.T) { + originalDecode := nip57.DecodeBolt11 + defer func() { nip57.DecodeBolt11 = originalDecode }() + nip57.DecodeBolt11 = func(bolt11 string) (*nip57.Invoice, error) { + return &nip57.Invoice{AmountMloki: 1000}, nil + } + + preimage := "preimage_hex" + receipt, err := NewAltZapReceipt(AltZapReceiptParams{ + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Recipient: Pubkey("recipientpubkey"), + Sender: Connection("discord", "someconnectionkey-external-id").WithHandle(""), + ResolvedSenderPubkey: "actualsenderpubkey", + Bolt11: "lnbc1...", + Preimage: &preimage, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + parsed, err := ParseAltZapReceipt(receipt) + if err != nil { + t.Fatalf("unexpected error parsing constructed receipt: %v", err) + } + if parsed.Sender.WebIdentity() != "discord" { + t.Errorf("expected P tag platform 'discord', got %q", parsed.Sender.WebIdentity()) + } + if parsed.Sender.Handle() != "" { + t.Errorf("expected no handle on the P tag, got %q", parsed.Sender.Handle()) + } + if parsed.ResolvedSenderPubkey != "actualsenderpubkey" { + t.Errorf("expected R tag 'actualsenderpubkey', got %q", parsed.ResolvedSenderPubkey) + } +} + +// New coverage: request-side identities never emit a handle on the wire, +// even if one is (mistakenly) set — matches the original NewAltZapRequest's +// wire behavior (2/3-element p/P tags only). +func TestNewAltZapRequest_NeverEmitsHandleOnRequests(t *testing.T) { + ev, err := NewAltZapRequest(AltZapRequestParams{ + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Recipient: Connection("discord", "id1").WithHandle("should-not-appear"), + Lnurl: validTestLnurl, + AmountMloki: 5000, + Relays: []string{"wss://relay.example.com"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - _ = eTagFound // the mock request has no "e" tag; kept for readability of intent - if !detailedPTagFound { - t.Error("expected the basic 'p' tag to be replaced by the detailed one from the embedded request") + for _, tag := range ev.Tags { + if len(tag) >= 1 && tag[0] == "p" && len(tag) > 3 { + t.Errorf("expected request p tag to have at most 3 elements, got %v", tag) + } } } @@ -319,3 +519,107 @@ func TestValidateAltZapReceipt_WithEmbeddedRequest(t *testing.T) { t.Fatal("expected description hash mismatch error") } } + +// ── Identity ───────────────────────────────────────────────────────────── + +func TestIdentity_ZeroValueIsZero(t *testing.T) { + var id Identity + if !id.IsZero() { + t.Error("expected the zero Identity to report IsZero() == true") + } +} + +func TestIdentity_PubkeyIsNotZero(t *testing.T) { + if Pubkey("abc").IsZero() { + t.Error("expected a Pubkey identity to be non-zero") + } +} + +func TestIdentity_ConnectionComputesConnectionKeyInternally(t *testing.T) { + id := Connection("discord", "123456") + if len(id.Value()) != 64 { + t.Errorf("expected a 64-hex-char ConnectionKey, got %d chars: %q", len(id.Value()), id.Value()) + } + if id.Value() != Connection("discord", "123456").Value() { + t.Error("expected Connection() to be deterministic for the same (platform, externalID) pair") + } + if id.Value() == Connection("discord", "654321").Value() { + t.Error("expected different externalIDs to produce different ConnectionKeys") + } + if id.WebIdentity() != "discord" { + t.Errorf("expected platform 'discord', got %q", id.WebIdentity()) + } +} + +// Resolves design-doc open question #2: a caller that already has a +// ConnectionKey (resolved upstream, e.g. by a caller like zapf's +// BuildZapReceipt which receives an already-resolved identity string from +// several layers up) must not have to pay for — or risk diverging from — a +// second hash computation. +func TestIdentity_ResolvedConnectionDoesNotRehash(t *testing.T) { + key := ConnectionKey(Connection("discord", "123456").Value()) + id := ResolvedConnection(key, "discord") + if id.Value() != key.String() { + t.Errorf("expected ResolvedConnection to use the given key verbatim, got %q want %q", id.Value(), key.String()) + } + if id.WebIdentity() != "discord" { + t.Errorf("expected platform 'discord', got %q", id.WebIdentity()) + } + // Cross-check against the hashing constructor: both paths must agree for + // the same logical (platform, externalID) identity. + if id.Value() != Connection("discord", "123456").Value() { + t.Error("expected ResolvedConnection(NewConnectionKey(...)) to equal Connection(...) for the same inputs") + } +} + +func TestIdentity_ResolvedConnectionSupportsHandle(t *testing.T) { + key := ConnectionKey(Connection("discord", "123456").Value()) + id := ResolvedConnection(key, "discord").WithHandle("cool_username") + if id.Handle() != "cool_username" { + t.Errorf("expected handle to round-trip, got %q", id.Handle()) + } +} + +// ── DescriptionHash ────────────────────────────────────────────────────── +// New coverage: this was previously duplicated (and undertested) across +// zapf's pkg/nostr/altzap.go and internal/nostr/events.go. + +func TestDescriptionHash_MatchesRawSHA256(t *testing.T) { + input := `{"kind":5520,"tags":[]}` + want := sha256.Sum256([]byte(input)) + wantHex := hex.EncodeToString(want[:]) + + if got := DescriptionHash(input); got != wantHex { + t.Errorf("DescriptionHash(%q) = %q, want %q", input, got, wantHex) + } +} + +func TestDescriptionHash_Deterministic(t *testing.T) { + input := "some description string" + if DescriptionHash(input) != DescriptionHash(input) { + t.Error("expected DescriptionHash to be deterministic") + } +} + +func TestDescriptionHash_HashesVerbatimNotReserialized(t *testing.T) { + // Two JSON strings that decode to the same object but differ byte-for-byte + // (key order, whitespace) MUST hash differently — DescriptionHash must + // never re-marshal its input, since a receiver later hashes the exact + // stored wire string to verify a match (see the doc comment). + a := `{"a":1,"b":2}` + b := `{"b": 2, "a": 1}` + if DescriptionHash(a) == DescriptionHash(b) { + t.Error("expected DescriptionHash to hash the raw string verbatim, not a re-serialized form") + } +} + +func TestIdentity_WithHandleIsImmutable(t *testing.T) { + base := Pubkey("abc") + withHandle := base.WithHandle("alice") + if base.Handle() != "" { + t.Error("expected WithHandle to not mutate the receiver") + } + if withHandle.Handle() != "alice" { + t.Errorf("expected handle 'alice', got %q", withHandle.Handle()) + } +} diff --git a/nipAZ/nipAZ_test.go b/nipAZ/nipAZ_test.go index 7390c37..c506a41 100644 --- a/nipAZ/nipAZ_test.go +++ b/nipAZ/nipAZ_test.go @@ -157,6 +157,59 @@ func TestParseAltZapRequest(t *testing.T) { } } +func TestParseAltZapRequest_IdentityFields(t *testing.T) { + validPubkey := "0000000000000000000000000000000000000000000000000000000000000001" + + t.Run("native pubkey, empty platform element", func(t *testing.T) { + req, err := ParseAltZapRequest(mockEvent(KindAltZapRequest, [][]string{ + {"relays", "wss://relay.com"}, + {"p", validPubkey, ""}, + {"amount", "1000"}, + {"chain", "flokicoin"}, + {"lnurl", "lnurl1dp68gurn8ghj7ar9wd6zucm0d5hkzurf9akxuatjdsyukzu5"}, + }, "")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Recipient.WebIdentity() != "" || req.Recipient.Value() != validPubkey { + t.Errorf("expected native recipient identity, got %+v", req.Recipient) + } + }) + + t.Run("native pubkey, literal 'nostr' platform element treated as native", func(t *testing.T) { + req, err := ParseAltZapRequest(mockEvent(KindAltZapRequest, [][]string{ + {"relays", "wss://relay.com"}, + {"p", validPubkey, "nostr"}, + {"amount", "1000"}, + {"chain", "flokicoin"}, + {"lnurl", "lnurl1dp68gurn8ghj7ar9wd6zucm0d5hkzurf9akxuatjdsyukzu5"}, + }, "")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Recipient.WebIdentity() != "" { + t.Errorf("expected literal 'nostr' platform element to normalize to native (zero WebIdentity), got %q", req.Recipient.WebIdentity()) + } + }) + + t.Run("ConnectionKey recipient carries platform", func(t *testing.T) { + connKey := "aa11223344556677889900112233445566778899001122334455667788990011" + req, err := ParseAltZapRequest(mockEvent(KindAltZapRequest, [][]string{ + {"relays", "wss://relay.com"}, + {"p", connKey, "discord"}, + {"amount", "1000"}, + {"chain", "flokicoin"}, + {"lnurl", "lnurl1dp68gurn8ghj7ar9wd6zucm0d5hkzurf9akxuatjdsyukzu5"}, + }, "")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Recipient.WebIdentity() != "discord" || req.Recipient.Value() != connKey { + t.Errorf("expected discord-scoped recipient, got %+v", req.Recipient) + } + }) +} + func TestParseAltZapReceipt(t *testing.T) { validPubkey := "0000000000000000000000000000000000000000000000000000000000000001" @@ -246,8 +299,8 @@ func TestParseAltZapReceipt(t *testing.T) { }, ""), wantErr: false, check: func(zr *AltZapReceipt) error { - if zr.Recipient != "" { - return fmt.Errorf("expected empty recipient") + if !zr.Recipient.IsZero() { + return fmt.Errorf("expected zero recipient identity") } return nil }, @@ -375,13 +428,13 @@ func TestNewAltZapReceipt(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var preimage = "1122" got, err := NewAltZapReceipt(AltZapReceiptParams{ - Chain: "flokicoin", - ProviderPubkey: "provider", - RecipientPubkey: "recipient", - SenderPubkey: "sender", - Bolt11: tt.bolt11, - Description: tt.desc, - Preimage: &preimage, + PrivateKey: zapsTestPrivKey, + Chain: "flokicoin", + Recipient: Pubkey("recipient"), + Sender: Pubkey("sender"), + Bolt11: tt.bolt11, + Description: tt.desc, + Preimage: &preimage, }) if (err != nil) != tt.wantErr { t.Errorf("NewAltZapReceipt() error = %v, wantErr %v", err, tt.wantErr) @@ -402,6 +455,9 @@ func TestNewAltZapReceipt(t *testing.T) { if !amountFound { t.Errorf("NewAltZapReceipt() missing amount tag") } + if err := got.Verify(); err != nil { + t.Errorf("expected NewAltZapReceipt to return an already-signed, valid event: %v", err) + } } }) } diff --git a/nipIC/attestation.go b/nipIC/attestation.go new file mode 100644 index 0000000..754ea7a --- /dev/null +++ b/nipIC/attestation.go @@ -0,0 +1,192 @@ +package nipIC + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/ohstr/nmilat/nip01" +) + +// evidenceWire is the on-the-wire JSON shape of the "evidence" tag content — +// includes Version/AuthType, which Evidence deliberately omits since they're +// always 1/"public_post" for v1 and never a meaningful caller input. +type evidenceWire struct { + Version int `json:"version"` + Platform string `json:"platform"` + AuthType string `json:"auth_type"` + UserID string `json:"user_id"` + Username string `json:"username,omitempty"` + VerifiedAt int64 `json:"verified_at"` + EvidenceURL string `json:"evidence_url,omitempty"` + Challenge string `json:"challenge,omitempty"` + PreAuthCode string `json:"pre_auth_code,omitempty"` +} + +// AttestationParams describes a Kind 35522 IA Attestation. PrivateKey, +// ConnectionKey, UserPubkey, and Platform are required. +type AttestationParams struct { + PrivateKey string // IA's nsec hex — required, signs internally + ConnectionKey ConnectionKey + UserPubkey string + Platform WebIdentity + Evidence Evidence + ExpirationDays int // 0 = no expiry; NIP-IC.md recommends 90 +} + +// NewAttestation creates and signs a Kind 35522 attestation event. +func NewAttestation(p AttestationParams) (*nip01.Event, error) { + wire := evidenceWire{ + Version: 1, + Platform: string(p.Platform), + AuthType: "public_post", + UserID: p.Evidence.UserID, + Username: p.Evidence.Username, + VerifiedAt: p.Evidence.VerifiedAt, + EvidenceURL: p.Evidence.EvidenceURL, + Challenge: string(p.Evidence.Challenge), + PreAuthCode: p.Evidence.PreAuthCode, + } + raw, err := json.Marshal(wire) + if err != nil { + return nil, fmt.Errorf("nipIC: marshal evidence: %w", err) + } + + tags := [][]string{ + {TagDTag, string(p.ConnectionKey)}, + {TagRecipient, p.UserPubkey}, + {TagPlatform, string(p.Platform)}, + {TagEvidence, string(raw)}, + } + + now := time.Now() + if p.ExpirationDays > 0 { + expiresAt := now.AddDate(0, 0, p.ExpirationDays).Unix() + tags = append(tags, []string{TagExpiration, strconv.FormatInt(expiresAt, 10)}) + } + + event := &nip01.Event{ + Kind: KindAttestation, + CreatedAt: uint64(now.Unix()), + Tags: tags, + Content: "", + } + if err := event.Sign(p.PrivateKey); err != nil { + return nil, fmt.Errorf("nipIC: sign attestation: %w", err) + } + return event, nil +} + +// NewAttestationRevocation creates and signs a NIP-09 Kind 5 deletion event +// targeting a previously published Kind 35522 attestation. +func NewAttestationRevocation(privateKeyHex, attestationEventID string) (*nip01.Event, error) { + event := &nip01.Event{ + Kind: KindAttestationRevocation, + CreatedAt: uint64(time.Now().Unix()), + Tags: [][]string{{TagEventRef, attestationEventID}}, + Content: "revoked", + } + if err := event.Sign(privateKeyHex); err != nil { + return nil, fmt.Errorf("nipIC: sign attestation revocation: %w", err) + } + return event, nil +} + +// Attestation is a parsed and validated Kind 35522 event. +type Attestation struct { + *nip01.Event + ConnectionKey ConnectionKey + UserPubkey string + Platform WebIdentity + Evidence Evidence + ExpiresAt *time.Time // nil = no expiry +} + +// ParseAttestation parses and validates a Kind 35522 attestation event: +// correct kind, valid signature, required tags present, #d not +// platform-prefixed, and evidence tag content is valid v1 JSON. An expired +// ExpiresAt does not itself cause an error — an expired attestation is still +// structurally valid; callers decide what to do with an expired one. +func ParseAttestation(event *nip01.Event) (*Attestation, error) { + if event == nil { + return nil, fmt.Errorf("%w: event is nil", ErrInvalidTag) + } + if event.Kind != KindAttestation { + return nil, fmt.Errorf("%w: expected kind %d, got %d", ErrWrongKind, KindAttestation, event.Kind) + } + if err := event.Verify(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidSignature, err) + } + + var dValue, pValue, platformValue, evidenceValue, expValue string + for _, tag := range event.Tags { + if len(tag) < 2 { + continue + } + switch tag[0] { + case TagDTag: + dValue = tag[1] + case TagRecipient: + pValue = tag[1] + case TagPlatform: + platformValue = tag[1] + case TagEvidence: + evidenceValue = tag[1] + case TagExpiration: + expValue = tag[1] + } + } + + if dValue == "" { + return nil, fmt.Errorf("%w: #d tag is required for Kind 35522", ErrMissingTag) + } + if strings.Contains(dValue, ":") { + return nil, fmt.Errorf("%w: %q", ErrPlatformPrefixed, dValue) + } + if pValue == "" { + return nil, fmt.Errorf("%w: #p tag (user pubkey) is required for Kind 35522", ErrMissingTag) + } + if platformValue == "" { + return nil, fmt.Errorf("%w: #platform tag is required for Kind 35522", ErrMissingTag) + } + if evidenceValue == "" { + return nil, fmt.Errorf("%w: #evidence tag is required for Kind 35522", ErrMissingTag) + } + + var wire evidenceWire + if err := json.Unmarshal([]byte(evidenceValue), &wire); err != nil { + return nil, fmt.Errorf("%w: evidence tag is not valid JSON: %v", ErrInvalidTag, err) + } + + att := &Attestation{ + Event: event, + ConnectionKey: ConnectionKey(dValue), + UserPubkey: pValue, + Platform: WebIdentity(platformValue), + Evidence: Evidence{ + Platform: WebIdentity(platformValue), + UserID: wire.UserID, + Username: wire.Username, + VerifiedAt: wire.VerifiedAt, + EvidenceURL: wire.EvidenceURL, + Challenge: ChallengeToken(wire.Challenge), + PreAuthCode: wire.PreAuthCode, + }, + } + if expValue != "" { + if ts, err := strconv.ParseInt(expValue, 10, 64); err == nil { + expiresAt := time.Unix(ts, 0) + att.ExpiresAt = &expiresAt + } + } + return att, nil +} + +// ValidateAttestation is a convenience wrapper for callers that only need a +// pass/fail check and don't need the parsed Attestation itself. +func ValidateAttestation(event *nip01.Event) error { + _, err := ParseAttestation(event) + return err +} diff --git a/nipIC/attestation_test.go b/nipIC/attestation_test.go new file mode 100644 index 0000000..741df93 --- /dev/null +++ b/nipIC/attestation_test.go @@ -0,0 +1,410 @@ +package nipIC + +import ( + "encoding/json" + "strconv" + "testing" + "time" + + "github.com/ohstr/nmilat/nip01" +) + +const ( + testIAPrivKey = "67dea2ed018072d675f5415ecfa0a3f99969a5db773c2583831a29779c58155b" + testUserPubKey = "d80a8834fbab8b33adae2e1e78f5e2e30d42df72b4881f87920ee33dd9fc2a97" +) + +func findTag(event *nip01.Event, name string) []string { + for _, tag := range event.Tags { + if len(tag) >= 2 && tag[0] == name { + return tag + } + } + return nil +} + +func makeAttestationParams() AttestationParams { + return AttestationParams{ + PrivateKey: testIAPrivKey, + ConnectionKey: ConnectionKey("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), + UserPubkey: testUserPubKey, + Platform: "github", + Evidence: Evidence{ + UserID: "user42", + Username: "alice", + EvidenceURL: "https://gist.github.com/alice/abc123", + Challenge: "npv11qqsddvq9arp", + VerifiedAt: 1700000000, + }, + ExpirationDays: 90, + } +} + +// ── NewAttestation: evidence tag structure ────────────────────────────────── +// Ported from zapf's attestation_event_test.go (T-13b.1 .. T-13b.7). + +func TestNewAttestation_EvidenceTagIsJSON(t *testing.T) { + event, err := NewAttestation(makeAttestationParams()) + if err != nil { + t.Fatalf("NewAttestation failed: %v", err) + } + tag := findTag(event, TagEvidence) + if len(tag) < 2 { + t.Fatal("missing evidence tag") + } + var parsed map[string]any + if err := json.Unmarshal([]byte(tag[1]), &parsed); err != nil { + t.Errorf("evidence tag must be valid JSON, got: %q", tag[1]) + } +} + +func TestNewAttestation_EvidenceHasVersion1(t *testing.T) { + event, _ := NewAttestation(makeAttestationParams()) + tag := findTag(event, TagEvidence) + var parsed map[string]any + json.Unmarshal([]byte(tag[1]), &parsed) //nolint:errcheck + if parsed["version"].(float64) != 1 { + t.Errorf("expected version=1, got %v", parsed["version"]) + } +} + +func TestNewAttestation_EvidenceAuthTypeIsPublicPost(t *testing.T) { + event, _ := NewAttestation(makeAttestationParams()) + tag := findTag(event, TagEvidence) + var parsed map[string]any + json.Unmarshal([]byte(tag[1]), &parsed) //nolint:errcheck + if parsed["auth_type"] != "public_post" { + t.Errorf("expected auth_type='public_post', got %q", parsed["auth_type"]) + } +} + +func TestNewAttestation_EvidenceFieldsRoundTrip(t *testing.T) { + event, _ := NewAttestation(makeAttestationParams()) + tag := findTag(event, TagEvidence) + var parsed map[string]any + json.Unmarshal([]byte(tag[1]), &parsed) //nolint:errcheck + + if parsed["evidence_url"] != "https://gist.github.com/alice/abc123" { + t.Errorf("unexpected evidence_url: %v", parsed["evidence_url"]) + } + if parsed["challenge"] != "npv11qqsddvq9arp" { + t.Errorf("unexpected challenge: %v", parsed["challenge"]) + } + if parsed["user_id"] != "user42" { + t.Errorf("unexpected user_id: %v", parsed["user_id"]) + } + if parsed["username"] != "alice" { + t.Errorf("unexpected username: %v", parsed["username"]) + } + if parsed["verified_at"].(float64) != 1700000000 { + t.Errorf("unexpected verified_at: %v", parsed["verified_at"]) + } + if parsed["platform"] != "github" { + t.Errorf("unexpected platform: %v", parsed["platform"]) + } +} + +// Version/AuthType are not caller-settable — confirm a caller "trying" to +// override them has no effect, since Evidence has no such fields at all +// (this is enforced at compile time, but we still assert the wire output). +func TestNewAttestation_VersionAuthTypeAlwaysCanonical(t *testing.T) { + p := makeAttestationParams() + event, err := NewAttestation(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tag := findTag(event, TagEvidence) + var parsed map[string]any + json.Unmarshal([]byte(tag[1]), &parsed) //nolint:errcheck + if parsed["version"].(float64) != 1 || parsed["auth_type"] != "public_post" { + t.Error("version/auth_type must always be canonical, regardless of caller input") + } +} + +// ── Signing (new behavior — construction+signing is now one call) ────────── + +func TestNewAttestation_IsSigned(t *testing.T) { + event, err := NewAttestation(makeAttestationParams()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.ID == "" || event.Sig == "" { + t.Error("expected NewAttestation to return an already-signed event") + } + if err := event.Verify(); err != nil { + t.Errorf("expected a valid signature, got: %v", err) + } +} + +func TestNewAttestation_InvalidPrivateKeyErrors(t *testing.T) { + p := makeAttestationParams() + p.PrivateKey = "not-a-valid-key" + if _, err := NewAttestation(p); err == nil { + t.Error("expected error for invalid private key") + } +} + +// ── Expiration — ported from attestation_critical_test.go (A6) ───────────── + +func TestNewAttestation_ExpirationPrecision(t *testing.T) { + p := makeAttestationParams() + p.ExpirationDays = 90 + + beforeCreate := time.Now() + event, err := NewAttestation(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + afterCreate := time.Now() + + expTag := findTag(event, TagExpiration) + if expTag == nil { + t.Fatal("missing expiration tag") + } + expTs, _ := strconv.ParseInt(expTag[1], 10, 64) + expectedMin := beforeCreate.AddDate(0, 0, 90).Unix() + expectedMax := afterCreate.AddDate(0, 0, 90).Unix() + if expTs < expectedMin || expTs > expectedMax { + t.Errorf("expiration %d not in expected range [%d, %d]", expTs, expectedMin, expectedMax) + } +} + +func TestNewAttestation_NoExpirationWhenZero(t *testing.T) { + p := makeAttestationParams() + p.ExpirationDays = 0 + + event, err := NewAttestation(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tag := findTag(event, TagExpiration); tag != nil { + t.Errorf("expected no expiration tag when ExpirationDays=0, found: %v", tag) + } +} + +// ── NewAttestationRevocation (new — wasn't a testable public path before) ── + +func TestNewAttestationRevocation(t *testing.T) { + deletion, err := NewAttestationRevocation(testIAPrivKey, "some-attestation-event-id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deletion.Kind != KindAttestationRevocation { + t.Errorf("expected kind %d, got %d", KindAttestationRevocation, deletion.Kind) + } + if err := deletion.Verify(); err != nil { + t.Errorf("expected a validly signed deletion event, got: %v", err) + } + tag := findTag(deletion, TagEventRef) + if tag == nil || tag[1] != "some-attestation-event-id" { + t.Errorf("expected e-tag referencing the attestation, got: %v", tag) + } +} + +// ── ParseAttestation / ValidateAttestation ────────────────────────────────── +// Ported from zapf's internal/nostr/validator_identity_test.go 35522 cases +// (D4-D6, D11, D12, D15, C6, wrong-kind, missing-platform). + +func signedAttestationEvent(t *testing.T, dValue string, extraTags ...[]string) *nip01.Event { + t.Helper() + tags := [][]string{ + {TagDTag, dValue}, + {TagRecipient, testUserPubKey}, + {TagPlatform, "discord"}, + {TagEvidence, `{"version":1,"platform":"discord","auth_type":"public_post","evidence_url":"https://discord.com/channels/1/2/3","challenge":"npv1qqpx9er9wehxumq78f5k4q8"}`}, + } + tags = append(tags, extraTags...) + evt := &nip01.Event{Kind: KindAttestation, Tags: tags} + if err := evt.Sign(testIAPrivKey); err != nil { + t.Fatalf("failed to sign event: %v", err) + } + return evt +} + +const testConnKeyHex = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + +func TestParseAttestation_BareConnectionKeyPasses(t *testing.T) { + evt := signedAttestationEvent(t, testConnKeyHex) + att, err := ParseAttestation(evt) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if att.ConnectionKey != ConnectionKey(testConnKeyHex) { + t.Errorf("unexpected ConnectionKey: %v", att.ConnectionKey) + } + if att.Platform != "discord" { + t.Errorf("unexpected Platform: %v", att.Platform) + } + if att.UserPubkey != testUserPubKey { + t.Errorf("unexpected UserPubkey: %v", att.UserPubkey) + } +} + +func TestParseAttestation_PlatformPrefixedDTagRejected(t *testing.T) { + evt := signedAttestationEvent(t, "discord:"+testConnKeyHex) + if _, err := ParseAttestation(evt); err == nil { + t.Error("expected error for platform-prefixed #d tag") + } +} + +func TestParseAttestation_MissingPTagRejected(t *testing.T) { + evt := &nip01.Event{ + Kind: KindAttestation, + Tags: [][]string{ + {TagDTag, testConnKeyHex}, + {TagPlatform, "discord"}, + {TagEvidence, "proof"}, + }, + } + evt.Sign(testIAPrivKey) //nolint:errcheck + if _, err := ParseAttestation(evt); err == nil { + t.Error("expected error for missing p-tag") + } +} + +func TestParseAttestation_MissingEvidenceTagRejected(t *testing.T) { + evt := &nip01.Event{ + Kind: KindAttestation, + Tags: [][]string{ + {TagDTag, testConnKeyHex}, + {TagRecipient, testUserPubKey}, + {TagPlatform, "discord"}, + }, + } + evt.Sign(testIAPrivKey) //nolint:errcheck + if _, err := ParseAttestation(evt); err == nil { + t.Error("expected error for missing evidence tag") + } +} + +func TestParseAttestation_MissingPlatformTagRejected(t *testing.T) { + evt := &nip01.Event{ + Kind: KindAttestation, + Tags: [][]string{ + {TagDTag, testConnKeyHex}, + {TagRecipient, testUserPubKey}, + {TagEvidence, `{"version":1}`}, + }, + } + evt.Sign(testIAPrivKey) //nolint:errcheck + if _, err := ParseAttestation(evt); err == nil { + t.Error("expected error for missing platform tag") + } +} + +func TestParseAttestation_NilEventRejected(t *testing.T) { + if _, err := ParseAttestation(nil); err == nil { + t.Error("expected error for nil event") + } +} + +func TestParseAttestation_WrongKindRejected(t *testing.T) { + evt := signedAttestationEvent(t, testConnKeyHex) + evt.Kind = KindIdentityConnection + evt.Sign(testIAPrivKey) //nolint:errcheck + if _, err := ParseAttestation(evt); err == nil { + t.Error("expected error for wrong kind (35521 passed as 35522)") + } +} + +func TestParseAttestation_ExpiredAttestationStructurallyValid(t *testing.T) { + pastExp := strconv.FormatInt(time.Now().Add(-24*time.Hour).Unix(), 10) + evt := signedAttestationEvent(t, testConnKeyHex, []string{TagExpiration, pastExp}) + att, err := ParseAttestation(evt) + if err != nil { + t.Fatalf("expired attestation should still pass structural validation, got: %v", err) + } + if att.ExpiresAt == nil { + t.Fatal("expected ExpiresAt to be populated") + } + if !att.ExpiresAt.Before(time.Now()) { + t.Error("expected ExpiresAt to be in the past") + } +} + +func TestParseAttestation_NoExpirationTagValid(t *testing.T) { + evt := signedAttestationEvent(t, testConnKeyHex) + att, err := ParseAttestation(evt) + if err != nil { + t.Fatalf("attestation without expiration should be valid: %v", err) + } + if att.ExpiresAt != nil { + t.Error("expected nil ExpiresAt when no expiration tag present") + } +} + +func TestParseAttestation_Npv1ChallengeInEvidenceValid(t *testing.T) { + evt := signedAttestationEvent(t, testConnKeyHex) + att, err := ParseAttestation(evt) + if err != nil { + t.Fatalf("npv1 challenge in evidence should pass validation, got: %v", err) + } + if att.Evidence.Challenge != "npv1qqpx9er9wehxumq78f5k4q8" { + t.Errorf("unexpected Challenge: %v", att.Evidence.Challenge) + } +} + +func TestParseAttestation_ForgedSignatureRejected(t *testing.T) { + evt := signedAttestationEvent(t, testConnKeyHex) + evt.Tags = append(evt.Tags, []string{"extra", "tampered"}) // modify after signing + if _, err := ParseAttestation(evt); err == nil { + t.Error("expected error for tampered event (invalid signature)") + } +} + +// New coverage: malformed evidence JSON is now caught by Parse (the old +// zapf validator only checked tag *presence*, not JSON validity). +func TestParseAttestation_MalformedEvidenceJSONRejected(t *testing.T) { + evt := &nip01.Event{ + Kind: KindAttestation, + Tags: [][]string{ + {TagDTag, testConnKeyHex}, + {TagRecipient, testUserPubKey}, + {TagPlatform, "discord"}, + {TagEvidence, "{not valid json"}, + }, + } + evt.Sign(testIAPrivKey) //nolint:errcheck + if _, err := ParseAttestation(evt); err == nil { + t.Error("expected error for malformed evidence JSON") + } +} + +func TestValidateAttestation_MatchesParseAttestation(t *testing.T) { + valid := signedAttestationEvent(t, testConnKeyHex) + if err := ValidateAttestation(valid); err != nil { + t.Errorf("expected valid attestation to pass, got: %v", err) + } + + invalid := signedAttestationEvent(t, "discord:"+testConnKeyHex) + if err := ValidateAttestation(invalid); err == nil { + t.Error("expected platform-prefixed attestation to fail") + } +} + +// D-tag format coverage across several platform-prefix shapes. +func TestParseAttestation_DTagFormatCoverage(t *testing.T) { + tests := []struct { + name string + dValue string + wantError bool + }{ + {"bare_hex_passes", testConnKeyHex, false}, + {"discord_prefix_fails", "discord:" + testConnKeyHex, true}, + {"telegram_prefix_fails", "telegram:" + testConnKeyHex, true}, + {"x_prefix_fails", "x:" + testConnKeyHex, true}, + {"github_prefix_fails", "github:" + testConnKeyHex, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + evt := signedAttestationEvent(t, tt.dValue) + _, err := ParseAttestation(evt) + if tt.wantError && err == nil { + t.Errorf("expected error for d-tag %q, got nil", tt.dValue) + } + if !tt.wantError && err != nil { + t.Errorf("expected no error for d-tag %q, got: %v", tt.dValue, err) + } + }) + } +} diff --git a/nipIC/identity_connection.go b/nipIC/identity_connection.go new file mode 100644 index 0000000..02c6aa2 --- /dev/null +++ b/nipIC/identity_connection.go @@ -0,0 +1,82 @@ +package nipIC + +import ( + "fmt" + "strings" + + "github.com/ohstr/nmilat/nip01" +) + +// AttestationRef is the "e" tag on a Kind 35521 event: a reference to (not +// an embed of) the witnessing Kind 35522, so the 35521 event stays small. +// A client fetches the attestation from RelayURL on demand (a "Deep Check") +// rather than trusting the reference blindly. +type AttestationRef struct { + EventID string + RelayURL string +} + +// IdentityConnection is a parsed and validated Kind 35521 event. On its own +// it is an unverified claim — see NIP-IC.md's Verification Model: a client +// must fetch and check at least one of Attestations before trusting it. +type IdentityConnection struct { + *nip01.Event + ConnectionKey ConnectionKey + Platform WebIdentity + Attestations []AttestationRef // may be empty, may have multiple (multi-IA stacking) +} + +// ParseIdentityConnection parses and validates a Kind 35521 event: correct +// kind, valid signature, #d present and not platform-prefixed. +func ParseIdentityConnection(event *nip01.Event) (*IdentityConnection, error) { + if event == nil { + return nil, fmt.Errorf("%w: event is nil", ErrInvalidTag) + } + if event.Kind != KindIdentityConnection { + return nil, fmt.Errorf("%w: expected kind %d, got %d", ErrWrongKind, KindIdentityConnection, event.Kind) + } + if err := event.Verify(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidSignature, err) + } + + var dValue, platformValue string + var refs []AttestationRef + for _, tag := range event.Tags { + if len(tag) < 2 { + continue + } + switch tag[0] { + case TagDTag: + dValue = tag[1] + case TagPlatform: + platformValue = tag[1] + case TagEventRef: + ref := AttestationRef{EventID: tag[1]} + if len(tag) >= 3 { + ref.RelayURL = tag[2] + } + refs = append(refs, ref) + } + } + + if dValue == "" { + return nil, fmt.Errorf("%w: #d tag is required for Kind 35521", ErrMissingTag) + } + if strings.Contains(dValue, ":") { + return nil, fmt.Errorf("%w: %q", ErrPlatformPrefixed, dValue) + } + + return &IdentityConnection{ + Event: event, + ConnectionKey: ConnectionKey(dValue), + Platform: WebIdentity(platformValue), + Attestations: refs, + }, nil +} + +// ValidateIdentityConnection is a convenience wrapper for callers that only +// need a pass/fail check and don't need the parsed IdentityConnection itself. +func ValidateIdentityConnection(event *nip01.Event) error { + _, err := ParseIdentityConnection(event) + return err +} diff --git a/nipIC/identity_connection_test.go b/nipIC/identity_connection_test.go new file mode 100644 index 0000000..a4df403 --- /dev/null +++ b/nipIC/identity_connection_test.go @@ -0,0 +1,136 @@ +package nipIC + +import ( + "fmt" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +// Ported from zapf's internal/nostr/validator_identity_test.go 35521 cases +// (D1, D2, wrong-kind, missing-d-tag, nil-event, forged-signature). + +func signedIdentityConnectionEvent(t *testing.T, dValue string, extraTags ...[]string) *nip01.Event { + t.Helper() + tags := [][]string{ + {TagDTag, dValue}, + {TagPlatform, "discord"}, + } + tags = append(tags, extraTags...) + evt := &nip01.Event{Kind: KindIdentityConnection, Tags: tags} + if err := evt.Sign(testIAPrivKey); err != nil { + t.Fatalf("failed to sign event: %v", err) + } + return evt +} + +func TestParseIdentityConnection_BareConnectionKeyPasses(t *testing.T) { + evt := signedIdentityConnectionEvent(t, testConnKeyHex) + conn, err := ParseIdentityConnection(evt) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if conn.ConnectionKey != ConnectionKey(testConnKeyHex) { + t.Errorf("unexpected ConnectionKey: %v", conn.ConnectionKey) + } + if conn.Platform != "discord" { + t.Errorf("unexpected Platform: %v", conn.Platform) + } +} + +func TestParseIdentityConnection_PlatformPrefixedDTagRejected(t *testing.T) { + evt := signedIdentityConnectionEvent(t, fmt.Sprintf("discord:%s", testConnKeyHex)) + if _, err := ParseIdentityConnection(evt); err == nil { + t.Error("expected error for 'discord:' prefixed d-tag") + } +} + +func TestParseIdentityConnection_WrongKindRejected(t *testing.T) { + evt := signedIdentityConnectionEvent(t, testConnKeyHex) + evt.Kind = 1 // text note + evt.Sign(testIAPrivKey) //nolint:errcheck + if _, err := ParseIdentityConnection(evt); err == nil { + t.Error("expected error for wrong kind") + } +} + +func TestParseIdentityConnection_MissingDTagRejected(t *testing.T) { + evt := &nip01.Event{ + Kind: KindIdentityConnection, + Tags: [][]string{{TagPlatform, "discord"}}, + } + evt.Sign(testIAPrivKey) //nolint:errcheck + if _, err := ParseIdentityConnection(evt); err == nil { + t.Error("expected error for missing d-tag") + } +} + +func TestParseIdentityConnection_NilEventRejected(t *testing.T) { + if _, err := ParseIdentityConnection(nil); err == nil { + t.Error("expected error for nil event") + } +} + +func TestParseIdentityConnection_PrefixVariants(t *testing.T) { + for _, prefix := range []string{"discord", "telegram", "x", "github", "email"} { + t.Run(prefix+"_prefix_rejected", func(t *testing.T) { + evt := signedIdentityConnectionEvent(t, prefix+":abc123def456") + if _, err := ParseIdentityConnection(evt); err == nil { + t.Errorf("expected error for %q prefixed d-tag", prefix) + } + }) + } +} + +func TestParseIdentityConnection_ForgedSignatureRejected(t *testing.T) { + evt := signedIdentityConnectionEvent(t, testConnKeyHex) + evt.Tags = append(evt.Tags, []string{"extra", "tampered"}) + if _, err := ParseIdentityConnection(evt); err == nil { + t.Error("expected error for tampered event (invalid signature)") + } +} + +func TestValidateIdentityConnection_MatchesParseIdentityConnection(t *testing.T) { + valid := signedIdentityConnectionEvent(t, testConnKeyHex) + if err := ValidateIdentityConnection(valid); err != nil { + t.Errorf("expected valid connection to pass, got: %v", err) + } + + invalid := signedIdentityConnectionEvent(t, "discord:"+testConnKeyHex) + if err := ValidateIdentityConnection(invalid); err == nil { + t.Error("expected platform-prefixed connection to fail") + } +} + +// New coverage: AttestationRef parsing from e-tags (not covered by zapf's +// validator, which never parsed 35521 into a typed struct). +func TestParseIdentityConnection_AttestationRefs(t *testing.T) { + evt := signedIdentityConnectionEvent(t, testConnKeyHex, + []string{TagEventRef, "event-id-1", "wss://relay-one.example.com"}, + []string{TagEventRef, "event-id-2", "wss://relay-two.example.com"}, + ) + conn, err := ParseIdentityConnection(evt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conn.Attestations) != 2 { + t.Fatalf("expected 2 attestation refs (multi-IA stacking), got %d", len(conn.Attestations)) + } + if conn.Attestations[0].EventID != "event-id-1" || conn.Attestations[0].RelayURL != "wss://relay-one.example.com" { + t.Errorf("unexpected first ref: %+v", conn.Attestations[0]) + } + if conn.Attestations[1].EventID != "event-id-2" || conn.Attestations[1].RelayURL != "wss://relay-two.example.com" { + t.Errorf("unexpected second ref: %+v", conn.Attestations[1]) + } +} + +func TestParseIdentityConnection_NoAttestationRefsIsEmptyNotNilPanic(t *testing.T) { + evt := signedIdentityConnectionEvent(t, testConnKeyHex) + conn, err := ParseIdentityConnection(evt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conn.Attestations) != 0 { + t.Errorf("expected no attestation refs, got %d", len(conn.Attestations)) + } +} diff --git a/nipIC/nconnection.go b/nipIC/nconnection.go new file mode 100644 index 0000000..fbdfa57 --- /dev/null +++ b/nipIC/nconnection.go @@ -0,0 +1,104 @@ +package nipIC + +import ( + "encoding/hex" + "fmt" + + "github.com/flokiorg/go-flokicoin/chainutil/bech32" +) + +// NConnectionPrefix is the bech32 human-readable prefix for nconnection strings. +const NConnectionPrefix = "nconnection" + +// TLV types within an nconnection payload — same convention NIP-19 uses for +// nprofile. +const ( + nconnTypeConnectionKey = 0 // 32-byte ConnectionKey, required, at most once + nconnTypeRelay = 1 // relay URL, UTF-8, repeatable + nconnTypePlatform = 2 // platform name, UTF-8, optional, at most once +) + +// EncodeNConnection bundles a ConnectionKey with relay hints (and optionally +// a platform name) into a portable, shareable "nconnection1..." string — the +// same pattern NIP-19 uses for nprofile/nevent, handed e.g. from a Discord +// bot to a mobile wallet. +func EncodeNConnection(key ConnectionKey, relays []string, platform WebIdentity) (string, error) { + keyBytes, err := hex.DecodeString(string(key)) + if err != nil { + return "", fmt.Errorf("nipIC: invalid ConnectionKey hex: %w", err) + } + if len(keyBytes) != 32 { + return "", fmt.Errorf("nipIC: ConnectionKey must be 32 bytes, got %d", len(keyBytes)) + } + + var tlv []byte + tlv = append(tlv, nconnTypeConnectionKey, byte(len(keyBytes))) + tlv = append(tlv, keyBytes...) + + for _, relay := range relays { + relayBytes := []byte(relay) + tlv = append(tlv, nconnTypeRelay, byte(len(relayBytes))) + tlv = append(tlv, relayBytes...) + } + + if platform != "" { + platformBytes := []byte(platform) + tlv = append(tlv, nconnTypePlatform, byte(len(platformBytes))) + tlv = append(tlv, platformBytes...) + } + + bits5, err := bech32.ConvertBits(tlv, 8, 5, true) + if err != nil { + return "", fmt.Errorf("nipIC: bech32 convert: %w", err) + } + encoded, err := bech32.Encode(NConnectionPrefix, bits5) + if err != nil { + return "", fmt.Errorf("nipIC: bech32 encode: %w", err) + } + return encoded, nil +} + +// DecodeNConnection reverses EncodeNConnection. +func DecodeNConnection(s string) (key ConnectionKey, relays []string, platform WebIdentity, err error) { + prefix, bits5, err := bech32.DecodeNoLimit(s) + if err != nil { + return "", nil, "", fmt.Errorf("nipIC: bech32 decode: %w", err) + } + if prefix != NConnectionPrefix { + return "", nil, "", fmt.Errorf("nipIC: expected prefix %q, got %q", NConnectionPrefix, prefix) + } + data, err := bech32.ConvertBits(bits5, 5, 8, false) + if err != nil { + return "", nil, "", fmt.Errorf("nipIC: bech32 convert: %w", err) + } + + pos := 0 + var keyHex string + for pos+2 <= len(data) { + t := data[pos] + l := int(data[pos+1]) + pos += 2 + if pos+l > len(data) { + break + } + v := data[pos : pos+l] + pos += l + + switch t { + case nconnTypeConnectionKey: + if l != 32 { + return "", nil, "", fmt.Errorf("nipIC: ConnectionKey must be 32 bytes, got %d", l) + } + keyHex = hex.EncodeToString(v) + case nconnTypeRelay: + relays = append(relays, string(v)) + case nconnTypePlatform: + platform = WebIdentity(v) + } + } + + if keyHex == "" { + return "", nil, "", fmt.Errorf("nipIC: nconnection missing ConnectionKey") + } + return ConnectionKey(keyHex), relays, platform, nil +} diff --git a/nipIC/nconnection_test.go b/nipIC/nconnection_test.go new file mode 100644 index 0000000..91302ba --- /dev/null +++ b/nipIC/nconnection_test.go @@ -0,0 +1,99 @@ +package nipIC + +import ( + "strings" + "testing" +) + +// Ported from bot/pkg/nconnection/nconnection_test.go — same TLV wire +// format, now exercised through the SDK's typed ConnectionKey/WebIdentity. + +func TestEncodeDecodeNConnection_Roundtrip(t *testing.T) { + key := ConnectionKey("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") + relays := []string{"wss://relay.zapf.app/v1", "wss://relay.damus.io"} + + encoded, err := EncodeNConnection(key, relays, "discord") + if err != nil { + t.Fatalf("EncodeNConnection failed: %v", err) + } + if !strings.HasPrefix(encoded, NConnectionPrefix+"1") { + t.Errorf("expected prefix %q, got %q", NConnectionPrefix+"1", encoded[:len(NConnectionPrefix)+1]) + } + + gotKey, gotRelays, gotPlatform, err := DecodeNConnection(encoded) + if err != nil { + t.Fatalf("DecodeNConnection failed: %v", err) + } + if gotKey != key { + t.Errorf("key mismatch: got %q, want %q", gotKey, key) + } + if len(gotRelays) != 2 || gotRelays[0] != relays[0] || gotRelays[1] != relays[1] { + t.Errorf("relay mismatch: got %v, want %v", gotRelays, relays) + } + if gotPlatform != "discord" { + t.Errorf("platform mismatch: got %q, want %q", gotPlatform, "discord") + } +} + +func TestEncodeNConnection_NoRelaysNoPlatform(t *testing.T) { + key := ConnectionKey("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") + + encoded, err := EncodeNConnection(key, nil, "") + if err != nil { + t.Fatalf("EncodeNConnection failed: %v", err) + } + gotKey, gotRelays, gotPlatform, err := DecodeNConnection(encoded) + if err != nil { + t.Fatalf("DecodeNConnection failed: %v", err) + } + if gotKey != key { + t.Errorf("key mismatch: got %q, want %q", gotKey, key) + } + if len(gotRelays) != 0 { + t.Errorf("expected 0 relays, got %d", len(gotRelays)) + } + if gotPlatform != "" { + t.Errorf("expected empty platform, got %q", gotPlatform) + } +} + +func TestEncodeNConnection_ArbitraryPlatformNames(t *testing.T) { + // No predefined platform set — anything round-trips, including ones this + // package has never heard of. + key := ConnectionKey("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") + for _, platform := range []WebIdentity{"discord", "telegram", "mastodon", "some-future-platform"} { + encoded, err := EncodeNConnection(key, nil, platform) + if err != nil { + t.Fatalf("EncodeNConnection failed for %s: %v", platform, err) + } + _, _, gotPlatform, err := DecodeNConnection(encoded) + if err != nil { + t.Fatalf("DecodeNConnection failed for %s: %v", platform, err) + } + if gotPlatform != platform { + t.Errorf("platform mismatch for %s: got %q", platform, gotPlatform) + } + } +} + +func TestEncodeNConnection_InvalidKeyLength(t *testing.T) { + if _, err := EncodeNConnection(ConnectionKey("abcd"), nil, ""); err == nil { + t.Error("expected error for short key") + } +} + +func TestDecodeNConnection_WrongPrefix(t *testing.T) { + key := ConnectionKey("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") + encoded, _ := EncodeNConnection(key, nil, "") + + tampered := "npub1" + encoded[len(NConnectionPrefix)+1:] + if _, _, _, err := DecodeNConnection(tampered); err == nil { + t.Error("expected error for wrong prefix") + } +} + +func TestDecodeNConnection_InvalidBech32(t *testing.T) { + if _, _, _, err := DecodeNConnection("not-bech32-at-all!!"); err == nil { + t.Error("expected error for invalid bech32") + } +} diff --git a/nipIC/nipIC.go b/nipIC/nipIC.go new file mode 100644 index 0000000..4146724 --- /dev/null +++ b/nipIC/nipIC.go @@ -0,0 +1,241 @@ +// Package nipIC implements NIP-IC (Identity Connection): binding a Nostr +// public key to an account on a non-Nostr platform, witnessed by a +// permissionless network of Identity Authorities (IAs). It defines Kind +// 35521 (Identity Connection) and Kind 35522 (IA Attestation), plus the +// supporting ConnectionKey / npv1 challenge-token / nconnection primitives +// NIP-AZ (github.com/ohstr/nmilat/nipAZ) uses to address a recipient who has +// no Nostr keypair yet. +// +// This package defines no platform or chain names of its own — see the +// WebIdentity doc comment. A caller (or an application built on this SDK) +// supplies whatever platform string its own deployment uses. +package nipIC + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + + "github.com/flokiorg/go-flokicoin/chainutil/bech32" +) + +const ( + // KindIdentityConnection is the parameterized-replaceable event a user + // publishes to claim a connection between a ConnectionKey and their + // Nostr pubkey (Kind 35521). + KindIdentityConnection = 35521 + + // KindAttestation is the parameterized-replaceable event an Identity + // Authority publishes to witness a Kind 35521 claim (Kind 35522). + KindAttestation = 35522 + + // KindAttestationRevocation is the standard NIP-09 deletion kind, used + // here specifically to revoke a previously published Kind 35522. + KindAttestationRevocation = 5 +) + +// Wire tag names, exported so callers building custom tooling around raw +// events don't have to hardcode these strings themselves. +const ( + TagDTag = "d" + TagRecipient = "p" + TagPlatform = "platform" + TagEvidence = "evidence" + TagExpiration = "expiration" + TagEventRef = "e" +) + +var ( + // ErrInvalidTag is returned for a structurally malformed tag (wrong + // element count, empty required value, etc.). + ErrInvalidTag = errors.New("nipIC: invalid tag") + // ErrMissingTag is returned when a required tag is absent entirely. + ErrMissingTag = errors.New("nipIC: missing required tag") + // ErrPlatformPrefixed is returned when a #d tag carries a "platform:" + // prefix — NIP-IC requires the bare ConnectionKey so #d relay filters + // from third-party clients keep working. + ErrPlatformPrefixed = errors.New("nipIC: #d tag must be a bare ConnectionKey, not platform-prefixed") + // ErrWrongKind is returned when parsing an event of the wrong Nostr kind. + ErrWrongKind = errors.New("nipIC: unexpected event kind") + // ErrInvalidSignature is returned when an event's signature does not verify. + ErrInvalidSignature = errors.New("nipIC: invalid event signature") + // ErrChallengeMismatch is returned by ChallengeToken.Verify when the + // token does not match the given pubkey/pre-auth code pair. + ErrChallengeMismatch = errors.New("nipIC: challenge does not match pubkey/pre-auth code") + // ErrInvalidChallengeToken is returned for a malformed npv1 token + // (wrong bech32 prefix, corrupt TLV payload, etc.). + ErrInvalidChallengeToken = errors.New("nipIC: malformed challenge token") +) + +// WebIdentity names the platform a ConnectionKey is scoped to — e.g. an +// application chooses "discord" or "email". This package defines no +// predefined values: a different consumer of nipIC may support an entirely +// different set of platforms, and every one of them can define its own +// ConnectionKeys. An application that wants named constants for its own +// known platforms defines them itself, on top of this type. +// +// The Go zero value WebIdentity("") is the one value this package does give +// meaning to: NIP-AZ's wire rule is that an omitted or empty platform +// element on a p/P identity tag MUST be read as "native Nostr pubkey", so +// the zero value already carries that meaning for free. +type WebIdentity string + +// ConnectionKey is the deterministic hash standing in for a Nostr pubkey +// when the real recipient/sender has no keypair yet: SHA256(":"). +// Third-party clients MUST use NewConnectionKey (not their own reimplementation) +// so independently computed keys for the same (platform, externalID) pair agree. +type ConnectionKey string + +// NewConnectionKey computes the ConnectionKey for externalID on platform. +// Deterministic: the same (platform, externalID) pair always produces the +// same key. +func NewConnectionKey(platform WebIdentity, externalID string) ConnectionKey { + sum := sha256.Sum256([]byte(string(platform) + ":" + externalID)) + return ConnectionKey(hex.EncodeToString(sum[:])) +} + +// String returns the hex-encoded ConnectionKey. +func (k ConnectionKey) String() string { return string(k) } + +const ( + challengeTokenPrefix = "npv1" + tlvTypeSessionHash = byte(0) + preAuthCodeRandomBytes = 16 // 32 hex chars — this code is embedded in a publicly posted token, never hand-typed, so it favors entropy over brevity +) + +// ChallengeToken is a bech32-encoded, session-bound proof string +// (npv1-prefixed) a user publishes publicly on a web identity platform as +// evidence of control. It binds SHA256(pubkey || preAuthCode) into a TLV +// payload, so a token minted for one user/session cannot be replayed for +// another — see Verify. +type ChallengeToken string + +// NewChallenge mints a fresh challenge token for pubkeyHex, generating and +// returning the random pre-auth code that backs it (32-char hex — this code +// is embedded in the token and posted publicly by the user, not hand-typed, +// so it favors entropy over brevity; a caller whose flow instead needs a +// short, human-typeable code, e.g. "/verify ", should generate that +// code itself and call NewChallengeToken directly once bound). The token and +// pre-auth code are minted together so they can never end up mismatched or +// under-entropy; the caller must persist PreAuthCode (it's required again by +// Verify, and belongs in the eventual Evidence.PreAuthCode once the +// challenge is fulfilled). +func NewChallenge(pubkeyHex string) (token ChallengeToken, preAuthCode string, err error) { + buf := make([]byte, preAuthCodeRandomBytes) + if _, err := rand.Read(buf); err != nil { + return "", "", fmt.Errorf("nipIC: generate pre-auth code: %w", err) + } + preAuthCode = hex.EncodeToString(buf) + token, err = NewChallengeToken(pubkeyHex, preAuthCode) + if err != nil { + return "", "", err + } + return token, preAuthCode, nil +} + +// NewChallengeToken rebuilds the challenge token for a pubkey/pre-auth-code +// pair that already exists (e.g. resuming a session whose pre-auth code was +// already minted and persisted) — no fresh code is generated. +func NewChallengeToken(pubkeyHex, preAuthCode string) (ChallengeToken, error) { + sessionHash, err := sessionHash(pubkeyHex, preAuthCode) + if err != nil { + return "", err + } + + tlv := make([]byte, 0, 34) + tlv = append(tlv, tlvTypeSessionHash, byte(len(sessionHash))) + tlv = append(tlv, sessionHash...) + + bits5, err := bech32.ConvertBits(tlv, 8, 5, true) + if err != nil { + return "", fmt.Errorf("nipIC: bech32 convert: %w", err) + } + encoded, err := bech32.Encode(challengeTokenPrefix, bits5) + if err != nil { + return "", fmt.Errorf("nipIC: bech32 encode: %w", err) + } + return ChallengeToken(encoded), nil +} + +// Verify confirms t was genuinely minted for pubkeyHex bound to preAuthCode +// — i.e. that t decodes to SHA256(pubkeyHex-bytes || preAuthCode). Returns +// ErrInvalidChallengeToken if t is malformed, ErrChallengeMismatch if it +// decodes fine but doesn't match the given pubkey/pre-auth-code pair. +func (t ChallengeToken) Verify(pubkeyHex, preAuthCode string) error { + got, err := t.decode() + if err != nil { + return err + } + want, err := sessionHash(pubkeyHex, preAuthCode) + if err != nil { + return err + } + if subtle.ConstantTimeCompare(got, want) != 1 { + return ErrChallengeMismatch + } + return nil +} + +func sessionHash(pubkeyHex, preAuthCode string) ([]byte, error) { + pubkeyBytes, err := hex.DecodeString(pubkeyHex) + if err != nil { + return nil, fmt.Errorf("nipIC: decode pubkey: %w", err) + } + h := sha256.New() + h.Write(pubkeyBytes) + h.Write([]byte(preAuthCode)) + return h.Sum(nil), nil +} + +// decode unwraps the bech32/TLV envelope and returns the raw 32-byte session +// hash. Unexported: callers verify a claimed (pubkey, preAuthCode) pair via +// Verify, they never need the raw hash — see the design rubric's "no bare +// wire-format decoding at the call site" rule. +func (t ChallengeToken) decode() ([]byte, error) { + prefix, bits5, err := bech32.DecodeNoLimit(string(t)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidChallengeToken, err) + } + if prefix != challengeTokenPrefix { + return nil, fmt.Errorf("%w: expected prefix %q, got %q", ErrInvalidChallengeToken, challengeTokenPrefix, prefix) + } + data, err := bech32.ConvertBits(bits5, 5, 8, false) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidChallengeToken, err) + } + + pos := 0 + for pos+2 <= len(data) { + typ := data[pos] + length := int(data[pos+1]) + pos += 2 + if pos+length > len(data) { + break + } + if typ == tlvTypeSessionHash && length == 32 { + return data[pos : pos+length], nil + } + pos += length + } + return nil, fmt.Errorf("%w: no session-hash field found", ErrInvalidChallengeToken) +} + +// Evidence is the cleartext v1 "evidence" tag payload of a Kind 35522 +// attestation. Version and AuthType are not caller-settable fields — v1 +// evidence is always version 1, auth_type "public_post" — NewAttestation +// sets them internally so callers never have to pass constants that only +// ever have one value. +type Evidence struct { + Platform WebIdentity // duplicated from the outer "platform" tag, for self-contained evidence + UserID string // normalized platform account identifier + Username string // provider handle at verification time + VerifiedAt int64 // unix timestamp of successful verification + EvidenceURL string // URL of the public post carrying the challenge + Challenge ChallengeToken // the npv1 token published at EvidenceURL + // PreAuthCode enables cross-IA re-verification: Challenge.Verify(userPubkeyHex, PreAuthCode) + // must succeed. Required for any evidence a second IA might need to re-check. + PreAuthCode string +} diff --git a/nipIC/nipIC_test.go b/nipIC/nipIC_test.go new file mode 100644 index 0000000..8e6660b --- /dev/null +++ b/nipIC/nipIC_test.go @@ -0,0 +1,252 @@ +package nipIC + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +// ── ConnectionKey ────────────────────────────────────────────────────────── +// Ported from zapf's TestGenerateConnectionKey_Deterministic. + +func TestNewConnectionKey_Deterministic(t *testing.T) { + key1 := NewConnectionKey("discord", "123456") + key2 := NewConnectionKey("discord", "123456") + if key1 != key2 { + t.Error("NewConnectionKey should be deterministic") + } + + key3 := NewConnectionKey("discord", "999999") + if key1 == key3 { + t.Error("different external IDs should produce different keys") + } + + key4 := NewConnectionKey("x", "123456") + if key1 == key4 { + t.Error("different platforms should produce different keys") + } + + if len(key1.String()) != 64 { + t.Errorf("ConnectionKey should be 64 hex chars, got %d", len(key1.String())) + } +} + +func TestNewConnectionKey_NoPredefinedPlatforms(t *testing.T) { + // The whole point of the open-string design: a platform this package has + // never heard of works exactly the same as a "known" one. + key := NewConnectionKey("mastodon", "@alice@example.social") + if key.String() == "" { + t.Error("expected a non-empty key for an arbitrary platform string") + } + if key != NewConnectionKey("mastodon", "@alice@example.social") { + t.Error("expected determinism for an arbitrary platform string too") + } +} + +// ── ChallengeToken ────────────────────────────────────────────────────────── +// Ported from zapf's pkg/nostr/challenge_token_test.go, adapted to the new +// public-contract API (NewChallenge/NewChallengeToken/Verify instead of raw +// Generate/Decode). + +const ( + testPubkeyHex = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + altPubkeyHex = "112233445566778899001122334455667788990011223344556677889900112233" + testPreAuthCode = "abc123preauth" + altPreAuthCode = "xyz789preauth" +) + +func TestNewChallengeToken_HasNpv1Prefix(t *testing.T) { + token, err := NewChallengeToken(testPubkeyHex, testPreAuthCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.HasPrefix(string(token), "npv1") { + t.Errorf("expected npv1 prefix, got: %s", token) + } +} + +func TestNewChallengeToken_DifferentPubkeysProduceDifferentTokens(t *testing.T) { + t1, err := NewChallengeToken(testPubkeyHex, testPreAuthCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t2, err := NewChallengeToken(altPubkeyHex, testPreAuthCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if t1 == t2 { + t.Error("different pubkeys must produce different tokens") + } +} + +func TestNewChallengeToken_Deterministic(t *testing.T) { + t1, _ := NewChallengeToken(testPubkeyHex, testPreAuthCode) + t2, _ := NewChallengeToken(testPubkeyHex, testPreAuthCode) + if t1 != t2 { + t.Error("same inputs must produce identical tokens") + } +} + +func TestNewChallengeToken_DifferentPreAuthCodeProducesDifferentToken(t *testing.T) { + t1, _ := NewChallengeToken(testPubkeyHex, testPreAuthCode) + t2, _ := NewChallengeToken(testPubkeyHex, altPreAuthCode) + if t1 == t2 { + t.Error("different pre-auth codes must produce different tokens") + } +} + +func TestNewChallengeToken_ReasonableLength(t *testing.T) { + token, _ := NewChallengeToken(testPubkeyHex, testPreAuthCode) + // bech32 of 34 TLV bytes ≈ 68 chars including prefix — fits in a + // Twitter/GitHub bio (160 chars). + if len(token) > 80 { + t.Errorf("token must fit in a bio field, got len=%d: %s", len(token), token) + } +} + +func TestNewChallengeToken_InvalidPubkeyHex(t *testing.T) { + if _, err := NewChallengeToken("not-hex!!", testPreAuthCode); err == nil { + t.Error("expected error for invalid pubkey hex") + } +} + +// ── Verify (the primitive that never had a real caller before this package) ─ + +func TestChallengeToken_Verify_Success(t *testing.T) { + token, err := NewChallengeToken(testPubkeyHex, testPreAuthCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := token.Verify(testPubkeyHex, testPreAuthCode); err != nil { + t.Errorf("expected verification to succeed, got: %v", err) + } +} + +func TestChallengeToken_Verify_WrongPubkeyFails(t *testing.T) { + token, _ := NewChallengeToken(testPubkeyHex, testPreAuthCode) + if err := token.Verify(altPubkeyHex, testPreAuthCode); err == nil { + t.Error("expected verification to fail for a different pubkey") + } +} + +func TestChallengeToken_Verify_WrongPreAuthCodeFails(t *testing.T) { + token, _ := NewChallengeToken(testPubkeyHex, testPreAuthCode) + if err := token.Verify(testPubkeyHex, altPreAuthCode); err == nil { + t.Error("expected verification to fail for a different pre-auth code") + } +} + +func TestChallengeToken_Verify_MalformedTokenFails(t *testing.T) { + if err := ChallengeToken("not-a-token-at-all").Verify(testPubkeyHex, testPreAuthCode); err == nil { + t.Error("expected verification to fail for a malformed token") + } +} + +func TestChallengeToken_Verify_WrongPrefixFails(t *testing.T) { + if err := ChallengeToken("nprofile1qqsxxx").Verify(testPubkeyHex, testPreAuthCode); err == nil { + t.Error("expected verification to fail for a non-npv1 bech32 string") + } +} + +// Security properties — ported from challenge_token_test.go's replay-resistance +// section, expressed against Verify (the actual attack surface: an IA calling +// Verify on an evidence payload) instead of raw token equality. + +func TestChallengeToken_AttackerCannotReplayAcrossSessions(t *testing.T) { + attackerCode := "attacker-session-001" + victimCode := "victim-session-002" + + victimToken, err := NewChallengeToken(testPubkeyHex, victimCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Attacker took a token minted for their own session and tries to pass + // it off as proof for the victim's session (same pubkey, different code). + attackerToken, _ := NewChallengeToken(testPubkeyHex, attackerCode) + if err := attackerToken.Verify(testPubkeyHex, victimCode); err == nil { + t.Error("attacker's token must not verify against the victim's session pre-auth code") + } + if attackerToken == victimToken { + t.Error("tokens for different sessions must differ") + } +} + +func TestChallengeToken_AttackerCannotForgeVictimPubkey(t *testing.T) { + // Attacker has their own valid token but tries to claim it proves + // control of the victim's pubkey. + attackerToken, _ := NewChallengeToken(altPubkeyHex, testPreAuthCode) + if err := attackerToken.Verify(testPubkeyHex, testPreAuthCode); err == nil { + t.Error("a token minted for one pubkey must not verify against a different pubkey") + } +} + +// ── NewChallenge (mint token + pre-auth code together) ────────────────────── + +func TestNewChallenge_ReturnsVerifiableToken(t *testing.T) { + token, preAuthCode, err := NewChallenge(testPubkeyHex) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if preAuthCode == "" { + t.Fatal("expected a non-empty pre-auth code") + } + if len(preAuthCode) != 32 { + t.Errorf("expected a 32-char hex pre-auth code, got %d chars: %q", len(preAuthCode), preAuthCode) + } + if err := token.Verify(testPubkeyHex, preAuthCode); err != nil { + t.Errorf("expected the minted token to verify against its own pre-auth code, got: %v", err) + } +} + +func TestNewChallenge_DifferentCallsProduceDifferentCodes(t *testing.T) { + _, code1, err := NewChallenge(testPubkeyHex) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + _, code2, err := NewChallenge(testPubkeyHex) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code1 == code2 { + t.Error("expected fresh randomness on each call, got identical pre-auth codes") + } +} + +func TestNewChallenge_InvalidPubkeyHex(t *testing.T) { + if _, _, err := NewChallenge("not-hex!!"); err == nil { + t.Error("expected error for invalid pubkey hex") + } +} + +// ── White-box: confirm the TLV really carries SHA256(pubkey||preAuthCode) ─── +// (same-package test, exercising the unexported decode() the way +// challenge_token_test.go's TestGenerateChallengeToken_HashMatchesPubkeyAndPreAuthCode did.) + +func TestChallengeToken_decode_MatchesRawHash(t *testing.T) { + token, err := NewChallengeToken(testPubkeyHex, testPreAuthCode) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, err := token.decode() + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if len(got) != 32 { + t.Fatalf("expected a 32-byte session hash, got %d bytes", len(got)) + } + + pubkeyBytes, err := hex.DecodeString(testPubkeyHex) + if err != nil { + t.Fatalf("test setup: %v", err) + } + h := sha256.New() + h.Write(pubkeyBytes) + h.Write([]byte(testPreAuthCode)) + want := h.Sum(nil) + + if string(got) != string(want) { + t.Error("decoded session hash must equal SHA256(pubkey || preAuthCode)") + } +} diff --git a/relay/store_zaps.go b/relay/store_zaps.go index eb6a889..181fa88 100644 --- a/relay/store_zaps.go +++ b/relay/store_zaps.go @@ -140,7 +140,7 @@ func parseZapEvent(event *nip01.Event) (uint64, string, error) { // 1. Extract Receiver: collect p[1]/p[2] and r[1] in one pass. // p[1] may be a Nostr pubkey or a ConnectionKey (both are valid 64-hex). // p[2] is the provider ("nostr", "discord", "telegram", …); absent means "nostr". - // r[1] is the resolved Nostr pubkey when p[1] is a ConnectionKey (LIDP-linked). + // r[1] is the resolved Nostr pubkey when p[1] is a ConnectionKey (web-identity-linked). var pVal, pProvider, rVal string for _, tag := range event.Tags { if len(tag) < 2 { @@ -168,14 +168,14 @@ func parseZapEvent(event *nip01.Event) (uint64, string, error) { var receiver string switch { case utils.Validate32Key(rVal) == nil: - // r tag present and valid → use resolved Nostr pubkey (LIDP-linked, with or without p[2]) + // r tag present and valid → use resolved Nostr pubkey (web-identity-linked, with or without p[2]) receiver = rVal case pProvider == "" || pProvider == "nostr": - // no r tag, no LIDP marker → p[1] is a pure Nostr pubkey + // no r tag, no web identity marker → p[1] is a pure Nostr pubkey receiver = pVal default: - // LIDP (Lightning Identity/Address Provider) recipient with no resolved - // Nostr pubkey in the "r" tag → exclude from leaderboard, since there's no + // Web Identity (non-Nostr platform) recipient with no resolved Nostr + // pubkey in the "r" tag → exclude from leaderboard, since there's no // Nostr identity to credit. return 0, "", fmt.Errorf("p tag references a non-nostr provider (%q) but no resolved pubkey was found in an r tag", pProvider) } diff --git a/relay/store_zaps_test.go b/relay/store_zaps_test.go index e122ad1..b4c255c 100644 --- a/relay/store_zaps_test.go +++ b/relay/store_zaps_test.go @@ -15,8 +15,8 @@ import ( bolt "go.etcd.io/bbolt" ) -func TestZapCacheLIDPScenarios(t *testing.T) { - tmpFile, err := os.CreateTemp("", "nmilat_zap_lidp_test_*.db") +func TestZapCacheWebIdentityScenarios(t *testing.T) { + tmpFile, err := os.CreateTemp("", "nmilat_zap_web_identity_test_*.db") require.NoError(t, err) defer os.Remove(tmpFile.Name()) tmpFile.Close() @@ -26,10 +26,10 @@ func TestZapCacheLIDPScenarios(t *testing.T) { defer store.Close() const ( - // ConnectionKeys are SHA256(lidp:userID) — valid 64-hex, not Nostr pubkeys + // ConnectionKeys are SHA256(platform:userID) — valid 64-hex, not Nostr pubkeys ConnKeyDiscord = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ConnKeyTelegram = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - // Resolved Nostr pubkeys for the LIDP-linked users + // Resolved Nostr pubkeys for the web-identity-linked users ResolvedDiscord = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" ResolvedTelegram = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" // A pure Nostr user who also receives a direct zap (same as ResolvedDiscord, to test merging) @@ -40,7 +40,7 @@ func TestZapCacheLIDPScenarios(t *testing.T) { ts := uint64(now.Add(-1 * time.Hour).Unix()) events := []*nip01.Event{ - // 1. LIDP-linked with p[2] set and r tag → indexed under ResolvedDiscord + // 1. Web-identity-linked with p[2] set and r tag → indexed under ResolvedDiscord { Kind: 5521, CreatedAt: ts, @@ -50,7 +50,7 @@ func TestZapCacheLIDPScenarios(t *testing.T) { {"amount", "1000"}, }, }, - // 2. LIDP-linked without p[2] but r tag present → indexed under ResolvedTelegram + // 2. Web-identity-linked without p[2] but r tag present → indexed under ResolvedTelegram { Kind: 5521, CreatedAt: ts, @@ -60,7 +60,7 @@ func TestZapCacheLIDPScenarios(t *testing.T) { {"amount", "2000"}, }, }, - // 3. LIDP-unlinked: p[2] = "discord", no r tag → NOT indexed + // 3. Web-identity-unlinked: p[2] = "discord", no r tag → NOT indexed { Kind: 5521, CreatedAt: ts, @@ -93,7 +93,7 @@ func TestZapCacheLIDPScenarios(t *testing.T) { count, err := store.ReindexZaps(context.Background(), nil) require.NoError(t, err) // ReindexZaps counts all Kind 5521 events processed (4), regardless of whether - // they were stored — LIDP-unlinked zaps are silently skipped by IndexZap. + // they were stored — web-identity-unlinked zaps are silently skipped by IndexZap. assert.Equal(t, 4, count) until := uint64(now.Unix()) @@ -102,14 +102,14 @@ func TestZapCacheLIDPScenarios(t *testing.T) { require.NoError(t, err) // ResolvedTelegram: 2000 - // NostrUser (=ResolvedDiscord): 1000 (LIDP-linked) + 500 (direct) = 1500 - require.Len(t, stats, 2, "LIDP-unlinked entry must not appear") + // NostrUser (=ResolvedDiscord): 1000 (web-identity-linked) + 500 (direct) = 1500 + require.Len(t, stats, 2, "web-identity-unlinked entry must not appear") assert.Equal(t, ResolvedTelegram, stats[0].Pubkey) assert.Equal(t, uint64(2000), stats[0].TotalMLoki) assert.Equal(t, NostrUser, stats[1].Pubkey) - assert.Equal(t, uint64(1500), stats[1].TotalMLoki, "LIDP-linked and direct zap amounts must merge") + assert.Equal(t, uint64(1500), stats[1].TotalMLoki, "web-identity-linked and direct zap amounts must merge") } func TestZapCacheScenarios(t *testing.T) {