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

go-ap / client / #22

29 Apr 2026 11:45AM UTC coverage: 81.5% (-0.8%) from 82.286%
#22

push

sourcehut

mariusor
Switched to dadrus/httpsig library for RFC9421 signatures

64 of 88 new or added lines in 1 file covered. (72.73%)

2 existing lines in 1 file now uncovered.

956 of 1173 relevant lines covered (81.5%)

7.24 hits per line

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

71.23
/s2s/httpsignatures.go
1
package s2s
2

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

21
        "git.sr.ht/~mariusor/lw"
22
        rfc "github.com/dadrus/httpsig"
23
        vocab "github.com/go-ap/activitypub"
24
        "github.com/go-ap/errors"
25
        draft "github.com/go-fed/httpsig"
26
)
27

28
var (
29
        nilLogger = lw.Dev(lw.SetOutput(io.Discard))
30

31
        digestAlgorithm  = draft.DigestSha256
32
        sigValidDuration = time.Hour
33
)
34

35
type Transport struct {
36
        Base http.RoundTripper
37

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

43
        nonceFn           noncer
44
        skipRFCSignatures bool
45

46
        Key   crypto.PrivateKey
47
        Actor *vocab.Actor
48

49
        l lw.Logger
50
}
51

52
type OptionFn func(transport *Transport) error
53

54
func WithTransport(tr http.RoundTripper) OptionFn {
2✔
55
        return func(h *Transport) error {
4✔
56
                h.Base = tr
2✔
57
                return nil
2✔
58
        }
2✔
59
}
60

61
func NoRFC9421(h *Transport) error {
4✔
62
        h.skipRFCSignatures = true
4✔
63
        return nil
4✔
64
}
4✔
65

66
func WithNonce(nonceFn func() (string, error)) OptionFn {
1✔
67
        return func(h *Transport) error {
2✔
68
                h.nonceFn = nonceFn
1✔
69
                return nil
1✔
70
        }
1✔
71
}
72

73
func WithActor(act *vocab.Actor, prv crypto.PrivateKey) OptionFn {
16✔
74
        return func(h *Transport) error {
32✔
75
                h.Actor = act
16✔
76
                h.Key = prv
16✔
77

16✔
78
                return nil
16✔
79
        }
16✔
80
}
81

82
func WithLogger(l lw.Logger) OptionFn {
2✔
83
        return func(h *Transport) error {
4✔
84
                h.l = l
2✔
85
                return nil
2✔
86
        }
2✔
87
}
88

89
func WithApplicationTag(t string) OptionFn {
2✔
90
        return func(h *Transport) error {
4✔
91
                h.Tag = t
2✔
92
                return nil
2✔
93
        }
2✔
94
}
95

96
// New initializes the Transport
97
// TODO(marius): we need to add to the return values the errors
98
//  that might come from the initialization functions.
99
func New(initFns ...OptionFn) *Transport {
11✔
100
        h := new(Transport)
11✔
101
        h.nonceFn = randomNonce
11✔
102
        h.l = nilLogger
11✔
103
        for _, fn := range initFns {
27✔
104
                if err := fn(h); err != nil {
16✔
105
                        h.l.Errorf("unable to initialize HTTP Signature transport: %s", err)
×
106
                        return h /*, err*/
×
107
                }
×
108
        }
109
        return h /*, nil*/
11✔
110
}
111

112
type privateKey interface {
113
        Public() crypto.PublicKey
114
}
115

116
func pemEncodePublicKey(prvKey crypto.PrivateKey) string {
8✔
117
        prv, ok := prvKey.(privateKey)
8✔
118
        if !ok {
8✔
119
                return fmt.Sprintf("invalid private key: %T", prvKey)
×
120
        }
×
121
        pubEnc, err := x509.MarshalPKIXPublicKey(prv.Public())
8✔
122
        if err != nil {
8✔
123
                return "invalid public key: %s" + err.Error()
×
124
        }
×
125
        p := pem.Block{
8✔
126
                Type:  "PUBLIC KEY",
8✔
127
                Bytes: pubEnc,
8✔
128
        }
8✔
129
        return strings.ReplaceAll(string(pem.EncodeToMemory(&p)), "\n", "")
8✔
130
}
131

132
func randomNonce() (string, error) {
3✔
133
        nonceBytes := make([]byte, 32)
3✔
134
        _, err := rand.Read(nonceBytes)
3✔
135
        if err != nil {
3✔
136
                return "", fmt.Errorf("could not generate nonce: %w", err)
×
137
        }
×
138
        return base64.URLEncoding.EncodeToString(nonceBytes), nil
3✔
139
}
140

