Add OpenPGP key to LDAP directory and User model
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
8f7994d82e
commit
90a8a70c15
2
Gemfile
2
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'
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 = {
|
||||
|
5
db/migrate/20240922205634_add_pgp_fpr_to_users.rb
Normal file
5
db/migrate/20240922205634_add_pgp_fpr_to_users.rb
Normal file
@ -0,0 +1,5 @@
|
||||
class AddPgpFprToUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :users, :pgp_fpr, :string
|
||||
end
|
||||
end
|
@ -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
|
||||
|
@ -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
|
||||
|
8
schemas/ldap/pgp_key.ldif
Normal file
8
schemas/ldap/pgp_key.ldif
Normal file
@ -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 )
|
11
spec/fixtures/files/pgp_key_invalid.asc
vendored
Normal file
11
spec/fixtures/files/pgp_key_invalid.asc
vendored
Normal file
@ -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-----
|
16
spec/fixtures/files/pgp_key_valid_alice.asc
vendored
Normal file
16
spec/fixtures/files/pgp_key_valid_alice.asc
vendored
Normal file
@ -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-----
|
13
spec/fixtures/files/pgp_key_valid_jimmy.asc
vendored
Normal file
13
spec/fixtures/files/pgp_key_valid_jimmy.asc
vendored
Normal file
@ -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-----
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user