about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-08-26 13:47:38 +0200
committerGitHub <noreply@github.com>2017-08-26 13:47:38 +0200
commit00840f4f2edb8d1d46638ccbc90a1f4462d0867a (patch)
treec4f6c9a4967df5d5f23094ddefed88c621d6c3ff
parent1cebfed23e03b9d31796cdc139acde1b6dccd9f3 (diff)
Add handling of Linked Data Signatures in payloads (#4687)
* Add handling of Linked Data Signatures in payloads

* Add a way to sign JSON, fix canonicalization of signature options

* Fix signatureValue encoding, send out signed JSON when distributing

* Add missing security context
-rw-r--r--.rubocop.yml1
-rw-r--r--Gemfile3
-rw-r--r--Gemfile.lock16
-rw-r--r--app/helpers/jsonld_helper.rb13
-rw-r--r--app/lib/activitypub/adapter.rb2
-rw-r--r--app/lib/activitypub/linked_data_signature.rb56
-rw-r--r--app/services/activitypub/process_collection_service.rb11
-rw-r--r--app/services/authorize_follow_service.rb4
-rw-r--r--app/services/batched_remove_status_service.rb8
-rw-r--r--app/services/block_service.rb4
-rw-r--r--app/services/favourite_service.rb4
-rw-r--r--app/services/follow_service.rb4
-rw-r--r--app/services/process_mentions_service.rb4
-rw-r--r--app/services/reblog_service.rb4
-rw-r--r--app/services/reject_follow_service.rb4
-rw-r--r--app/services/remove_status_service.rb10
-rw-r--r--app/services/unblock_service.rb4
-rw-r--r--app/services/unfavourite_service.rb4
-rw-r--r--app/services/unfollow_service.rb4
-rw-r--r--app/workers/activitypub/distribution_worker.rb8
-rw-r--r--config/initializers/json_ld.rb4
-rw-r--r--lib/json_ld/identity.rb86
-rw-r--r--lib/json_ld/security.rb50
-rw-r--r--spec/lib/activitypub/linked_data_signature_spec.rb86
-rw-r--r--spec/services/activitypub/process_collection_service_spec.rb5
25 files changed, 369 insertions, 30 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index ae3697174..a36aa5cae 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -10,6 +10,7 @@ AllCops:
   - 'node_modules/**/*'
   - 'Vagrantfile'
   - 'vendor/**/*'
+  - 'lib/json_ld/*'
 
 Bundler/OrderedGems:
   Enabled: false
diff --git a/Gemfile b/Gemfile
index 52ac43b9a..ae90697f1 100644
--- a/Gemfile
+++ b/Gemfile
@@ -68,6 +68,9 @@ gem 'tzinfo-data', '~> 1.2017'
 gem 'webpacker', '~> 2.0'
 gem 'webpush'
 
+gem 'json-ld-preloaded', '~> 2.2.1'
+gem 'rdf-normalize', '~> 0.3.1'
+
 group :development, :test do
   gem 'fabrication', '~> 2.16'
   gem 'fuubar', '~> 2.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index adc37f7de..cd4573637 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -179,6 +179,8 @@ GEM
       activesupport (>= 4.0.1)
       hamlit (>= 1.2.0)
       railties (>= 4.0.1)
+    hamster (3.0.0)
+      concurrent-ruby (~> 1.0)
     hashdiff (0.3.5)
     highline (1.7.8)
     hiredis (0.6.1)
@@ -211,6 +213,13 @@ GEM
     idn-ruby (0.1.0)
     jmespath (1.3.1)
     json (2.1.0)
+    json-ld (2.1.5)
+      multi_json (~> 1.12)
+      rdf (~> 2.2)
+    json-ld-preloaded (2.2.1)
+      json-ld (~> 2.1, >= 2.1.5)
+      multi_json (~> 1.11)
+      rdf (~> 2.2)
     jsonapi-renderer (0.1.3)
     jwt (1.5.6)
     kaminari (1.0.1)
@@ -348,6 +357,11 @@ GEM
     rainbow (2.2.2)
       rake
     rake (12.0.0)
+    rdf (2.2.8)
+      hamster (~> 3.0)
+      link_header (~> 0.0, >= 0.0.8)
+    rdf-normalize (0.3.2)
+      rdf (~> 2.0)
     redis (3.3.3)
     redis-actionpack (5.0.1)
       actionpack (>= 4.0, < 6)
@@ -531,6 +545,7 @@ DEPENDENCIES
   httplog (~> 0.99)
   i18n-tasks (~> 0.9)
   idn-ruby
+  json-ld-preloaded (~> 2.2.1)
   kaminari (~> 1.0)
   letter_opener (~> 1.4)
   letter_opener_web (~> 1.3)
@@ -560,6 +575,7 @@ DEPENDENCIES
   rails-controller-testing (~> 1.0)
   rails-i18n (~> 5.0)
   rails-settings-cached (~> 0.6)
+  rdf-normalize (~> 0.3.1)
   redis (~> 3.3)
   redis-namespace (~> 1.5)
   redis-rails (~> 5.0)
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index 8355eb055..09446c8be 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -17,6 +17,11 @@ module JsonLdHelper
     !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
   end
 
+  def canonicalize(json)
+    graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
+    graph.dump(:normalize)
+  end
+
   def fetch_resource(uri)
     response = build_request(uri).perform
     return if response.code != 200
@@ -29,6 +34,14 @@ module JsonLdHelper
     nil
   end
 
+  def merge_context(context, new_context)
+    if context.is_a?(Array)
+      context << new_context
+    else
+      [context, new_context]
+    end
+  end
+
   private
 
   def build_request(uri)
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index df132f019..92210579e 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -11,7 +11,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
 
   def serializable_hash(options = nil)
     options = serialization_options(options)
-    serialized_hash = { '@context': ActivityPub::TagManager::CONTEXT }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
+    serialized_hash = { '@context': [ActivityPub::TagManager::CONTEXT, 'https://w3id.org/security/v1'] }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
     self.class.transform_key_casing!(serialized_hash, instance_options)
   end
 end
diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb
new file mode 100644
index 000000000..7173aed19
--- /dev/null
+++ b/app/lib/activitypub/linked_data_signature.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class ActivityPub::LinkedDataSignature
+  include JsonLdHelper
+
+  CONTEXT = 'https://w3id.org/identity/v1'
+
+  def initialize(json)
+    @json = json
+  end
+
+  def verify_account!
+    return unless @json['signature'].is_a?(Hash)
+
+    type        = @json['signature']['type']
+    creator_uri = @json['signature']['creator']
+    signature   = @json['signature']['signatureValue']
+
+    return unless type == 'RsaSignature2017'
+
+    creator   = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
+    creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri)
+
+    return if creator.nil?
+
+    options_hash   = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+    document_hash  = hash(@json.without('signature'))
+    to_be_verified = options_hash + document_hash
+
+    if creator.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), to_be_verified)
+      creator
+    end
+  end
+
+  def sign!(creator)
+    options = {
+      'type'    => 'RsaSignature2017',
+      'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join,
+      'created' => Time.now.utc.iso8601,
+    }
+
+    options_hash  = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+    document_hash = hash(@json.without('signature'))
+    to_be_signed  = options_hash + document_hash
+
+    signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed))
+
+    @json.merge('@context' => merge_context(@json['@context'], CONTEXT), 'signature' => options.merge('signatureValue' => signature))
+  end
+
+  private
+
+  def hash(obj)
+    Digest::SHA256.hexdigest(canonicalize(obj))
+  end
+end
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
index cd861c075..2cf15553d 100644
--- a/app/services/activitypub/process_collection_service.rb
+++ b/app/services/activitypub/process_collection_service.rb
@@ -9,6 +9,8 @@ class ActivityPub::ProcessCollectionService < BaseService
 
     return if @account.suspended? || !supported_context?
 
