diff --git a/.rubocop.yml b/.rubocop.yml index 425709187c3..7da1cbf0dca 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,7 @@ AllCops: - Vagrantfile - config/initializers/json_ld* - lib/mastodon/migration_helpers.rb + - lib/json-canonicalization/floats_fix.rb ExtraDetails: true NewCops: enable TargetRubyVersion: 3.3 # Oldest supported ruby version diff --git a/Gemfile b/Gemfile index 57ad858c1e9..301e89039c0 100644 --- a/Gemfile +++ b/Gemfile @@ -96,6 +96,7 @@ gem 'webauthn', '~> 3.0' gem 'webpush', github: 'mastodon/webpush', ref: '9631ac63045cfabddacc69fc06e919b4c13eb913' gem 'json' +gem 'json-canonicalization', '~> 1.0' gem 'json-ld' gem 'json-ld-preloaded', '~> 3.2' gem 'rdf-normalize', '~> 0.5' diff --git a/Gemfile.lock b/Gemfile.lock index 316555f68e6..b55550e7212 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1002,6 +1002,7 @@ DEPENDENCIES irb (~> 1.8) jd-paperclip-azure (~> 3.0) json + json-canonicalization (~> 1.0) json-ld json-ld-preloaded (~> 3.2) json-schema (~> 6.0) diff --git a/app/lib/activitypub/object_integrity_proof.rb b/app/lib/activitypub/object_integrity_proof.rb new file mode 100644 index 00000000000..af700333893 --- /dev/null +++ b/app/lib/activitypub/object_integrity_proof.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# This is an implementation of https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md +class ActivityPub::ObjectIntegrityProof + include JsonLdHelper + + CONTEXT = 'https://w3id.org/identity/v1' + SIGNATURE_CONTEXT = 'https://w3id.org/security/v1' + + def initialize(json) + @json = json + end + + def verify_actor!(proof_purpose: 'assertionMethod') + return unless @json.is_a?(Hash) && @json['proof'].is_a?(Hash) + + proof = @json['proof'] + return unless proof['type'].present? && proof['verificationMethod'].present? && proof['proofPurpose'].present? + + return if proof_purpose != proof['proofPurpose'] + + return if proof['type'] != 'DataIntegrityProof' + + cryptosuite = proof['cryptosuite'] + key_uri = proof['verificationMethod'] + + return unless cryptosuite == 'eddsa-jcs-2022' && proof['proofValue'].present? + + keypair = Keypair.from_keyid(key_uri) + keypair = ActivityPub::FetchRemoteKeyService.new.call(key_uri) if keypair&.public_key.blank? + return if keypair.nil? || !keypair.usable? || keypair.type != 'ed25519' + + keypair.actor if ActivityPub::ObjectIntegrityProof.verify_eddsa_jcs_2022(@json, keypair.keypair) + rescue OpenSSL::PKey::RSAError + false + end + + # https://www.w3.org/TR/vc-di-eddsa/#verify-proof-eddsa-jcs-2022 + def self.verify_eddsa_jcs_2022(document, keypair) # rubocop:disable Naming/VariableNumber + unsecured_document = document.without('proof') + proof_options = document['proof'].without('proofValue') + proof_bytes = Multibase.decode(document['proof']['proofValue']) + + if proof_options['@context'].present? + return unless unsecured_document['@context'].is_a?(Array) + return unless unsecured_document['@context'][...proof_options['@context'].length] == proof_options['@context'] + + # Step 4.2. from the aforementioned algorithm, it's useful when vocabulary necessary to the cryptosuite had to be added on top of the to-be-signed document + unsecured_document['@context'] = proof_options['@context'] + end + + transformed_data = unsecured_document.to_json_c14n + proof_config = proof_options.to_json_c14n + + to_be_verified = Digest::SHA256.digest(proof_config) + Digest::SHA256.digest(transformed_data) + + keypair.verify(nil, proof_bytes, to_be_verified) + end +end diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb index 87f74decce6..315e0c26354 100644 --- a/app/services/activitypub/process_collection_service.rb +++ b/app/services/activitypub/process_collection_service.rb @@ -37,8 +37,20 @@ class ActivityPub::ProcessCollectionService < BaseService @options[:relayed_through_actor] = @account - # TODO: handle FEP-8b32 + # Linked Data Signature verification @account = actor_from_verified_ld_signature + + # If Linked Data Signature verification failed, throw away the signature + # as other parts of the code use its presence as an indication of whether + # to forward the activity (don't throw away compaction though, it is still useful) + @json = @json.without('signature') if @account.nil? + + # TODO: in the future, we might extend our forwarding rules to allow activities with + # FEP-8b32 Object Integrity Proofs to be forwarded. + # This would require keeping the original JSON around and changing forwarding logic in + # a few places. This is not worth it right now since FEP-8b32 is not widely supported, + # but could be worth doing in the future. + @account ||= actor_from_verified_object_integrity_proof(original_json) end return if !@account.is_a?(Account) || different_actor? || suspended_actor? || @account.local? @@ -96,4 +108,12 @@ class ActivityPub::ProcessCollectionService < BaseService Rails.logger.debug { "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}" } nil end + + def actor_from_verified_object_integrity_proof(original_json) + return unless original_json['proof'].present? && original_json['actor'] == @json['actor'] + return if domain_not_allowed?(@json['proof']['verificationMethod']) + + # Verification is done on the original JSON without a signature + ActivityPub::ObjectIntegrityProof.new(original_json.without('signature')).verify_actor! + end end diff --git a/config/application.rb b/config/application.rb index 09fe38065e3..34899d6a690 100644 --- a/config/application.rb +++ b/config/application.rb @@ -52,6 +52,7 @@ require_relative '../lib/active_record/database_tasks_extensions' require_relative '../lib/active_record/batches' require_relative '../lib/simple_navigation/item_extensions' require_relative '../lib/vite_ruby/sri_extensions' +require_relative '../lib/json-canonicalization/floats_fix' Bundler.require(:pam_authentication) if ENV['PAM_ENABLED'] == 'true' diff --git a/lib/json-canonicalization/floats_fix.rb b/lib/json-canonicalization/floats_fix.rb new file mode 100644 index 00000000000..f2c28ad67b7 --- /dev/null +++ b/lib/json-canonicalization/floats_fix.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# Originally written by ViaCelestia +# https://github.com/dryruby/json-canonicalization/pull/6 + +class Numeric + # This is intended to be compliant with ECMA-262, version 6.0 (ES6) + # + # See https://262.ecma-international.org/6.0/#sec-tostring-applied-to-the-number-type + # + # JSON does not permit NaN or infinite values, so those raise an error + def to_json_c14n + raise RangeError if self.is_a?(Float) && !self.finite? + return '0' if self.zero? + + # We may or may not be using scientific notation (see https://en.wikipedia.org/wiki/Scientific_notation) + # at this point, but the terminology is the same. Numbers are represented as a significand + # (also known as a mantissa) multiplied by 10 raised to an exponent. A number like 1701 may be represented + # as 1701 * 10^0, 170.1 * 10^1, 17.01 * 10^2, or (in scientific notation) 1.701 * 10^3. ES6 and Ruby don't + # always agree on when to use scientific notation, but if Ruby has done the conversion, we can use the + # exponent below when reproducing the behavior in the ES6 spec. + significand_digits, exponent_digits = self.abs.to_s.split('e', 2) + + integer_digits, fraction_digits = significand_digits.split('.', 2) + + + # From the ES6 spec: + # + # "The abstract operation ToString converts a Number m to String format as follows ... + # let n, k, and s be integers such that k ≥ 1, 10k−1 ≤ s < 10k, the Number value for s × 10n−k is m, + # and k is as small as possible. Note that k is the number of digits in the decimal representation of s, + # that s is not divisible by 10, and that the least significant digit of s is not necessarily uniquely + # determined by these criteria." + # + # This is just a different sort of exponential notation, but instead of preferring 0 < s < 10 as in + # scientific notation, here we want s to be an integer, and not divisible by 10. Since we're relying + # on ruby's existing #to_s, s is just the significand without the decimal or any leading or trailing + # zeroes + s = significand_digits.sub('.', '').sub(/^-?0*/, '').sub(/0*$/, '') + + # Once we know s, k is easy + k = s.length + + # If n is positive, it represents the number of digits (including trailing zeroes) to the left of the + # decimal. If n is negative or zero, it represents the number of zeroes to the right of the decimal. + # n-1 is also equal to the exponent used in scientific notation represenations of m, so if we already + # have that representation, we can use that rather than try to recalculate where the decimal would be + # If we don't already have an exponent, we just do digit counting rather than using Math.log10(self) or + # the slightly more precise Math.log2(self)/Math.log2(10) since that can lose precision for values very + # close to n = 22, like the Integer value 999999999999999700000 + n = if exponent_digits + exponent_digits.to_i + 1 + elsif integer_digits.to_i > 0 + integer_digits.length + else + -fraction_digits.index(/[1-9]/) + end + + exponent = n - 1 + + # Per the spec, positive numbers do not include a sign, but exponents always do + sign = self.negative? ? '-' : '' + exponent_sign = exponent.negative? ? '-' : '+' + + if k <= n && n <= 21 # Whole numbers, possibly with trailing zeroes, and < 10^21 + # return the String consisting of the code units of the k digits of the decimal representation of s + # (in order, with no leading zeroes), followed by n−k occurrences of the code unit 0x0030 (DIGIT ZERO). + [sign, s, '0' * (n - k)].join + elsif 0 < n && n <= 21 # Numbers with an integer component < 10^21 + # return the String consisting of the code units of the most significant n digits of the decimal + # representation of s, followed by the code unit 0x002E (FULL STOP), followed by the code units of the + # remaining k−n digits of the decimal representation of s. + [sign, s[0..(n-1)], '.', s[n..-1]].join + elsif -6 < n && n <= 0 # Fractional numbers to no more than 6 decimal places + # return the String consisting of the code unit 0x0030 (DIGIT ZERO), followed by the code unit 0x002E + # (FULL STOP), followed by −n occurrences of the code unit 0x0030 (DIGIT ZERO), followed by the code + # units of the k digits of the decimal representation of s + [sign, '0.', '0' * (-n), s].join + elsif k == 1 # single significant digit outside of -6 < n <= 21 + # return the String consisting of the code unit of the single digit of s, followed by code unit 0x0065 + # (LATIN SMALL LETTER E), followed by the code unit 0x002B (PLUS SIGN) or the code unit 0x002D + # (HYPHEN-MINUS) according to whether n−1 is positive or negative, followed by the code units of the decimal + # representation of the integer abs(n−1) (with no leading zeroes). + # + # This produces "1e-18", rather than Ruby's default "1.0e-18" + [sign, s, 'e', exponent_sign, exponent.abs].join + else # multiple significant digits outside of -6 < n <= 21 + # Return the String consisting of the code units of the most significant digit of the decimal representation + # of s, followed by code unit 0x002E (FULL STOP), followed by the code units of the remaining k−1 digits of + # the decimal representation of s, followed by code unit 0x0065 (LATIN SMALL LETTER E), followed by code unit + # 0x002B (PLUS SIGN) or the code unit 0x002D (HYPHEN-MINUS) according to whether n−1 is positive or negative, + # followed by the code units of the decimal representation of the integer abs(n−1) (with no leading zeroes). + [sign, s[0], '.', s[1..-1], 'e', exponent_sign, exponent.abs].join + end + end +end diff --git a/spec/lib/activitypub/object_integrity_proof_spec.rb b/spec/lib/activitypub/object_integrity_proof_spec.rb new file mode 100644 index 00000000000..d674bc8b75e --- /dev/null +++ b/spec/lib/activitypub/object_integrity_proof_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::ObjectIntegrityProof do + describe '#verify_actor!' do + # https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.feature#L68 + + let(:actor) { Fabricate(:account, username: 'alice', domain: 'server.example.org', uri: 'https://server.example/users/alice', public_key: '') } + + let(:json) do + JSON.parse(<<~JSON) + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v2" + ], + "id": "https://server.example/activities/1", + "type": "Create", + "actor": "https://server.example/users/alice", + "object": { + "id": "https://server.example/objects/1", + "type": "Note", + "attributedTo": "https://server.example/users/alice", + "content": "Hello world", + "location": { + "type": "Place", + "longitude": -71.184902, + "latitude": 25.273962 + } + }, + "proof": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v2" + ], + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "verificationMethod": "https://server.example/users/alice#ed25519-key", + "proofPurpose": "assertionMethod", + "proofValue": "z42ffGu6AUKPCFcFPiabmUvnGLPJzC7e4DGWC52NUasSSH37UMa9c58tdgVszUcZfytxa4fQ5TYHaJENCxUDe9SdL", + "created": "2023-02-24T23:36:38Z" + } + } + JSON + end + + before do + asn1 = OpenSSL::ASN1::Sequence( + [ + OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('ED25519')]), + OpenSSL::ASN1::BitString(Multibase.decode_multicodec('z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2')[1]), + ] + ) + keypair = OpenSSL::PKey.read(asn1.to_der) + + Fabricate(:keypair, account: actor, uri: 'https://server.example/users/alice#ed25519-key', type: :ed25519, public_key: keypair.public_to_pem) + end + + context 'when the signature is correct' do + it 'returns the actor' do + expect(described_class.new(json).verify_actor!).to eq actor + end + end + end + + describe 'verify_eddsa_jcs_2022' do + # https://www.w3.org/TR/vc-di-eddsa/#representation-eddsa-jcs-2022 + + let(:keypair) do + asn1 = OpenSSL::ASN1::Sequence( + [ + OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('ED25519')]), + OpenSSL::ASN1::BitString(Multibase.decode_multicodec('z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2')[1]), + ] + ) + + OpenSSL::PKey.read(asn1.to_der) + end + + let(:secured_json) do + JSON.parse(<<~JSON) + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "id": "urn:uuid:58172aac-d8ba-11ed-83dd-0b3aef56cc33", + "type": [ + "VerifiableCredential", + "AlumniCredential" + ], + "name": "Alumni Credential", + "description": "A minimum viable example of an Alumni Credential.", + "issuer": "https://vc.example/issuers/5678", + "validFrom": "2023-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:example:abcdefgh", + "alumniOf": "The School of Examples" + }, + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "created": "2023-02-24T23:36:38Z", + "verificationMethod": "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2#z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2", + "proofPurpose": "assertionMethod", + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "proofValue": "z2HnFSSPPBzR36zdDgK8PbEHeXbR56YF24jwMpt3R1eHXQzJDMWS93FCzpvJpwTWd3GAVFuUfjoJdcnTMuVor51aX" + } + } + JSON + end + + context 'with a correct signature' do + it 'verifies correctly' do + expect(described_class.verify_eddsa_jcs_2022(secured_json, keypair)).to be true + end + end + + context 'with an incorrect signature' do + let(:keypair) { OpenSSL::PKey.generate_key('ed25519') } + + it 'does not verify document' do + expect(described_class.verify_eddsa_jcs_2022(secured_json, keypair)).to be false + end + end + end +end diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb index c5fa38c641f..7e3e5a66957 100644 --- a/spec/services/activitypub/process_collection_service_spec.rb +++ b/spec/services/activitypub/process_collection_service_spec.rb @@ -75,7 +75,7 @@ RSpec.describe ActivityPub::ProcessCollectionService do context 'when actor differs from sender' do let(:forwarder) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/other_account') } - it 'does not process payload if no signature exists' do + it 'does not process payload if no signature nor FEP-8b32 proof exists' do signature_double = instance_double(ActivityPub::LinkedDataSignature, verify_actor!: nil) allow(ActivityPub::LinkedDataSignature).to receive(:new).and_return(signature_double) allow(ActivityPub::Activity).to receive(:factory) @@ -97,6 +97,59 @@ RSpec.describe ActivityPub::ProcessCollectionService do expect(ActivityPub::Activity).to have_received(:factory).with(instance_of(Hash), actor, instance_of(Hash)) end + it 'processes payload with actor if valid proof exists' do + payload['proof'] = { + 'type' => 'DataIntegrityProof', + 'proofPurpose' => 'assertionMethod', + 'cryptosuite' => 'eddsa-jcs-2022', + } + + signature_double = instance_double(ActivityPub::ObjectIntegrityProof, verify_actor!: actor) + allow(ActivityPub::ObjectIntegrityProof).to receive(:new).and_return(signature_double) + allow(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), actor, instance_of(Hash)) + + subject.call(json, forwarder) + + expect(ActivityPub::Activity).to have_received(:factory).with(instance_of(Hash), actor, instance_of(Hash)) + end + + it 'does not process payload if invalid proof exists' do + payload['proof'] = { + 'type' => 'DataIntegrityProof', + 'proofPurpose' => 'assertionMethod', + 'cryptosuite' => 'eddsa-jcs-2022', + } + + signature_double = instance_double(ActivityPub::ObjectIntegrityProof, verify_actor!: nil) + allow(ActivityPub::ObjectIntegrityProof).to receive(:new).and_return(signature_double) + allow(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), actor, instance_of(Hash)) + + subject.call(json, forwarder) + + expect(ActivityPub::Activity).to_not have_received(:factory) + end + + it 'processes payload with actor if invalid signature exists but valid proof exists' do + payload['signature'] = { 'type' => 'RsaSignature2017' } + payload['proof'] = { + 'type' => 'DataIntegrityProof', + 'proofPurpose' => 'assertionMethod', + 'cryptosuite' => 'eddsa-jcs-2022', + } + + ld_sig_double = instance_double(ActivityPub::LinkedDataSignature, verify_actor!: nil) + allow(ActivityPub::LinkedDataSignature).to receive(:new).and_return(ld_sig_double) + + signature_double = instance_double(ActivityPub::ObjectIntegrityProof, verify_actor!: actor) + allow(ActivityPub::ObjectIntegrityProof).to receive(:new).and_return(signature_double) + + allow(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), actor, instance_of(Hash)) + + subject.call(json, forwarder) + + expect(ActivityPub::Activity).to have_received(:factory).with(instance_of(Hash), actor, instance_of(Hash)) + end + it 'does not process payload if invalid signature exists' do payload['signature'] = { 'type' => 'RsaSignature2017' }