From c15a1053d31406dd7f1ea0ef1164d9c1579410df Mon Sep 17 00:00:00 2001 From: Miguel Michelson Martinez Date: Tue, 12 Sep 2023 01:48:03 -0300 Subject: [PATCH] activity pub first implementation --- .github/workflows/main.yml | 18 +++++ .gitignore | 11 +++ .rspec | 3 + .rubocop.yml | 10 +++ Gemfile | 19 +++++ Gemfile.lock | 91 ++++++++++++++++++++++++ README.md | 133 +++++++++++++++++++++++++++++++++++ Rakefile | 12 ++++ activitypub.gemspec | 38 ++++++++++ app.rb | 52 ++++++++++++++ bin/console | 15 ++++ bin/setup | 8 +++ lib/activitypub.rb | 16 +++++ lib/activitypub/activity.rb | 70 ++++++++++++++++++ lib/activitypub/actor.rb | 55 +++++++++++++++ lib/activitypub/inbox.rb | 45 ++++++++++++ lib/activitypub/object.rb | 51 ++++++++++++++ lib/activitypub/outbox.rb | 36 ++++++++++ lib/activitypub/signature.rb | 30 ++++++++ lib/activitypub/version.rb | 5 ++ spec/activity_spec.rb | 36 ++++++++++ spec/activitypub_spec.rb | 7 ++ spec/actor_spec.rb | 70 ++++++++++++++++++ spec/inbox_spec.rb | 36 ++++++++++ spec/object_spec.rb | 47 +++++++++++++ spec/outbox_spec.rb | 33 +++++++++ spec/signature_spec.rb | 36 ++++++++++ spec/spec_helper.rb | 17 +++++ 28 files changed, 1000 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 README.md create mode 100644 Rakefile create mode 100644 activitypub.gemspec create mode 100644 app.rb create mode 100755 bin/console create mode 100755 bin/setup create mode 100644 lib/activitypub.rb create mode 100644 lib/activitypub/activity.rb create mode 100644 lib/activitypub/actor.rb create mode 100644 lib/activitypub/inbox.rb create mode 100644 lib/activitypub/object.rb create mode 100644 lib/activitypub/outbox.rb create mode 100644 lib/activitypub/signature.rb create mode 100644 lib/activitypub/version.rb create mode 100644 spec/activity_spec.rb create mode 100644 spec/activitypub_spec.rb create mode 100644 spec/actor_spec.rb create mode 100644 spec/inbox_spec.rb create mode 100644 spec/object_spec.rb create mode 100644 spec/outbox_spec.rb create mode 100644 spec/signature_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..132a47f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,18 @@ +name: Ruby + +on: [push,pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.0 + - name: Run the default task + run: | + gem install bundler -v 2.2.3 + bundle install + bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04a8c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..00a72e3 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,10 @@ +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Layout/LineLength: + Max: 120 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..baeb205 --- /dev/null +++ b/Gemfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in activitypub.gemspec +gemspec + +gem "pry" + +gem "webmock" + +gem "sinatra" +gem "puma" + +gem "rake", "~> 13.0" + +gem "rspec", "~> 3.0" + +gem "rubocop", "~> 0.80" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..12b554e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,91 @@ +PATH + remote: . + specs: + activitypub (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + ast (2.4.2) + coderay (1.1.3) + crack (0.4.5) + rexml + diff-lcs (1.5.0) + hashdiff (1.0.1) + method_source (1.0.0) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) + nio4r (2.5.9) + parallel (1.23.0) + parser (3.2.2.3) + ast (~> 2.4.1) + racc + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + public_suffix (5.0.3) + puma (6.3.1) + nio4r (~> 2.0) + racc (1.7.1) + rack (2.2.8) + rack-protection (3.1.0) + rack (~> 2.2, >= 2.2.4) + rainbow (3.1.1) + rake (13.0.6) + regexp_parser (2.8.1) + rexml (3.2.6) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.2) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.6) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.1) + rubocop (0.93.1) + parallel (~> 1.10) + parser (>= 2.7.1.5) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8) + rexml + rubocop-ast (>= 0.6.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 2.0) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + sinatra (3.1.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.1.0) + tilt (~> 2.0) + tilt (2.2.0) + unicode-display_width (1.8.0) + webmock (3.19.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-21 + +DEPENDENCIES + activitypub! + pry + puma + rake (~> 13.0) + rspec (~> 3.0) + rubocop (~> 0.80) + sinatra + webmock + +BUNDLED WITH + 2.2.3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a9fedf --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# ActivityPub Ruby Library + +This library provides a simple interface to work with the ActivityPub protocol in Ruby applications. + +## Features +- Create and manage ActivityPub Objects. +- Send and receive Activities using Outbox and Inbox functionality. +- Signature verification for secured interactions. +- Simple Sinatra example to demonstrate usage. + +## Installation + +```bash +gem install activitypub +``` + +Or add it to your Gemfile: + +```ruby +gem 'activitypub' +``` + +Then run `bundle install`. + +## Usage + +### Creating an ActivityPub Object + +```ruby +activity = ActivityPub::Object.new(type: "Note", content: "Hello ActivityPub!") +``` + +### Sending an Activity + +```ruby +outbox = ActivityPub::Outbox.new("https://recipient.com/actors/2/inbox") +outbox.send(activity) +``` + +### Receiving an Activity + +```ruby +inbox = ActivityPub::Inbox.new +received_activity = inbox.receive(request_body) +``` + +### Verifying Signatures + +```ruby +signature = ActivityPub::Signature.new +if signature.verify(received_activity) + puts "Signature is valid!" +else + puts "Signature is invalid!" +end +``` + +## Sinatra Example + +A simple Sinatra app is included to demonstrate the library's functionality. To run it: + +1. Navigate to the `example` directory. +2. Run `bundle install`. +3. Run the app with `ruby app.rb`. + +Visit `http://localhost:4567` to access the example interface. + +## Contributing + +Contributions are welcome! Please submit a pull request or open an issue to discuss any changes or features. + +## License + +This library is released under the MIT License. See `LICENSE` for details. + +## Sinatra app testing: + +To test the Sinatra app that we just set up, you'll need to interact with its endpoints, specifically sending and receiving activities to and from the outbox and inbox respectively. + +Let's first ensure that the Sinatra app is running: + +1. Run the Sinatra application: + ```bash + bundle exec ruby app.rb + ``` + +2. Visit `http://localhost:4567/` in your browser to confirm that the app is running. + +Now, there are a few ways to interact with the application: + +### 1. Using `curl` + +**Sending an Activity to Outbox**: +Let's send a "Note" type activity to our actor's outbox which should forward it to another actor's inbox. + +```bash +curl -X POST -d "inbox_url=http://recipient.com/actors/2/inbox" -d "activity_data={\"type\":\"Note\",\"content\":\"Hello from Sinatra!\"}" http://localhost:4567/actors/1/outbox +``` + +**Receiving an Activity in Inbox**: +You can simulate another actor sending an activity to our actor's inbox: + +```bash +curl -X POST -H "Content-Type: application/json" -d "{\"type\":\"Note\",\"content\":\"Hello to Sinatra from another actor!\"}" http://localhost:4567/actors/1/inbox +``` + +### 2. Using a Web Browser + +You can easily check the root endpoint by navigating to `http://localhost:4567/` in your browser. However, for POST requests like the outbox and inbox interactions, you'll need a tool more suited for the task. + +### 3. Using Postman + +[Postman](https://www.postman.com/) is a popular tool for testing API endpoints. + +1. **Sending an Activity to Outbox**: + - Set the method to `POST`. + - URL: `http://localhost:4567/actors/1/outbox` + - Set the headers: + - `Content-Type: application/x-www-form-urlencoded` + - In the body, select `x-www-form-urlencoded` and add two keys: + - `inbox_url` with the value `http://recipient.com/actors/2/inbox` + - `activity_data` with the value `{"type":"Note","content":"Hello from Sinatra!"}` + - Click on "Send". + +2. **Receiving an Activity in Inbox**: + - Set the method to `POST`. + - URL: `http://localhost:4567/actors/1/inbox` + - Set the headers: + - `Content-Type: application/json` + - In the body, select `raw` and paste in `{"type":"Note","content":"Hello to Sinatra from another actor!"}` + - Click on "Send". + +**Note**: As this example is highly simplified, it doesn't handle potential errors or specific responses you'd expect from a fully-fledged ActivityPub service. Ensure that any production implementation follows the ActivityPub specification closely and manages potential risks. \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..cca7175 --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: %i[spec rubocop] diff --git a/activitypub.gemspec b/activitypub.gemspec new file mode 100644 index 0000000..71123cc --- /dev/null +++ b/activitypub.gemspec @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative "lib/activitypub/version" + +Gem::Specification.new do |spec| + spec.name = "activitypub" + spec.version = Activitypub::VERSION + spec.authors = ["Miguel Michelson Martinez"] + spec.email = ["miguelmichelson@gmail.com"] + + spec.summary = "A Ruby library for the ActivityPub protocol." + spec.description = "A comprehensive library for building and parsing ActivityPub content in Ruby." + spec.homepage = "https://github.com/rauversion/activitypub_ruby" + spec.license = "MIT" + + spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") + + # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage # "TODO: Put your gem's public repo URL here." + spec.metadata["changelog_uri"] = spec.homepage # "TODO: Put your gem's CHANGELOG.md URL here." + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + # Uncomment to register a new dependency of your gem + # spec.add_dependency "example-gem", "~> 1.0" + + # For more information and examples about making a new gem, checkout our + # guide at: https://bundler.io/guides/creating_gem.html +end diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..ca3ae4f --- /dev/null +++ b/app.rb @@ -0,0 +1,52 @@ +require 'sinatra' +require_relative "lib/activitypub" + +# For simplicity, we'll hardcode actor_id and generate a keypair on startup +actor_id = "https://example.com/actors/1" +keypair = ActivityPub::Signature.generate_keypair +outbox = ActivityPub::Outbox.new(actor_id: actor_id, private_key: keypair[:private]) + +post '/actors/1/outbox' do + target_inbox_url = params[:inbox_url] # expect the client to provide target inbox URL + activity_data = params[:activity_data] # and the activity data + + response = outbox.send_activity(target_inbox_url, activity_data) + [response.code.to_i, response.body] +end + +post '/actors/1/inbox' do + # This is where other actors would send activities to our actor + incoming_activity = JSON.parse(params['incoming_activity']) # JSON.parse(request.body.read) + + # For demonstration purposes, we'll just print the activity to the console + puts "Received activity: #{incoming_activity}" + + # Respond with a 200 OK for simplicity + [200, "Activity received"] +end + + + +get '/' do + %( + "ActivityPub Sinatra Example" +

Send an Activity to Outbox

+
+ + +

+ + +

+ +
+


+

Simulate Sending an Activity to our Inbox

+
+ + +

+ +
+ ) +end \ No newline at end of file diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..854dafe --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "activitypub" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/activitypub.rb b/lib/activitypub.rb new file mode 100644 index 0000000..d953fa7 --- /dev/null +++ b/lib/activitypub.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +require "json" +require 'time' +require 'base64' +require_relative "activitypub/version" +require_relative "activitypub/activity" +require_relative "activitypub/object" +require_relative "activitypub/actor" +require_relative "activitypub/signature" +require_relative "activitypub/inbox" +require_relative "activitypub/outbox" + +module Activitypub + class Error < StandardError; end + # Your code goes here... +end diff --git a/lib/activitypub/activity.rb b/lib/activitypub/activity.rb new file mode 100644 index 0000000..cfbebc8 --- /dev/null +++ b/lib/activitypub/activity.rb @@ -0,0 +1,70 @@ +module ActivityPub + class Activity + # The type of the activity, e.g., 'Create', 'Like', 'Follow', etc. + attr_accessor :type + + # The actor performing the activity. + attr_accessor :actor + + # The object of the activity, e.g., a post, a comment, etc. + attr_accessor :object + + # Any additional properties can go here. + # ... + + def initialize(type:, actor:, object:, **other_attributes) + @type = type + @actor = actor + @object = object + + # Handle other attributes as needed + other_attributes.each do |key, value| + instance_variable_set("@#{key}", value) + end + end + + # Convert the activity into a hash representation. + def to_h + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: @type, + actor: @actor, + object: @object + # Add any other attributes as needed. + # ... + } + end + + # Convert the activity into a JSON representation. + def to_json(*args) + to_h.to_json(*args) + end + + # Load an activity from a hash. + def self.from_h(hash) + new( + type: hash['type'], + actor: hash['actor'], + object: hash['object'] + # Handle any other attributes as needed. + # ... + ) + end + end +end + +#Usage example: + +#ruby +#Copy code +#activity = ActivityPub::Activity.new(type: 'Like', actor: 'https://example.com/users/alice', object: 'https://example.com/posts/1') +#puts activity.to_json + +#loaded_activity = ActivityPub::Activity.from_h(JSON.parse(activity.to_json)) +#puts loaded_activity.type # => "Like" +#Note that this is a basic and illustrative implementation. In a real-world scenario, you would need to handle more attributes, validations, and scenarios specified by the ActivityStreams and ActivityPub specifications. + + + + + diff --git a/lib/activitypub/actor.rb b/lib/activitypub/actor.rb new file mode 100644 index 0000000..02c0f3a --- /dev/null +++ b/lib/activitypub/actor.rb @@ -0,0 +1,55 @@ +# The ActivityPub::Actor represents individual and +# collective entities which perform activities. +# Commonly used actor types include Person, Organization, +# Service, etc. For simplicity, we will focus on the Person +# actor type. + +# Here's a basic representation for the ActivityPub::Actor: + +module ActivityPub + class Actor + attr_accessor :id, :type, :name, :preferred_username, :inbox, :outbox, :followers, :following + + def initialize(id:, type: 'Person', name:, preferred_username:, inbox:, outbox:, followers: nil, following: nil) + @id = id + @type = type + @name = name + @preferred_username = preferred_username + @inbox = inbox + @outbox = outbox + @followers = followers + @following = following + end + + def to_h + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: @id, + type: @type, + name: @name, + preferredUsername: @preferred_username, + inbox: @inbox, + outbox: @outbox, + followers: @followers, + following: @following + }.reject { |_, v| v.nil? } + end + + def to_json(*args) + to_h.to_json(*args) + end + + def self.from_h(hash) + new( + id: hash['id'], + type: hash['type'] || 'Person', + name: hash['name'], + preferred_username: hash['preferredUsername'], + inbox: hash['inbox'], + outbox: hash['outbox'], + followers: hash['followers'], + following: hash['following'] + ) + end + end +end diff --git a/lib/activitypub/inbox.rb b/lib/activitypub/inbox.rb new file mode 100644 index 0000000..a8a13a7 --- /dev/null +++ b/lib/activitypub/inbox.rb @@ -0,0 +1,45 @@ +require 'net/http' +require 'json' + +module ActivityPub + class Inbox + attr_reader :actor_id + + def initialize(actor_id:) + @actor_id = actor_id + end + + def accept_activity(activity_data, headers) + # Fetch the actor's public key + public_key = fetch_actor_public_key(actor_id) + + # Extract the signature from headers + signature = headers["Signature"] + + # Verify the activity's signature + unless ActivityPub::Signature.verify?(activity_data, signature, public_key) + raise "Invalid signature" + end + + # Process activity + process_activity(activity_data) + end + + private + + def fetch_actor_public_key(actor_id) + # Fetch the actor's profile + uri = URI(actor_id) + response = Net::HTTP.get(uri) + profile = JSON.parse(response) + + # This assumes the public key is stored under a 'publicKey' key in the actor's profile. + # This structure may vary based on the implementation details of the server hosting the actor's profile. + profile['publicKey']['publicKeyPem'] + end + + def process_activity(activity_data) + puts "Received activity: #{activity_data}" + end + end +end diff --git a/lib/activitypub/object.rb b/lib/activitypub/object.rb new file mode 100644 index 0000000..3661fbf --- /dev/null +++ b/lib/activitypub/object.rb @@ -0,0 +1,51 @@ +module ActivityPub + class Object + attr_accessor :id, :type, :published, :updated, :content, :attributed_to + + def initialize(id:, type: 'Object', published: nil, updated: nil, content: nil, attributed_to: nil) + @id = id + @type = type + @published = published || Time.now.utc.iso8601 + @updated = updated || Time.now.utc.iso8601 + @content = content + @attributed_to = attributed_to + end + + def to_h + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: @id, + type: @type, + published: @published, + updated: @updated, + content: @content, + attributedTo: @attributed_to + }.reject { |_, v| v.nil? } + end + + def to_json(*args) + to_h.to_json(*args) + end + + def self.from_h(hash) + new( + id: hash['id'], + type: hash['type'] || 'Object', + published: hash['published'], + updated: hash['updated'], + content: hash['content'], + attributed_to: hash['attributedTo'] + ) + end + end +end + + +=begin + obj = ActivityPub::Object.new(id: 'https://example.com/objects/1', content: 'Hello world!') +puts obj.to_json + +loaded_obj = ActivityPub::Object.from_h(JSON.parse(obj.to_json)) +puts loaded_obj.content # => "Hello world!" + +=end \ No newline at end of file diff --git a/lib/activitypub/outbox.rb b/lib/activitypub/outbox.rb new file mode 100644 index 0000000..7486c56 --- /dev/null +++ b/lib/activitypub/outbox.rb @@ -0,0 +1,36 @@ +require 'net/http' + +module ActivityPub + class Outbox + attr_reader :actor_id, :private_key + + def initialize(actor_id:, private_key:) + @actor_id = actor_id + @private_key = private_key + end + + # Simulate sending a new activity to a target actor's inbox + def send_activity(target_inbox_url, activity_data) + # Sign the activity data + signature = ActivityPub::Signature.sign(activity_data, private_key) + + # Send the signed activity to the target actor's inbox + response = post_activity(target_inbox_url, activity_data, signature) + + # Save the activity in the outbox (not implemented here for simplicity, but would be in a real-world scenario) + # save_activity(activity_data) + + response + end + + private + + def post_activity(inbox_url, activity_data, signature) + uri = URI(inbox_url) + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/ld+json', 'Signature' => signature }) + request.body = activity_data + http.request(request) + end + end +end diff --git a/lib/activitypub/signature.rb b/lib/activitypub/signature.rb new file mode 100644 index 0000000..f43dcbf --- /dev/null +++ b/lib/activitypub/signature.rb @@ -0,0 +1,30 @@ +require 'openssl' + +# Digital signatures are essential in federated networks +# like ActivityPub to ensure data integrity and authenticity. +# A commonly used algorithm for generating these signatures +# in the ActivityPub community is the RSA-SHA256 algorithm. + +module ActivityPub + class Signature + # Generates a new RSA private and public key pair + def self.generate_keypair + key = OpenSSL::PKey::RSA.new(2048) + { private: key.to_pem, public: key.public_key.to_pem } + end + + # Signs data with the given private key + def self.sign(data, private_key_pem) + private_key = OpenSSL::PKey::RSA.new(private_key_pem) + signature = private_key.sign(OpenSSL::Digest::SHA256.new, data) + Base64.encode64(signature).gsub("\n", '') + end + + # Verifies the signature with the provided public key + def self.verify?(data, signature, public_key_pem) + public_key = OpenSSL::PKey::RSA.new(public_key_pem) + signature_decoded = Base64.decode64(signature) + public_key.verify(OpenSSL::Digest::SHA256.new, signature_decoded, data) + end + end +end diff --git a/lib/activitypub/version.rb b/lib/activitypub/version.rb new file mode 100644 index 0000000..afd2cf9 --- /dev/null +++ b/lib/activitypub/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Activitypub + VERSION = "0.1.0" +end diff --git a/spec/activity_spec.rb b/spec/activity_spec.rb new file mode 100644 index 0000000..8b975a7 --- /dev/null +++ b/spec/activity_spec.rb @@ -0,0 +1,36 @@ +RSpec.describe ActivityPub::Activity do + describe '#initialize' do + subject { described_class.new(type: 'Like', actor: 'https://example.com/users/alice', object: 'https://example.com/posts/1') } + + it 'initializes with given attributes' do + expect(subject.type).to eq('Like') + expect(subject.actor).to eq('https://example.com/users/alice') + expect(subject.object).to eq('https://example.com/posts/1') + end + end + + describe '#to_json' do + subject { described_class.new(type: 'Like', actor: 'https://example.com/users/alice', object: 'https://example.com/posts/1') } + + it 'converts the activity to a JSON representation' do + json_output = subject.to_json + parsed_output = JSON.parse(json_output) + + expect(parsed_output['type']).to eq('Like') + expect(parsed_output['actor']).to eq('https://example.com/users/alice') + expect(parsed_output['object']).to eq('https://example.com/posts/1') + end + end + + describe '.from_h' do + let(:activity_hash) { { 'type' => 'Like', 'actor' => 'https://example.com/users/alice', 'object' => 'https://example.com/posts/1' } } + + it 'loads an activity from a hash' do + activity = described_class.from_h(activity_hash) + + expect(activity.type).to eq('Like') + expect(activity.actor).to eq('https://example.com/users/alice') + expect(activity.object).to eq('https://example.com/posts/1') + end + end +end \ No newline at end of file diff --git a/spec/activitypub_spec.rb b/spec/activitypub_spec.rb new file mode 100644 index 0000000..bc4d35d --- /dev/null +++ b/spec/activitypub_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Activitypub do + it "has a version number" do + expect(Activitypub::VERSION).not_to be nil + end +end diff --git a/spec/actor_spec.rb b/spec/actor_spec.rb new file mode 100644 index 0000000..6251488 --- /dev/null +++ b/spec/actor_spec.rb @@ -0,0 +1,70 @@ +RSpec.describe ActivityPub::Actor do + describe '#initialize' do + subject do + described_class.new( + id: 'https://example.com/users/alice', + name: 'Alice', + preferred_username: 'alice', + inbox: 'https://example.com/users/alice/inbox', + outbox: 'https://example.com/users/alice/outbox' + ) + end + + it 'initializes with given attributes' do + expect(subject.id).to eq('https://example.com/users/alice') + expect(subject.type).to eq('Person') + expect(subject.name).to eq('Alice') + expect(subject.preferred_username).to eq('alice') + expect(subject.inbox).to eq('https://example.com/users/alice/inbox') + expect(subject.outbox).to eq('https://example.com/users/alice/outbox') + end + end + + describe '#to_json' do + subject do + described_class.new( + id: 'https://example.com/users/alice', + name: 'Alice', + preferred_username: 'alice', + inbox: 'https://example.com/users/alice/inbox', + outbox: 'https://example.com/users/alice/outbox' + ) + end + + it 'converts the actor to a JSON representation' do + json_output = subject.to_json + parsed_output = JSON.parse(json_output) + + expect(parsed_output['id']).to eq('https://example.com/users/alice') + expect(parsed_output['type']).to eq('Person') + expect(parsed_output['name']).to eq('Alice') + expect(parsed_output['preferredUsername']).to eq('alice') + expect(parsed_output['inbox']).to eq('https://example.com/users/alice/inbox') + expect(parsed_output['outbox']).to eq('https://example.com/users/alice/outbox') + end + end + + describe '.from_h' do + let(:actor_hash) do + { + 'id' => 'https://example.com/users/alice', + 'type' => 'Person', + 'name' => 'Alice', + 'preferredUsername' => 'alice', + 'inbox' => 'https://example.com/users/alice/inbox', + 'outbox' => 'https://example.com/users/alice/outbox' + } + end + + it 'loads an actor from a hash' do + actor = described_class.from_h(actor_hash) + + expect(actor.id).to eq('https://example.com/users/alice') + expect(actor.type).to eq('Person') + expect(actor.name).to eq('Alice') + expect(actor.preferred_username).to eq('alice') + expect(actor.inbox).to eq('https://example.com/users/alice/inbox') + expect(actor.outbox).to eq('https://example.com/users/alice/outbox') + end + end +end \ No newline at end of file diff --git a/spec/inbox_spec.rb b/spec/inbox_spec.rb new file mode 100644 index 0000000..6c64374 --- /dev/null +++ b/spec/inbox_spec.rb @@ -0,0 +1,36 @@ +# spec/activitypub/inbox_spec.rb + +RSpec.describe ActivityPub::Inbox do + let(:actor_id) { "https://example.com/actors/1" } + let(:keypair) { ActivityPub::Signature.generate_keypair } + let(:activity_data) { '{"type":"Note","content":"Hello, world!"}' } + let(:headers) { { "Signature" => ActivityPub::Signature.sign(activity_data, keypair[:private]) } } + let(:public_key_response) do + { + publicKey: { + id: "#{actor_id}#main-key", + owner: actor_id, + publicKeyPem: keypair[:public] + } + }.to_json + end + + subject { described_class.new(actor_id: actor_id) } + + # Stubbing the HTTP request to return our example public key + before do + allow(Net::HTTP).to receive(:get).with(URI(actor_id)).and_return(public_key_response) + end + + describe '#accept_activity' do + it 'accepts and processes a valid activity' do + expect { subject.accept_activity(activity_data, headers) }.not_to raise_error + end + + it 'raises an error for an activity with an invalid signature' do + tampered_headers = headers.merge("Signature" => "INVALID_SIGNATURE") + + expect { subject.accept_activity(activity_data, tampered_headers) }.to raise_error("Invalid signature") + end + end +end diff --git a/spec/object_spec.rb b/spec/object_spec.rb new file mode 100644 index 0000000..606dd17 --- /dev/null +++ b/spec/object_spec.rb @@ -0,0 +1,47 @@ +# spec/activitypub/object_spec.rb + + +RSpec.describe ActivityPub::Object do + let(:current_time) { Time.now.utc.iso8601 } + + describe '#initialize' do + subject { described_class.new(id: 'https://example.com/objects/1', content: 'Hello world!') } + + it 'initializes with given attributes' do + expect(subject.id).to eq('https://example.com/objects/1') + expect(subject.type).to eq('Object') + expect(subject.content).to eq('Hello world!') + expect(subject.published).to be_a(String) # Checking if it's a formatted string for simplicity. In a real-world scenario, you might want to parse and validate. + expect(subject.updated).to be_a(String) + end + end + + describe '#to_json' do + subject { described_class.new(id: 'https://example.com/objects/1', content: 'Hello world!', published: current_time, updated: current_time) } + + it 'converts the object to a JSON representation' do + json_output = subject.to_json + parsed_output = JSON.parse(json_output) + + expect(parsed_output['id']).to eq('https://example.com/objects/1') + expect(parsed_output['type']).to eq('Object') + expect(parsed_output['content']).to eq('Hello world!') + expect(parsed_output['published']).to eq(current_time) + expect(parsed_output['updated']).to eq(current_time) + end + end + + describe '.from_h' do + let(:object_hash) { { 'id' => 'https://example.com/objects/1', 'type' => 'Object', 'content' => 'Hello world!', 'published' => current_time, 'updated' => current_time } } + + it 'loads an object from a hash' do + obj = described_class.from_h(object_hash) + + expect(obj.id).to eq('https://example.com/objects/1') + expect(obj.type).to eq('Object') + expect(obj.content).to eq('Hello world!') + expect(obj.published).to eq(current_time) + expect(obj.updated).to eq(current_time) + end + end +end diff --git a/spec/outbox_spec.rb b/spec/outbox_spec.rb new file mode 100644 index 0000000..9270420 --- /dev/null +++ b/spec/outbox_spec.rb @@ -0,0 +1,33 @@ +# spec/activitypub/outbox_spec.rb + +RSpec.describe ActivityPub::Outbox do + let(:actor_id) { "https://example.com/actors/1" } + let(:inbox_url) { "https://recipient.com/actors/2/inbox" } + let(:keypair) { ActivityPub::Signature.generate_keypair } + let(:activity_data) { '{"type":"Note","content":"Hello from outbox!"}' } + + subject { described_class.new(actor_id: actor_id, private_key: keypair[:private]) } + + # Stubbing the HTTP request to avoid actual posts during tests + before do + stub_request(:post, "http://recipient.com:443/actors/2/inbox") + .with( + body: activity_data, + headers: { + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Content-Type' => 'application/ld+json', + 'User-Agent'=>'Ruby', + 'Signature' => ActivityPub::Signature.sign(activity_data, keypair[:private]) + } + ) + .to_return(status: 200, body: "", headers: {}) + end + + describe '#send_activity' do + it 'sends the activity to the target inbox' do + response = subject.send_activity(inbox_url, activity_data) + expect(response.code).to eq("200") + end + end +end diff --git a/spec/signature_spec.rb b/spec/signature_spec.rb new file mode 100644 index 0000000..de01d1a --- /dev/null +++ b/spec/signature_spec.rb @@ -0,0 +1,36 @@ +# spec/activitypub/signature_spec.rb + + +RSpec.describe ActivityPub::Signature do + let(:keypair) { described_class.generate_keypair } + let(:data) { "Important data that needs to be signed" } + + describe '.generate_keypair' do + it 'generates a valid RSA key pair' do + expect(keypair[:private]).to be_a(String) + expect(keypair[:public]).to be_a(String) + end + end + + describe '.sign' do + let(:signature) { described_class.sign(data, keypair[:private]) } + + it 'signs data using a private key' do + expect(signature).to be_a(String) + # Note: In a real-world scenario, you'd have more comprehensive checks + # or perhaps try verifying the signature as a test. + end + end + + describe '.verify?' do + let(:signature) { described_class.sign(data, keypair[:private]) } + + it 'verifies the signed data with the correct public key' do + expect(described_class.verify?(data, signature, keypair[:public])).to be_truthy + end + + it 'returns false for incorrect data' do + expect(described_class.verify?("Tampered data", signature, keypair[:public])).to be_falsey + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f1aded9 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "activitypub" +require "webmock/rspec" +require "pry" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end