• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

go-ap / client / #9

15 Apr 2026 07:46PM UTC coverage: 54.442% (+0.9%) from 53.561%
#9

push

sourcehut

mariusor
A final iteration of multiple tries for http signatures round-tripper

103 of 158 new or added lines in 2 files covered. (65.19%)

6 existing lines in 1 file now uncovered.

619 of 1137 relevant lines covered (54.44%)

2.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.66
/s2s/httpsignatures.go
1
package s2s
2

3
import (
4
        "bytes"
5
        "crypto"
6
        "crypto/ecdsa"
7
        "crypto/ed25519"
8
        "crypto/rand"
9
        "crypto/rsa"
10
        "crypto/x509"
11
        "encoding/base64"
12
        "encoding/pem"
13
        "fmt"
14
        "io"
15
        "net/http"
16
        "slices"
17
        "strings"
18
        "time"
19

20
        "git.sr.ht/~mariusor/lw"
21
        e "github.com/common-fate/httpsig/alg_ecdsa"
22
        ed "github.com/common-fate/httpsig/alg_ed25519"
23
        r "github.com/common-fate/httpsig/alg_rsa"
24
        "github.com/common-fate/httpsig/sigbase"
25
        "github.com/common-fate/httpsig/signature"
26
        "github.com/common-fate/httpsig/signer"
27
        "github.com/common-fate/httpsig/sigparams"
28
        "github.com/common-fate/httpsig/sigset"
29
        vocab "github.com/go-ap/activitypub"
30
        "github.com/go-ap/errors"
31
        "github.com/go-fed/httpsig"
32
)
33

34
var (
35
        nilLogger = lw.Dev(lw.SetOutput(io.Discard))
36

37
        digestAlgorithm     = httpsig.DigestSha256
38
        signatureExpiration = int64(time.Hour.Seconds())
39
)
40

41
type Transport struct {
42
        Base http.RoundTripper
43

44
        // Tag is an application-specific tag for the signature as a String value.
45
        // This value is used by applications to help identify signatures relevant for specific applications or protocols.
46
        // See: https://www.rfc-editor.org/rfc/rfc9421.html#section-2.3-4.12
47
        Tag string
48

49
        CoveredComponents []string
50
        nonceFn           func() (string, error)
51
        skipRFCSignatures bool
52

53
        Key   crypto.PrivateKey
54
        Actor *vocab.Actor
55

56
        l lw.Logger
57
}
58

59
type OptionFn func(transport *Transport) error
60

61
func WithTransport(tr http.RoundTripper) OptionFn {
2✔
62
        return func(h *Transport) error {
4✔
63
                h.Base = tr
2✔
64
                return nil
2✔
65
        }
2✔
66
}
67

68
func NoRFC9421(h *Transport) error {
2✔
69
        h.skipRFCSignatures = true
2✔
70
        return nil
2✔
71
}
2✔
72

73
func WithCoveredComponents(s ...string) OptionFn {
×
74
        return func(h *Transport) error {
×
NEW
75
                h.CoveredComponents = s
×
76
                return nil
×
77
        }
×
78
}
79

80
func WithNonce(nonceFn func() (string, error)) OptionFn {
1✔
81
        return func(h *Transport) error {
2✔
82
                h.nonceFn = nonceFn
1✔
83
                return nil
1✔
84
        }
1✔
85
}
86

87
func WithActor(act *vocab.Actor, prv crypto.PrivateKey) OptionFn {
12✔
88
        return func(h *Transport) error {
24✔
89
                h.Actor = act
12✔
90
                h.Key = prv
12✔
91

12✔
92
                return nil
12✔
93
        }
12✔
94
}
95

96
func WithLogger(l lw.Logger) OptionFn {
2✔
97
        return func(h *Transport) error {
4✔
98
                h.l = l
2✔
99
                return nil
2✔
100
        }
2✔
101
}
102

103
func WithApplicationTag(t string) OptionFn {
2✔
104
        return func(h *Transport) error {
4✔
105
                h.Tag = t
2✔
106
                return nil
2✔
107
        }
2✔
108
}
109

110
// New initializes the Transport
111
// TODO(marius): we need to add to the return values the errors
112
//  that might come from the initialization functions.
113
func New(initFns ...OptionFn) *Transport {
7✔
114
        h := new(Transport)
7✔
115
        h.CoveredComponents = FetchCoveredComponents
7✔
116
        h.nonceFn = randomNonce
7✔
117
        h.l = nilLogger
7✔
118
        for _, fn := range initFns {
17✔
119
                if err := fn(h); err != nil {
10✔
120
                        h.l.Errorf("unable to initialize HTTP Signature transport: %s", err)
×
121
                        return h /*, err*/
×
122
                }
×
123
        }
124
        return h /*, nil*/
7✔
125
}
126

