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

go-ap / client / #54

08 Jun 2026 05:56PM UTC coverage: 80.356% (-1.0%) from 81.388%
#54

push

sourcehut

mariusor
Allow setting the RSA encoding type when initializing the HTTP-Signer

5 of 14 new or added lines in 1 file covered. (35.71%)

57 existing lines in 1 file now uncovered.

1039 of 1293 relevant lines covered (80.36%)

7.79 hits per line

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

76.04
/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/pem"
13
        "fmt"
14
        "io"
15
        "net/http"
16
        "slices"
17
        "time"
18

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

26
var (
27
        nilLogger = lw.Dev(lw.SetOutput(io.Discard))
28

29
        digestAlgorithm  = draft.DigestSha256
30
        sigValidDuration = time.Hour
31
)
32

33
type Transport struct {
34
        Base http.RoundTripper
35

36
        // RFC9421 relevant data
37
        skipRFCSignatures bool
38
        nonceFn           noncer
39
        coveredComponents []string
40
        // tag is an application-specific tag for the signature as a String value.
41
        // This value is used by applications to help identify signatures relevant for specific applications or protocols.
42
        // See: https://www.rfc-editor.org/rfc/rfc9421.html#section-2.3-4.12
43
        tag string
44

45
        Alg   KeyEncoding
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 WithCoveredComponents(comp ...string) OptionFn {
1✔
74
        return func(h *Transport) error {
2✔
75
                h.coveredComponents = comp
1✔
76
                return nil
1✔
77
        }
1✔
78
}
79

NEW
80
func WithAlg(alg KeyEncoding) OptionFn {
×
NEW
81
        return func(h *Transport) error {
×
NEW
82
                h.Alg = alg
×
NEW
83
                return nil
×
NEW
84
        }
×
85
}
86

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

19✔
92
                return nil
19✔
93
        }
19✔
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 {
14✔
114
        h := new(Transport)
14✔
115
        h.nonceFn = randomNonce
14✔
116
        h.l = nilLogger
14✔
117
        for _, fn := range initFns {
33✔
118
                if err := fn(h); err != nil {
19✔
119
                        h.l.Errorf("unable to initialize HTTP Signature transport: %s", err)
×
120
                        return h /*, err*/
×
121
                }
×
122
        }
123
        return h /*, nil*/
14✔
124
}
125

126
func randomNonce() (string, error) {
6✔
127
        return rand.Text(), nil
6✔
128
}
6✔
129

130
type noncer func() (string, error)
131

132
func (n noncer) GetNonce(_ context.Context) (string, error) {
7✔
133
        return n()
7✔
134
}
7✔
135

136
func validateActorPublicKey(key crypto.PrivateKey, actorPubKey crypto.PublicKey) error {
7✔
137
        switch pk := key.(type) {
7✔
138
        case *rsa.PrivateKey:
2✔
139
                pub := &pk.PublicKey
2✔
140
                if !pub.Equal(actorPubKey) {
2✔
UNCOV
141
                        return keyMismatchErr(pk, actorPubKey)
×
UNCOV
142
                }
×
143
        case *ecdsa.PrivateKey:
1✔
144
                pub, ok := pk.Public().(*ecdsa.PublicKey)
1✔
145
                if !ok || !pub.Equal(actorPubKey) {
1✔
146
                        return keyMismatchErr(pk, actorPubKey)
×
UNCOV
147
                }
×
148
        case ed25519.PrivateKey:
4✔
149
                pub, _ := pk.Public().(ed25519.PublicKey)
4✔
150
                if !pub.Equal(actorPubKey) {
4✔
151
                        return keyMismatchErr(pk, actorPubKey)
×
UNCOV
152
                }
×
153
        }
154
        return nil
7✔
155
}
156