141
type noncer func() (string, error)
142

143
func (n noncer) GetNonce(_ context.Context) (string, error) {
4✔
144
        return n()
4✔
145
}
4✔
146

147
func validateActorPublicKey(key crypto.PrivateKey, act vocab.Actor) error {
4✔
148
        actorPubKey, err := toCryptoPublicKey(act.PublicKey)
4✔
149
        if err != nil {
4✔
150
                return errors.Annotatef(err, "unable to sign request, Actor public key type %T is invalid", actorPubKey)
×
151
        }
×
152
        switch pk := key.(type) {
4✔
153
        case *rsa.PrivateKey:
2✔
154
                pub := &pk.PublicKey
2✔
155
                if !pub.Equal(actorPubKey) {
2✔
156
                        return keyMismatchErr(pk, actorPubKey)
×
157
                }
×
158
        case *ecdsa.PrivateKey:
1✔
159
                pub, ok := pk.Public().(*ecdsa.PublicKey)
1✔
160
                if !ok || !pub.Equal(actorPubKey) {
1✔
161
                        return keyMismatchErr(pk, actorPubKey)
×
162
                }
×
163
        case ed25519.PrivateKey:
1✔
164
                pub, _ := pk.Public().(ed25519.PublicKey)
1✔
165
                if !pub.Equal(actorPubKey) {
1✔
166
                        return keyMismatchErr(pk, actorPubKey)
×
167
                }
×
168
        }
169
        return nil
4✔
170
}
171

172
func rfcAlgorithmFromPrivateKey(key crypto.PrivateKey) rfc.SignatureAlgorithm {
4✔
173
        // NOTE(marius): I'm not sure what purpose it serves to validate the public key of the actor
4✔
174
        // against the private key
4✔
175
        var alg rfc.SignatureAlgorithm
4✔
176
        switch pk := key.(type) {
4✔
177
        case *rsa.PrivateKey:
2✔
178
                switch pk.PublicKey.Size() {
2✔
179
                case 128, 256:
2✔
180
                        alg = rfc.RsaPkcs1v15Sha256
2✔
NEW
181
                case 384:
×
NEW
182
                        alg = rfc.RsaPkcs1v15Sha384
×
NEW
183
                case 512:
×
NEW
184
                        alg = rfc.RsaPkcs1v15Sha512
×
185
                }
186
        case *ecdsa.PrivateKey:
1✔
187
                if p := pk.Params(); p != nil {
2✔
188
                        switch p.BitSize {
1✔
NEW
189
                        case 128, 256:
×
NEW
190
                                alg = rfc.EcdsaP256Sha256
×
191
                        case 384:
1✔
192
                                alg = rfc.EcdsaP384Sha384
1✔
NEW
193
                        case 512:
×
NEW
194
                                alg = rfc.EcdsaP521Sha512
×
195
                        }
196
                }
197
        case ed25519.PrivateKey:
1✔
198
                alg = rfc.Ed25519
1✔
199
        }
200
        return alg
4✔
201
}
202

203
func (s *Transport) signRequestRFC(req *http.Request) error {
4✔
204
        if s.Actor == nil {
4✔
NEW
205
                return errors.Newf("unable to sign request, Actor is invalid")
×
NEW
206
        }
×
207
        if s.Key == nil {
4✔
NEW
208
                return errors.Newf("unable to sign request, private key is invalid")
×
UNCOV
209
        }
×
210

211
        if err := validateActorPublicKey(s.Key, *s.Actor); err != nil {
4✔
NEW
212
                return errors.Annotatef(err, "unable to sign request, Actor public key does not match it's private key")
×
UNCOV
213
        }
×
214

215
        key := rfc.Key{
4✔
216
                KeyID:     string(s.Actor.PublicKey.ID),
4✔
217
                Algorithm: rfcAlgorithmFromPrivateKey(s.Key),
4✔
218
                Key:       s.Key,
4✔
219
        }
4✔
220

4✔
221
        initFns := []rfc.SignerOption{
4✔
222
                rfc.WithTTL(sigValidDuration),
4✔
223
                rfc.WithNonce(s.nonceFn),
4✔
224
        }
4✔
225

4✔
226
        switch req.Method {
4✔
NEW
227
        case http.MethodHead, http.MethodGet:
×
NEW
228
                initFns = append(initFns, rfc.WithComponents(FetchCoveredComponents...))
×
229
        case http.MethodPost:
4✔
230
                initFns = append(initFns, rfc.WithComponents(PostCoveredComponents...))
4✔
231
                initFns = append(initFns, rfc.WithContentDigestAlgorithm(rfc.Sha256))
4✔
232
        }
233
        signer, err := rfc.NewSigner(key, initFns...)
4✔
234
        if err != nil {
4✔
NEW
235
                return err
×
236
        }
×
237
        postSignHeaders, err := signer.Sign(rfc.MessageFromRequest(req))
4✔
238
        if err != nil {
4✔
NEW
239
                return err
×
240
        }
×
241
        req.Header = postSignHeaders
4✔
242
        return nil
4✔
243
}
244

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

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

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

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

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

