Add inbound support for FEP-521a (#39497)

This commit is contained in:
Claire
2026-06-19 11:49:59 +02:00
committed by GitHub
parent cbbc640e08
commit bea833274d
9 changed files with 308 additions and 6 deletions

View File

@@ -231,3 +231,5 @@ gem 'hcaptcha', '~> 7.1'
gem 'mail', '~> 2.8'
gem 'vite_rails'
gem 'base58', '~> 0.2.3'

View File

@@ -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)

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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?

View 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

View File

@@ -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

View File

@@ -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
{