A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

Changed files
+185 -91
internal
+4 -1
internal/atproto/auth/jwt.go
···
// Claims represents the standard JWT claims we care about
type Claims struct {
jwt.RegisteredClaims
-
Scope string `json:"scope,omitempty"`
+
// Confirmation claim for DPoP token binding (RFC 9449)
+
// Contains "jkt" (JWK thumbprint) when token is bound to a DPoP key
+
Confirmation map[string]interface{} `json:"cnf,omitempty"`
+
Scope string `json:"scope,omitempty"`
}
// stripBearerPrefix removes the "Bearer " prefix from a token string
+32 -73
internal/atproto/auth/jwt_test.go
···
import (
"context"
-
"os"
"testing"
"time"
···
issuer := "https://pds.coves.social"
ResetJWTConfigForTesting()
-
os.Setenv("PDS_JWT_SECRET", secret)
-
os.Setenv("HS256_ISSUERS", issuer)
-
defer func() {
-
os.Unsetenv("PDS_JWT_SECRET")
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("PDS_JWT_SECRET", secret)
+
t.Setenv("HS256_ISSUERS", issuer)
+
t.Cleanup(ResetJWTConfigForTesting)
tokenString := createHS256Token(t, "did:plc:test123", issuer, secret, 1*time.Hour)
···
issuer := "https://pds.coves.social"
ResetJWTConfigForTesting()
-
os.Setenv("PDS_JWT_SECRET", "correct-secret")
-
os.Setenv("HS256_ISSUERS", issuer)
-
defer func() {
-
os.Unsetenv("PDS_JWT_SECRET")
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("PDS_JWT_SECRET", "correct-secret")
+
t.Setenv("HS256_ISSUERS", issuer)
+
t.Cleanup(ResetJWTConfigForTesting)
// Create token with wrong secret
tokenString := createHS256Token(t, "did:plc:test123", issuer, "wrong-secret", 1*time.Hour)
···
issuer := "https://pds.coves.social"
ResetJWTConfigForTesting()
-
os.Unsetenv("PDS_JWT_SECRET") // Ensure secret is not set
-
os.Setenv("HS256_ISSUERS", issuer)
-
defer func() {
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("PDS_JWT_SECRET", "") // Ensure secret is not set (empty = not configured)
+
t.Setenv("HS256_ISSUERS", issuer)
+
t.Cleanup(ResetJWTConfigForTesting)
tokenString := createHS256Token(t, "did:plc:test123", issuer, "any-secret", 1*time.Hour)
···
// An attacker tries to use HS256 with an issuer that should use RS256/ES256
ResetJWTConfigForTesting()
-
os.Setenv("PDS_JWT_SECRET", "some-secret")
-
os.Setenv("HS256_ISSUERS", "https://trusted.example.com") // Different from token issuer
-
defer func() {
-
os.Unsetenv("PDS_JWT_SECRET")
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("PDS_JWT_SECRET", "some-secret")
+
t.Setenv("HS256_ISSUERS", "https://trusted.example.com") // Different from token issuer
+
t.Cleanup(ResetJWTConfigForTesting)
// Create HS256 token with non-whitelisted issuer (simulating attack)
tokenString := createHS256Token(t, "did:plc:attacker", "https://victim-pds.example.com", "some-secret", 1*time.Hour)
···
// SECURITY TEST: When no issuers are whitelisted for HS256, all HS256 tokens should be rejected
ResetJWTConfigForTesting()
-
os.Setenv("PDS_JWT_SECRET", "some-secret")
-
os.Unsetenv("HS256_ISSUERS") // Empty whitelist
-
defer func() {
-
os.Unsetenv("PDS_JWT_SECRET")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("PDS_JWT_SECRET", "some-secret")
+
t.Setenv("HS256_ISSUERS", "") // Empty whitelist
+
t.Cleanup(ResetJWTConfigForTesting)
tokenString := createHS256Token(t, "did:plc:test123", "https://any-pds.example.com", "some-secret", 1*time.Hour)
···
issuer := "https://pds.coves.social"
ResetJWTConfigForTesting()
-
os.Setenv("PDS_JWT_SECRET", "test-secret")
-
os.Setenv("HS256_ISSUERS", issuer)
-
defer func() {
-
os.Unsetenv("PDS_JWT_SECRET")
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("PDS_JWT_SECRET", "test-secret")
+
t.Setenv("HS256_ISSUERS", issuer)
+
t.Cleanup(ResetJWTConfigForTesting)
// Create RS256-signed token (can't actually sign without RSA key, but we can test the header check)
claims := &Claims{
···
func TestIsHS256IssuerWhitelisted_Whitelisted(t *testing.T) {
ResetJWTConfigForTesting()
-
os.Setenv("HS256_ISSUERS", "https://pds1.example.com,https://pds2.example.com")
-
defer func() {
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("HS256_ISSUERS", "https://pds1.example.com,https://pds2.example.com")
+
t.Cleanup(ResetJWTConfigForTesting)
if !isHS256IssuerWhitelisted("https://pds1.example.com") {
t.Error("Expected pds1 to be whitelisted")
···
func TestIsHS256IssuerWhitelisted_NotWhitelisted(t *testing.T) {
ResetJWTConfigForTesting()
-
os.Setenv("HS256_ISSUERS", "https://pds1.example.com")
-
defer func() {
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("HS256_ISSUERS", "https://pds1.example.com")
+
t.Cleanup(ResetJWTConfigForTesting)
if isHS256IssuerWhitelisted("https://attacker.example.com") {
t.Error("Expected non-whitelisted issuer to return false")
···
func TestIsHS256IssuerWhitelisted_EmptyWhitelist(t *testing.T) {
ResetJWTConfigForTesting()
-
os.Unsetenv("HS256_ISSUERS")
-
defer ResetJWTConfigForTesting()
+
t.Setenv("HS256_ISSUERS", "") // Empty whitelist
+
t.Cleanup(ResetJWTConfigForTesting)
if isHS256IssuerWhitelisted("https://any.example.com") {
t.Error("Expected false when whitelist is empty (safe default)")
···
func TestIsHS256IssuerWhitelisted_WhitespaceHandling(t *testing.T) {
ResetJWTConfigForTesting()
-
os.Setenv("HS256_ISSUERS", " https://pds1.example.com , https://pds2.example.com ")
-
defer func() {
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("HS256_ISSUERS", " https://pds1.example.com , https://pds2.example.com ")
+
t.Cleanup(ResetJWTConfigForTesting)
if !isHS256IssuerWhitelisted("https://pds1.example.com") {
t.Error("Expected whitespace-trimmed issuer to be whitelisted")
···
func TestShouldUseHS256_WithKid_AlwaysFalse(t *testing.T) {
// Tokens with kid should NEVER use HS256, regardless of issuer whitelist
ResetJWTConfigForTesting()
-
os.Setenv("HS256_ISSUERS", "https://whitelisted.example.com")
-
defer func() {
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("HS256_ISSUERS", "https://whitelisted.example.com")
+
t.Cleanup(ResetJWTConfigForTesting)
header := &JWTHeader{
Alg: AlgorithmHS256,
···
func TestShouldUseHS256_WithoutKid_WhitelistedIssuer(t *testing.T) {
ResetJWTConfigForTesting()
-
os.Setenv("HS256_ISSUERS", "https://my-pds.example.com")
-
defer func() {
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("HS256_ISSUERS", "https://my-pds.example.com")
+
t.Cleanup(ResetJWTConfigForTesting)
header := &JWTHeader{
Alg: AlgorithmHS256,
···
func TestShouldUseHS256_WithoutKid_NotWhitelisted(t *testing.T) {
ResetJWTConfigForTesting()
-
os.Setenv("HS256_ISSUERS", "https://my-pds.example.com")
-
defer func() {
-
os.Unsetenv("HS256_ISSUERS")
-
ResetJWTConfigForTesting()
-
}()
+
t.Setenv("HS256_ISSUERS", "https://my-pds.example.com")
+
t.Cleanup(ResetJWTConfigForTesting)
header := &JWTHeader{
Alg: AlgorithmHS256,
+134 -2
internal/atproto/auth/README.md
···
5. Find matching key by `kid` from JWT header
6. Cache the JWKS for 1 hour
+
## DPoP Token Binding
+
+
DPoP (Demonstrating Proof-of-Possession) binds access tokens to client-controlled cryptographic keys, preventing token theft and replay attacks.
+
+
### What is DPoP?
+
+
DPoP is an OAuth extension (RFC 9449) that adds proof-of-possession semantics to bearer tokens. When a PDS issues a DPoP-bound access token:
+
+
1. Access token contains `cnf.jkt` claim (JWK thumbprint of client's public key)
+
2. Client creates a DPoP proof JWT signed with their private key
+
3. Server verifies the proof signature and checks it matches the token's `cnf.jkt`
+
+
### CRITICAL: DPoP Security Model
+
+
> โš ๏ธ **DPoP is an ADDITIONAL security layer, NOT a replacement for token signature verification.**
+
+
The correct verification order is:
+
1. **ALWAYS verify the access token signature first** (via JWKS, HS256 shared secret, or DID resolution)
+
2. **If the verified token has `cnf.jkt`, REQUIRE valid DPoP proof**
+
3. **NEVER use DPoP as a fallback when signature verification fails**
+
+
**Why This Matters**: An attacker could create a fake token with `sub: "did:plc:victim"` and their own `cnf.jkt`, then present a valid DPoP proof signed with their key. If we accept DPoP as a fallback, the attacker can impersonate any user.
+
+
### How DPoP Works
+
+
```
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ Client โ”‚ โ”‚ Server โ”‚
+
โ”‚ โ”‚ โ”‚ (Coves) โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
โ”‚ โ”‚
+
โ”‚ 1. Authorization: Bearer <token> โ”‚
+
โ”‚ DPoP: <proof-jwt> โ”‚
+
โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
+
โ”‚ โ”‚
+
โ”‚ โ”‚ 2. VERIFY token signature
+
โ”‚ โ”‚ (REQUIRED - no fallback!)
+
โ”‚ โ”‚
+
โ”‚ โ”‚ 3. If token has cnf.jkt:
+
โ”‚ โ”‚ - Verify DPoP proof
+
โ”‚ โ”‚ - Check thumbprint match
+
โ”‚ โ”‚
+
โ”‚ 200 OK โ”‚
+
โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
+
```
+
+
### When DPoP is Required
+
+
DPoP verification is **REQUIRED** when:
+
- Access token signature has been verified AND
+
- Access token contains `cnf.jkt` claim (DPoP-bound)
+
+
If the token has `cnf.jkt` but no DPoP header is present, the request is **REJECTED**.
+
+
### Replay Protection
+
+
DPoP proofs include a unique `jti` (JWT ID) claim. The server tracks seen `jti` values to prevent replay attacks:
+
+
```go
+
// Create a verifier with replay protection (default)
+
verifier := auth.NewDPoPVerifier()
+
defer verifier.Stop() // Stop cleanup goroutine on shutdown
+
+
// The verifier automatically rejects reused jti values within the proof validity window (5 minutes)
+
```
+
+
### DPoP Implementation
+
+
The `dpop.go` module provides:
+
+
```go
+
// Create a verifier with replay protection
+
verifier := auth.NewDPoPVerifier()
+
defer verifier.Stop()
+
+
// Verify the DPoP proof
+
proof, err := verifier.VerifyDPoPProof(dpopHeader, "POST", "https://coves.social/xrpc/...")
+
if err != nil {
+
// Invalid proof (includes replay detection)
+
}
+
+
// Verify it binds to the VERIFIED access token
+
expectedThumbprint, err := auth.ExtractCnfJkt(claims)
+
if err != nil {
+
// Token not DPoP-bound
+
}
+
+
if err := verifier.VerifyTokenBinding(proof, expectedThumbprint); err != nil {
+
// Proof doesn't match token
+
}
+
```
+
+
### DPoP Proof Format
+
+
The DPoP header contains a JWT with:
+
+
**Header**:
+
- `typ`: `"dpop+jwt"` (required)
+
- `alg`: `"ES256"` (or other supported algorithm)
+
- `jwk`: Client's public key (JWK format)
+
+
**Claims**:
+
- `jti`: Unique proof identifier (tracked for replay protection)
+
- `htm`: HTTP method (e.g., `"POST"`)
+
- `htu`: HTTP URI (without query/fragment)
+
- `iat`: Timestamp (must be recent, within 5 minutes)
+
+
**Example**:
+
```json
+
{
+
"typ": "dpop+jwt",
+
"alg": "ES256",
+
"jwk": {
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "...",
+
"y": "..."
+
}
+
}
+
{
+
"jti": "unique-id-123",
+
"htm": "POST",
+
"htu": "https://coves.social/xrpc/social.coves.community.create",
+
"iat": 1700000000
+
}
+
```
+
## Security Considerations
### โœ… Implemented
···
- Required claims validation (sub, iss)
- Key caching with TTL
- Secure error messages (no internal details leaked)
+
- **DPoP proof verification** (proof-of-possession for token binding)
+
- **DPoP thumbprint validation** (prevents token theft attacks)
+
- **DPoP freshness checks** (5-minute proof validity window)
+
- **DPoP replay protection** (jti tracking with in-memory cache)
+
- **Secure DPoP model** (DPoP required AFTER signature verification, never as fallback)
### โš ๏ธ Not Yet Implemented
-
- DPoP validation (for replay attack prevention)
+
- Server-issued DPoP nonces (additional replay protection)
- Scope validation (checking `scope` claim)
- Audience validation (checking `aud` claim)
- Rate limiting per DID
···
## Future Enhancements
-
- [ ] DPoP proof validation
+
- [ ] DPoP nonce validation (server-managed nonce for additional replay protection)
- [ ] Scope-based authorization
- [ ] Audience claim validation
- [ ] Token revocation support
+4 -1
.gitignore
···
# Build artifacts
/validate-lexicon
-
/bin/
+
/bin/
+
+
# Go build cache
+
.cache/
+5 -6
go.mod
···
module Coves
-
go 1.24.0
+
go 1.25
require (
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36
github.com/go-chi/chi/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
···
github.com/lestrrat-go/jwx/v2 v2.0.12
github.com/lib/pq v1.10.9
github.com/pressly/goose/v3 v3.22.1
-
github.com/stretchr/testify v1.9.0
+
github.com/stretchr/testify v1.10.0
+
github.com/xeipuuv/gojsonschema v1.2.0
golang.org/x/net v0.46.0
golang.org/x/time v0.3.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
-
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
···
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
-
github.com/stretchr/objx v0.5.2 // indirect
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
-
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
+6 -8
go.sum
···
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
-
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
-
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36 h1:Vc+l4sltxQfBT8qC3dm87PRYInmxlGyF1dmpjaW0WkU=
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
···
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
+
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
···
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
···
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=