Implement verification for Object Integrity Proofs (fep-8b32)

This commit is contained in:
Claire
2026-06-19 17:20:50 +02:00
parent b1dbe6a94c
commit 7a69ae2e80
4 changed files with 189 additions and 0 deletions

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

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

View File

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

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