From 90a8a70c150d9c90df4b39f405b37a2f67abb9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Sep 2024 15:20:00 +0200 Subject: [PATCH] Add OpenPGP key to LDAP directory and User model --- Gemfile | 2 + Gemfile.lock | 5 ++ app/models/user.rb | 30 ++++++- app/services/ldap_service.rb | 7 +- .../20240922205634_add_pgp_fpr_to_users.rb | 5 ++ db/schema.rb | 3 +- lib/tasks/ldap.rake | 4 +- schemas/ldap/pgp_key.ldif | 8 ++ spec/fixtures/files/pgp_key_invalid.asc | 11 +++ spec/fixtures/files/pgp_key_valid_alice.asc | 16 ++++ spec/fixtures/files/pgp_key_valid_jimmy.asc | 13 +++ spec/models/user_spec.rb | 86 ++++++++++++++++--- 12 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 db/migrate/20240922205634_add_pgp_fpr_to_users.rb create mode 100644 schemas/ldap/pgp_key.ldif create mode 100644 spec/fixtures/files/pgp_key_invalid.asc create mode 100644 spec/fixtures/files/pgp_key_valid_alice.asc create mode 100644 spec/fixtures/files/pgp_key_valid_jimmy.asc diff --git a/Gemfile b/Gemfile index fe749cb..3cf7942 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,8 @@ gem 'pagy', '~> 6.0', '>= 6.0.2' gem 'flipper' gem 'flipper-active_record' gem 'flipper-ui' +gem 'gpgme', '~> 2.0.24' +gem 'zbase32', '~> 0.1.1' # HTTP requests gem 'faraday' diff --git a/Gemfile.lock b/Gemfile.lock index 59cb7d8..ffcd15b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -197,6 +197,8 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) + gpgme (2.0.24) + mini_portile2 (~> 2.7) hashdiff (1.1.0) i18n (1.14.1) concurrent-ruby (~> 1.0) @@ -483,6 +485,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.34) + zbase32 (0.1.1) zeitwerk (2.6.12) PLATFORMS @@ -507,6 +510,7 @@ DEPENDENCIES flipper flipper-active_record flipper-ui + gpgme (~> 2.0.24) image_processing (~> 1.12.2) importmap-rails jbuilder (~> 2.7) @@ -540,6 +544,7 @@ DEPENDENCIES warden web-console (~> 4.2) webmock + zbase32 (~> 0.1.1) BUNDLED WITH 2.5.5 diff --git a/app/models/user.rb b/app/models/user.rb index 248a865..b0539ec 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,9 +3,10 @@ require 'nostr' class User < ApplicationRecord include EmailValidatable - attr_accessor :display_name - attr_accessor :avatar_new attr_accessor :current_password + attr_accessor :avatar_new + attr_accessor :display_name + attr_accessor :pgp_pubkey serialize :preferences, coder: UserPreferences @@ -51,6 +52,8 @@ class User < ApplicationRecord validate :acceptable_avatar + validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey != "" } + # # Scopes # @@ -165,6 +168,23 @@ class User < ApplicationRecord Nostr::PublicKey.new(nostr_pubkey).to_bech32 end + def pgp_pubkey + @pgp_pubkey ||= ldap_entry[:pgp_key] + end + + def gnupg_key + return nil unless pgp_pubkey.present? + @gnupg_key ||= GPGME::Key.get(pgp_fpr) + end + + def pgp_pubkey_contains_user_address? + gnupg_key.uids.map(&:email).include?(address) + end + + def wkd_hash + ZBase32.encode(Digest::SHA1.digest(cn)) + end + def avatar @avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn) end @@ -214,4 +234,10 @@ class User < ApplicationRecord errors.add(:avatar, "must be a JPEG or PNG file") end end + + def acceptable_pgp_key_format + unless GPGME::Key.valid?(pgp_pubkey) + errors.add(:pgp_pubkey, 'is not a valid armored PGP public key block') + end + end end diff --git a/app/services/ldap_service.rb b/app/services/ldap_service.rb index 8364eb5..316a58a 100644 --- a/app/services/ldap_service.rb +++ b/app/services/ldap_service.rb @@ -58,7 +58,7 @@ class LdapService < ApplicationService attributes = %w[ dn cn uid mail displayName admin serviceEnabled - mailRoutingAddress mailpassword nostrKey + mailRoutingAddress mailpassword nostrKey pgpKey ] filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*") @@ -73,7 +73,8 @@ class LdapService < ApplicationService services_enabled: e.try(:serviceEnabled), email_maildrop: e.try(:mailRoutingAddress), email_password: e.try(:mailpassword), - nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil + nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil, + pgp_key: e.try(:pgpKey) ? e.pgpKey.first : nil } end end @@ -101,7 +102,7 @@ class LdapService < ApplicationService dn = "ou=#{ou},cn=users,#{ldap_suffix}" aci = <<-EOS -(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";) +(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || pgpKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";) EOS attrs = { diff --git a/db/migrate/20240922205634_add_pgp_fpr_to_users.rb b/db/migrate/20240922205634_add_pgp_fpr_to_users.rb new file mode 100644 index 0000000..d3b1572 --- /dev/null +++ b/db/migrate/20240922205634_add_pgp_fpr_to_users.rb @@ -0,0 +1,5 @@ +class AddPgpFprToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :pgp_fpr, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 90a60e6..31e617c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_06_07_123654) do +ActiveRecord::Schema[7.1].define(version: 2024_09_22_205634) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -132,6 +132,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_07_123654) do t.datetime "remember_created_at" t.string "remember_token" t.text "preferences" + t.string "pgp_fpr" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/lib/tasks/ldap.rake b/lib/tasks/ldap.rake index 3beb0e9..244a7d2 100644 --- a/lib/tasks/ldap.rake +++ b/lib/tasks/ldap.rake @@ -21,7 +21,7 @@ namespace :ldap do desc "Add custom attributes to schema" task add_custom_attributes: :environment do |t, args| - %w[ admin service_enabled nostr_key ].each do |name| + %w[ admin service_enabled nostr_key pgp_key ].each do |name| Rake::Task["ldap:modify_ldap_schema"].invoke(name, "add") Rake::Task['ldap:modify_ldap_schema'].reenable end @@ -29,7 +29,7 @@ namespace :ldap do desc "Delete custom attributes from schema" task delete_custom_attributes: :environment do |t, args| - %w[ admin service_enabled nostr_key ].each do |name| + %w[ admin service_enabled nostr_key pgp_key ].each do |name| Rake::Task["ldap:modify_ldap_schema"].invoke(name, "delete") Rake::Task['ldap:modify_ldap_schema'].reenable end diff --git a/schemas/ldap/pgp_key.ldif b/schemas/ldap/pgp_key.ldif new file mode 100644 index 0000000..e8ca2a6 --- /dev/null +++ b/schemas/ldap/pgp_key.ldif @@ -0,0 +1,8 @@ +dn: cn=schema +changetype: modify +add: attributeTypes +attributeTypes: ( 1.3.6.1.4.1.3401.8.2.11 + NAME 'pgpKey' + DESC 'OpenPGP public key block' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) diff --git a/spec/fixtures/files/pgp_key_invalid.asc b/spec/fixtures/files/pgp_key_invalid.asc new file mode 100644 index 0000000..08bd918 --- /dev/null +++ b/spec/fixtures/files/pgp_key_invalid.asc @@ -0,0 +1,11 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE +ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy +MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO +dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4 +OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s +E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb +DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn +0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE= +=iIGO +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/fixtures/files/pgp_key_valid_alice.asc b/spec/fixtures/files/pgp_key_valid_alice.asc new file mode 100644 index 0000000..d509cb1 --- /dev/null +++ b/spec/fixtures/files/pgp_key_valid_alice.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: Alice's OpenPGP certificate +Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html + +mDMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/U +b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE +ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy +MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO +dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4 +OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s +E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb +DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn +0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE= +=iIGO + +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/fixtures/files/pgp_key_valid_jimmy.asc b/spec/fixtures/files/pgp_key_valid_jimmy.asc new file mode 100644 index 0000000..385caaf --- /dev/null +++ b/spec/fixtures/files/pgp_key_valid_jimmy.asc @@ -0,0 +1,13 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEZvFjRhYJKwYBBAHaRw8BAQdACUxVX9bGlbuNR0MNYUyHHxTcOgm4qjwq8Bjg +7P41OFK0GEppbW15IDxqaW1teUBrb3Ntb3Mub3JnPoiZBBMWCgBBFiEEMWv1FiNt +r3cjaxX2BX2Tly+4YsMFAmbxY0YCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYC +AwECHgcCF4AACgkQBX2Tly+4YsMjHgEAoOOLrv9pWbi8hhrSMkqJ7FJvsBTQF//U +aJUQRa8CTgoBAI3kyGKZ8gOC8UOOKsUC0LiNCVXPyX45h8T4QFRdEVYKuDgEZvFj +RhIKKwYBBAGXVQEFAQEHQIomqcQ59UjtQex54pz8qGqyxCj2DPJYUat9pXinDgN8 +AwEIB4h+BBgWCgAmFiEEMWv1FiNtr3cjaxX2BX2Tly+4YsMFAmbxY0YCGwwFCQWj +moAACgkQBX2Tly+4YsPoVgEA/9Q5Gs1klP4u/nw343V57e9s4RKmEiRSkErnC9wW +Iu0A/jp6Elz2pDQPB2XLwcb+n7JlgA05HI0zWj1+EoM7TC4J +=KQbn +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5f6c7c1..60ce8ed 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,20 +1,16 @@ require 'rails_helper' RSpec.describe User, type: :model do - let(:user) { create :user, cn: "philipp" } + let(:user) { create :user, cn: "philipp", ou: "kosmos.org", email: "philipp@example.com" } let(:dn) { "cn=philipp,ou=kosmos.org,cn=users,dc=kosmos,dc=org" } describe "#address" do - let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" } - it "returns the user address" do - expect(user.address).to eq("jimmy@kosmos.org") + expect(user.address).to eq("philipp@kosmos.org") end end describe "#mastodon_address" do - let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" } - context "Mastodon service not configured" do before do Setting.mastodon_enabled = false @@ -32,7 +28,7 @@ RSpec.describe User, type: :model do describe "domain is the same as primary domain" do it "returns the user address" do - expect(user.mastodon_address).to eq("jimmy@kosmos.org") + expect(user.mastodon_address).to eq("philipp@kosmos.org") end end @@ -42,7 +38,7 @@ RSpec.describe User, type: :model do end it "returns the user address" do - expect(user.mastodon_address).to eq("jimmy@kosmos.social") + expect(user.mastodon_address).to eq("philipp@kosmos.social") end end @@ -239,7 +235,7 @@ RSpec.describe User, type: :model do describe "#nostr_pubkey" do before do - allow_any_instance_of(User).to receive(:ldap_entry) + allow(user).to receive(:ldap_entry) .and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" }) end @@ -250,7 +246,7 @@ RSpec.describe User, type: :model do describe "#nostr_pubkey_bech32" do before do - allow_any_instance_of(User).to receive(:ldap_entry) + allow(user).to receive(:ldap_entry) .and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" }) end @@ -258,4 +254,74 @@ RSpec.describe User, type: :model do expect(user.nostr_pubkey_bech32).to eq("npub1qlsc3g0lsl8pw8230w8d9wm6xxcax3f6pkemz5measrmwfxjxteslf2hac") end end + + describe "OpenPGP key" do + let(:alice) { create :user, id: 2, cn: "alice", email: "alice@example.com" } + let(:jimmy) { create :user, id: 3, cn: "jimmy", email: "jimmy@example.com" } + let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:valid_key_jimmy) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.asc") } + let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + let(:fingerprint_jimmy) { "316BF516236DAF77236B15F6057D93972FB862C3" } + let(:gnupg_key_alice) { } + let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") } + + before do + GPGME::Key.import(valid_key_alice) + GPGME::Key.import(valid_key_jimmy) + alice.update pgp_fpr: fingerprint_alice + jimmy.update pgp_fpr: fingerprint_jimmy + allow(alice).to receive(:ldap_entry).and_return({ pgp_key: valid_key_alice }) + allow(jimmy).to receive(:ldap_entry).and_return({ pgp_key: valid_key_jimmy }) + end + + after do + GPGME::Key.get(fingerprint_alice).delete! + GPGME::Key.get(fingerprint_jimmy).delete! + end + + describe "#acceptable_pgp_key_format" do + it "validates the record when the key is valid" do + alice.pgp_pubkey = valid_key_alice + expect(alice).to be_valid + end + + it "adds a validation error when the key is not valid" do + user.pgp_pubkey = invalid_key + expect(user).to_not be_valid + expect(user.errors[:pgp_pubkey]).to be_present + end + end + + describe "#pgp_pubkey" do + it "returns the raw pubkey from LDAP" do + expect(alice.pgp_pubkey).to eq(valid_key_alice) + end + end + + describe "#gnupg_key" do + subject { alice.gnupg_key } + + it "returns a GPGME::Key object from the system's GPG keyring" do + expect(subject).to be_a(GPGME::Key) + expect(subject.fingerprint).to eq(fingerprint_alice) + expect(subject.email).to eq("alice@openpgp.example") + end + end + + describe "#pgp_pubkey_contains_user_address?" do + it "returns false when the user address is one of the UIDs of the key" do + expect(alice.pgp_pubkey_contains_user_address?).to eq(false) + end + + it "returns true when the user address is missing from the UIDs of the key" do + expect(jimmy.pgp_pubkey_contains_user_address?).to eq(true) + end + end + + describe "wkd_hash" do + it "returns a z-base32 encoded SHA-1 digest of the username" do + expect(alice.wkd_hash).to eq("kei1q4tipxxu1yj79k9kfukdhfy631xe") + end + end + end end