157
func rfcAlgorithmFromPrivateKey(key crypto.PrivateKey, typ KeyEncoding) rfc.SignatureAlgorithm {
7✔
158
        // NOTE(marius): I'm not sure what purpose it serves to validate the public key of the actor
7✔
159
        // against the private key
7✔
160
        var alg rfc.SignatureAlgorithm = "unknown"
7✔
161

7✔
162
        switch pk := key.(type) {
7✔
163
        case *rsa.PrivateKey:
2✔
164
                switch pk.Size() {
2✔
165
                case 128, 256:
2✔
166
                        switch typ {
2✔
167
                        case KeyTypePKCS:
2✔
168
                                alg = rfc.RsaPkcs1v15Sha256
2✔
NEW
169
                        case KeyTypePSS:
×
170
                                alg = rfc.RsaPssSha256
×
171
                        }
172
                case 384:
×
173
                        switch typ {
×
UNCOV
174
                        case KeyTypePKCS:
×
UNCOV
175
                                alg = rfc.RsaPkcs1v15Sha384
×
NEW
176
                        case KeyTypePSS:
×
UNCOV
177
                                alg = rfc.RsaPssSha384
×
178
                        }
179
                case 512:
×
UNCOV
180
                        switch typ {
×
UNCOV
181
                        case KeyTypePKCS:
×
182
                                alg = rfc.RsaPkcs1v15Sha512
×
NEW
183
                        case KeyTypePSS:
×
184
                        }
185
                }
186
        case *ecdsa.PrivateKey:
1✔
187
                if p := pk.Params(); p != nil {
2✔
188
                        switch p.BitSize {
1✔
UNCOV
189
                        case 128, 256:
×
UNCOV
190
                                alg = rfc.EcdsaP256Sha256
×
191
                        case 384:
1✔
192
                                alg = rfc.EcdsaP384Sha384
1✔
UNCOV
193
                        case 512:
×
UNCOV
194
                                alg = rfc.EcdsaP521Sha512
×
195
                        }
196
                }
197
        case ed25519.PrivateKey:
4✔
198
                alg = rfc.Ed25519
4✔
199
        }
200
        return alg
7✔
201
}
202

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

212
                pubKey, err := toCryptoPublicKey(s.Actor.PublicKey)
7✔
213
                if err != nil {
7✔
UNCOV
214
                        return errors.Annotatef(err, "unable to sign request, unable to validate the Actor's public key")
×
UNCOV
215
                }
×
216
                if err = validateActorPublicKey(s.Key, pubKey); err != nil {
7✔
217
                        return errors.Annotatef(err, "unable to sign request, Actor public key does not match it's private key")
×
218
                }
×
219

220
                key := rfc.Key{
7✔
221
                        KeyID:     string(s.Actor.PublicKey.ID),
7✔
222
                        Algorithm: rfcAlgorithmFromPrivateKey(s.Key, s.Alg),
7✔
223
                        Key:       s.Key,
7✔
224
                }
7✔
225

7✔
226
                initFns := []rfc.SignerOption{
7✔
227
                        rfc.WithTTL(sigValidDuration),
7✔
228
                }
7✔
229

7✔
230
                if s.tag != "" {
7✔
UNCOV
231
                        initFns = append(initFns, rfc.WithTag(s.tag))
×
232
                }
×
233

234
                if coveredComponents != nil {
14✔
235
                        initFns = append(initFns, rfc.WithComponents(coveredComponents...))
7✔
236
                }
7✔
237
                if req.Method == http.MethodPost {
13✔
238
                        initFns = append(initFns, rfc.WithContentDigestAlgorithm(rfc.Sha256))
6✔
239
                }
6✔
240
                if s.nonceFn != nil {
14✔
241
                        initFns = append(initFns, rfc.WithNonce(s.nonceFn))
7✔
242
                }
7✔
243
                signer, err := rfc.NewSigner(key, initFns...)
7✔
244
                if err != nil {
7✔
UNCOV
245
                        return err
×
UNCOV
246
                }
×
247
                postSignHeaders, err := signer.Sign(rfc.MessageFromRequest(req))
7✔
248
                if err != nil {
7✔
UNCOV
249
                        return err
×
UNCOV
250
                }
×
251
                req.Header = postSignHeaders
7✔
252
                return nil
7✔
253
        }
254
}
255

256
var ErrRetry = errors.Newf("retry")
257

