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 69654af40c5..671f5885216 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1001,6 +1001,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..e8f5e5c2874 --- /dev/null +++ b/app/lib/activitypub/object_integrity_proof.rb @@ -0,0 +1,56 @@ +# 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.with_indifferent_access + 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.present? && 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'] + 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/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