127
type privateKey interface {
128
        Public() crypto.PublicKey
129
}
130

131
func pemEncodePublicKey(prvKey crypto.PrivateKey) string {
4✔
132
        prv, ok := prvKey.(privateKey)
4✔
133
        if !ok {
4✔
134
                return fmt.Sprintf("invalid private key: %T", prvKey)
×
135
        }
×
136
        pubEnc, err := x509.MarshalPKIXPublicKey(prv.Public())
4✔
137
        if err != nil {
4✔
138
                return "invalid public key: %s" + err.Error()
×
139
        }
×
140
        p := pem.Block{
4✔
141
                Type:  "PUBLIC KEY",
4✔
142
                Bytes: pubEnc,
4✔
143
        }
4✔
144
        return strings.ReplaceAll(string(pem.EncodeToMemory(&p)), "\n", "")
4✔
145
}
146

147
var getCurrentTime = func() time.Time {
2✔
148
        return time.Now().Truncate(time.Millisecond).UTC()
2✔
149
}
2✔
150

151
func randomNonce() (string, error) {
1✔
152
        nonceBytes := make([]byte, 32)
1✔
153
        _, err := rand.Read(nonceBytes)
1✔
154
        if err != nil {
1✔
NEW
155
                return "", fmt.Errorf("could not generate nonce: %w", err)
×
NEW
156
        }
×
157
        return base64.URLEncoding.EncodeToString(nonceBytes), nil
1✔
158
}
159

160
func (s *Transport) signRequestRFC(or *http.Request) error {
2✔
161
        if s.Actor == nil {
2✔
NEW
162
                return errors.Newf("unable to sign request, Actor is invalid")
×
NEW
163
        }
×
164
        if s.Key == nil {
2✔
NEW
165
                return errors.Newf("unable to sign request, private key is invalid")
×
NEW
166
        }
×
167

168
        act := s.Actor
2✔
169
        prv := s.Key
2✔
170
        actorPubKey, err := toCryptoPublicKey(act.PublicKey)
2✔
171
        if err != nil {
2✔
NEW
172
                return errors.Annotatef(err, "unable to sign request, Actor public key type %T is invalid", actorPubKey)
×
NEW
173
        }
×
174
        keyID := string(act.PublicKey.ID)
2✔
175

2✔
176
        var alg signer.Algorithm
2✔
177
        switch pk := prv.(type) {
2✔
178
        case *rsa.PrivateKey:
1✔
179
                pub, _ := pk.Public().(*rsa.PublicKey)
1✔
180
                if !pub.Equal(actorPubKey) {
1✔
NEW
181
                        return keyMismatchErr(pk, actorPubKey)
×
NEW
182
                }
×
183
                alg = r.NewRSAPKCS256Signer(pk)
1✔
NEW
184
        case *ecdsa.PrivateKey:
×
NEW
185
                pub, _ := pk.Public().(*ecdsa.PublicKey)
×
NEW
186
                if !pub.Equal(actorPubKey) {
×
NEW
187
                        return keyMismatchErr(pk, actorPubKey)
×
NEW
188
                }
×
NEW
189
                alg = e.NewP384Signer(pk)
×
190
        case ed25519.PrivateKey:
1✔
191
                pub, _ := pk.Public().(ed25519.PublicKey)
1✔
192
                if !pub.Equal(actorPubKey) {
1✔
NEW
193
                        return keyMismatchErr(pk, actorPubKey)
×
NEW
194
                }
×
195
                alg = &ed.Ed25519{PrivateKey: pk, PublicKey: pub}
1✔
196
        }
197
        // parse the existing signature set on the request
198
        set, err := sigset.Unmarshal(or)
2✔
199
        if err != nil {
2✔
NEW
200
                return err
×
NEW
201
        }
×
202

203
        // derive the signature.
204
        nonce, err := s.nonceFn()
2✔
205
        if err != nil {
2✔
NEW
206
                return fmt.Errorf("generating nonce: %w", err)
×
NEW
207
        }
×
208

209
        params := sigparams.Params{
2✔
210
                KeyID:             keyID,
2✔
211
                Tag:               s.Tag,
2✔
212
                Alg:               alg.Type(),
2✔
213
                Created:           getCurrentTime(),
2✔
214
                CoveredComponents: s.CoveredComponents,
2✔
215
                Nonce:             nonce,
2✔
216
        }
2✔
217

2✔
218
        // derive the signature base following the process in https://www.rfc-editor.org/rfc/rfc9421.html#create-sig-input
2✔
219
        base, err := sigbase.Derive(params, nil, or, alg.ContentDigest())
2✔
220
        if err != nil {
2✔
NEW
221
                return fmt.Errorf("deriving signature base: %w", err)
×
NEW
222
        }
×
223

224
        stringToSign, err := base.CanonicalString(params)
2✔
225
        if err != nil {
2✔
NEW
226
                return fmt.Errorf("creating string to sign: %w", err)
×
NEW
227
        }
×
228
        // sign the signature base according to the signing algorithm
229
        sig, err := alg.Sign(or.Context(), stringToSign)
2✔
230
        if err != nil {
2✔
NEW
231
                return fmt.Errorf("error signing request: %w", err)
×
NEW
232
        }
×
233

234
        // construct the HTTP message signature
235
        ms := signature.Message{Input: params, Signature: sig}
2✔
236

2✔
237
        // add the signature to the set
2✔
238
        set.Add(&ms)
2✔
239

2✔
240
        // include the signature in the cloned HTTP request.
2✔
241
        return set.Include(or)
2✔
242
}
243

