Add verification of FEP-8b32 Object Integrity Proofs (#39530)

This commit is contained in:
Claire
2026-07-01 15:10:31 +02:00
committed by GitHub
parent 7fc19f81aa
commit 830159c8b6
9 changed files with 365 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, 10k1 ≤ s < 10k, the Number value for s × 10nk 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 nk 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 kn 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 n1 is positive or negative, followed by the code units of the decimal
# representation of the integer abs(n1) (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 k1 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 n1 is positive or negative,
# followed by the code units of the decimal representation of the integer abs(n1) (with no leading zeroes).
[sign, s[0], '.', s[1..-1], 'e', exponent_sign, exponent.abs].join
end
end
end

View File

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

View File

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