Opinions expressed here belong to your're mom


GPL Praxis: How To Get OpenProject 13 Enterprise Edition (For Free)

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.

Installing OpenProject

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.

Tracking Down The Upgrade Functionality

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.

if_i_had_one.png

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.

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:

  1. ./lib/constraints/enterprise.rb
  2. ./lib/open_project/enterprise.rb
  3. ./vendor/bundle/ruby/3.2.0/gems/openproject-token-3.0.1/lib/open_project/token.rb
  4. ./app/helpers/enterprise_helper.rb
  5. ./app/helpers/enterprise_trial_helper.rb
  6. ./app/services/authorization/enterprise_service.rb
  7. ./app/contracts/concerns/requires_enterprise_guard.rb
  8. ./app/models/enterprise_token.rb
  9. ./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:

  1. Disable restrictions so that OP doesn't stop you from doing anything without a token
  2. Modify token validation so that OP doesn't check if your token is legit

Changing The Constraints

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.

Spoofing A Key

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 Enterprise Token

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:

  1. Read in the token data sent via email from the OP Foundation
  2. Strip off the header and footer
  3. Convert the base64 text from the token to JSON
  4. Use the OP Foundation's public RSA key to decrypt the AES key
  5. Use the decrypted AES key and IV to decrypt the license text
  6. Parse the license text to JSON

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:

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.

Recreating The Token

The structure of the token is like so:

token.png

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:

  1. AES-encrypt the license
  2. RSA-encrypt the AES-encryption key
  3. Base64-encoded the AES-encrypted license, RSA-encrypted AES key, and the AES IV
  4. Store those encoded versions in JSON
  5. Base64-encode that JSON
  6. Slap a header and footer on that encoded JSON

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.

there_are_4_essential_freedoms.png

How To Use This Hack

If you self-host OpenProject, you can remove those annoying banners and get Enterprise Edition for free by following this 2-step process:

  1. curl -s https://punkto.org/no_cdn_lol/openproject/ruby/public.rsa | sudo tee /var/openproject/.openproject-token.pub && sudo systemctl restart openproject
  2. Go to the page on your installation to enter an enterprise license. This is the domain.tld/admin/enterprise page by default and paste in this key:
-----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.

gratis_and_libre.png


This page is being served digitally. For a physical copy, print it off

Dancing Baby Seal Of Approval Webserver Callout My Text Editor Bash Scripting 4 Lyfe yarg Blog RSS Feed