Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ privacy and performance fixes.

### Added

- Adds `MaxAmountMillis` to `nipcw.CreateCircleWalletResponse`, the spend cap
as the Hub resolved it, and the refusal codes `nipcw.ErrNotReady`,
`ErrRestricted`, `ErrWalletExists` and `ErrRateLimited`, as NIP-CW now specifies.
([#123](https://github.com/ohstr/nmilat/pull/123))
- Adds `nipAZ.ValidateAltZapRequestForRelay`/`ValidateAltZapReceiptForRelay`,
which `nipAZ/relayreg` now uses: relays keep receipts for real payments and
reject only forged AltZap events, as NIP-AZ specifies.
Expand Down Expand Up @@ -134,6 +138,11 @@ privacy and performance fixes.

### Changed

- Changes the NIP-CASH params hash (`transport.CanonicalParamsHash`) to write
strings as RFC 8785 does, so `<`, `>`, `&`, U+2028 and U+2029 are no longer
escaped. Params containing them hash differently from earlier releases, so
clients and Hubs must upgrade together.
([#123](https://github.com/ohstr/nmilat/pull/123))
- Moves `nipAZ` to NIP-AZ's new kinds: 9520 requests, 9521 receipts, 9523
on-behalf requests. Events of the old kinds 5520, 5521 and 5523 are no longer
parsed, validated or indexed. ([#114](https://github.com/ohstr/nmilat/pull/114))
Expand Down
65 changes: 65 additions & 0 deletions nipcash/transport/canonical_jcs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package transport

import (
"encoding/json"
"testing"
)

// NIP-CASH §Item Proofs: canonical params serialize strings as RFC 8785 does.
// The input spells every string escaped, and in a different key order, so only
// the canonicaliser decides the output. The expected hash was computed outside
// this package: printf of the expected bytes piped to sha256sum.
func TestCanonicalParams_RFC8785Strings(t *testing.T) {
in := json.RawMessage(`{
"z": "<>&",
"q": "\"\\\b\t\n\f\r\u0001\u001f\u007f é",
"n": 12345678901234567890,
"arr": [1.50, true, null],
"a": "x
y
z"
}`)
want := `{"a":"x` + "
" + `y` + "
" + `z","arr":[1.50,true,null],"n":12345678901234567890,` +
`"q":"\"\\\b\t\n\f\r\u0001\u001f` + "\x7f" + ` é","z":"<>&"}`
const wantHash = "069de3ad539b89d0ab1afbbce26f1e49fc4afc0f5ddb4bb75342cdd22b823f7b"

got, err := canonicalJSON(in)
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Fatalf("canonical form:\n got %q\nwant %q", got, want)
}
hash, err := CanonicalParamsHash(in)
if err != nil {
t.Fatal(err)
}
if hash != wantHash {
t.Fatalf("ph = %s, want %s", hash, wantHash)
}
}

// Each character the spec names on its own, so a regression points at one.
func TestCanonicalParams_CharactersWrittenLiterally(t *testing.T) {
for name, tc := range map[string]struct{ in, want string }{
"less-than": {`{"s":"<"}`, `{"s":"<"}`},
"greater": {`{"s":">"}`, `{"s":">"}`},
"ampersand": {`{"s":"&"}`, `{"s":"&"}`},
"U+2028": {`{"s":"
"}`, "{\"s\":\"
\"}"},
"U+2029": {`{"s":"
"}`, "{\"s\":\"
\"}"},
"non-ASCII": {`{"s":"é😀"}`, "{\"s\":\"é\U0001F600\"}"},
"slash": {`{"s":"\/"}`, `{"s":"/"}`},
"key escaped": {`{"<k":1}`, `{"<k":1}`},
"backspace": {`{"s":"\u0008"}`, `{"s":"\b"}`},
"formfeed": {`{"s":"\u000c"}`, `{"s":"\f"}`},
"other ctrl": {`{"s":"\u000b"}`, `{"s":"\u000b"}`},
} {
t.Run(name, func(t *testing.T) {
got, err := canonicalJSON(json.RawMessage(tc.in))
if err != nil {
t.Fatal(err)
}
if string(got) != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
101 changes: 96 additions & 5 deletions nipcash/transport/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
)
Expand Down Expand Up @@ -184,7 +185,8 @@ func NewNonce() (string, error) {

// CanonicalParamsHash returns the sha256, hex-encoded, of an item's params in a
// canonical form: object keys sorted, insignificant whitespace removed, numbers
// left exactly as written.
// left exactly as written, strings serialized as RFC 8785 does (NIP-CASH §Item
// Proofs).
//
// Canonicalising rather than hashing the received bytes is deliberate. The bytes
// do travel verbatim inside the ciphertext today, so hashing them raw would work
Expand Down Expand Up @@ -217,12 +219,101 @@ func canonicalJSON(raw json.RawMessage) ([]byte, error) {
if dec.More() {
return nil, fmt.Errorf("%w: params carry trailing content", ErrEnvelopeMalformed)
}
// json.Marshal sorts map keys, which is what makes this canonical.
out, err := json.Marshal(value)
if err != nil {
var out bytes.Buffer
if err := writeCanonical(&out, value); err != nil {
return nil, fmt.Errorf("transport: canonicalise params: %w", err)
}
return out, nil
return out.Bytes(), nil
}

// writeCanonical writes v in NIP-CASH's canonical form: keys sorted, no
// insignificant whitespace, numbers verbatim, strings as RFC 8785 writes them.
// Not json.Marshal: it escapes <, >, &, U+2028 and U+2029, so its hash differs
// from any implementation following the spec.
func writeCanonical(buf *bytes.Buffer, v any) error {
switch v := v.(type) {
case nil:
buf.WriteString("null")
case bool:
if v {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
case json.Number:
buf.WriteString(v.String())
case string:
writeCanonicalString(buf, v)
case []any:
buf.WriteByte('[')
for i, elem := range v {
if i > 0 {
buf.WriteByte(',')
}
if err := writeCanonical(buf, elem); err != nil {
return err
}
}
buf.WriteByte(']')
case map[string]any:
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
writeCanonicalString(buf, k)
buf.WriteByte(':')
if err := writeCanonical(buf, v[k]); err != nil {
return err
}
}
buf.WriteByte('}')
default:
return fmt.Errorf("unexpected JSON value of type %T", v)
}
return nil
}

// writeCanonicalString writes s as RFC 8785 does: only ", \ and control
// characters below U+0020 are escaped, as \b \t \n \f \r or else lowercase
// \u00XX; every other character is literal UTF-8. The decoder has already
// replaced invalid UTF-8, so s is valid.
func writeCanonicalString(buf *bytes.Buffer, s string) {
const hexDigits = "0123456789abcdef"
buf.WriteByte('"')
for i := 0; i < len(s); i++ {
c := s[i]
switch c {
case '"':
buf.WriteString(`\"`)
case '\\':
buf.WriteString(`\\`)
case '\b':
buf.WriteString(`\b`)
case '\t':
buf.WriteString(`\t`)
case '\n':
buf.WriteString(`\n`)
case '\f':
buf.WriteString(`\f`)
case '\r':
buf.WriteString(`\r`)
default:
if c < 0x20 {
buf.WriteString(`\u00`)
buf.WriteByte(hexDigits[c>>4])
buf.WriteByte(hexDigits[c&0xf])
} else {
buf.WriteByte(c)
}
}
}
buf.WriteByte('"')
}

// VerificationCost reports how many signature verifications this item will demand
Expand Down
36 changes: 21 additions & 15 deletions nipcw/create_circle_wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,21 +82,26 @@ type createCircleWalletResponseWire struct {
// circleWalletDetailsWire is the plaintext inside EncryptedDetails — the
// joining member's own wallet identity and terms, readable only by them.
type circleWalletDetailsWire struct {
PairingURI string `json:"pairing_uri"`
WalletPubkey string `json:"wallet_pubkey"`
ExpiresAt int64 `json:"expires_at"`
FeesPpm int `json:"fees_ppm"`
BudgetRenewal string `json:"budget_renewal"`
PairingURI string `json:"pairing_uri"`
WalletPubkey string `json:"wallet_pubkey"`
MaxAmount *uint64 `json:"max_amount,omitempty"`
ExpiresAt int64 `json:"expires_at"`
FeesPpm int `json:"fees_ppm"`
BudgetRenewal string `json:"budget_renewal"`
}

// CreateCircleWalletResponse is create_circle_wallet's response, with
// PairingURI already decrypted — connect straight from it.
type CreateCircleWalletResponse struct {
PairingURI string
WalletPubkey string
ExpiresAt int64
FeesPpm int
BudgetRenewal string
PairingURI string
WalletPubkey string
// MaxAmountMillis is the spend cap as the Hub resolved it, which may differ
// from the requested one (NIP-CW §Response). Nil when the Hub did not say,
// so a missing cap never reads as a cap of zero.
MaxAmountMillis *uint64
ExpiresAt int64
FeesPpm int
BudgetRenewal string
}

// ParseResult parses create_circle_wallet's wire response, decrypting
Expand Down Expand Up @@ -133,11 +138,12 @@ func (p CreateCircleWalletParams) ParseResult(hubPubkey string, data []byte) (*C
return nil, fmt.Errorf("nipcw: parse wallet details: %w", err)
}
return &CreateCircleWalletResponse{
PairingURI: details.PairingURI,
WalletPubkey: details.WalletPubkey,
ExpiresAt: details.ExpiresAt,
FeesPpm: details.FeesPpm,
BudgetRenewal: details.BudgetRenewal,
PairingURI: details.PairingURI,
WalletPubkey: details.WalletPubkey,
MaxAmountMillis: details.MaxAmount,
ExpiresAt: details.ExpiresAt,
FeesPpm: details.FeesPpm,
BudgetRenewal: details.BudgetRenewal,
}, nil
}

Expand Down
36 changes: 36 additions & 0 deletions nipcw/create_circle_wallet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,42 @@ func TestCreateCircleWalletParams_ParseResult_DecryptsDetails(t *testing.T) {
if resp.WalletPubkey != "walletpubkey" || resp.ExpiresAt != 1234 || resp.BudgetRenewal != "never" {
t.Fatalf("resp: %+v", resp)
}
if resp.MaxAmountMillis != nil {
t.Fatalf("MaxAmountMillis = %d for a Hub that did not send max_amount, want nil", *resp.MaxAmountMillis)
}
}

// NIP-CW §Response: max_amount is the cap as the Hub resolved it, which can differ
// from the request. Built from the wire text, so a dropped or renamed field shows.
func TestCreateCircleWalletParams_ParseResult_CarriesResolvedMaxAmount(t *testing.T) {
memberPrivHex, memberPubHex := generateTestKeypair(t)
hubPrivHex, hubPubHex := generateTestKeypair(t)
details := `{"pairing_uri":"` + testPairingURI + `","wallet_pubkey":"walletpubkey","max_amount":99000,` +
`"expires_at":1234,"fees_ppm":0,"budget_renewal":"monthly"}`
raw, err := json.Marshal(createCircleWalletResponseWire{EncryptedDetails: encryptForTest(t, hubPrivHex, memberPubHex, details)})
if err != nil {
t.Fatal(err)
}
p := CreateCircleWalletParams{Credential: BySigning(memberPrivHex), MaxAmountMillis: 100000}
resp, err := p.ParseResult(hubPubHex, raw)
if err != nil {
t.Fatalf("ParseResult: %v", err)
}
if resp.MaxAmountMillis == nil || *resp.MaxAmountMillis != 99000 {
t.Fatalf("MaxAmountMillis = %v, want the Hub's resolved 99000", resp.MaxAmountMillis)
}
}

// NIP-CW §Errors: the four refusal codes, spelled as the spec spells them.
func TestCreateCircleWalletRefusalCodes(t *testing.T) {
for got, want := range map[string]string{
ErrNotReady: "NOT_READY", ErrRestricted: "RESTRICTED",
ErrWalletExists: "WALLET_EXISTS", ErrRateLimited: "RATE_LIMITED",
} {
if got != want {
t.Errorf("code %q, want %q", got, want)
}
}
}

// TestCreateCircleWalletParams_ParseResult_NoDetailsIsAnError pins that a
Expand Down
16 changes: 16 additions & 0 deletions nipcw/nipcw.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// the full spec this package implements.
package nipcw

import "github.com/ohstr/nmilat/nip47"

// KindCircleIdentityProof is NIP-CW's own per-call identity proof kind —
// deliberately its own number, not NIP-CASH's 23198 (a structurally
// similar but distinct NIP's proof), and not NIP-IC's 35521 (a long-lived,
Expand All @@ -27,3 +29,17 @@ const KindCircleIdentityProof = 23199
// MethodCreateCircleWallet is the "method" field of a create_circle_wallet
// NIP-47 request.
const MethodCreateCircleWallet = "create_circle_wallet"

// NIP-47 error codes a Hub MUST use to refuse create_circle_wallet (NIP-CW
// §Errors), exported so Hubs and clients share one spelling.
const (
// ErrNotReady: the Hub cannot decide membership yet. Retry later.
ErrNotReady = "NOT_READY"
// ErrRestricted: the requester is not a member of this circle.
ErrRestricted = nip47.ErrRestricted
// ErrWalletExists: the requester already holds an active Circle Wallet
// under this Hub, until it is deleted.
ErrWalletExists = "WALLET_EXISTS"
// ErrRateLimited: the requester has asked too often. Retry later.
ErrRateLimited = nip47.ErrRateLimited
)
Loading