Opinions expressed here belong to your're mom
CLICK HERE TO GO TO INSTRUCTIONS
CLICK HERE TO GO TO INSTRUCTIONS
CLICK HERE TO GO TO INSTRUCTIONS
CLICK HERE TO GO TO INSTRUCTIONS
CLICK HERE TO GO TO INSTRUCTIONS
OpenProject is a Libre project management application. The primary interface for the application is through the web browser, but a terminal client is under active development. OpenProject (OP) is sometimes useful for managing projects. Recently a new major version, version 13, was realeased. This new version of the software includes various new features such as time tracking and iCalendar integration.
Previously, I have been disappointed by the fact that some features are locked behind an enterprise license. In this post I will explore reverse engineering OP and removing these restrictions without paying. There are a few motivations:
"Reverse engineering" may not be the most accurate term here since I am not decompling anything. However, I am delving into a completely alien codebase in a language that I do not know to try to understand enough to make changes.
I opted to install OP via the officially supported method on Ubuntu 22.04 of just using their repos. The official installation walks through setting up the repository and key and installing the software and then passes off to another section on the initial configuration. I want this as automated as I can get it, so instead of using the installer in interactive mode, I run it in non-interactive mode. This requires that I already have a postgresql database up and running. This isn't a tutorial on PSQL setup so I'm gonna breeze past this. You need:
You also probably want to front OP with Nginx or some other reverse proxy. This will keep you from exposing OP directly to the network and you can manage your SSL certs in one less place. My Nginx config looks like this:
server {
listen 443 ssl;
ssl_certificate YOUR_SSL_CERT;
ssl_certificate_key YOUR_SSL_KEY;
server_name YOUR_SERVER_FQDN;
client_max_body_size 100M;
client_body_buffer_size 128k;
root /opt/openproject/public;
location ~ / {
proxy_pass_request_headers on;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Server $host:$server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:6000;
}
}
This will of course return a 502 because OP is not yet set up. To set
OP up, you can put a file at /etc/openproject/installer.dat
with the contents:
openproject/edition default
postgres/autoinstall reuse
postgres/db_host 127.0.0.1
postgres/db_port 5432
postgres/db_username openproject
postgres/db_password DATABASE_PASSWORD_HERE
postgres/db_name openproject
server/autoinstall skip
smtp/autoinstall skip
memcached/autoinstall install
server/hostname YOUR_SERVER_FQDN
server/ssl no
openproject/admin_email example@example.com
And then run the command sudo openproject configure
.
This will set up OP for you and then it should be listening in the web
browser. The default creds are admin/admin
and you will be
prompted to change this password on your first login. If you have
accomplished this then you have met all of the prerequisites for getting
OP Enterprise Edition for free.
In the webapp, I got a huge annoying banner upon login that I didn't have an enterprise license. This looked like a promising place to start. However, clicking on the "Upgrade Now" button brought me to an external site, which was not what I'm looking for (yet). I instead clicked on the user button in the top right corner ("OA" for "Openproject Admin" by default) and go to Administration and then Enterprise Edition, where I found a page where I would put my "Enterprise edition support token" if I had one.
At first I thought "maybe I can just go to the path
/admin/enterprise
in the nginx root to find something", but
sadly the file /opt/openproject/public/admin/enterprise
does not exist (and neither does its parent directory). Looking at the
nginx config, the requests are being passed to a process running on port
6000. You can inspect what is actually running on port 6000 like so:
root@openproject:/opt/openproject/public# netstat -tulpane | grep :6000
tcp 0 0 127.0.0.1:6000 0.0.0.0:* LISTEN 997 121934 30374/puma 6.3.0 (t
tcp 0 0 127.0.0.1:34586 127.0.0.1:6000 TIME_WAIT 0 0 -
tcp 0 0 127.0.0.1:33480 127.0.0.1:6000 TIME_WAIT 0 0 -
tcp 0 0 127.0.0.1:46618 127.0.0.1:6000 TIME_WAIT 0 0 -
I have no clue what "puma" is but it is the application that is doing the serving, so let's look at that:
root@openproject:/opt/openproject/public# ps -ef | grep puma
openpro+ 30374 30318 0 18:12 ? 00:00:11 puma 6.3.0 (tcp://127.0.0.1:6000) [openproject]
openpro+ 30397 30374 0 18:12 ? 00:00:05 puma: cluster worker 0: 30374 [openproject]
openpro+ 30401 30374 0 18:12 ? 00:00:04 puma: cluster worker 1: 30374 [openproject]
root@openproject:/opt/openproject/public# systemctl status | grep puma -B 2
├─openproject-web-1.service
│ ├─30318 /bin/bash -e ./packaging/scripts/web
│ ├─30374 puma 6.3.0 (tcp://127.0.0.1:6000) [openproject]
│ ├─30397 puma: cluster worker 0: 30374 [openproject]
│ └─30401 puma: cluster worker 1: 30374 [openproject]
It looks like the process is being run from the
openproject-web-1
systemd service. Looking at the contents
of /etc/systemd/system/openproject-web-1.service
to see
where the actual starting point for the web application is yeilds:
root@openproject:/opt/openproject/public# cat /etc/systemd/system/openproject-web-1.service
[Unit]
StopWhenUnneeded=true
Requires=openproject-web.service
After=openproject-web.service
[Service]
Environment=APP_PROCESS_INDEX=1
ExecStart=/usr/bin/openproject run web
Restart=always
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=%n
[Install]
WantedBy=openproject-web.service
This command in ExecStart
is the main OP executable, and
thankfully it isn't even a binary, it is just a really long Bash script
generated by some tool called "pkgr". In this script I found what
happens when using the run subcommand:
while : ; do
case "$1" in
run)
{...}
exec sh -c "cd $(_p ${APP_HOME}) && $runnable"
{...}
That ${APP_HOME}
variable is set in
/etc/default/openproject
to be
/opt/openproject
. I could have come to this same conclusion
by looking at the output of dpkg-query -L openproject
and
sifting through the files there, but this way was more fun. Looking at
some of the other variable setting in this script, I eventually figured
out that the binary that gets run by the above exec command is
/opt/openproject/vendor/pkgr/processes/web
, which itself
just launches /opt/openproject/scripts/web
, which in turn
launches our puma process from earlier:
#!/bin/bash -e
HOST="${HOST:=127.0.0.1}"
PORT="${PORT:=8080}"
bundle exec rails server -u puma -b $HOST -p $PORT
Everything from here on is Ruby and I was well out of my depth.
Ignorance, however, is not a sufficient excuse to not do something. Now
that I had found the OP application files, I just needed to find the
code that manages the enterprise license. Running the command
grep -ril enterprrise .
in the
/opt/openproject/
directory will show everywhere that that
string shows up in the codebase, but there are a lot of
results. Of the 843 files containing this string, a lot can be
completely ignored.
./modules/
. This directory contains
OP-specific Ruby code for web features. All of the modules highlighted
by the grep search are either completely or partially locked behind the
Enterprise paywall. These modules are:
./db/migrate/
which I assume is used for
database migration when OP gets updated. When you upgrade OP, you should
get to bring your license with you, and the code here provides for
that../lookbook/docs/
which dictate which colors to
use for various things, such as warnings that you should buy an
enterprise license../lib_static/open_project/configuration/helpers.rb
,
among other things provides a link for users to go sign up for a 14 day
free trial of OP Enterprise on a managed installation "in the
cloud"../.heroku/
for tools which have their own
enterprise licenses unrelated to OP../frontend/
and
./public/assets/frontend/
which contain HTML and JavaScript
and TypeScript and graphics and all that good stuff../tmp/cache/
../docs/
../config/
which are mostly just locales
so that OP can translate "PLEASE GIVE US MONEY" into any language../spec/
which are used for testing the
code../lib/
,
./vendor/bundle/ruby/3.2.0/gems/
, and ./app/
that are obviously related to the modules which depend, in whole or in
part, on an enterprise license..erb
files, which are not related to the epic rap
battles of history, but instead are Ruby templates for HTML pages.All of these aren't worth looking at beacuse they are not where the code to import and validate an enterprise token is coming from. Of the original 843 files, this leaves only 9 to actually look through:
./lib/constraints/enterprise.rb
./lib/open_project/enterprise.rb
./vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token.rb
./app/helpers/enterprise_helper.rb
./app/helpers/enterprise_trial_helper.rb
./app/services/authorization/enterprise_service.rb
./app/contracts/concerns/requires_enterprise_guard.rb
./app/models/enterprise_token.rb
./app/models/token/enterprise_trial_key.rb
The very first file here just creates a function called "matches" in
the "Enterprise" module.
The next file, ./lib/open_project/enterprises.rb
appears to
set some methods that can be used for enterprise tokens . The "token"
method checks the EnterpriseToken.current.presence,
which is defined in the ./app/models/enterprise_token.rb
file. The "current" method of that class calls the
RequestStore
module and then returns (I think) the content
of the key that it is querying, in this case the EE (enterprise edition)
token. Grepping for RequestStore.fetch
shows a lot
of places in the OP code that this is used, so to avoid breaking other
functionality, I don't think that this is the best place to make
modifications. The ./app/helpers/enterprise_helper.rb
,
enterprise_trial_helper.rb
, and
./app/models/token/enterprise_trial_key.rb
files are all
used for (I think) creating the metadata for the trial license.
That only leaves three files left. One is the Ruby gem file
token.rb
which takes in the token data that OpenProject
(the company) gives you when you pay them money, validates it, and sets
it up in your database. The other two files, which are in the
./app/
directory, look like the place where restrictions
and enforcements of what is and is not allowed without an EE token are
defined and enforced. This leaves two options with how to move
forward:
The first place that I tried to get around this licensing issue was
in the ./app/services/authorization/enterprise_service.rb
file. That has some particularly interesting snippets:
GUARDED_ACTIONS = %i(
attribute_help_texts
baseline_comparison
board_view
conditional_highlighting
custom_actions
custom_fields_in_projects_list
date_alerts
define_custom_style
edit_attribute_groups
grid_widget_wp_graph
ldap_groups
openid_providers
placeholder_users
readonly_work_packages
team_planner_view
two_factor_authentication
work_package_query_relation_columns
).freeze
and
# Return a true ServiceResult if the token contains this particular action.
def call(action)
allowed =
if token.nil? || token.token_object.nil? || token.expired?
false
else
process(action)
end
result(allowed)
end
Initially, this first section looks like a list of actions that OP
won't let you do unless you have an EE token. Those are in fact all of
the different places where pieces of the application are guarded behind
a paywall. The syntax is something that I hadn't seen before, the
%i().freeze
formatting being completely alien to me. With
some looking around online, it appears that the "freeze" part of this is
used to make the
variable immutable, which means that it can't be changed after that
point in the code. The parentheses give us easy string interpoltaion
without needing to worry about quotes. The "%i" part at the beginning converts
ths contents of the parentheses into an array of "symbols", which
are just immutable
strings. So I tried to replace this list of guarded actions like
so:
GUARDED_ACTIONS = %i().freeze
And then restarted openproject
(systemctl restart openproject
). Doing this did not
immeidately allow me to use Kanban boards in the browser interface,
which was a bummer. Maybe the second section of code, which appears to
check if a token is present or expired, was my real target. This second
section of code defines a method named call which takes
in an action name, sets a boolean variable named
allowed based on whether or not a token is
present/absent/expired, and then runs result with that
boolean. This final result function goes on to use
something else from another module (ServiceResult), but I suspected that
the check for token validity was all that I needed to bypass. I tried
changing:
allowed =
if token.nil? || token.token_object.nil? || token.expired?
false
else
process(action)
end
To:
allowed = true
Sadly, this still did not allow me to access enterprise features
without a license. At this point I didn't even know for sure if OP was
getting to my modified code or not, since my dissection of what this
file is for was pure speculation. To test, this, I figured that I would
honor the time-tested tradition of debugging
with print statements. One quick "how to hello world in Ruby" later,
I added a puts "we dem boyz"
to the part of the function
where I forcefully set allowed
to true
.
Restarting openproject and reloading a kanban page did in fact print
many copies of this string to the systemd journal for
openproject-web-1.service. However, the restrictions
were still in place.
"Maybe I need to look a level deeper at the
ServiceResult
call" was my next thought. This class is
defined in ./app/services/service_result.rb
, and it is
really long and complicated, but it does fortunately have two lines at
the top that look like they might be very impactful:
SUCCESS = true
FAILURE = false
So I changed the false
for failure to a
true
. Sadly, I still got a "please buy OP EE" message when
I wanted to just move rectangles around three columns with my mouse.
Some functions, however, I was able to use, such as the team planner and
site themeing. Looking instead at one of the actual restrictions in the
code, I see a chain of calls to various other classes and methods just
to check if some action is allowed:
./app/controllers/boards/boards_controller.rb
def restricted_board_type?
!EnterpriseToken.allows_to?(:board_view) && board_grid_params[:attribute] != 'basic'
end
./app/models/enterprise_token.rb
class EnterpriseToken < ApplicationRecord
class << self
def current
RequestStore.fetch(:current_ee_token) do
set_current_token
end
end
def table_exists?
connection.data_source_exists? table_name
end
def allows_to?(action)
Authorization::EnterpriseService.new(current).call(action).result
end
def active?
current && !current.expired?
end
def show_banners?
OpenProject::Configuration.ee_manager_visible? && !active?
end
def set_current_token
token = EnterpriseToken.order(Arel.sql('created_at DESC')).first
if token&.token_object
token
end
end
end
"Maybe I can force the changes in enterprise_token.rb", I thought. I
set the functions table_exists
, allows_to
, and
active
to just be true
, I set
show_banners
to false
because I don't want to
see the banners in the web application, and I didn't modify the
set_current_token
because it looked too complicated. This
actually allowed me to get the Kanban boards working in the web browser.
This is all well and good but modifications spread across several
different files with unknown implications elsewhere is not really what I
was going for when I set out on this path. The real best solution would
be a clean solution. After some tinkering to see how much I could
possibly reduce my modifications, the actual modifications that matter
are:
./app/models/enterprise_token.rb
def table_exists?
#connection.data_source_exists? table_name
true
end
def allows_to?(action)
#Authorization::EnterpriseService.new(current).call(action).result
true
end
def active?
#current && !current.expired?
true
end
def show_banners?
#OpenProject::Configuration.ee_manager_visible? && !active?
false
end
Making just these changes allows me to access the themeing in the administration view and the Kanban boards. Technically, this meets my original desires for getting EE features without paying. However, this isn't a great solution. Here I am replacing a bunch of different lines of code (after writing this blogpost I discovered that I was not the first person to think of this). However, I worried that there could be soemthing that I was missing since it seemed like OP checked in multiple places for token validity. What if I could trick OP into thinking that I had a perfectly valid key? Then I could let all of the key validity checks keep going without worry, since OP would think that the key is totally valid.
When you attempt to import a token into OpenProject on the
/admin/enterprise page, the software looks at the token
and makes sure that it is legit, then it grants you the EE features.
This is done in the file
./vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token.rb
,
provided by this
Ruby gem. When you attempt to add a new license, the software checks
that it is in fact a license file, checks that it is from the OP
Foundation, and sets various values about user count and expiration. To
assist in debugging this script, I got a free trial version of the
license, which lasts for 2 weeks. This is plenty of time to figure out
how to remove such silly checks. In order to get a trial license, you
can just go to this
page. Keep in mind that you need a verified email address.
get the useful information out of this token, I added a piece to the code that writes the data from my temporary trial license to a file. I added this just after the final JSON is acquired:
** ./vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token.rb**
out_file = File.new("/tmp/op_json.txt", "w")
out_file.puts(json)
out_file.close
Then I imported the token. This wrote the file mentioned above, which had plaintext JSON data:
{"version":"2.0","subscriber":"Firstname Lastname","mail":"someemail@domain.tld","company":"Company Name","domain":"openproject.domain.tld","issued_at":"2023-09-31","starts_at":"2023-09-31","expires_at":"2023-12-52","reprieve_days":14,"restrictions":{"active_user_count":10}}
And that looks like something that I can edit. Modifying this JSON and replacing the code with a static declaration of my very legitimate license:
** ./vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token.rb**
#data = Armor.decode(data)
#json = extractor.read(data)
json = '{"version": "1.9","subscriber": "Richard Stallman","mail": "rms@gnu.org","issued_at": "1970-01-01","starts_at": "1970-01-01","expires_at": "2970-01-01","reprieve_days": 14,"restrictions": {"active_user_count": 1000}}'
attributes = JSON.parse(json)
The different options here all have reasoning behind them:
After making this change and restarting OP, I went to the page where
you can enter a new enterprise key. Entering the letter a
and clicking the "Save" button did now show the fake license and
unlocked all enterprise features. This was an improvement to the
previous solution because it required editing less lines of source code.
However, this still required modifying the actual source code files of
OP, which could be updated to require different workarounds in the
future, increasing the amount of work that I have to do when OP 14 comes
out if they completely rework the token validation code. At this point I
had stars in my eyes and a dream in my head. Is there a way to do
accomplish this task without editing any source code files from OP?
Would it be possible to make my own completely valid token from scratch?
The only way to find out would be to dive into the actual token
extraction process.
The actual token license key looks like this:
-----BEGIN OPENPROJECT-EE TOKEN-----
owGbwMvMwMX4qcPly8HAJ8aMa9YlCSTl5KfH56YWFyemp+qVVJSksvz3icwvVUgs
SlUoSy2qVCjOS03MrlSw1uTi8sgvTwWK6Sh4KiTmKuTllyik52fmpSuU5CsUZ+SX
K1QC9eVWKiQml5Qm5ihkp1YqlGQklgBVpwOVphXl5yr4B7jpcXF5KmTn5ZcrKoSm
Z4DMAnH0FDxL1IsVskqLgerVIYangA3PLFHILFZIzMnR4+pkcmFhYORi0BNTZNFf
ZJn8p9bcaI1gsgLMQ6xMINcLyBTl55c4FJTmZZfk6+UXpTNwcQrAlIh18f+PeiRj
01x4Qk5dwCam5+7VpVmhp3mc5y8q7Huh9mJC3osz9+48MptwNCv66f1d800SOWr2
CO5hnbYw+sq/mXvyO6YuuvegYU9XTO68gsO7nqlc47zWFRckO3HHPNM1LD4LvRyf
WP8XeCOYXXGpQbxhqZUos/UG8eTk19tfJ4doqDQmKPouPBfR+Vb4msY873m/Gvq/
Bs1c4p7oGqVbleudW9at8Fj38dZdkXJzXARfbkz9sje6wjbx4NP6AH/5+QJ111dk
eqz+WvhVkEeGU//Tw0vLH87lz76sZdEurSRkL3gwN3uywume3VpGZm8yckMONX7n
4+Yystu894P2haTFmju175gv/TNz+v3Pa/znuXx8Xb3UqyO2viQ3WPiiyuo16oZa
OrdNcm2muq64Z515KDx3wQLj8w+YwqIZc/iTgu5urKmZWJBoHqx6ND/CZOLat2u4
Mhe933lyc7bR/PW7K79cTXI8zb/A6PqmHVZq3q233AXfcjQrfGFe/E726+mXBoov
zNpDvx088jm4L3fGifnfTrbnGFwyTPNI3qiZ8bdT/1rWFP9tCW9PSHKcO/nnaeG7
lpq78ao8L9zWOKqt+FNdvjDwo5Ni6uYty5548IZFrqpK2TdHmEfLYDXvjPvf3hSF
ZOptcG5yTzU8XJ660fDNu5pNWxpSlr8K2btgWudqtp8LVhs/EfhiZrfut+HmvX7H
/pYfmmuwdaE9AA==
=e1w4
-----END OPENPROJECT-EE TOKEN-----
Which looks a lot like a PGP ASCII-armored message. Changing the
header and footer of the file from OPENPROJECT-EE TOKEN
to PGP MESSAGE did not allow me to
gpg --deamor ee-token.asc
, however. Luckily, I know base64
when I see it. Taking out the middle part and putting it into a base64 decoder, I got some JSON
output with plaintext (JSON) keys and (JSON) values that are again
base64 encoded:
{
"data": "SSdtIG5vdCBzaG93aW5nIHlvdSBsb2wK\n",
"key": "dGhlIGluZHVzdHJpYWwgY29uc2VxdWVuY2VzIGFuZCBpdHMgZXRjCkxpc3RlbiBtYW4gZG8geW91IGV2ZXIgZ2V0IHRpcmVkIG9mIHNlZWluZyB0aGUgc2FtZSBpZGVhcyBvbmxpbmUgYWxsIHRoZSB0aW1lPyBNYW5raW5kIHdhc24ndCBtZWFudCB0byBoYXZlIHRoZSBzYW1lIHRob3VnaHRzIGV2ZXJ5IGRheSBmb3JldmVyLiBJIGRvbid0IHRoaW5rIHRoYXQgY2FuIGJlIG9yZ2FuaWMgb3IgaGVhbHRoeSBmb3IgdXMuIE1heWJlIGl0IHdvdWxkIGJlIGEgZ29vZCBpZGVhIHRvIGN1dCBvZmYgZXh0ZXJuYWwgc291cmNlcyBvZiB0aG91Z2h0LiBJIGRvbid0IGtub3csIEknbSBqdXN0IGEgZHVkZSB3aXRoIGEga2V5Ym9hcmQu=\n"
"iv": "c2hvcnR5==\n"
}
But when I plugged those values into a base64 decoder, I got binary
gibberish. Judging by the contents of
vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token/extractor.rb
,
this is because these values are either encrypted or binary
representations of the data. The "data" section is the actual stuff that
I want, the "key" section is the decryption key for that data, and the
initialization
vector is the "aes_iv":
./vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token/extractor.rb
encrypted_data = Base64.decode64(encryption_data["data"])
encrypted_key = Base64.decode64(encryption_data["key"])
aes_iv = Base64.decode64(encryption_data["iv"])
And that AES encrypted key is itself actually encrypted with an RSA key. AES is symmetric and RSA is asymmetric. The RSA public key is needed to get the AES key out. The openproject-token gem actually just calls out to the openssl library, using the verify_recover function via the backwards compatibility of the deprecated "public_decrypt" function.
./vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token/extractor.rb
begin
# Decrypt the AES key using asymmetric RSA encryption.
aes_key = self.key.public_decrypt(encrypted_key)
rescue OpenSSL::PKey::RSAError
raise DecryptionError, "AES encryption key could not be decrypted."
end
# Decrypt the data using symmetric AES encryption.
cipher = OpenSSL::Cipher::AES128.new(:CBC)
cipher.decrypt
./vendor/ruby-3.2.1/lib/ruby/3.2.0/openssl/pkey.rb
def public_decrypt(string, padding = PKCS1_PADDING)
n or raise OpenSSL::PKey::RSAError, "incomplete RSA"
begin
verify_recover(nil, string, {
"rsa_padding_mode" => translate_padding_mode(padding),
})
rescue OpenSSL::PKey::PKeyError
raise OpenSSL::PKey::RSAError, $!.message
end
end
At this point I had to figure out where the RSA public/private
keypair comes from. The extractor.rb
file gets its RSA key
from the token.rb
file, which gets it from:
** ./vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token.rb**
def key=(key)
if key && !key.is_a?(OpenSSL::PKey::RSA)
raise ArgumentError, "Key is missing."
end
@key = key
@extractor = Extractor.new(self.key)
end
But I had no idea where the "key" here came from. I assumed probably
that it was passed to the Token object when it is defined, so if I could
find the location in the OP code where that happens, maybe I could find
the key. By doing a recursive grep for [^a-z]Token
and
looking through the results similar to how I found the original relevant
files for the enterprise licnse itself, I was able to track down that
the RSA key was pulled in from the disk:
./config/initializers/ee_token.rb
begin
data = File.read(Rails.root.join(".openproject-token.pub"))
key = OpenSSL::PKey::RSA.new(data)
OpenProject::Token.key = key
rescue StandardError
warn "WARNING: Missing .openproject-token.pub key"
end
It can be a bit difficult to fully wrap your head around what all is going on here, so I wrote a script that imitates OP's functionality to go from an "OPENPROJECT-EE TOKEN" to actual usable license JSON:
#!/usr/bin/env ruby
require "base64"
require "openssl"
require "json"
token_file = 'REAL.token'
pub_key = 'REAL.pub'
# Read in Token text
ee_token = File.read token_file
# Remove header and footer
header = "-----BEGIN OPENPROJECT-EE TOKEN-----"
footer = "-----END OPENPROJECT-EE TOKEN-----"
match = ee_token.match /#{header}\r?\n(.+?)\r?\n#{footer}/m
# Base64 decode string
json_data = Base64.decode64(match[1].chomp)
# Parse JSON
encryption_data = JSON.parse(json_data)
# Base64-decode RSA-encrypted AES key, AES IV, and AES-encrypted License
encrypted_data = Base64.decode64(encryption_data['data'])
encrypted_key = Base64.decode64(encryption_data['key'])
aes_iv = Base64.decode64(encryption_data['iv'])
# RSA decrypt AES key
rsa_public_key = OpenSSL::PKey::RSA.new File.read pub_key
aes_key = rsa_public_key.public_decrypt(encrypted_key)
# AES decrypt the license
cipher = OpenSSL::Cipher::AES128.new(:CBC)
cipher.decrypt
cipher.key = aes_key
cipher.iv = aes_iv
data = cipher.update(encrypted_data) + cipher.final
# Parse License JSON
license = JSON.parse(data)
puts license
Step by step, this script exactly imitates what OP does:
Writing this script was very helpful in the process of fully understanding what OP is doing at each step, and it helped me learn a little bit of Ruby. This script requires two files in the current working directory:
/opt/openproject/.openproject-token.pub
Since asymmetrical encryption is used to secure the AES key, I needed
the private key in order to create and sign my own EE tokens. I assume
that this private key is kept safe on the OP Foundation's servers and
cannot be accessed by regular people, as that would allow for exactly
what I am trying to do here. Probably this file is named
openproject-token.key
and the contents look something like
this:
-----BEGIN PRIVATE KEY-----
MIIEFjANBgkqhkiG9w0BAQEFAAOCBAMAMIID/gKCA/UAquIZchoog2ffcr9J2KSl
mlum6sN3smTVNsp9JGd1q4fr/kUFGch6q1cFEX3x5BGDXx7wPPI4ppKzeQHaxWmx
wxqs3eevcTFUEF9A2MPX7p5Ia0TbH4d7e7D9YMWvDXoQLggrxMFdUHY3ppUnBPgB
+EJG1Pv0FlBAdxYX0em7kLwhcp9PBP/zXso/qkkKK/pncyKizOLC3zv3E0ixcQ7o
Nq0aolTJFMHcqEquKaQN1jicDdzU6ks+YKh7kByZvVChe/InlroVXKrUa34hAZDM
acEkURJma3meN0IyPFA7fHRe1AhiNYF2MatNKysPrbOffYLOjamlaqmHTeJAec6e
vMHd+LlIz4xXivR0lY2wDawqp0waSLJaW8lZetOf0iwbqQkzZhz4sWDZopyGiqAU
v9/zS4OjUBr7JQbVcV3LIkzGWwNysSvTMrlvzCesYVsCwpLjP6gFxdclYJuTwEeL
o+T+AgoNyuj6ixhwHTJxIVhuBpebX44/YTYyUGMgItekDCH2Dxvtv2DaCL7YIqNG
ibvCyzCyLak7Tz97CMvCUf1EIRVfolbGpphi2Zzpoeqhfheh+0LQ3gmMBJpuLnJU
VXEtrOPunTkOrdUqL5rD/+mfd8yufsJ16Uk5j7gNUcIJsCcGWZ3Nhfidi3tvmJPF
H8HgNZ6W6smj9k7+TdZbRsH1LZp1LL/stLch3ffFHHcJye7d2t75uKiOoz1/1JMY
fl/wfaEaKvTGBKr/NFKcVDSBXHgx6VMA3oWV2AyLnTaY98XVPY1zzbyNKlNQkSaD
p/VCl334+YMwR9/5aJYJg58lljw6aBu+cozNkydKjCmqEpZNdR1tFusAY7jd/M2G
yXPBTdUJD6GrLx0Hot/wImJ30gOMgpeoetNUfM9/FimySLy65DRzCtLm1hthAlWM
Is5vFwMmFFSb3ozTsnkj9W16jHk9HdodKJfezzcPqu3TW2EMMNbbtXa3OPaHkht2
huYJGnGu0hNhAj+x+KCxkpLveS0Ajw322qmtAqnwJLvfwShnz//cptNX5kXtrNGy
+o6I4jHibIATfaMKMt8gHmCZ381zAwrAOU7c0FQna+IkU8dgZDx7T+Xo1Q/GXuaO
1b2aT6geT60A3VgF+OBnoHe5Ext7vfNL9v0wcN5NLR5KgjexwEhcBcA2FauCTrVt
FUNgir+5XALd/wBlvxkvPKTJnQld/aK7xF0ui3c3/ryPX5cKzpfm9APK/hOFzkJ7
ieARHqrQGqOYdClIUJIH0b/92dFq49Eqn1cKpztVzsU9xzdI/4w5JUSw/kbguVf7
Yd0Rdc9KF/9WMwjzrWSti4meNBUO6/18cAognx0Pf5qsrSzOewIDAQAB
-----END PRIVATE KEY-----
I looked (not very hard) but couldn't locate an accidentally-public private key with the regular search engines. This means that a universal key for all OP installations is not possible (until some point when such a key can be found). But if I replace the public key file with my own public key, then I should be able to generate my own license from it.
The structure of the token is like so:
This seems pretty complicated at first glance. Certainly it is more complicated than it needs to be. This could be as simple as
I have no idea why this extra step of AES encryption is used, execept possibly as a deterrance for people reverse engineering the token.
To start, I needed to create my own RSA public/private keypair, since the OP Foundation was not interested in sharing their private copy (I'm just assuming here, since I didn't ask). This can be done pretty easily on Linux with the commands:
openssl genrsa -out private.rsa 4096
openssl rsa -in private.rsa -pubout -out public.rsa
Which creates two files in the CWD, private.rsa for
the private key and public.rsa for the public key. The
public.rsa key needs to go in the OP installation
directory. On Ubuntu server, the location that it needs to go is
/opt/openproject/.openproject-token.pub
. I replaced the
file that was there with this newly generated one. After doing this, the
next steps (in order) are:
I tried a few ways to do this directly on the shell, but I ran into
issues when trying to Base64-encode the AES IV. This was becasue the IV
is a hexadecimal number and when I cat $iv | base64
, that
Base64-encodes the ASCII version of the string, not the hex version of
the string. Eventually I bit the bullet and wrote a Ruby script inspired
by OP itself to do the heavy lifting:
#!/usr/bin/env ruby
require 'base64'
require 'openssl'
require 'json'
# License Plaintext
license_plaintext = '{"version": "1.9","subscriber": "Richard Stallman","mail": "rms@gnu.org","issued_at": "1970-01-01","starts_at": "1970-01-01","expires_at": "2970-01-01","reprieve_days": 14,"restrictions": {"active_user_count": 1000}}'
# AES encryption setup
aes = OpenSSL::Cipher::AES128.new(:CBC)
aes.encrypt
aes_key = aes.random_key
aes_iv = aes.random_iv
aes.key = aes_key
aes.iv = aes_iv
# Encrypt license with AES
aes_encrypted_license = aes.update(license_plaintext)
aes_encrypted_license << aes.final
# RSA encryption stuff
rsa_private_key = OpenSSL::PKey::RSA.new File.read 'private.rsa'
# Encrypt AES key with RSA
rsa_aes_key = rsa_private_key.private_encrypt(aes_key)
# Base64 encode RSA-encrypted AES key, AES IV, and AES-encrypted License
encoded_license = Base64.encode64(aes_encrypted_license)
encoded_key = Base64.encode64(rsa_aes_key)
encoded_iv = Base64.encode64(aes_iv)
# Make a JSON out of the above data
json_data = JSON[{
'data': encoded_license,
'key': encoded_key,
'iv': encoded_iv
}]
# Base64 encode the JSON
data = Base64.encode64(json_data)
# Print with header and footer
puts '-----BEGIN OPENPROJECT-EE TOKEN-----'
puts data
puts '-----END OPENPROJECT-EE TOKEN-----'
Running this code spits out a working OP license which grants unlimited enterprise features.
If you self-host OpenProject, you can remove those annoying banners and get Enterprise Edition for free by following this 2-step process:
curl -s https://punkto.org/no_cdn_lol/openproject/ruby/public.rsa | sudo tee /var/openproject/.openproject-token.pub && sudo systemctl restart openproject
-----BEGIN OPENPROJECT-EE TOKEN-----
eyJkYXRhIjoieTN3MlpRek9aemJ0UGlhT0I4RE1qRys3UGhta2FVR1BkLzhh
RkcyZFE4SEs0Njh5VEhjeE9tTnRSRTV3XG5qY2srSHpieWZhUU9PeFZMVFRm
bFovbDdkWitkV2pySjAzajhlMTRQQXk4RXVhSGE4UlpOT3lHc1BUbytcblh3
OHprU3k0NC9yekM5L09wRW1pQkNHbFRCK0R2M3RTNzJqWWtKTnR5VWRBMmg0
WGFiRFk2OUJaNmI4ZVxuNHdLSGplT3ZTNXcxWUFqQmpkYmZ2ZHFYYXlOZlUv
V1ZYSGF1YkhIR3p3SjUxeUxhY3U5QUFaSEFQMEVLXG5mTERnaWlubXVXWjJZ
eGJVcnlmeHR2SGU2cldvS3owaDdPa1VXQXhmeGkwL3RuUnJ4cDczdS95eGUr
YjZcbnk1bWVwOUI4dU5ZZ1NCWUl0Q28yXG4iLCJrZXkiOiJUMjR2SmxxOXFt
M0JoU09LWCtXb2ZSd2ZzWCtkUXUxZ0NWT2ZBdjRMZURTRHdzSVIvb3FnREtz
MGwvdXBcbjYxeHg1aWloY1FHbHJrcTZEeG5Tc1BlSndtZjFHeXNES0pZeVB0
ZERyeUsvZmhXQWFPNnRtU1Z6YVREQ1xueFBrdW9qR25hZXhjQ05ibDFtUXBR
QThiTDJObUJmRDIzRlNjUVE0aHJlb3NmWTRHZHBFSVRCcTlFRC9EXG5nRXZU
ZXR4RmRXVmUwVVhvT2ZXa2FvWUFzS3htZXRoR0FObXBDWEJETUhZVXJkRE11
TnZQd24yd2h4WFFcblU4VzJMdVpYRkd1aUE4NFZoR3VacTR0TForNjlhU2h3
K0V4SnJjdWJ1b0tjaklhaGxDMnNyTVV0MXNWWVxucnZ6NlNRY2NvSlh2bkhZ
Tk95WTNGSGF2WU5KOG96MWxUN0dvZGZUdFVvdEY3aC9VV3o1RzBnM2hHKzhE
XG5NUlFsSlJDdmkxL1VhM1gvd2x1cEVYNHhQUnFtVXpDWEkrN2NPM3Zsb05v
WEhRa3RYYmg4a1F5d0NEQ0NcbmVtaHduT3Jqdk9wcGR2OVdoR0dGT0Y1amtD
d0c3MnFzZTdPWC9BMjJxaThXVC8wN3hqa0NvN0R6dThZeFxuOCtmVUlkTFpY
WTJLc0I4MEw3ZTJ0eDZFR2hiVjJhUG9wbVBsdW01Z05wcDBFbHlxelNXYWpo
UkdhNGhpXG5ua2l6N2o0YmFFZW1GYWtLYXJ1aUxKME00QzZvUW5JbUM0SHRY
Mk1HRzJDakhVY1VncWpPNURZNVN0SlBcbnViM2lwYWRwdFZkSmFjVzJ5OVhL
dTFVWWptY1ErNWRtWEdnN1RDdUZVV0VOeG90SitDQnIzZjFwYWEyT1xuNG15
NFRTL2tEU3NNWnp4Vmh5WGxjRkk9XG4iLCJpdiI6InJLVU40WHNGT2p1S0Nj
aWtrNmlWQUE9PVxuIn0=
-----END OPENPROJECT-EE TOKEN-----
There you have it, a simple working method to get a never-ending OP enterprise license. This requires no modifications to the OP source code, so this should drastically lower the barrier of entry to free OP for users around the globe.
Hackers need to pay their bills, too. By donating Monero to:
89zUPdtgXvo268ZtbzE7LBTEdo2Y75V1FUSs1rETeaRudQZy8BRs4rwTQuFzWqkQ6VU14Ei5TUBGhYCY6TmbCoEvQ2YkqC9
you will be supporting this effort and contributing to future development, maintenance, and continuous improvement.