From 0ec9ef12d585367ce6b56e328f68d8418df9ed05 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 3 Jul 2026 11:20:35 +0200 Subject: [PATCH] Add support for `expires` in Linked Data Signatures and Object Integrity Proofs (#39701) --- app/helpers/json_ld_helper.rb | 8 +- app/lib/activitypub/linked_data_signature.rb | 15 +- app/lib/activitypub/object_integrity_proof.rb | 5 +- .../activitypub/linked_data_signature_spec.rb | 32 +++++ .../object_integrity_proof_spec.rb | 136 ++++++++++++++++-- 5 files changed, 172 insertions(+), 24 deletions(-) diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 0277ae0bee8..f3361ee206a 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -122,10 +122,10 @@ module JsonLdHelper graph.dump(:normalize) end - def compact(json) - compacted = JSON::LD::API.compact(json.without('signature'), full_context, documentLoader: method(:load_jsonld_context)) - compacted['signature'] = json['signature'] - compacted + def compact(json, context = full_context) + JSON::LD::API.compact(json.without('signature'), context, documentLoader: method(:load_jsonld_context)).tap do |compacted| + compacted['signature'] = json['signature'] + end end def unsupported_jsonld_features?(json) diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index 35427e0fef7..c659d318a1f 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -14,9 +14,13 @@ class ActivityPub::LinkedDataSignature return unless @json['signature'].is_a?(Hash) return if unsupported_jsonld_features?(@json) - type = @json['signature']['type'] - creator_uri = @json['signature']['creator'] - signature = @json['signature']['signatureValue'] + signature_options = compact(@json['signature'].merge({ '@context' => CONTEXT }), CONTEXT) + + type = signature_options['type'] + creator_uri = signature_options['creator'] + signature = signature_options['signatureValue'] + + return if signature_options['expires']&.to_datetime&.past? return unless type == 'RsaSignature2017' @@ -24,7 +28,7 @@ class ActivityPub::LinkedDataSignature keypair = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if keypair&.public_key.blank? return if keypair.nil? || !keypair.usable? || keypair.type != 'rsa' - options_hash = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT)) + options_hash = hash(signature_options.without('type', 'id', 'signatureValue')) document_hash = hash(@json.without('signature')) to_be_verified = options_hash + document_hash @@ -33,13 +37,14 @@ class ActivityPub::LinkedDataSignature false end - def sign!(creator, sign_with: nil) + def sign!(creator, sign_with: nil, expires_in: 2.days) keypair = sign_with.presence || creator.keypair(type: :rsa) options = { 'type' => 'RsaSignature2017', 'creator' => keypair.full_uri, 'created' => Time.now.utc.iso8601, + 'expires' => expires_in.from_now.utc.iso8601, } options_hash = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT)) diff --git a/app/lib/activitypub/object_integrity_proof.rb b/app/lib/activitypub/object_integrity_proof.rb index af700333893..ffdea61b7db 100644 --- a/app/lib/activitypub/object_integrity_proof.rb +++ b/app/lib/activitypub/object_integrity_proof.rb @@ -17,9 +17,8 @@ class ActivityPub::ObjectIntegrityProof 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' + return if proof_purpose != proof['proofPurpose'] || proof['type'] != 'DataIntegrityProof' + return if proof['expires']&.to_datetime&.past? cryptosuite = proof['cryptosuite'] key_uri = proof['verificationMethod'] diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index deb6cb43a1f..a06f2379106 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -38,6 +38,38 @@ RSpec.describe ActivityPub::LinkedDataSignature do end end + context 'when signature matches and has an expiration date set in the future' do + let(:raw_signature) do + { + 'creator' => keyid, + 'created' => '2017-09-23T20:21:34Z', + 'expires' => '3000-01-01T00:00:00Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + + it 'returns creator' do + expect(subject.verify_actor!).to eq sender + end + end + + context 'when signature matches but has an expiration date set in the past' do + let(:raw_signature) do + { + 'creator' => keyid, + 'created' => '2017-09-23T20:21:34Z', + 'expires' => '2017-09-23T20:21:35Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + + it 'returns nil' do + expect(subject.verify_actor!).to be_nil + end + end + context 'when local account record is missing a public key' do let(:raw_signature) do { diff --git a/spec/lib/activitypub/object_integrity_proof_spec.rb b/spec/lib/activitypub/object_integrity_proof_spec.rb index d674bc8b75e..5732d3bdfe8 100644 --- a/spec/lib/activitypub/object_integrity_proof_spec.rb +++ b/spec/lib/activitypub/object_integrity_proof_spec.rb @@ -45,23 +45,135 @@ RSpec.describe ActivityPub::ObjectIntegrityProof do 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 + 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 + it 'returns the actor' do expect(described_class.new(json).verify_actor!).to eq actor end end + + context 'when the signature is incorrect' do + before do + Fabricate(:keypair, account: actor, uri: 'https://server.example/users/alice#ed25519-key', type: :ed25519, public_key: OpenSSL::PKey.generate_key('Ed25519').public_to_pem) + end + + it 'returns nil' do + expect(described_class.new(json).verify_actor!).to be_nil + end + end + + context 'when the signature is correct and has an expiration date set in the future' do + 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" + }, + "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": "z4bp44KNvTEeb7h8gY7tpbF9EaJxzbcYc6JNPxMv1wdWDakBt2vVEC3UzjctghA5XP2NevDnDCPzph2oNNb3qRJ8K", + "created": "2023-02-24T23:36:38Z", + "expires": "3000-01-01T00:00:00Z" + } + } + 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 + + it 'returns the actor' do + expect(described_class.new(json).verify_actor!).to eq actor + end + end + + context 'when the signature is correct and has an expiration date set in the past' do + 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" + }, + "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": "z25og1nNL6TLjQ8fHK4WmzWZzjb8BhcVvaoXToxE9qpcB6iqCWmWhmSAifmcWA11rtdomgGpkRNeEPDMXWJY5BDVQ", + "created": "2023-02-24T23:36:38Z", + "expires": "2023-02-24T23:36:39Z" + } + } + 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 + + it 'returns nil' do + expect(described_class.new(json).verify_actor!).to be_nil + end + end end describe 'verify_eddsa_jcs_2022' do