258
// RoundTrip dispatches the received request after signing it.
259
// We currently use the double knocking mechanism Mastodon popularized:
260
// * we first attempt to sign the request with RFC9421 compliant signature,
261
// * if it failed, we try again using a Cavage draft 8 version signature.
262
// Additionally, if everything failed, and we're operating with a fetch request,
263
// we make one last, non-signed attempt.
264
func (s *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
12✔
265
        tr := s.Base
12✔
266
        if tr == nil {
22✔
267
                tr = http.DefaultTransport
10✔
268
        }
10✔
269
        isFetchRequest := slices.Contains([]string{http.MethodGet, http.MethodHead}, req.Method)
12✔
270

12✔
271
        coveredComponents := s.coveredComponents
12✔
272
        if coveredComponents == nil {
24✔
273
                // NOTE(marius): ideally the caller knows if we're about to sign a Fetch or not,
12✔
274
                // and provide all necessary covered components at initialization time.
12✔
275
                coveredComponents = FetchCoveredComponents
12✔
276
                if !isFetchRequest {
21✔
277
                        coveredComponents = append(coveredComponents, AdditionalPostCoveredComponents...)
9✔
278
                }
9✔
279
        }
280
        roundTripFn := func(req *http.Request, signFn func(*http.Request) error, l lw.Logger) (*http.Response, error) {
28✔
281
                lastTry := false
16✔
282
                lc := lw.Ctx{}
16✔
283
                if signFn == nil {
18✔
284
                        lastTry = true
2✔
285
                } else {
16✔
286
                        lc["key-type"] = fmt.Sprintf("%T", s.Key)
14✔
287
                        if s.Actor != nil {
28✔
288
                                lc["actor"] = s.Actor.ID
14✔
289
                                if s.Actor.PublicKey.ID != "" {
28✔
290
                                        lc["key-id"] = s.Actor.PublicKey.ID
14✔
291
                                }
14✔
292
                        }
293
                        if err := signFn(req); err != nil {
14✔
UNCOV
294
                                lc["err"] = err
×
UNCOV
295
                                l.WithContext(lc).Errorf("failed to sign request")
×
UNCOV
296
                                return nil, ErrRetry
×
UNCOV
297
                        }
×
298
                }
299
                lc["host"] = req.URL.Hostname()
16✔
300

16✔
301
                res, err := tr.RoundTrip(req)
16✔
302
                if lastTry || err != nil {
18✔
303
                        return res, err
2✔
304
                }
2✔
305

306
                switch res.StatusCode {
14✔
UNCOV
307
                case http.StatusServiceUnavailable:
×
UNCOV
308
                        // NOTE(marius): this is a hack for mastoart.social
×
UNCOV
309
                        // which returns a 503 if it encountered previous errors.
×
UNCOV
310
                        fallthrough
×
311
                case http.StatusBadRequest:
2✔
312
                        // NOTE(marius): this is a hack for tags.pub that doesn't
2✔
313
                        // return a 403 or 401 error status on failing signatures
2✔
314
                        // See https://todo.sr.ht/~mariusor/go-activitypub/473
2✔
315
                        fallthrough
2✔
316
                case http.StatusNotFound:
2✔
317
                        // NOTE(marius): many services, among which the GoActivityPub ones, return not found
2✔
318
                        // for resources that are actually forbidden.
2✔
319
                        fallthrough
2✔
320
                case http.StatusUnauthorized, http.StatusForbidden:
5✔
321
                        // NOTE(marius): Not an acceptable response status, so we want to try again.
5✔
322
                        lc["status"] = res.StatusCode
5✔
323
                        l.WithContext(lc).Errorf("error response from remote server")
5✔
324
                        _, _ = io.Copy(io.Discard, res.Body)
5✔
325
                        _ = res.Body.Close()
5✔
326

5✔
327
                        return nil, ErrRetry
5✔
328
                default:
9✔
329
                        // NOTE(marius): some kind of success
9✔
330
                        return res, nil
9✔
331
                }
332
        }
333

334
        or := *req
12✔
335

12✔
336
        if or.URL != nil && or.URL.Path == "" {
24✔
337
                or.URL.Path = "/"
12✔
338
        }
12✔
339

340
        lctx := lw.Ctx{}
12✔
341
        var res *http.Response
12✔
342
        var err error
12✔
343
        if s.Actor != nil && s.Key != nil {
23✔
344
                // NOTE(marius): we're to sign the request, so we need to copy the body
11✔
345
                var buff []byte
11✔
346
                if or.Body != nil {
22✔
347
                        if buff, err = io.ReadAll(or.Body); err != nil {
11✔
UNCOV
348
                                return nil, err
×
UNCOV
349
                        }
×
350
                }
351
                if !s.skipRFCSignatures {
18✔
352
                        lctx["tries"] = 0
7✔
353
                        lctx["sig-alg"] = "rfc9421"
7✔
354
                        // NOTE(marius): try #1: use RFC9421 signature
7✔
355
                        res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestRFC(coveredComponents), s.l.WithContext(lctx))
7✔
356
                        if err == nil || !errors.Is(err, ErrRetry) {
11✔
357
                                return res, err
4✔
358
                        }
4✔
359
                        if res != nil && res.Body != nil {
3✔
UNCOV
360
                                _ = res.Body.Close()
×
UNCOV
361
                        }
×
362
                }
363

364
                // NOTE(marius): try #2: use Cavage-12 draft signature
365
                lctx["tries"] = 1
7✔
366
                lctx["sig-alg"] = "draft"
7✔
367
                res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestDraft, s.l.WithContext(lctx))
7✔
368
                if err == nil || !errors.Is(err, ErrRetry) {
12✔
369
                        return res, err
5✔
370
                }
5✔
371

372
                // NOTE(marius): if draft signatures failed also, and this is not
373
                // a request that we can retry w/o a signature, we return the resulting
374
                // errors.
375
                if err != nil && errors.Is(err, ErrRetry) && !isFetchRequest {
3✔
376
                        return res, errors.Unauthorizedf("unauthorized")
1✔
377
                }
1✔
378

379
                if res != nil && res.Body != nil {
1✔
UNCOV
380
                        _ = res.Body.Close()
×
UNCOV
381
                }