+    verify_account! if different_actor?
+
     case @json['type']
     when 'Collection', 'CollectionPage'
       process_items @json['items']
@@ -23,6 +25,10 @@ class ActivityPub::ProcessCollectionService < BaseService
 
   private
 
+  def different_actor?
+    @json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present?
+  end
+
   def process_items(items)
     items.reverse_each.map { |item| process_item(item) }.compact
   end
@@ -35,4 +41,9 @@ class ActivityPub::ProcessCollectionService < BaseService
     activity = ActivityPub::Activity.factory(item, @account)
     activity&.perform
   end
+
+  def verify_account!
+    account  = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+    @account = account unless account.nil?
+  end
 end
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
index 6f036dc5a..b1bff8962 100644
--- a/app/services/authorize_follow_service.rb
+++ b/app/services/authorize_follow_service.rb
@@ -24,11 +24,11 @@ class AuthorizeFollowService < BaseService
   end
 
   def build_json(follow_request)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       follow_request,
       serializer: ActivityPub::AcceptFollowSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(follow_request.target_account))
   end
 
   def build_xml(follow_request)
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index e6c8c9208..c90f4401d 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -138,10 +138,14 @@ class BatchedRemoveStatusService < BaseService
   def build_json(status)
     return @activity_json[status.id] if @activity_json.key?(status.id)
 