293
        or := *req
9✔
294
        isFetchRequest := req.Method == http.MethodGet || req.Method == http.MethodHead
9✔
295

9✔
296
        if or.URL != nil && or.URL.Path == "" {
18✔
297
                or.URL.Path = "/"
9✔
298
        }
9✔
299

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

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

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

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

347
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
4✔
348
        block, _ := pem.Decode([]byte(key.PublicKeyPem))
4✔
349
        if block == nil {
4✔
350
                return nil, errors.Errorf("invalid PEM decode on public key")
×
351
        }
×
352
        pk, err := x509.ParsePKIXPublicKey(block.Bytes)
4✔
353
        if err == nil {
7✔
354
                return pk, err
3✔
355
        }
3✔
356
        return x509.ParsePKCS1PublicKey(block.Bytes)
1✔
357
}
358

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

363
func (s *Transport) signRequestDraft(req *http.Request) error {
4✔
364
        if s.Actor == nil {
4✔
365
                return errors.Newf("unable to sign request, Actor is invalid")
×
366
        }
×
367
        if s.Key == nil {
4✔
368
                return errors.Newf("unable to sign request, private key is invalid")
×
369
        }
×
370
        if !s.Actor.PublicKey.ID.IsValid() {
4✔
371
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
372
        }
×
373

374
        keyID := s.Actor.PublicKey.ID
4✔
375

4✔
376
        headers := HeadersToSign
4✔
377
        bodyBuf := bytes.Buffer{}
4✔
378
        if req.Body != nil {
8✔
379
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
8✔
380
                        req.Body = io.NopCloser(&bodyBuf)
4✔
381
                        if bodyBuf.Len() > 0 {
7✔
382
                                headers = append(HeadersToSign, "digest")
3✔
383
                        }
3✔
384
                }
385
        }
386

387
        algos := make([]draft.Algorithm, 0)
4✔
388
        switch pk := s.Key.(type) {
4✔
389
        case *rsa.PrivateKey:
2✔
390
                switch pk.PublicKey.Size() {
2✔
391
                case 128, 256:
2✔
392
                        algos = append(algos, draft.RSA_SHA256)
2✔
NEW
393
                case 384:
×
NEW
394
                        algos = append(algos, draft.RSA_SHA384)
×
NEW
395
                case 512:
×
NEW
396
                        algos = append(algos, draft.RSA_SHA512)
×
397
                }
398
        case *ecdsa.PrivateKey:
1✔
399
                if p := pk.Params(); p != nil {
2✔
400
                        switch p.BitSize {
1✔
NEW
401
                        case 128, 256:
×
NEW
402
                                algos = append(algos, draft.ECDSA_SHA256)
×
403
                        case 384:
1✔
404
                                algos = append(algos, draft.ECDSA_SHA384)
1✔
NEW
405
                        case 512:
×
NEW
406
                                algos = append(algos, draft.ECDSA_SHA512)
×
407
                        }
408
                }
409
        case ed25519.PrivateKey:
1✔
410
                algos = append(algos, draft.ED25519)
1✔
411
        }
412

413
        secToExpiration := int64(sigValidDuration.Seconds())
4✔
414
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
4✔
415
        sig, _, err := draft.NewSigner(algos, digestAlgorithm, headers, draft.Signature, secToExpiration)
4✔
416
        if err != nil {
4✔
417
                return err
×
418
        }
×
419
        return sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes())
4✔
420
}
421

422
var _ http.RoundTripper = new(Transport)
423

424
// cloneRequest returns a clone of the provided *http.Request.
425
// The clone is a shallow copy of the struct and its Header map.
426
func cloneRequest(r *http.Request, buff []byte) *http.Request {
8✔
427
        r2 := r.Clone(r.Context())
8✔
428
        if buff != nil {
16✔
429
                r2.Body = io.NopCloser(bytes.NewReader(buff))
8✔
430
        }
8✔
431
        return r2
8✔
432
}
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