244
var ErrRetry = errors.Newf("retry")
245

246
// RoundTrip dispatches the received request after signing it.
247
// We currently use the double knocking mechanism Mastodon popularized:
248
// * we first attempt to sign the request with RFC9421 compliant signature,
249
// * if it failed, we try again using a draft Cavage-12 version signature.
250
// Additionally, if everything failed, and we're operating with a fetch request,
251
// we make one last, non-signed attempt.
252
func (s *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
5✔
253
        tr := s.Base
5✔
254
        if tr == nil {
8✔
255
                tr = http.DefaultTransport
3✔
256
        }
3✔
257

258
        errs := make([]error, 0, 3)
5✔
259
        roundTripFn := func(req *http.Request, signFn func(*http.Request) error) (*http.Response, error) {
10✔
260
                if signFn != nil {
9✔
261
                        lctx := lw.Ctx{"key": pemEncodePublicKey(s.Key)}
4✔
262
                        if s.Actor != nil {
8✔
263
                                lctx["actor"] = s.Actor.ID
4✔
264
                        }
4✔
265
                        err := signFn(req)
4✔
266
                        if err != nil {
4✔
NEW
267
                                if s.l != nil {
×
NEW
268
                                        s.l.WithContext(lctx, lw.Ctx{"err": err.Error()}).Errorf("unable to sign request")
×
NEW
269
                                }
×
NEW
270
                                errs = append(errs, err)
×
NEW
271
                                return nil, ErrRetry
×
272
                        }
273
                }
274

275
                res, err := tr.RoundTrip(req)
5✔
276
                if err != nil {
5✔
NEW
277
                        return nil, err
×
NEW
278
                }
×
279

280
                switch res.StatusCode {
5✔
NEW
281
                case http.StatusUnauthorized, http.StatusForbidden:
×
NEW
282
                        // NOTE(marius): Not an acceptable response status, so we want to try again.
×
NEW
283
                        // We also need to close the body of discarded response to avoid leaks.
×
NEW
284
                        _ = res.Body.Close()
×
NEW
285
                        return nil, ErrRetry
×
286
                default:
5✔
287
                        // NOTE(marius): some kind of success
5✔
288
                        return res, nil
5✔
289
                }
290
        }
291

292
        or := *req
5✔
293
        isFetchRequest := req.Method == http.MethodGet || req.Method == http.MethodHead
5✔
294

5✔
295
        if or.URL != nil && or.URL.Path == "" {
10✔
296
                or.URL.Path = "/"
5✔
297
        }
5✔
298

299
        var res *http.Response
5✔
300
        var err error
5✔
301
        if s.Actor != nil && s.Key != nil {
9✔
302
                // NOTE(marius): we're to sign the request, so we need to copy the body
4✔
303
                var buff []byte
4✔
304
                if or.Body != nil {
8✔
305
                        if buff, err = io.ReadAll(or.Body); err != nil {
4✔
NEW
306
                                return nil, err
×
UNCOV
307
                        }
×
308
                }
309
                if !s.skipRFCSignatures {
6✔
310
                        // NOTE(marius): try #1: use RFC9421 signature
2✔
311
                        res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestRFC)
2✔
312
                        if err == nil || !errors.Is(err, ErrRetry) {
4✔
313
                                return res, err
2✔
314
                        }
2✔
NEW
315
                        if res != nil && res.Body != nil {
×
UNCOV
316
                                _ = res.Body.Close()
×
UNCOV
317
                        }
×
318
                }
319

320
                // NOTE(marius): try #2: use cavage-12 draft signature
321
                res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestCavage)
