diff --git a/Gemfile b/Gemfile index f133f63c851..57ad858c1e9 100644 --- a/Gemfile +++ b/Gemfile @@ -231,3 +231,5 @@ gem 'hcaptcha', '~> 7.1' gem 'mail', '~> 2.8' gem 'vite_rails' + +gem 'base58', '~> 0.2.3' diff --git a/Gemfile.lock b/Gemfile.lock index e31ccba9040..69654af40c5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -119,6 +119,7 @@ GEM aws-eventstream (~> 1, >= 1.0.2) azure-blob (0.5.9.1) rexml + base58 (0.2.3) base64 (0.3.0) bcp47_spec (0.2.1) bcrypt (3.1.22) @@ -950,6 +951,7 @@ DEPENDENCIES annotaterb (~> 4.13) aws-sdk-core aws-sdk-s3 (~> 1.123) + base58 (~> 0.2.3) better_errors (~> 2.9) binding_of_caller blurhash (~> 0.1) diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index e092a441e91..25414c20031 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -4,6 +4,7 @@ module ContextHelper NAMED_CONTEXT_MAP = { activitystreams: 'https://www.w3.org/ns/activitystreams', security: 'https://w3id.org/security/v1', + controlled_identifiers: 'https://www.w3.org/ns/cid/v1', webfinger: 'https://purl.archive.org/socialweb/webfinger', }.freeze diff --git a/app/lib/multibase.rb b/app/lib/multibase.rb new file mode 100644 index 00000000000..4ba20ba68a0 --- /dev/null +++ b/app/lib/multibase.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Multibase + # Multicodec uses a variable-length unsigned integer to tag + # data. We precompute here the types we are interested about. + # Registered types are available at https://github.com/multiformats/multicodec/blob/master/table.csv + MULTICODEC_PREFIXES = { + 'rsa-pub': 0x1205, + 'ed25519-pub': 0xED, + }.transform_values do |code| + bytes = [] + + while code > 127 + bytes << ((code & 0x7F) | 128) + code >>= 7 + end + + bytes << code + bytes.pack('C*') + end.freeze + + def self.decode(string) + raise ArgumentError if string.nil? + + case string[0] + when 'u' + Base64.decode(string[1...]) + when 'z' + Base58.base58_to_binary(string[1...], :bitcoin) + else + raise ArgumentError + end + end + + def self.decode_multicodec(string) + binary = decode(string) + + MULTICODEC_PREFIXES.each do |tag, prefix| + return [tag, binary[prefix.length..]] if binary.starts_with?(prefix) + end + + raise ArgumentError + end +end diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb index 378401c5741..cd1edffe4fa 100644 --- a/app/services/activitypub/fetch_remote_key_service.rb +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -16,7 +16,7 @@ class ActivityPub::FetchRemoteKeyService < BaseService @json = fetch_resource(uri, false) raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil? - raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json) || (supported_security_context?(@json) && @json['owner'].present? && !actor_type?) + raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context_for_type?(@json) raise Error, "Unexpected object type for key #{uri}" unless expected_type? return keypair_from_actor_json(@json['id'], @json) if actor_type? @@ -35,6 +35,15 @@ class ActivityPub::FetchRemoteKeyService < BaseService private + def supported_context_for_type?(json) + case @json['type'] + when 'Multikey' + equals_or_includes?(json['@context'], 'https://www.w3.org/ns/cid/v1') + else + supported_context?(json) || (supported_security_context?(@json) && @json['owner'].present? && !actor_type?) + end + end + def keypair_from_actor_json(actor_uri, actor_json) actor = find_actor(actor_uri, actor_json) return if actor.nil? @@ -65,11 +74,15 @@ class ActivityPub::FetchRemoteKeyService < BaseService end def public_key? - @json['publicKeyPem'].present? && @json['owner'].present? + if @json['type'] == 'Multikey' + @json['publicKeyMultibase'].present? && @json['controller'].present? + else + @json['publicKeyPem'].present? && @json['owner'].present? + end end def owner_uri - @owner_uri ||= value_or_id(@json['owner']) + @owner_uri ||= @json['type'] == 'Multikey' ? value_or_id(@json['controller']) : value_or_id(@json['owner']) end def expected_owner_type? @@ -77,6 +90,11 @@ class ActivityPub::FetchRemoteKeyService < BaseService end def confirmed_owner? - as_array(@owner['publicKey']).map { |value| value_or_id(value) }.include?(@json['id']) + case @json['type'] + when 'Multikey' + as_array(@owner['assertionMethod']).map { |value| value_or_id(value) }.include?(@json['id']) + else + as_array(@owner['publicKey']).map { |value| value_or_id(value) }.include?(@json['id']) + end end end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 71d8e476e66..045d3019cc7 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -271,9 +271,11 @@ class ActivityPub::ProcessAccountService < BaseService end def public_keys - # TODO: handle FEP-521a + @public_keys ||= fep_521a_public_keys.presence || legacy_public_keys + end - @public_keys ||= as_array(@json['publicKey']).take(MAX_PUBLIC_KEYS).filter_map do |value| + def legacy_public_keys + as_array(@json['publicKey']).take(MAX_PUBLIC_KEYS).filter_map do |value| next if value.nil? if value.is_a?(Hash) @@ -302,6 +304,48 @@ class ActivityPub::ProcessAccountService < BaseService end end + def fep_521a_public_keys + return if @json['assertionMethod'].blank? + + as_array(@json['assertionMethod']).take(MAX_PUBLIC_KEYS).filter_map do |value| + next if value.nil? + + if value.is_a?(Hash) + next unless value['type'] == 'Multikey' && value['controller'] == @account.uri + + key_type, key = key_from_multikey(value['publicKeyMultibase']) + next if key_type.nil? + + value = value['id'] + + # Key is contained within the actor document, no need to fetch anything else + next { type: key_type, public_key: key, uri: value } if value.split('#').first == @account.uri + end + + key_id = value + + value = fetch_resource(key_id, true) + + next unless value['type'] == 'Multikey' && value['controller'] == @account.uri + + key_type, key = key_from_multikey(value['publicKeyMultibase']) + next if key_type.nil? + + { type: key_type, public_key: key, uri: key_id } + end + end + + def key_from_multikey(value) + tag, key = Multibase.decode_multicodec(value) + + case tag + when :'rsa-pub' + [:rsa, OpenSSL::PKey::RSA.new(key).to_pem] + end + rescue ArgumentError + nil + end + def url return if @json['url'].blank? diff --git a/config/initializers/json_ld_cid.rb b/config/initializers/json_ld_cid.rb new file mode 100644 index 00000000000..60ad67f2351 --- /dev/null +++ b/config/initializers/json_ld_cid.rb @@ -0,0 +1,26 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +# This file generated automatically from https://www.w3.org/ns/cid/v1 + +require 'json/ld' + +class JSON::LD::Context + add_preloaded("http://www.w3.org/ns/cid/v1") do + new(processingMode: "json-ld-1.1", term_definitions: { + "JsonWebKey" => TermDefinition.new("JsonWebKey", id: "https://w3id.org/security#JsonWebKey", context: {"@protected" => true, "id" => "@id", "type" => "@type", "controller" => {"@id" => "https://w3id.org/security#controller", "@type" => "@id"}, "revoked" => {"@id" => "https://w3id.org/security#revoked", "@type" => "http://www.w3.org/2001/XMLSchema#dateTime"}, "expires" => {"@id" => "https://w3id.org/security#expiration", "@type" => "http://www.w3.org/2001/XMLSchema#dateTime"}, "publicKeyJwk" => {"@id" => "https://w3id.org/security#publicKeyJwk", "@type" => "@json"}, "secretKeyJwk" => {"@id" => "https://w3id.org/security#secretKeyJwk", "@type" => "@json"}}, protected: true), + "Multikey" => TermDefinition.new("Multikey", id: "https://w3id.org/security#Multikey", context: {"@protected" => true, "id" => "@id", "type" => "@type", "controller" => {"@id" => "https://w3id.org/security#controller", "@type" => "@id"}, "revoked" => {"@id" => "https://w3id.org/security#revoked", "@type" => "http://www.w3.org/2001/XMLSchema#dateTime"}, "expires" => {"@id" => "https://w3id.org/security#expiration", "@type" => "http://www.w3.org/2001/XMLSchema#dateTime"}, "publicKeyMultibase" => {"@id" => "https://w3id.org/security#publicKeyMultibase", "@type" => "https://w3id.org/security#multibase"}, "secretKeyMultibase" => {"@id" => "https://w3id.org/security#secretKeyMultibase", "@type" => "https://w3id.org/security#multibase"}}, protected: true), + "alsoKnownAs" => TermDefinition.new("alsoKnownAs", id: "https://www.w3.org/ns/activitystreams#alsoKnownAs", type_mapping: "@id", protected: true), + "assertionMethod" => TermDefinition.new("assertionMethod", id: "https://w3id.org/security#assertionMethod", type_mapping: "@id", container_mapping: "@set", protected: true), + "authentication" => TermDefinition.new("authentication", id: "https://w3id.org/security#authenticationMethod", type_mapping: "@id", container_mapping: "@set", protected: true), + "capabilityDelegation" => TermDefinition.new("capabilityDelegation", id: "https://w3id.org/security#capabilityDelegationMethod", type_mapping: "@id", container_mapping: "@set", protected: true), + "capabilityInvocation" => TermDefinition.new("capabilityInvocation", id: "https://w3id.org/security#capabilityInvocationMethod", type_mapping: "@id", container_mapping: "@set", protected: true), + "controller" => TermDefinition.new("controller", id: "https://w3id.org/security#controller", type_mapping: "@id", protected: true), + "id" => TermDefinition.new("id", id: "@id", simple: true, protected: true), + "keyAgreement" => TermDefinition.new("keyAgreement", id: "https://w3id.org/security#keyAgreementMethod", type_mapping: "@id", container_mapping: "@set", protected: true), + "service" => TermDefinition.new("service", id: "https://www.w3.org/ns/did#service", type_mapping: "@id", context: {"@protected" => true, "id" => "@id", "type" => "@type", "serviceEndpoint" => {"@id" => "https://www.w3.org/ns/did#serviceEndpoint", "@type" => "@id"}}, protected: true), + "type" => TermDefinition.new("type", id: "@type", simple: true, protected: true), + "verificationMethod" => TermDefinition.new("verificationMethod", id: "https://w3id.org/security#verificationMethod", type_mapping: "@id", protected: true) + }) + end + alias_preloaded("https://www.w3.org/ns/cid/v1", "http://www.w3.org/ns/cid/v1") +end diff --git a/spec/services/activitypub/fetch_remote_key_service_spec.rb b/spec/services/activitypub/fetch_remote_key_service_spec.rb index fe418a1b1ad..7780d0353ec 100644 --- a/spec/services/activitypub/fetch_remote_key_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_key_service_spec.rb @@ -115,4 +115,113 @@ RSpec.describe ActivityPub::FetchRemoteKeyService do end end end + + context 'with FEP-521a' do + let(:ed25519_key_id) { 'https://example.com/alice#ed25519-key' } + let(:actor_ed25519_key) { ed25519_multikey } + let(:ed25519_multikey) do + { + id: ed25519_key_id, + type: 'Multikey', + controller: 'https://example.com/alice', + publicKeyMultibase: 'z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + } + end + + let(:rsa_key_id) { 'https://example.com/alice#rsa-key' } + let(:actor_rsa_key) { rsa_multikey } + let(:rsa_multikey) do + { + id: rsa_key_id, + type: 'Multikey', + controller: 'https://example.com/alice', + publicKeyMultibase: 'z4MXj1wBzi9jUstyPMS4jQqB6KdJaiatPkAtVtGc6bQEQEEsKTic4G7Rou3iBf9vPmT5dbkm9qsZsuVNjq8HCuW1w24nhBFGkRE4cd2Uf2tfrB3N7h4mnyPp1BF3ZttHTYv3DLUPi1zMdkULiow3M1GfXkoC6DoxDUm1jmN6GBj22SjVsr6dxezRVQc7aj9TxE7JLbMH1wh5X3kA58H3DFW8rnYMakFGbca5CB2Jf6CnGQZmL7o5uJAdTwXfy2iiiyPxXEGerMhHwhjTA1mKYobyk2CpeEcmvynADfNZ5MBvcCS7m3XkFCMNUYBS9NQ3fze6vMSUPsNa6GVYmKx2x6JrdEjCk3qRMMmyjnjCMfR4pXbRMZa3i', # rubocop:disable Layout/LineLength + } + end + let(:rsa_key_pem) do + <<~TEXT + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsbX82NTV6IylxCh7MfV4 + hlyvaniCajuP97GyOqSvTmoEdBOflFvZ06kR/9D6ctt45Fk6hskfnag2GG69NALV + H2o4RCR6tQiLRpKcMRtDYE/thEmfBvDzm/VVkOIYfxu+Ipuo9J/S5XDNDjczx2v+ + 3oDh5+CIHkU46hvFeCvpUS+L8TJSbgX0kjVk/m4eIb9wh63rtmD6Uz/KBtCo5mmR + 4TEtcLZKYdqMp3wCjN+TlgHiz/4oVXWbHUefCEe8rFnX1iQnpDHU49/SaXQoud1j + CaexFn25n+Aa8f8bc5Vm+5SeRwidHa6ErvEhTvf1dz6GoNPp2iRvm+wJ1gxwWJEY + PQIDAQAB + -----END PUBLIC KEY----- + TEXT + end + + let(:actor) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://www.w3.org/ns/cid/v1', + ], + id: 'https://example.com/alice', + type: 'Person', + preferredUsername: 'alice', + name: 'Alice', + summary: 'Foo bar', + inbox: 'http://example.com/alice/inbox', + assertionMethod: [ + actor_ed25519_key, + actor_rsa_key, + ], + } + end + + describe '#call' do + let(:keypair) { subject.call(rsa_key_id) } + + context 'when the key is a sub-object from the actor' do + before do + stub_request(:get, rsa_key_id).to_return(body: actor.to_json, headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'returns the expected account' do + expect(keypair.account.uri).to eq 'https://example.com/alice' + + expect(keypair) + .to have_attributes( + uri: rsa_key_id, + type: 'rsa', + public_key: rsa_key_pem + ) + end + end + + context 'when the key is a separate document' do + let(:rsa_key_id) { 'https://example.com/alice-public-key.json' } + let(:actor_rsa_key) { rsa_key_id } + + before do + stub_request(:get, rsa_key_id).to_return(body: rsa_multikey.merge({ '@context': ['https://www.w3.org/ns/cid/v1'] }).to_json, headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'returns the expected account' do + expect(keypair.account.uri).to eq 'https://example.com/alice' + expect(keypair) + .to have_attributes( + uri: rsa_key_id, + type: 'rsa', + public_key: rsa_key_pem + ) + end + end + + context 'when the key and owner do not match' do + let(:rsa_key_id) { 'https://example.com/fake-public-key.json' } + let(:actor_rsa_key) { 'https://example.com/alice-public-key.json' } + + before do + stub_request(:get, rsa_key_id).to_return(body: rsa_multikey.merge({ '@context': ['https://www.w3.org/ns/cid/v1'] }).to_json, headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'returns the nil' do + expect(keypair).to be_nil + end + end + end + end end diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb index 56f117ca824..3392531765f 100644 --- a/spec/services/activitypub/process_account_service_spec.rb +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -304,6 +304,62 @@ RSpec.describe ActivityPub::ProcessAccountService do end end + context 'with multiple keypairs using FEP-521a' do + let(:payload) do + { + id: 'https://foo.test/actor', + type: 'Actor', + inbox: 'https://foo.test/inbox', + preferredUsername: 'alice', + assertionMethod: [ + { + id: 'https://foo.test/actor#key1', + type: 'Multikey', + controller: 'https://foo.test/actor', + publicKeyMultibase: 'z4MXj1wBzi9jUstyPMS4jQqB6KdJaiatPkAtVtGc6bQEQEEsKTic4G7Rou3iBf9vPmT5dbkm9qsZsuVNjq8HCuW1w24nhBFGkRE4cd2Uf2tfrB3N7h4mnyPp1BF3ZttHTYv3DLUPi1zMdkULiow3M1GfXkoC6DoxDUm1jmN6GBj22SjVsr6dxezRVQc7aj9TxE7JLbMH1wh5X3kA58H3DFW8rnYMakFGbca5CB2Jf6CnGQZmL7o5uJAdTwXfy2iiiyPxXEGerMhHwhjTA1mKYobyk2CpeEcmvynADfNZ5MBvcCS7m3XkFCMNUYBS9NQ3fze6vMSUPsNa6GVYmKx2x6JrdEjCk3qRMMmyjnjCMfR4pXbRMZa3i', # rubocop:disable Layout/LineLength + }, + { + id: 'https://foo.test/actor#key2', + type: 'Multikey', + controller: 'https://foo.test/actor', + publicKeyMultibase: 'z2MGw4gk84USotaWf4AkJ83DcnrfgGaceF86KQXRYMfQ7xqnUG81FVWa2N5inzNigXsDkm2LxpuyYSajqZr1CwHqnJbVEw1rhN25tbJSFyej6TejRh3k67CK9nTVHdXFoVKgAFxLwgiqJwCyyYWesaQKXAQfwXYqCBxPyaDjFfWkya6xeLaNuKFYGLcVzZZQjL99dnzUpNiENFPkVmJokE1wKPpHttGpLgm9sizHNDFuwHaz2ZZRnnZ6CT95FzdrMmaDXofn1ikbKBTdumuiRWSVwwZXffcXRN6Ti1a8NfhxQDdqhT7CAmM9NjQhnrqs1vss6YdcrHP5GmQN2Mz8GenQZFnyhJZK2iPxETnxq7YJRqTduN8KC8SMfjLVB8LD7rBM5d6s8dopdgJCVBpy2p', # rubocop:disable Layout/LineLength + }, + ], + }.with_indifferent_access + end + + it 'stores the keys' do + account = subject.call('alice', 'example.com', payload) + + expect(account.public_key).to eq '' + expect(account.keypairs).to contain_exactly( + have_attributes( + uri: 'https://foo.test/actor#key1', + type: 'rsa' + ), + have_attributes( + uri: 'https://foo.test/actor#key2', + type: 'rsa' + ) + ) + end + + context 'when the account was known with a legacy key' do + let!(:alice) { Fabricate(:account, uri: 'https://foo.test/actor', domain: 'example.com', username: 'alice') } + + it 'invalidates the legacy key and stores the new keys' do + expect { subject.call('alice', 'example.com', payload) } + .to change { alice.reload.public_key }.to('') + .and change { alice.keypairs.to_a }.from([]).to( + contain_exactly( + have_attributes({ uri: 'https://foo.test/actor#key1', type: 'rsa' }), + have_attributes({ uri: 'https://foo.test/actor#key2', type: 'rsa' }) + ) + ) + end + end + end + context 'with attribution domains' do let(:payload) do {