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 01/14] 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
From ba683a7b95699000f7549e7b696d22bd1f7e84ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 23 Sep 2024 16:03:02 +0200
Subject: [PATCH 02/14] Move some Rails app services to UserManager namespace
---
app/controllers/admin/users_controller.rb | 2 +-
app/controllers/signup_controller.rb | 2 +-
app/services/create_account.rb | 54 ------------------
app/services/create_invitations.rb | 17 ------
app/services/user_manager/create_account.rb | 56 +++++++++++++++++++
.../user_manager/create_invitations.rb | 19 +++++++
app/services/user_manager_service.rb | 2 +
spec/features/signup_spec.rb | 4 +-
.../{ => user_manager}/create_account_spec.rb | 12 ++--
.../create_invitations_spec.rb | 6 +-
10 files changed, 90 insertions(+), 84 deletions(-)
delete mode 100644 app/services/create_account.rb
delete mode 100644 app/services/create_invitations.rb
create mode 100644 app/services/user_manager/create_account.rb
create mode 100644 app/services/user_manager/create_invitations.rb
create mode 100644 app/services/user_manager_service.rb
rename spec/services/{ => user_manager}/create_account_spec.rb (89%)
rename spec/services/{ => user_manager}/create_invitations_spec.rb (84%)
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 6b6f510..82b91da 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -30,7 +30,7 @@ class Admin::UsersController < Admin::BaseController
amount = params[:amount].to_i
notify_user = ActiveRecord::Type::Boolean.new.cast(params[:notify_user])
- CreateInvitations.call(user: @user, amount: amount, notify: notify_user)
+ UserManager::CreateInvitations.call(user: @user, amount: amount, notify: notify_user)
redirect_to admin_user_path(@user.cn), flash: {
success: "Added #{amount} invitations to #{@user.cn}'s account"
diff --git a/app/controllers/signup_controller.rb b/app/controllers/signup_controller.rb
index 236db54..631551d 100644
--- a/app/controllers/signup_controller.rb
+++ b/app/controllers/signup_controller.rb
@@ -96,7 +96,7 @@ class SignupController < ApplicationController
session[:new_user] = nil
session[:validation_error] = nil
- CreateAccount.call(account: {
+ UserManager::CreateAccount.call(account: {
username: @user.cn,
domain: Setting.primary_domain,
email: @user.email,
diff --git a/app/services/create_account.rb b/app/services/create_account.rb
deleted file mode 100644
index 12bf07b..0000000
--- a/app/services/create_account.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-class CreateAccount < ApplicationService
- def initialize(account:)
- @username = account[:username]
- @domain = account[:ou] || Setting.primary_domain
- @email = account[:email]
- @password = account[:password]
- @invitation = account[:invitation]
- @confirmed = account[:confirmed]
- end
-
- def call
- user = create_user_in_database
- add_ldap_document
- create_lndhub_account(user) if Setting.lndhub_enabled
-
- if @invitation.present?
- update_invitation(user.id)
- end
- end
-
- private
-
- def create_user_in_database
- User.create!(
- cn: @username,
- ou: @domain,
- email: @email,
- password: @password,
- password_confirmation: @password,
- confirmed_at: @confirmed ? DateTime.now : nil
- )
- end
-
- def update_invitation(user_id)
- @invitation.update! invited_user_id: user_id, used_at: DateTime.now
- end
-
- def add_ldap_document
- hashed_pw = Devise.ldap_auth_password_builder.call(@password)
- CreateLdapUserJob.perform_later(
- username: @username,
- domain: @domain,
- email: @email,
- hashed_pw: hashed_pw,
- confirmed: @confirmed
- )
- end
-
- def create_lndhub_account(user)
- #TODO enable in development when we have a local lndhub (mock?) API
- return if Rails.env.development?
- CreateLndhubAccountJob.perform_later(user)
- end
-end
diff --git a/app/services/create_invitations.rb b/app/services/create_invitations.rb
deleted file mode 100644
index 3003b1a..0000000
--- a/app/services/create_invitations.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-class CreateInvitations < ApplicationService
- def initialize(user:, amount:, notify: true)
- @user = user
- @amount = amount
- @notify = notify
- end
-
- def call
- @amount.times do
- Invitation.create(user: @user)
- end
-
- if @notify
- NotificationMailer.with(user: @user).new_invitations_available.deliver_later
- end
- end
-end
diff --git a/app/services/user_manager/create_account.rb b/app/services/user_manager/create_account.rb
new file mode 100644
index 0000000..ca65451
--- /dev/null
+++ b/app/services/user_manager/create_account.rb
@@ -0,0 +1,56 @@
+module UserManager
+ class CreateAccount < UserManagerService
+ def initialize(account:)
+ @username = account[:username]
+ @domain = account[:ou] || Setting.primary_domain
+ @email = account[:email]
+ @password = account[:password]
+ @invitation = account[:invitation]
+ @confirmed = account[:confirmed]
+ end
+
+ def call
+ user = create_user_in_database
+ add_ldap_document
+ create_lndhub_account(user) if Setting.lndhub_enabled
+
+ if @invitation.present?
+ update_invitation(user.id)
+ end
+ end
+
+ private
+
+ def create_user_in_database
+ User.create!(
+ cn: @username,
+ ou: @domain,
+ email: @email,
+ password: @password,
+ password_confirmation: @password,
+ confirmed_at: @confirmed ? DateTime.now : nil
+ )
+ end
+
+ def update_invitation(user_id)
+ @invitation.update! invited_user_id: user_id, used_at: DateTime.now
+ end
+
+ def add_ldap_document
+ hashed_pw = Devise.ldap_auth_password_builder.call(@password)
+ CreateLdapUserJob.perform_later(
+ username: @username,
+ domain: @domain,
+ email: @email,
+ hashed_pw: hashed_pw,
+ confirmed: @confirmed
+ )
+ end
+
+ def create_lndhub_account(user)
+ #TODO enable in development when we have a local lndhub (mock?) API
+ return if Rails.env.development?
+ CreateLndhubAccountJob.perform_later(user)
+ end
+ end
+end
diff --git a/app/services/user_manager/create_invitations.rb b/app/services/user_manager/create_invitations.rb
new file mode 100644
index 0000000..67d2fe8
--- /dev/null
+++ b/app/services/user_manager/create_invitations.rb
@@ -0,0 +1,19 @@
+module UserManager
+ class CreateInvitations < UserManagerService
+ def initialize(user:, amount:, notify: true)
+ @user = user
+ @amount = amount
+ @notify = notify
+ end
+
+ def call
+ @amount.times do
+ Invitation.create(user: @user)
+ end
+
+ if @notify
+ NotificationMailer.with(user: @user).new_invitations_available.deliver_later
+ end
+ end
+ end
+end
diff --git a/app/services/user_manager_service.rb b/app/services/user_manager_service.rb
new file mode 100644
index 0000000..74f9d37
--- /dev/null
+++ b/app/services/user_manager_service.rb
@@ -0,0 +1,2 @@
+class UserManagerService < ApplicationService
+end
diff --git a/spec/features/signup_spec.rb b/spec/features/signup_spec.rb
index e668e9d..0691688 100644
--- a/spec/features/signup_spec.rb
+++ b/spec/features/signup_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe "Signup", type: :feature do
click_button "Continue"
expect(page).to have_content("Choose a password")
- expect(CreateAccount).to receive(:call)
+ expect(UserManager::CreateAccount).to receive(:call)
.with(account: {
username: "tony", domain: "kosmos.org",
email: "tony@example.com", password: "a-valid-password",
@@ -96,7 +96,7 @@ RSpec.describe "Signup", type: :feature do
click_button "Create account"
expect(page).to have_content("Password is too short")
- expect(CreateAccount).to receive(:call)
+ expect(UserManager::CreateAccount).to receive(:call)
.with(account: {
username: "tony", domain: "kosmos.org",
email: "tony@example.com", password: "a-valid-password",
diff --git a/spec/services/create_account_spec.rb b/spec/services/user_manager/create_account_spec.rb
similarity index 89%
rename from spec/services/create_account_spec.rb
rename to spec/services/user_manager/create_account_spec.rb
index 4aaf139..3958b5a 100644
--- a/spec/services/create_account_spec.rb
+++ b/spec/services/user_manager/create_account_spec.rb
@@ -1,8 +1,8 @@
require 'rails_helper'
-RSpec.describe CreateAccount, type: :model do
+RSpec.describe UserManager::CreateAccount, type: :model do
describe "#create_user_in_database" do
- let(:service) { CreateAccount.new(account: {
+ let(:service) { described_class.new(account: {
username: 'isaacnewton',
email: 'isaacnewton@example.com',
password: 'bright-ideas-in-autumn'
@@ -19,7 +19,7 @@ RSpec.describe CreateAccount, type: :model do
describe "#update_invitation" do
let(:invitation) { create :invitation }
- let(:service) { CreateAccount.new(account: {
+ let(:service) { described_class.new(account: {
username: 'isaacnewton',
email: 'isaacnewton@example.com',
password: 'bright-ideas-in-autumn',
@@ -42,7 +42,7 @@ RSpec.describe CreateAccount, type: :model do
describe "#add_ldap_document" do
include ActiveJob::TestHelper
- let(:service) { CreateAccount.new(account: {
+ let(:service) { described_class.new(account: {
username: 'halfinney',
email: 'halfinney@example.com',
password: 'remember-remember-the-5th-of-november'
@@ -68,7 +68,7 @@ RSpec.describe CreateAccount, type: :model do
describe "#add_ldap_document for pre-confirmed account" do
include ActiveJob::TestHelper
- let(:service) { CreateAccount.new(account: {
+ let(:service) { described_class.new(account: {
username: 'halfinney',
email: 'halfinney@example.com',
password: 'remember-remember-the-5th-of-november',
@@ -89,7 +89,7 @@ RSpec.describe CreateAccount, type: :model do
describe "#create_lndhub_account" do
include ActiveJob::TestHelper
- let(:service) { CreateAccount.new(account: {
+ let(:service) { described_class.new(account: {
username: 'halfinney', email: 'halfinney@example.com',
password: 'bright-ideas-in-winter'
})}
diff --git a/spec/services/create_invitations_spec.rb b/spec/services/user_manager/create_invitations_spec.rb
similarity index 84%
rename from spec/services/create_invitations_spec.rb
rename to spec/services/user_manager/create_invitations_spec.rb
index 4e5de23..d994c6e 100644
--- a/spec/services/create_invitations_spec.rb
+++ b/spec/services/user_manager/create_invitations_spec.rb
@@ -1,13 +1,13 @@
require 'rails_helper'
-RSpec.describe CreateInvitations, type: :model do
+RSpec.describe UserManager::CreateInvitations, type: :model do
include ActiveJob::TestHelper
let(:user) { create :user }
describe "#call" do
before do
- CreateInvitations.call(user: user, amount: 5)
+ described_class.call(user: user, amount: 5)
end
after(:each) { clear_enqueued_jobs }
@@ -28,7 +28,7 @@ RSpec.describe CreateInvitations, type: :model do
describe "#call with notification disabled" do
before do
- CreateInvitations.call(user: user, amount: 3, notify: false)
+ described_class.call(user: user, amount: 3, notify: false)
end
after(:each) { clear_enqueued_jobs }
From 118fddb497907815ca9372bb3c732fa3b105ef0a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 23 Sep 2024 16:07:02 +0200
Subject: [PATCH 03/14] Document URLs for settings controller actions
No need to read the route sources all the time
---
app/controllers/settings_controller.rb | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
index a21c542..2200bcb 100644
--- a/app/controllers/settings_controller.rb
+++ b/app/controllers/settings_controller.rb
@@ -21,6 +21,7 @@ class SettingsController < ApplicationController
end
end
+ # PUT /settings/:section
def update
@user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name]
@@ -44,6 +45,7 @@ class SettingsController < ApplicationController
end
end
+ # POST /settings/update_email
def update_email
if @user.valid_ldap_authentication?(security_params[:current_password])
if @user.update email: email_params[:email]
@@ -61,6 +63,7 @@ class SettingsController < ApplicationController
end
end
+ # POST /settings/reset_email_password
def reset_email_password
@user.current_password = security_params[:current_password]
@@ -83,6 +86,7 @@ class SettingsController < ApplicationController
end
end
+ # POST /settings/reset_password
def reset_password
current_user.send_reset_password_instructions
sign_out current_user
@@ -90,6 +94,7 @@ class SettingsController < ApplicationController
redirect_to check_your_email_path, notice: msg
end
+ # POST /settings/set_nostr_pubkey
def set_nostr_pubkey
signed_event = Nostr::Event.new(**nostr_event_from_params)
From 3042a02a179374b9684a72c3b2dafec2a81b85dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 23 Sep 2024 18:13:39 +0200
Subject: [PATCH 04/14] Allow users to update their OpenPGP pubkey
---
app/controllers/settings_controller.rb | 10 ++-
app/models/user.rb | 2 +-
app/services/ldap_manager/update_pgp_key.rb | 16 ++++
app/services/user_manager/update_pgp_key.rb | 24 ++++++
app/views/settings/_account.html.erb | 29 +++++++-
spec/features/settings/account_spec.rb | 41 ++++++++++
spec/features/settings/profile_spec.rb | 2 +-
spec/models/user_spec.rb | 5 +-
.../user_manager/update_pgp_key_spec.rb | 74 +++++++++++++++++++
9 files changed, 193 insertions(+), 10 deletions(-)
create mode 100644 app/services/ldap_manager/update_pgp_key.rb
create mode 100644 app/services/user_manager/update_pgp_key.rb
create mode 100644 spec/services/user_manager/update_pgp_key_spec.rb
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
index 2200bcb..e469d66 100644
--- a/app/controllers/settings_controller.rb
+++ b/app/controllers/settings_controller.rb
@@ -25,7 +25,8 @@ class SettingsController < ApplicationController
def update
@user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name]
- @user.avatar_new = user_params[:avatar]
+ @user.avatar_new = user_params[:avatar]
+ @user.pgp_pubkey = user_params[:pgp_pubkey]
if @user.save
if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name])
@@ -36,6 +37,10 @@ class SettingsController < ApplicationController
LdapManager::UpdateAvatar.call(dn: @user.dn, file: @user.avatar_new)
end
+ if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key])
+ UserManager::UpdatePgpKey.call(user: @user)
+ end
+
redirect_to setting_path(@settings_section), flash: {
success: 'Settings saved.'
}
@@ -157,7 +162,8 @@ class SettingsController < ApplicationController
def user_params
params.require(:user).permit(
- :display_name, :avatar, preferences: UserPreferences.pref_keys
+ :display_name, :avatar, :pgp_pubkey,
+ preferences: UserPreferences.pref_keys
)
end
diff --git a/app/models/user.rb b/app/models/user.rb
index b0539ec..67c6fcd 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -52,7 +52,7 @@ class User < ApplicationRecord
validate :acceptable_avatar
- validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey != "" }
+ validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey.present? }
#
# Scopes
diff --git a/app/services/ldap_manager/update_pgp_key.rb b/app/services/ldap_manager/update_pgp_key.rb
new file mode 100644
index 0000000..fa5b982
--- /dev/null
+++ b/app/services/ldap_manager/update_pgp_key.rb
@@ -0,0 +1,16 @@
+module LdapManager
+ class UpdatePgpKey < LdapManagerService
+ def initialize(dn:, pubkey:)
+ @dn = dn
+ @pubkey = pubkey
+ end
+
+ def call
+ if @pubkey.present?
+ replace_attribute @dn, :pgpKey, @pubkey
+ else
+ delete_attribute @dn, :pgpKey
+ end
+ end
+ end
+end
diff --git a/app/services/user_manager/update_pgp_key.rb b/app/services/user_manager/update_pgp_key.rb
new file mode 100644
index 0000000..16b78c7
--- /dev/null
+++ b/app/services/user_manager/update_pgp_key.rb
@@ -0,0 +1,24 @@
+module UserManager
+ class UpdatePgpKey < UserManagerService
+ def initialize(user:)
+ @user = user
+ end
+
+ def call
+ if @user.pgp_pubkey.blank?
+ @user.update! pgp_fpr: nil
+ else
+ result = GPGME::Key.import(@user.pgp_pubkey)
+
+ if result.imports.present?
+ @user.update! pgp_fpr: result.imports.first.fpr
+ else
+ # TODO notify Sentry, user
+ raise "Failed to import OpenPGP pubkey"
+ end
+ end
+
+ LdapManager::UpdatePgpKey.call(dn: @user.dn, pubkey: @user.pgp_pubkey)
+ end
+ end
+end
diff --git a/app/views/settings/_account.html.erb b/app/views/settings/_account.html.erb
index b1b3430..90144b2 100644
--- a/app/views/settings/_account.html.erb
+++ b/app/views/settings/_account.html.erb
@@ -1,6 +1,6 @@
<%= tag.section data: {
controller: "settings--account--email",
- "settings--account--email-validation-failed-value": @validation_errors.present?
+ "settings--account--email-validation-failed-value": @validation_errors&.[](:email)&.present?
} do %>
E-Mail
<%= form_for(@user, url: update_email_settings_path, method: "post") do |f| %>
@@ -23,7 +23,7 @@
- <% if @validation_errors.present? && @validation_errors[:email].present? %>
+ <% if @validation_errors&.[](:email)&.present? %>
<%= @validation_errors[:email].first %>
<% end %>
@@ -41,10 +41,33 @@
<% end %>
Password
- Use the following button to request an email with a password reset link:
+ Use the following button to request an email with a password reset link:
<%= form_with(url: reset_password_settings_path, method: :post) do %>
<%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %>
<% end %>
+<%= form_for(@user, url: setting_path(:account), html: { :method => :put }) do |f| %>
+
+
+
+ <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
+
+
+<% end %>
diff --git a/spec/features/settings/account_spec.rb b/spec/features/settings/account_spec.rb
index 51f1c83..47bb9ad 100644
--- a/spec/features/settings/account_spec.rb
+++ b/spec/features/settings/account_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe 'Account settings', type: :feature do
.with("invalid password").and_return(false)
allow_any_instance_of(User).to receive(:valid_ldap_authentication?)
.with("valid password").and_return(true)
+ allow_any_instance_of(User).to receive(:pgp_pubkey).and_return(nil)
end
scenario 'fails with invalid password' do
@@ -55,4 +56,44 @@ RSpec.describe 'Account settings', type: :feature do
end
end
end
+
+ feature "Update OpenPGP key" do
+ let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") }
+ let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") }
+ let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" }
+
+ before do
+ login_as user, :scope => :user
+ allow_any_instance_of(User).to receive(:ldap_entry).and_return({
+ uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil
+ })
+ end
+
+ scenario 'rejects an invalid key' do
+ expect(UserManager::UpdatePgpKey).not_to receive(:call)
+
+ visit setting_path(:account)
+ fill_in 'Public key', with: invalid_key
+ click_button "Save"
+
+ expect(current_url).to eq(setting_url(:account))
+ within ".error-msg" do
+ expect(page).to have_content("This is not a valid armored PGP public key block")
+ end
+ end
+
+ scenario 'stores a valid key' do
+ expect(UserManager::UpdatePgpKey).to receive(:call)
+ .with(user: user).and_return(true)
+
+ visit setting_path(:account)
+ fill_in 'Public key', with: valid_key_alice
+ click_button "Save"
+
+ expect(current_url).to eq(setting_url(:account))
+ within ".flash-msg" do
+ expect(page).to have_content("Settings saved")
+ end
+ end
+ end
end
diff --git a/spec/features/settings/profile_spec.rb b/spec/features/settings/profile_spec.rb
index 1d5fa8c..55b861e 100644
--- a/spec/features/settings/profile_spec.rb
+++ b/spec/features/settings/profile_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'Profile settings', type: :feature do
allow(user).to receive(:display_name).and_return("Mark")
allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org")
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
- uid: user.cn, ou: user.ou, display_name: "Mark"
+ uid: user.cn, ou: user.ou, display_name: "Mark", pgp_key: nil
})
allow_any_instance_of(User).to receive(:avatar).and_return(avatar_base64)
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 60ce8ed..a4e0f57 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -262,7 +262,6 @@ RSpec.describe User, type: :model do
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
@@ -275,8 +274,8 @@ RSpec.describe User, type: :model do
end
after do
- GPGME::Key.get(fingerprint_alice).delete!
- GPGME::Key.get(fingerprint_jimmy).delete!
+ alice.gnupg_key.delete!
+ jimmy.gnupg_key.delete!
end
describe "#acceptable_pgp_key_format" do
diff --git a/spec/services/user_manager/update_pgp_key_spec.rb b/spec/services/user_manager/update_pgp_key_spec.rb
new file mode 100644
index 0000000..844eaf4
--- /dev/null
+++ b/spec/services/user_manager/update_pgp_key_spec.rb
@@ -0,0 +1,74 @@
+require 'rails_helper'
+
+RSpec.describe UserManager::UpdatePgpKey, type: :model do
+ include ActiveJob::TestHelper
+
+ let(:alice) { create :user, cn: "alice" }
+ let(:dn) { "cn=alice,ou=kosmos.org,cn=users,dc=kosmos,dc=org" }
+ let(:pubkey_asc) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") }
+ let(:fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" }
+
+ before do
+ allow(alice).to receive(:dn).and_return(dn)
+ allow(alice).to receive(:ldap_entry).and_return({
+ uid: alice.cn, ou: alice.ou, pgp_key: nil
+ })
+ end
+
+ describe "#call" do
+ context "with valid key" do
+ before do
+ alice.pgp_pubkey = pubkey_asc
+
+ allow(LdapManager::UpdatePgpKey).to receive(:call)
+ .with(dn: alice.dn, pubkey: pubkey_asc)
+ end
+
+ after do
+ alice.gnupg_key.delete!
+ end
+
+ it "imports the key into the GnuPG keychain" do
+ described_class.call(user: alice)
+ expect(alice.gnupg_key).to be_present
+ end
+
+ it "stores the key's fingerprint on the user record" do
+ described_class.call(user: alice)
+ expect(alice.pgp_fpr).to eq(fingerprint)
+ end
+
+ it "updates the user's LDAP entry with the new key" do
+ expect(LdapManager::UpdatePgpKey).to receive(:call)
+ .with(dn: alice.dn, pubkey: pubkey_asc)
+ described_class.call(user: alice)
+ end
+ end
+
+ context "with empty key" do
+ before do
+ alice.update pgp_fpr: fingerprint
+ alice.pgp_pubkey = ""
+
+ allow(LdapManager::UpdatePgpKey).to receive(:call)
+ .with(dn: alice.dn, pubkey: "")
+ end
+
+ it "does not attempt to import the key" do
+ expect(GPGME::Key).not_to receive(:import)
+ described_class.call(user: alice)
+ end
+
+ it "removes the key's fingerprint from the user record" do
+ described_class.call(user: alice)
+ expect(alice.pgp_fpr).to be_nil
+ end
+
+ it "removes the key from the user's LDAP entry" do
+ expect(LdapManager::UpdatePgpKey).to receive(:call)
+ .with(dn: alice.dn, pubkey: "")
+ described_class.call(user: alice)
+ end
+ end
+ end
+end
From 4a677178e81930734dc3bf2b0a127b520e315be6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 23 Sep 2024 19:20:10 +0200
Subject: [PATCH 05/14] Add Web Key Directory endpoint
Serve public keys in binary and armored text, if they contain a user's
account address.
---
.../web_key_directory_controller.rb | 34 +++++++
config/routes.rb | 7 +-
db/seeds/admin.asc | 13 +++
spec/fixtures/files/pgp_key_valid_jimmy.pem | Bin 0 -> 420 bytes
spec/requests/web_key_directory_spec.rb | 84 ++++++++++++++++++
5 files changed, 135 insertions(+), 3 deletions(-)
create mode 100644 app/controllers/web_key_directory_controller.rb
create mode 100644 db/seeds/admin.asc
create mode 100644 spec/fixtures/files/pgp_key_valid_jimmy.pem
create mode 100644 spec/requests/web_key_directory_spec.rb
diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb
new file mode 100644
index 0000000..ffd2844
--- /dev/null
+++ b/app/controllers/web_key_directory_controller.rb
@@ -0,0 +1,34 @@
+class WebKeyDirectoryController < WellKnownController
+ before_action :allow_cross_origin_requests, only: [ :show ]
+
+ # /.well-known/openpgpkey/hu/:hashed_username(.txt)
+ def show
+ @user = User.find_by(cn: params[:l])
+
+ if @user.nil? ||
+ @user.pgp_pubkey.empty? ||
+ !@user.pgp_pubkey_contains_user_address?
+ http_status :not_found and return
+ end
+
+ if params[:hashed_username] != @user.wkd_hash
+ http_status :unprocessable_entity and return
+ end
+
+ respond_to do |format|
+ format.text do
+ response.headers['Content-Type'] = 'text/plain'
+ render plain: @user.pgp_pubkey
+ end
+
+ format.any do
+ key = @user.gnupg_key.export
+ send_data key, filename: "#{@user.wkd_hash}.pem",
+ type: "application/octet-stream"
+ end
+ end
+ end
+
+ private
+
+end
diff --git a/config/routes.rb b/config/routes.rb
index 07e1ffe..9751f3d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -70,10 +70,11 @@ Rails.application.routes.draw do
get '.well-known/webfinger', to: 'webfinger#show'
get '.well-known/nostr', to: 'well_known#nostr'
- get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: 'lightning_address'
- get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: 'lightning_address_keysend'
+ get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: :lightning_address
+ get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: :lightning_address_keysend
+ get '.well-known/openpgpkey/hu/:hashed_username(.:format)', to: 'web_key_directory#show', as: :wkd_key
- get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: 'lnurlpay_invoice'
+ get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: :lnurlpay_invoice
post 'webhooks/lndhub', to: 'webhooks#lndhub'
diff --git a/db/seeds/admin.asc b/db/seeds/admin.asc
new file mode 100644
index 0000000..d3b65a1
--- /dev/null
+++ b/db/seeds/admin.asc
@@ -0,0 +1,13 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEZvGiUxYJKwYBBAHaRw8BAQdARPZXLqyB3nylJuzuARlOJxqc9mchMKHI4Cy+
+hPWlzja0GEFkbWluIDxhZG1pbkBrb3Ntb3Mub3JnPoiZBBMWCgBBFiEE0pie1+fG
+ImdZwzGnwgEYSg8AulYFAmbxolMCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYC
+AwECHgcCF4AACgkQwgEYSg8AulaldAEA7yzh7XRCdIJDHgLUvKHsy2NnyLaDD1Tl
+hyZWbl5og0IBAJAQ2Dm82YXMdUK3X1OGlK8KH5O4E5lSFY4+8/xx0UEJuDgEZvGi
+UxIKKwYBBAGXVQEFAQEHQJc8pzzeIF7Hm5z1eseRAqGvFa+V1BIDf+1XQzuJhhxi
+AwEIB4h+BBgWCgAmFiEE0pie1+fGImdZwzGnwgEYSg8AulYFAmbxolMCGwwFCQWj
+moAACgkQwgEYSg8AulbLtgEApZvuDqSP77lrl1jmtCAJEEZk/ofsRFkf1g3U3Zhm
+9PcA/1+AbcyqjLTcqIPjHmZyGEPiaAvEsBzbPKEPiL3JYhkG
+=45sx
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/spec/fixtures/files/pgp_key_valid_jimmy.pem b/spec/fixtures/files/pgp_key_valid_jimmy.pem
new file mode 100644
index 0000000000000000000000000000000000000000..44c792b627c60f5f4018d07d882c09446c00c498
GIT binary patch
literal 420
zcmbPX%#!vo*-ea7n~jl$@s>M3BO|*5r%!17wPRCv_qsdtCi=9?i`=o|+_B0=>x0CD
zH~&m6g0@I_W#;BqD%fO!D2MF);@tdVz5JqdyN;PG!eU$uj$(=|hS^`mlyldYD`$&-
zW38P$U4KW?VOA!PWlYk{temWiXEm^Lb8@gVF)1;ziE?pra5J%pF)=eT$+0tuH!yH<
z3cz$J%P}%6c-+12e`e&4J#A8#jJ!JE1m$lKxgh@kO2$+H*Y!+(T#O98PfjGw{KVY!
z(Ya6SDAR=pZ#-T*J)B!QN5FA_4T28Uau7Ks<%)@)u!@4F9iKN}Wxa
z@=xzyjrrr!%D3-x9ttfJQVE*i^_=^Tn9^H@e^ymOG2fP$@Uy3$K6vck{7tD1yp}S(
O#!X+sK
literal 0
HcmV?d00001
diff --git a/spec/requests/web_key_directory_spec.rb b/spec/requests/web_key_directory_spec.rb
new file mode 100644
index 0000000..627c89a
--- /dev/null
+++ b/spec/requests/web_key_directory_spec.rb
@@ -0,0 +1,84 @@
+require 'rails_helper'
+
+RSpec.describe "OpenPGP Web Key Directory", type: :request do
+ describe "non-existent user" do
+ it "returns a 404 status" do
+ get "/.well-known/openpgpkey/hu/fmb8gw3n4zdj4xpwaziki4mwcxr1368i?l=aristotle"
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "user without pubkey" do
+ let(:user) { create :user, cn: 'bernd', ou: 'kosmos.org' }
+
+ it "returns a 404 status" do
+ get "/.well-known/openpgpkey/hu/kp95h369c89sx8ia1hn447i868nqyz4t?l=bernd"
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "user with pubkey" 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(: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
+ end
+
+ after do
+ alice.gnupg_key.delete!
+ jimmy.gnupg_key.delete!
+ end
+
+ describe "pubkey does not contain user address" do
+ before do
+ allow_any_instance_of(User).to receive(:ldap_entry)
+ .and_return({ pgp_key: valid_key_alice })
+ end
+
+ it "returns a 404 status" do
+ get "/.well-known/openpgpkey/hu/kei1q4tipxxu1yj79k9kfukdhfy631xe?l=alice"
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "pubkey contains user address" do
+ before do
+ allow_any_instance_of(User).to receive(:ldap_entry)
+ .and_return({ pgp_key: valid_key_jimmy })
+ end
+
+ it "returns the pubkey in binary format" do
+ get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf?l=jimmy"
+ expect(response).to have_http_status(:ok)
+ expect(response.headers['Content-Type']).to eq("application/octet-stream")
+ expected_binary_data = File.binread("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.pem")
+ expect(response.body).to eq(expected_binary_data)
+ end
+
+ context "with .txt extension" do
+ it "returns the pubkey as ASCII Armor plain text" do
+ get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf.txt?l=jimmy"
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to eq(valid_key_jimmy)
+ expect(response.headers['Content-Type']).to eq("text/plain")
+ end
+ end
+
+ context "invalid URL" do
+ it "returns a 422 status" do
+ get "/.well-known/openpgpkey/hu/123456abcdef?l=alice"
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+ end
+end
From c3f1f97e1a7402ed006551a9def8f190d3bcc4f4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 23 Sep 2024 19:21:59 +0200
Subject: [PATCH 06/14] Add display name and PGP key to admin user page
Link the key to the ASCII Armor WKD endpoint, if it contains the user's
account address
---
app/views/admin/users/show.html.erb | 48 ++++++++++++++++++++++++-----
1 file changed, 41 insertions(+), 7 deletions(-)
diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb
index 4184e31..11417d0 100644
--- a/app/views/admin/users/show.html.erb
+++ b/app/views/admin/users/show.html.erb
@@ -89,13 +89,47 @@
- <% if @avatar.present? %>
- LDAP
-
-
-
- <% end %>
-
+ LDAP
+
+
+
+ Avatar |
+
+ <% if @avatar.present? %>
+
+ <% else %>
+ —
+ <% end %>
+ |
+
+
+ Display name |
+ <%= @user.display_name || "—" %> |
+
+
+ PGP key |
+
+ <% if @user.pgp_pubkey.present? %>
+
+ <% if @user.pgp_pubkey_contains_user_address? %>
+ <%= link_to wkd_key_url(hashed_username: @user.wkd_hash, l: @user.cn, format: :txt),
+ class: "ks-text-link", target: "_blank" do %>
+ <%= "#{@user.pgp_fpr[0, 8]}…#{@user.pgp_fpr[-8..-1]}" %>
+ <% end %>
+ <% else %>
+ <%= "#{@user.pgp_fpr[0, 8]}…#{@user.pgp_fpr[-8..-1]}" %>
+ <% end %>
+
+ <% @user.gnupg_key.uids.each do |uid| %>
+ <%= uid.uid %>
+ <% end %>
+ <% else %>
+ —
+ <% end %>
+ |
+
+
+
From 37b106e73c5d16b52c8f329cdfc7d929779807fe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 23 Sep 2024 19:22:52 +0200
Subject: [PATCH 07/14] Whitespace
---
app/views/settings/_email.html.erb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/views/settings/_email.html.erb b/app/views/settings/_email.html.erb
index 8d48661..a041ea0 100644
--- a/app/views/settings/_email.html.erb
+++ b/app/views/settings/_email.html.erb
@@ -5,7 +5,7 @@
E-Mail Password
<%= form_for(@user, url: reset_email_password_settings_path, method: "post") do |f| %>
<%= hidden_field_tag :section, "email" %>
-
+
Use the following button to generate a new email password:
From 9f6fa6deba7bb7941322017b855345ff8c7b8779 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Mon, 23 Sep 2024 20:36:05 +0200
Subject: [PATCH 08/14] Remove example link
Until we have a live example on kosmos.org
---
app/views/settings/_account.html.erb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/views/settings/_account.html.erb b/app/views/settings/_account.html.erb
index 90144b2..072b769 100644
--- a/app/views/settings/_account.html.erb
+++ b/app/views/settings/_account.html.erb
@@ -54,7 +54,7 @@
<%= render FormElements::FieldsetComponent.new(
title: "Public key",
- description: "Your OpenPGP public key in ASCII Armor format ([example])"
+ description: "Your OpenPGP public key in ASCII Armor format"
) do %>
<%= f.text_area :pgp_pubkey,
value: @user.pgp_pubkey,
From 1b72c97f42396efd436656f9c262ad0c52eaf805 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Wed, 25 Sep 2024 00:17:30 +0200
Subject: [PATCH 09/14] Remove obsolete code
---
app/controllers/web_key_directory_controller.rb | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb
index ffd2844..730fe1d 100644
--- a/app/controllers/web_key_directory_controller.rb
+++ b/app/controllers/web_key_directory_controller.rb
@@ -1,5 +1,5 @@
class WebKeyDirectoryController < WellKnownController
- before_action :allow_cross_origin_requests, only: [ :show ]
+ before_action :allow_cross_origin_requests
# /.well-known/openpgpkey/hu/:hashed_username(.txt)
def show
@@ -28,7 +28,4 @@ class WebKeyDirectoryController < WellKnownController
end
end
end
-
- private
-
end
From 534e5a9d3c9349f0d5d1e11fd399581d86d96654 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Wed, 25 Sep 2024 00:20:30 +0200
Subject: [PATCH 10/14] Gracefully handle wrong capitalization of username
---
app/controllers/web_key_directory_controller.rb | 2 +-
spec/requests/web_key_directory_spec.rb | 9 +++++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb
index 730fe1d..d25c899 100644
--- a/app/controllers/web_key_directory_controller.rb
+++ b/app/controllers/web_key_directory_controller.rb
@@ -3,7 +3,7 @@ class WebKeyDirectoryController < WellKnownController
# /.well-known/openpgpkey/hu/:hashed_username(.txt)
def show
- @user = User.find_by(cn: params[:l])
+ @user = User.find_by(cn: params[:l].downcase)
if @user.nil? ||
@user.pgp_pubkey.empty? ||
diff --git a/spec/requests/web_key_directory_spec.rb b/spec/requests/web_key_directory_spec.rb
index 627c89a..1d8f083 100644
--- a/spec/requests/web_key_directory_spec.rb
+++ b/spec/requests/web_key_directory_spec.rb
@@ -64,6 +64,15 @@ RSpec.describe "OpenPGP Web Key Directory", type: :request do
expect(response.body).to eq(expected_binary_data)
end
+ context "with wrong capitalization of username" do
+ it "returns the pubkey as ASCII Armor plain text" do
+ get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf?l=JimmY"
+ expect(response).to have_http_status(:ok)
+ expected_binary_data = File.binread("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.pem")
+ expect(response.body).to eq(expected_binary_data)
+ end
+ end
+
context "with .txt extension" do
it "returns the pubkey as ASCII Armor plain text" do
get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf.txt?l=jimmy"
From 729e4fd5662fc65382e2b5772f224234add7a034 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Thu, 26 Sep 2024 23:11:21 +0200
Subject: [PATCH 11/14] Add WKD policy endpoint
---
app/controllers/web_key_directory_controller.rb | 4 ++++
config/routes.rb | 1 +
spec/requests/web_key_directory_spec.rb | 8 ++++++++
3 files changed, 13 insertions(+)
diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb
index d25c899..593a62f 100644
--- a/app/controllers/web_key_directory_controller.rb
+++ b/app/controllers/web_key_directory_controller.rb
@@ -28,4 +28,8 @@ class WebKeyDirectoryController < WellKnownController
end
end
end
+
+ def policy
+ head :ok
+ end
end
diff --git a/config/routes.rb b/config/routes.rb
index 9751f3d..b9c771e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -73,6 +73,7 @@ Rails.application.routes.draw do
get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: :lightning_address
get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: :lightning_address_keysend
get '.well-known/openpgpkey/hu/:hashed_username(.:format)', to: 'web_key_directory#show', as: :wkd_key
+ get '.well-known/openpgpkey/policy', to: 'web_key_directory#policy'
get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: :lnurlpay_invoice
diff --git a/spec/requests/web_key_directory_spec.rb b/spec/requests/web_key_directory_spec.rb
index 1d8f083..c7ef1f3 100644
--- a/spec/requests/web_key_directory_spec.rb
+++ b/spec/requests/web_key_directory_spec.rb
@@ -1,6 +1,14 @@
require 'rails_helper'
RSpec.describe "OpenPGP Web Key Directory", type: :request do
+ describe "policy" do
+ it "returns an empty 200 response" do
+ get "/.well-known/openpgpkey/policy"
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to be_empty
+ end
+ end
+
describe "non-existent user" do
it "returns a 404 status" do
get "/.well-known/openpgpkey/hu/fmb8gw3n4zdj4xpwaziki4mwcxr1368i?l=aristotle"
From 3ee76e26abdb3253353a9f4886178ae0c09a9b40 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Tue, 8 Oct 2024 11:34:18 +0200
Subject: [PATCH 12/14] Re-import user's pubkey on access
Sometimes, the pubkey might not be imported in the local keychain
(anymore), but at this point in the code it had been successfully
imported at least once before. So we just (re-)import every time for it
to never fail.
---
app/models/user.rb | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/models/user.rb b/app/models/user.rb
index 67c6fcd..284f350 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -174,7 +174,8 @@ class User < ApplicationRecord
def gnupg_key
return nil unless pgp_pubkey.present?
- @gnupg_key ||= GPGME::Key.get(pgp_fpr)
+ GPGME::Key.import(pgp_pubkey)
+ GPGME::Key.get(pgp_fpr)
end
def pgp_pubkey_contains_user_address?
From c4c2d16342a7af00cb486aac8bb22915c176a24d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Wed, 25 Sep 2024 23:43:11 +0200
Subject: [PATCH 13/14] Encrypt outgoing emails when possible
---
app/mailers/application_mailer.rb | 86 +++++++++++++++++++++++
app/mailers/custom_mailer.rb | 2 +-
app/mailers/notification_mailer.rb | 8 +--
app/services/user_manager/pgp_encrypt.rb | 19 ++++++
spec/mailers/notification_mailer_spec.rb | 87 ++++++++++++++++++++++++
5 files changed, 197 insertions(+), 5 deletions(-)
create mode 100644 app/services/user_manager/pgp_encrypt.rb
create mode 100644 spec/mailers/notification_mailer_spec.rb
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index d8bd387..a289acf 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,3 +1,89 @@
class ApplicationMailer < ActionMailer::Base
layout 'mailer'
+
+ private
+
+ def send_mail
+ @template ||= "#{self.class.name.underscore}/#{caller[0][/`([^']*)'/, 1]}"
+ headers['Message-ID'] = message_id
+
+ if @user.pgp_pubkey.present?
+ mail(to: @user.email, subject: "...", content_type: pgp_content_type) do |format|
+ format.text { render plain: pgp_content }
+ end
+ else
+ mail(to: @user.email, subject: @subject) do |format|
+ format.text { render @template }
+ end
+ end
+ end
+
+ def from_address
+ ENV.fetch('SMTP_FROM_ADDRESS', 'accounts@localhost')
+ end
+
+ def from_domain
+ Mail::Address.new(from_address).domain
+ end
+
+ def message_id
+ @message_id ||= "#{SecureRandom.uuid}@#{from_domain}"
+ end
+
+ def boundary
+ @boundary ||= SecureRandom.hex(8)
+ end
+
+ def pgp_content_type
+ "multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"------------#{boundary}\""
+ end
+
+ def pgp_nested_content
+ message_content = render_to_string(template: @template)
+ message_content_base64 = Base64.encode64(message_content)
+ nested_boundary = SecureRandom.hex(8)
+
+ <<~NESTED_CONTENT
+ Content-Type: multipart/mixed; boundary="------------#{nested_boundary}"; protected-headers="v1"
+ Subject: #{@subject}
+ From: <#{from_address}>
+ To: #{@user.display_name || @user.cn} <#{@user.email}>
+ Message-ID: <#{message_id}>
+
+ --------------#{nested_boundary}
+ Content-Type: text/plain; charset=UTF-8; format=flowed
+ Content-Transfer-Encoding: base64
+
+ #{message_content_base64}
+
+ --------------#{nested_boundary}--
+ NESTED_CONTENT
+ end
+
+ def pgp_content
+ encrypted_content = UserManager::PgpEncrypt.call(user: @user, text: pgp_nested_content)
+ encrypted_base64 = Base64.encode64(encrypted_content.to_s)
+
+ <<~EMAIL_CONTENT
+ This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+ --------------#{boundary}
+ Content-Type: application/pgp-encrypted
+ Content-Description: PGP/MIME version identification
+
+ Version: 1
+
+ --------------#{boundary}
+ Content-Type: application/octet-stream; name="encrypted.asc"
+ Content-Description: OpenPGP encrypted message
+ Content-Disposition: inline; filename="encrypted.asc"
+
+ -----BEGIN PGP MESSAGE-----
+
+ #{encrypted_base64}
+
+ -----END PGP MESSAGE-----
+
+ --------------#{boundary}--
+ EMAIL_CONTENT
+ end
end
diff --git a/app/mailers/custom_mailer.rb b/app/mailers/custom_mailer.rb
index 325a620..fd35185 100644
--- a/app/mailers/custom_mailer.rb
+++ b/app/mailers/custom_mailer.rb
@@ -18,6 +18,6 @@ class CustomMailer < ApplicationMailer
@user = params[:user]
@subject = params[:subject]
@body = params[:body]
- mail(to: @user.email, subject: @subject)
+ send_mail
end
end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index b4ec37d..d281380 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -3,7 +3,7 @@ class NotificationMailer < ApplicationMailer
@user = params[:user]
@amount_sats = params[:amount_sats]
@subject = "Sats received"
- mail to: @user.email, subject: @subject
+ send_mail
end
def remotestorage_auth_created
@@ -15,19 +15,19 @@ class NotificationMailer < ApplicationMailer
"#{access} #{directory}"
end
@subject = "New app connected to your storage"
- mail to: @user.email, subject: @subject
+ send_mail
end
def new_invitations_available
@user = params[:user]
@subject = "New invitations added to your account"
- mail to: @user.email, subject: @subject
+ send_mail
end
def bitcoin_donation_confirmed
@user = params[:user]
@donation = params[:donation]
@subject = "Donation confirmed"
- mail to: @user.email, subject: @subject
+ send_mail
end
end
diff --git a/app/services/user_manager/pgp_encrypt.rb b/app/services/user_manager/pgp_encrypt.rb
new file mode 100644
index 0000000..afb24b3
--- /dev/null
+++ b/app/services/user_manager/pgp_encrypt.rb
@@ -0,0 +1,19 @@
+require 'gpgme'
+
+module UserManager
+ class PgpEncrypt < UserManagerService
+ def initialize(user:, text:)
+ @user = user
+ @text = text
+ end
+
+ def call
+ crypto = GPGME::Crypto.new
+ crypto.encrypt(
+ @text,
+ recipients: @user.gnupg_key,
+ always_trust: true
+ )
+ end
+ end
+end
diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb
new file mode 100644
index 0000000..a2b3099
--- /dev/null
+++ b/spec/mailers/notification_mailer_spec.rb
@@ -0,0 +1,87 @@
+# spec/mailers/welcome_mailer_spec.rb
+require 'rails_helper'
+
+RSpec.describe NotificationMailer, type: :mailer do
+ describe '#lightning_sats_received' do
+
+ context "without PGP key" do
+ let(:user) { create(:user, cn: "phil", email: 'phil@example.com') }
+
+ before do
+ allow_any_instance_of(User).to receive(:ldap_entry).and_return({
+ uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil
+ })
+ end
+
+ describe "unencrypted email" do
+ let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received }
+
+ it 'renders the correct to/from headers' do
+ expect(mail.to).to eq([user.email])
+ expect(mail.from).to eq(['accounts@kosmos.org'])
+ end
+
+ it 'renders the correct subject' do
+ expect(mail.subject).to eq('Sats received')
+ end
+
+ it 'uses the correct content type' do
+ expect(mail.header['content-type'].to_s).to include('text/plain')
+ end
+
+ it 'renders the body with correct content' do
+ expect(mail.body.encoded).to match(/You just received 21,000 sats/)
+ expect(mail.body.encoded).to include(user.address)
+ end
+
+ it 'includes a link to the lightning service page' do
+ expect(mail.body.encoded).to include("https://accounts.kosmos.org/services/lightning")
+ end
+ end
+ end
+
+ context "with PGP key" do
+ let(:pgp_pubkey) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") }
+ let(:pgp_fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" }
+ let(:user) { create(:user, id: 2, cn: "alice", email: 'alice@example.com', pgp_fpr: pgp_fingerprint) }
+
+ before do
+ allow_any_instance_of(User).to receive(:ldap_entry).and_return({
+ uid: user.cn, ou: user.ou, display_name: nil, pgp_key: pgp_pubkey
+ })
+ end
+
+ describe "encrypted email" do
+ let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received }
+
+ it 'renders the correct to/from headers' do
+ expect(mail.to).to eq([user.email])
+ expect(mail.from).to eq(['accounts@kosmos.org'])
+ end
+
+ it 'encrypts the subject line' do
+ expect(mail.subject).to eq('...')
+ end
+
+ it 'uses the correct content type' do
+ expect(mail.header['content-type'].to_s).to include('multipart/encrypted')
+ expect(mail.header['content-type'].to_s).to include('protocol="application/pgp-encrypted"')
+ end
+
+ it 'renders the PGP version part' do
+ expect(mail.body.encoded).to include("Content-Type: application/pgp-encrypted")
+ expect(mail.body.encoded).to include("Content-Description: PGP/MIME version identification")
+ expect(mail.body.encoded).to include("Version: 1")
+ end
+
+ it 'renders the encrypted PGP part' do
+ expect(mail.body.encoded).to include('Content-Type: application/octet-stream; name="encrypted.asc"')
+ expect(mail.body.encoded).to include('Content-Description: OpenPGP encrypted message')
+ expect(mail.body.encoded).to include('Content-Disposition: inline; filename="encrypted.asc"')
+ expect(mail.body.encoded).to include('-----BEGIN PGP MESSAGE-----')
+ expect(mail.body.encoded).to include('hF4DR')
+ end
+ end
+ end
+ end
+end
From 339462f3205fa41c9e4344735dd5589907698fa5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Tue, 8 Oct 2024 14:06:10 +0200
Subject: [PATCH 14/14] Refactor mailer options usage
---
app/mailers/application_mailer.rb | 3 ++-
config/environments/development.rb | 16 +++++++++++-----
config/environments/production.rb | 2 +-
config/environments/test.rb | 8 ++++++--
4 files changed, 20 insertions(+), 9 deletions(-)
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index a289acf..a9ccecf 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,4 +1,5 @@
class ApplicationMailer < ActionMailer::Base
+ default Rails.application.config.action_mailer.default_options
layout 'mailer'
private
@@ -19,7 +20,7 @@ class ApplicationMailer < ActionMailer::Base
end
def from_address
- ENV.fetch('SMTP_FROM_ADDRESS', 'accounts@localhost')
+ self.class.default[:from]
end
def from_domain
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 9f73578..a5b46ac 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -57,16 +57,22 @@ Rails.application.configure do
# routes, locales, etc. This feature depends on the listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker
- config.action_mailer.default_options = {
- from: "accounts@localhost"
- }
-
# Don't actually send emails, cache them for viewing via letter opener
config.action_mailer.delivery_method = :letter_opener
+
# Don't care if the mailer can't send
config.action_mailer.raise_delivery_errors = true
+
# Base URL to be used by email template link helpers
- config.action_mailer.default_url_options = { host: "localhost:3000", protocol: "http" }
+ config.action_mailer.default_url_options = {
+ host: "localhost:3000",
+ protocol: "http"
+ }
+
+ config.action_mailer.default_options = {
+ from: "accounts@localhost",
+ message_id: -> { "<#{Mail.random_tag}@localhost>" },
+ }
# Allow requests from any IP
config.web_console.permissions = '0.0.0.0/0'
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 16e7fa5..da0fa6f 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -63,7 +63,7 @@ Rails.application.configure do
outgoing_email_domain = Mail::Address.new(outgoing_email_address).domain
config.action_mailer.default_url_options = {
- host: ENV['AKKOUNTS_DOMAIN'],
+ host: ENV.fetch('AKKOUNTS_DOMAIN'),
protocol: "https",
}
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 65473dd..0c474bf 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -46,8 +46,12 @@ Rails.application.configure do
config.action_mailer.default_url_options = {
host: "accounts.kosmos.org",
- protocol: "https",
- from: "accounts@kosmos.org"
+ protocol: "https"
+ }
+
+ config.action_mailer.default_options = {
+ from: "accounts@kosmos.org",
+ message_id: -> { "<#{Mail.random_tag}@kosmos.org>" },
}
config.active_job.queue_adapter = :test