2✔
322
                if err == nil || !errors.Is(err, ErrRetry) {
4✔
323
                        return res, err
2✔
324
                }
2✔
NEW
325
                if err != nil && errors.Is(err, ErrRetry) && !isFetchRequest {
×
NEW
326
                        slices.Reverse(errs)
×
NEW
327
                        err = errs[0]
×
NEW
328
                        return res, err
×
NEW
329
                }
×
NEW
330
                if res != nil && res.Body != nil {
×
NEW
331
                        _ = res.Body.Close()
×
UNCOV
332
                }
×
333
        }
334

335
        if isFetchRequest {
2✔
336
                // NOTE(marius): This is a mitigation for loading Public Keys for Actors on other instances,
1✔
337
                // which can create an infinite loop of requests if that instance tries to do an authorize-fetch
1✔
338
                // for our signing Actor.
1✔
339
                // There are more details in ticket: https://todo.sr.ht/~mariusor/go-activitypub/301
1✔
340
                return roundTripFn(&or, nil)
1✔
341
        }
1✔
342

NEW
343
        return res, errors.Join(errs...)
×
344
}
345

346
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
2✔
347
        block, _ := pem.Decode([]byte(key.PublicKeyPem))
2✔
348
        if block == nil {
2✔
UNCOV
349
                return nil, errors.Errorf("invalid PEM decode on public key")
×
UNCOV
350
        }
×
351
        return x509.ParsePKIXPublicKey(block.Bytes)
2✔
352
}
353

354
func keyMismatchErr(pk crypto.PrivateKey, pub crypto.PublicKey) error {
×
355
        return errors.Newf("unable to sign request, mismatch between the Actor's public and private key: %T : %T", pub, pk)
×
356
}
×
357

358
func (s *Transport) signRequestCavage(req *http.Request) error {
2✔
359
        if s.Actor == nil {
2✔
NEW
360
                return errors.Newf("unable to sign request, Actor is invalid")
×
NEW
361
        }
×
362
        if s.Key == nil {
2✔
NEW
363
                return errors.Newf("unable to sign request, private key is invalid")
×
NEW
364
        }
×
365
        if !s.Actor.PublicKey.ID.IsValid() {
2✔
366
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
367
        }
×
368

369
        keyID := s.Actor.PublicKey.ID
2✔
370

2✔
371
        headers := HeadersToSign
2✔
372
        bodyBuf := bytes.Buffer{}
2✔
373
        if req.Body != nil {
4✔
374
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
4✔
375
                        req.Body = io.NopCloser(&bodyBuf)
2✔
376
                        if bodyBuf.Len() > 0 {
3✔
377
                                headers = append(HeadersToSign, "digest")
1✔
378
                        }
1✔
379
                }
380
        }
381

382
        algos := make([]httpsig.Algorithm, 0)
2✔
383
        switch s.Key.(type) {
2✔
384
        case *rsa.PrivateKey:
1✔
385
                algos = append(algos, httpsig.RSA_SHA256, httpsig.RSA_SHA512)
1✔
386
        case *ecdsa.PrivateKey:
×
387
                algos = append(algos, httpsig.ECDSA_SHA512, httpsig.ECDSA_SHA256)
×
388
        case ed25519.PrivateKey:
1✔
389
                algos = append(algos, httpsig.ED25519)
1✔
390
        }
391

392
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
393
        sig, _, err := httpsig.NewSigner(algos, digestAlgorithm, headers, httpsig.Signature, signatureExpiration)
2✔
394
        if err != nil {
2✔
395
                return err
×
396
        }
×
397
        return sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes())
2✔
398
}
399

400
var _ http.RoundTripper = new(Transport)
401

402
// cloneRequest returns a clone of the provided *http.Request.
403
// The clone is a shallow copy of the struct and its Header map.
404
func cloneRequest(r *http.Request, buff []byte) *http.Request {
4✔
405
        r2 := r.Clone(r.Context())
4✔
406
        if buff != nil {
8✔
407
                r2.Body = io.NopCloser(bytes.NewReader(buff))
4✔
408
        }
4✔
409
        return r2
4✔
410
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc