mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-14 19:25:50 -05:00
Add inbound support for FEP-521a (#39497)
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -231,3 +231,5 @@ gem 'hcaptcha', '~> 7.1'
|
||||
gem 'mail', '~> 2.8'
|
||||
|
||||
gem 'vite_rails'
|
||||
|
||||
gem 'base58', '~> 0.2.3'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
44
app/lib/multibase.rb
Normal file
44
app/lib/multibase.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
26
config/initializers/json_ld_cid.rb
Normal file
26
config/initializers/json_ld_cid.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user