-    @activity_json[status.id] = ActiveModelSerializers::SerializableResource.new(
+    @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new(
       status,
       serializer: ActivityPub::DeleteSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json)
+  end
+
+  def sign_json(status, json)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account))
   end
 end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index f2253226b..b39c3eef2 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -27,11 +27,11 @@ class BlockService < BaseService
   end
 
   def build_json(block)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       block,
       serializer: ActivityPub::BlockSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(block.account))
   end
 
   def build_xml(block)
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 4aa935170..44df3ed13 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -34,11 +34,11 @@ class FavouriteService < BaseService
   end
 
   def build_json(favourite)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       favourite,
       serializer: ActivityPub::LikeSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(favourite.account))
   end
 
   def build_xml(favourite)
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 2be625cd8..a92eb6b88 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -67,10 +67,10 @@ class FollowService < BaseService
   end
 
   def build_json(follow_request)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       follow_request,
       serializer: ActivityPub::FollowSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(follow_request.account))
   end
 end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 2b8a77147..f123bf869 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -47,11 +47,11 @@ class ProcessMentionsService < BaseService
   end
 
   def build_json(status)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       status,
       serializer: ActivityPub::ActivitySerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(status.account))
   end
 
   def follow_remote_account_service
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 7f886af7c..5ed16c64b 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -42,10 +42,10 @@ class ReblogService < BaseService
   end
 
   def build_json(reblog)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       reblog,
       serializer: ActivityPub::ActivitySerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(reblog.account))
   end
 end
diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb
index a91266aa4..c1f7bcb60 100644
--- a/app/services/reject_follow_service.rb
+++ b/app/services/reject_follow_service.rb
@@ -19,11 +19,11 @@ class RejectFollowService < BaseService
   end
 
   def build_json(follow_request)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       follow_request,
       serializer: ActivityPub::RejectFollowSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(follow_request.target_account))
   end
 
   def build_xml(follow_request)
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index fcccbaa24..62eea677f 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -56,7 +56,7 @@ class RemoveStatusService < BaseService
 
     # ActivityPub
     ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |inbox_url|
-      [activity_json, @account.id, inbox_url]
+      [signed_activity_json, @account.id, inbox_url]
     end
   end
 
@@ -66,7 +66,7 @@ class RemoveStatusService < BaseService
 
     # ActivityPub
     ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
-      [activity_json, @account.id, inbox_url]
+      [signed_activity_json, @account.id, inbox_url]
     end
   end
 
@@ -74,12 +74,16 @@ class RemoveStatusService < BaseService
     @salmon_xml ||= stream_entry_to_xml(@stream_entry)
   end
 
+  def signed_activity_json
+    @signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account))
+  end
+
   def activity_json
     @activity_json ||= ActiveModelSerializers::SerializableResource.new(
       @status,
       serializer: ActivityPub::DeleteSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json
   end
 
   def remove_reblogs
diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb
index 72fc5ab15..869f62d1c 100644
--- a/app/services/unblock_service.rb
+++ b/app/services/unblock_service.rb
@@ -20,11 +20,11 @@ class UnblockService < BaseService
   end
 
   def build_json(unblock)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       unblock,
       serializer: ActivityPub::UndoBlockSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(unblock.account))
   end
 
   def build_xml(block)
diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb
index e53798e66..2fda11bd6 100644
--- a/app/services/unfavourite_service.rb
+++ b/app/services/unfavourite_service.rb
@@ -21,11 +21,11 @@ class UnfavouriteService < BaseService
   end
 
   def build_json(favourite)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       favourite,
       serializer: ActivityPub::UndoLikeSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(favourite.account))
   end
 
   def build_xml(favourite)
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 10af75146..bf151ee28 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -23,11 +23,11 @@ class UnfollowService < BaseService
   end
 
   def build_json(follow)