×
382
        }
383

384
        if isFetchRequest {
4✔
385
                lctx["tries"] = 2
2✔
386
                lctx["sig-alg"] = "none"
2✔
387
                // NOTE(marius): This is a mitigation for loading Public Keys for Actors on other instances,
2✔
388
                // which can create an infinite loop of requests if that instance tries to do an authorize-fetch
2✔
389
                // for our signing Actor.
2✔
390
                // There are more details in ticket: https://todo.sr.ht/~mariusor/go-activitypub/301
2✔
391
                return roundTripFn(&or, nil, s.l.WithContext(lctx))
2✔
392
        }
2✔
393

UNCOV
394
        return nil, errors.Unauthorizedf("unauthorized")
×
395
}
396

397
type KeyEncoding int
398

399
var (
400
        KeyTypePKCS KeyEncoding = 0
401
        KeyTypePSS  KeyEncoding = 1
402
)
403

404
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
7✔
405
        pubBytes, _ := pem.Decode([]byte(key.PublicKeyPem))
7✔
406
        if pubBytes == nil {
7✔
NEW
407
                return nil, errors.Newf("unable to decode PEM payload for public key")
×
UNCOV
408
        }
×
409
        pk, _ := x509.ParsePKIXPublicKey(pubBytes.Bytes)
7✔
410
        if pk != nil {
13✔
411
                return pk, nil
6✔
412
        }
6✔
413
        pk, err := x509.ParsePKCS1PublicKey(pubBytes.Bytes)
1✔
414
        return pk, err
1✔
415
}
416

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

421
func (s *Transport) signRequestDraft(req *http.Request) error {
7✔
422
        if s.Actor == nil {
7✔
423
                return errors.Newf("unable to sign request, Actor is invalid")
×
UNCOV
424
        }
×
425
        if s.Key == nil {
7✔
UNCOV
426
                return errors.Newf("unable to sign request, private key is invalid")
×
UNCOV
427
        }
×
428
        if !s.Actor.PublicKey.ID.IsValid() {
7✔
429
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
UNCOV
430
        }
×
431

432
        keyID := s.Actor.PublicKey.ID
7✔
433

7✔
434
        headers := HeadersToSign
7✔
435
        bodyBuf := bytes.Buffer{}
7✔
436
        if req.Body != nil {
14✔
437
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
14✔
438
                        req.Body = io.NopCloser(&bodyBuf)
7✔
439
                        if bodyBuf.Len() > 0 {
12✔
440
                                headers = append(HeadersToSign, "digest")
5✔
441
                        }
5✔
442
                }
443
        }
444

445
        algos := make([]draft.Algorithm, 0)
7✔
446
        switch pk := s.Key.(type) {
7✔
447
        case *rsa.PrivateKey:
2✔
448
                switch pk.Size() {
2✔
449
                case 128, 256:
2✔
450
                        algos = append(algos, draft.RSA_SHA256)
2✔
UNCOV
451
                case 384:
×
UNCOV
452
                        algos = append(algos, draft.RSA_SHA384)
×
UNCOV
453
                case 512:
×
UNCOV
454
                        algos = append(algos, draft.RSA_SHA512)
×
455
                }
456
        case *ecdsa.PrivateKey:
1✔
457
                if p := pk.Params(); p != nil {
2✔
458
                        switch p.BitSize {
1✔
UNCOV
459
                        case 128, 256:
×
UNCOV
460
                                algos = append(algos, draft.ECDSA_SHA256)
×
461
                        case 384:
1✔
462
                                algos = append(algos, draft.ECDSA_SHA384)
1✔
UNCOV
463
                        case 512:
×
UNCOV
464
                                algos = append(algos, draft.ECDSA_SHA512)
×
465
                        }
466
                }
467
        case ed25519.PrivateKey:
4✔
468
                algos = append(algos, draft.ED25519)
4✔
469
        }
470

471
        secToExpiration := int64(sigValidDuration.Seconds())
7✔
472
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
7✔
473
        sig, _, err := draft.NewSigner(algos, digestAlgorithm, headers, draft.Signature, secToExpiration)
7✔
474
        if err != nil {
7✔
UNCOV
475
                return err
×
UNCOV
476
        }
×
477
        return sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes())
7✔
478
}
479

480
var _ http.RoundTripper = new(Transport)
481

482
// cloneRequest returns a clone of the provided *http.Request.
483
// The clone is a shallow copy of the struct and its Header map.
484
func cloneRequest(r *http.Request, buff []byte) *http.Request {
14✔
485
        r2 := r.Clone(r.Context())
14✔
486
        if buff != nil {
28✔
487
                r2.Body = io.NopCloser(bytes.NewReader(buff))
14✔
488
        }
14✔
489
        return r2
14✔
490
}
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