-    ActiveModelSerializers::SerializableResource.new(
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       follow,
       serializer: ActivityPub::UndoFollowSerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json).sign!(follow.account))
   end
 
   def build_xml(follow)
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index 004dd25d1..14bb933c0 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -12,7 +12,7 @@ class ActivityPub::DistributionWorker
     return if skip_distribution?
 
     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
-      [payload, @account.id, inbox_url]
+      [signed_payload, @account.id, inbox_url]
     end
   rescue ActiveRecord::RecordNotFound
     true
@@ -28,11 +28,15 @@ class ActivityPub::DistributionWorker
     @inboxes ||= @account.followers.inboxes
   end
 
+  def signed_payload
+    @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account))
+  end
+
   def payload
     @payload ||= ActiveModelSerializers::SerializableResource.new(
       @status,
       serializer: ActivityPub::ActivitySerializer,
       adapter: ActivityPub::Adapter
-    ).to_json
+    ).as_json
   end
 end
diff --git a/config/initializers/json_ld.rb b/config/initializers/json_ld.rb
new file mode 100644
index 000000000..408e6490d
--- /dev/null
+++ b/config/initializers/json_ld.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+require_relative '../../lib/json_ld/identity'
+require_relative '../../lib/json_ld/security'
diff --git a/lib/json_ld/identity.rb b/lib/json_ld/identity.rb
new file mode 100644
index 000000000..cfe50b956
--- /dev/null
+++ b/lib/json_ld/identity.rb
@@ -0,0 +1,86 @@
+# -*- encoding: utf-8 -*-
+# frozen_string_literal: true
+# This file generated automatically from https://w3id.org/identity/v1
+require 'json/ld'
+class JSON::LD::Context
+  add_preloaded("https://w3id.org/identity/v1") do
+    new(processingMode: "json-ld-1.0", term_definitions: {
+      "Credential" => TermDefinition.new("Credential", id: "https://w3id.org/credentials#Credential", simple: true),
+      "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true),
+      "CryptographicKeyCredential" => TermDefinition.new("CryptographicKeyCredential", id: "https://w3id.org/credentials#CryptographicKeyCredential", simple: true),
+      "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true),
+      "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true),
+      "Group" => TermDefinition.new("Group", id: "https://www.w3.org/ns/activitystreams#Group", simple: true),
+      "Identity" => TermDefinition.new("Identity", id: "https://w3id.org/identity#Identity", simple: true),
+      "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true),
+      "Organization" => TermDefinition.new("Organization", id: "http://schema.org/Organization", simple: true),
+      "Person" => TermDefinition.new("Person", id: "http://schema.org/Person", simple: true),
+      "PostalAddress" => TermDefinition.new("PostalAddress", id: "http://schema.org/PostalAddress", simple: true),
+      "about" => TermDefinition.new("about", id: "http://schema.org/about", type_mapping: "@id"),
+      "accessControl" => TermDefinition.new("accessControl", id: "https://w3id.org/permissions#accessControl", type_mapping: "@id"),
+      "address" => TermDefinition.new("address", id: "http://schema.org/address", type_mapping: "@id"),
+      "addressCountry" => TermDefinition.new("addressCountry", id: "http://schema.org/addressCountry", simple: true),
+      "addressLocality" => TermDefinition.new("addressLocality", id: "http://schema.org/addressLocality", simple: true),
+      "addressRegion" => TermDefinition.new("addressRegion", id: "http://schema.org/addressRegion", simple: true),
+      "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true),
+      "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true),
+      "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true),
+      "claim" => TermDefinition.new("claim", id: "https://w3id.org/credentials#claim", type_mapping: "@id"),
+      "comment" => TermDefinition.new("comment", id: "http://www.w3.org/2000/01/rdf-schema#comment", simple: true),
+      "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"),
+      "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"),
+      "cred" => TermDefinition.new("cred", id: "https://w3id.org/credentials#", simple: true, prefix: true),
+      "credential" => TermDefinition.new("credential", id: "https://w3id.org/credentials#credential", type_mapping: "@id"),
+      "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true),
+      "description" => TermDefinition.new("description", id: "http://schema.org/description", simple: true),
+      "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true),
+      "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true),
+      "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true),
+      "email" => TermDefinition.new("email", id: "http://schema.org/email", simple: true),
+      "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"),
+      "familyName" => TermDefinition.new("familyName", id: "http://schema.org/familyName", simple: true),
+      "givenName" => TermDefinition.new("givenName", id: "http://schema.org/givenName", simple: true),
+      "id" => TermDefinition.new("id", id: "@id", simple: true),
+      "identity" => TermDefinition.new("identity", id: "https://w3id.org/identity#", simple: true, prefix: true),
+      "identityService" => TermDefinition.new("identityService", id: "https://w3id.org/identity#identityService", type_mapping: "@id"),
+      "idp" => TermDefinition.new("idp", id: "https://w3id.org/identity#idp", type_mapping: "@id"),
+      "image" => TermDefinition.new("image", id: "http://schema.org/image", type_mapping: "@id"),
+      "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true),
+      "issued" => TermDefinition.new("issued", id: "https://w3id.org/credentials#issued", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"),
+      "issuer" => TermDefinition.new("issuer", id: "https://w3id.org/credentials#issuer", type_mapping: "@id"),
+      "label" => TermDefinition.new("label", id: "http://www.w3.org/2000/01/rdf-schema#label", simple: true),
+      "member" => TermDefinition.new("member", id: "http://schema.org/member", type_mapping: "@id"),
+      "memberOf" => TermDefinition.new("memberOf", id: "http://schema.org/memberOf", type_mapping: "@id"),
+      "name" => TermDefinition.new("name", id: "http://schema.org/name", simple: true),
+      "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true),
+      "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true),
+      "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"),
+      "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true),
+      "paymentProcessor" => TermDefinition.new("paymentProcessor", id: "https://w3id.org/payswarm#processor", simple: true),
+      "perm" => TermDefinition.new("perm", id: "https://w3id.org/permissions#", simple: true, prefix: true),
+      "postalCode" => TermDefinition.new("postalCode", id: "http://schema.org/postalCode", simple: true),
+      "preferences" => TermDefinition.new("preferences", id: "https://w3id.org/payswarm#preferences", type_mapping: "@vocab"),
+      "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"),
+      "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true),
+      "ps" => TermDefinition.new("ps", id: "https://w3id.org/payswarm#", simple: true, prefix: true),
+      "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"),
+      "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true),
+      "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"),
+      "rdf" => TermDefinition.new("rdf", id: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", simple: true, prefix: true),
+      "rdfs" => TermDefinition.new("rdfs", id: "http://www.w3.org/2000/01/rdf-schema#", simple: true, prefix: true),
+      "recipient" => TermDefinition.new("recipient", id: "https://w3id.org/credentials#recipient", type_mapping: "@id"),
+      "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"),
+      "schema" => TermDefinition.new("schema", id: "http://schema.org/", simple: true, prefix: true),
+      "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true),
+      "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true),
+      "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signatureAlgorithm", simple: true),
+      "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true),
+      "streetAddress" => TermDefinition.new("streetAddress", id: "http://schema.org/streetAddress", simple: true),
+      "title" => TermDefinition.new("title", id: "http://purl.org/dc/terms/title", simple: true),
+      "type" => TermDefinition.new("type", id: "@type", simple: true),
+      "url" => TermDefinition.new("url", id: "http://schema.org/url", type_mapping: "@id"),
+      "writePermission" => TermDefinition.new("writePermission", id: "https://w3id.org/permissions#writePermission", type_mapping: "@id"),
+      "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true)
+    })
+  end
+end
diff --git a/lib/json_ld/security.rb b/lib/json_ld/security.rb
new file mode 100644
index 000000000..1230206f0
--- /dev/null
+++ b/lib/json_ld/security.rb
@@ -0,0 +1,50 @@
+# -*- encoding: utf-8 -*-
+# frozen_string_literal: true
+# This file generated automatically from https://w3id.org/security/v1
+require 'json/ld'
+class JSON::LD::Context
+  add_preloaded("https://w3id.org/security/v1") do
+    new(processingMode: "json-ld-1.0", term_definitions: {
+      "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true),
+      "EcdsaKoblitzSignature2016" => TermDefinition.new("EcdsaKoblitzSignature2016", id: "https://w3id.org/security#EcdsaKoblitzSignature2016", simple: true),
+      "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true),
+      "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true),
+      "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true),
+      "LinkedDataSignature2016" => TermDefinition.new("LinkedDataSignature2016", id: "https://w3id.org/security#LinkedDataSignature2016", simple: true),
+      "authenticationTag" => TermDefinition.new("authenticationTag", id: "https://w3id.org/security#authenticationTag", simple: true),
+      "canonicalizationAlgorithm" => TermDefinition.new("canonicalizationAlgorithm", id: "https://w3id.org/security#canonicalizationAlgorithm", simple: true),
+      "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true),
+      "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true),
+      "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true),
+      "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"),
+      "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"),
+      "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true),
+      "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true),
+      "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true),
+      "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true),
+      "encryptionKey" => TermDefinition.new("encryptionKey", id: "https://w3id.org/security#encryptionKey", simple: true),
+      "expiration" => TermDefinition.new("expiration", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"),
+      "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"),
+      "id" => TermDefinition.new("id", id: "@id", simple: true),
+      "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true),
+      "iterationCount" => TermDefinition.new("iterationCount", id: "https://w3id.org/security#iterationCount", simple: true),
+      "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true),
+      "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true),
+      "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"),
+      "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true),
+      "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"),
+      "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true),
+      "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"),
+      "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true),
+      "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"),
+      "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"),
+      "salt" => TermDefinition.new("salt", id: "https://w3id.org/security#salt", simple: true),
+      "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true),
+      "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true),
+      "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signingAlgorithm", simple: true),
+      "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true),
+      "type" => TermDefinition.new("type", id: "@type", simple: true),
+      "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true)
+    })
+  end
+end
diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb
new file mode 100644
index 000000000..ee4b68028
--- /dev/null
+++ b/spec/lib/activitypub/linked_data_signature_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::LinkedDataSignature do
+  include JsonLdHelper
+
+  let!(:sender) { Fabricate(:account, uri: 'http://example.com/alice') }
+
+  let(:raw_json) do
+    {
+      '@context' => 'https://www.w3.org/ns/activitystreams',
+      'id' => 'http://example.com/hello-world',
+    }
+  end
+
+  let(:json) { raw_json.merge('signature' => signature) }
+
+  subject { described_class.new(json) }
+
+  describe '#verify_account!' do
+    context 'when signature matches' do
+      let(:raw_signature) do
+        {
+          'creator' => 'http://example.com/alice',
+          'created' => '2017-09-23T20:21:34Z',
+        }
+      end
+
+      let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) }
+
+      it 'returns creator' do
+        expect(subject.verify_account!).to eq sender
+      end
+    end
+
+    context 'when signature is missing' do
+      let(:signature) { nil }
+
+      it 'returns nil' do
+        expect(subject.verify_account!).to be_nil
+      end
+    end
+
+    context 'when signature is tampered' do
+      let(:raw_signature) do
+        {
+          'creator' => 'http://example.com/alice',
+          'created' => '2017-09-23T20:21:34Z',
+        }
+      end
+
+      let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => 's69F3mfddd99dGjmvjdjjs81e12jn121Gkm1') }
+
+      it 'returns nil' do
+        expect(subject.verify_account!).to be_nil
+      end
+    end
+  end
+
+  describe '#sign!' do
+    subject { described_class.new(raw_json).sign!(sender) }
+
+    it 'returns a hash' do
+      expect(subject).to be_a Hash
+    end
+
+    it 'contains signature context' do
+      expect(subject['@context']).to include('https://www.w3.org/ns/activitystreams', 'https://w3id.org/identity/v1')
+    end
+
+    it 'contains signature' do
+      expect(subject['signature']).to be_a Hash
+      expect(subject['signature']['signatureValue']).to be_present
+    end
+
+    it 'can be verified again' do
+      expect(described_class.new(subject).verify_account!).to eq sender
+    end
+  end
+
+  def sign(from_account, options, document)
+    options_hash   = Digest::SHA256.hexdigest(canonicalize(options.merge('@context' => ActivityPub::LinkedDataSignature::CONTEXT)))
+    document_hash  = Digest::SHA256.hexdigest(canonicalize(document))
+    to_be_verified = options_hash + document_hash
+    Base64.strict_encode64(from_account.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_verified))
+  end
+end
diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb
index 6486483f6..bf3bc82aa 100644
--- a/spec/services/activitypub/process_collection_service_spec.rb
+++ b/spec/services/activitypub/process_collection_service_spec.rb
@@ -1,9 +1,10 @@
 require 'rails_helper'
 
 RSpec.describe ActivityPub::ProcessCollectionService do
-  subject { ActivityPub::ProcessCollectionService.new }
+  subject { described_class.new }
 
   describe '#call' do
-    pending
+    context 'when actor is the sender'
+    context 'when actor differs from sender'
   end
 end