Skip to content

Commit 43d4d2f

Browse files
authored
Merge pull request #2608 from OpenC3/maint/argon2
Switch password hashing algorithm to argon2
2 parents 997156a + 72d576a commit 43d4d2f

File tree

14 files changed

+156
-58
lines changed

14 files changed

+156
-58
lines changed

.github/workflows/cli.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ jobs:
116116
shell: 'script -q -e -c "bash {0}"'
117117
run: |
118118
set -e
119-
docker exec -it cosmos-openc3-redis-1 sh -c "echo -e 'AUTH openc3 openc3password\nset OPENC3__TOKEN 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8' | redis-cli"
119+
docker exec -it cosmos-openc3-redis-1 sh -c "echo -e 'AUTH openc3 openc3password\nset OPENC3__TOKEN \"\$argon2id\$v=19\$m=8,t=1,p=1\$KEDp3bRbyFK3lJLMa99kzQ\$INGoDEdgRbAG/wVie/ftzh//If91eeMTQQ5HoyKcfvY\"' | redis-cli"
120120
# list shows all the available file names
121121
./openc3.sh cli script list | tee /dev/tty | grep "INST/procedures/stash.rb"
122122
# spawning a script prints only a PID

.github/workflows/playwright.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ on:
1414
branches:
1515
- "**"
1616

17+
env:
18+
OPENC3_ARGON2_PROFILE: "unsafe_cheapest"
19+
1720
jobs:
1821
openc3-build-test:
1922
if: ${{ github.actor != 'dependabot[bot]' }}

docs.openc3.com/docs/development/json-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,5 @@ Note that you will need a valid session token in the `Authorization` header to a
128128
```bash
129129
curl http://localhost:2900/openc3-api/auth/verify \
130130
-H 'Content-Type: application/json' \
131-
-d '{"token": "your-password-here"}'
131+
-d '{"password": "your-password-here"}'
132132
```

docs.openc3.com/docs/getting-started/upgrading.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ The process should loook something like this:
156156

157157
5. Test and verify functionality and commit your changes.
158158

159+
## Migrating From COSMOS 6 to COSMOS 7
160+
161+
### Passwords
162+
163+
If you are using COSMOS Enterprise, you can skip this section. COSOMS 7 Core introduces some security enhancements around the handling of the user password.
164+
165+
First, we have switched the password hashing algorithm from SHA-256 to the industry standard argon2id. Before you can log in using COSMOS 7, you need to migrate the stored password hash used for user authentication. To do this, with Redis running, set the `OPENC3_API_PASSWORD` environment variable to your current password and run `openc3.sh cli migratepassword`. You can do this at any point of the upgrade process while COSMOS is running (e.g. either before tearing down COSMOS 6 or after starting up COSMOS 7).
166+
167+
Second, the JSON API no longer accepts plaintext passwords. You must instead use a session token. Please see the note at the bottom of our [JSON API documentation](../development/json-api#further-debugging) for how to acquire a session token for the API.
168+
159169
## Migrating From COSMOS 5 to COSMOS 6
160170

161171
:::info Developers Only

openc3-cosmos-cmd-tlm-api/app/controllers/auth_controller.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def token_exists
3333

3434
def verify
3535
begin
36-
if OpenC3::AuthModel.verify_no_service(params[:token], no_password: false)
36+
if OpenC3::AuthModel.verify_no_service(params[:password], no_password: false)
3737
render :plain => OpenC3::AuthModel.generate_session()
3838
else
3939
head :unauthorized
@@ -46,7 +46,7 @@ def verify
4646

4747
def verify_service
4848
begin
49-
if OpenC3::AuthModel.verify(params[:token], service_only: true)
49+
if OpenC3::AuthModel.verify(params[:password], service_only: true)
5050
render :plain => OpenC3::AuthModel.generate_session()
5151
else
5252
head :unauthorized
@@ -60,7 +60,7 @@ def verify_service
6060
def set
6161
begin
6262
# Set throws an exception if it fails for any reason
63-
OpenC3::AuthModel.set(params[:token], params[:old_token])
63+
OpenC3::AuthModel.set(params[:password], params[:old_password])
6464
OpenC3::Logger.info("Password changed", user: username())
6565
render :plain => OpenC3::AuthModel.generate_session()
6666
rescue StandardError => e

openc3-cosmos-cmd-tlm-api/spec/controllers/auth_controller_spec.rb

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
json = JSON.parse(response.body, allow_nan: true, create_additions: true)
3131
expect(json).to eql({"result" => false})
3232

33-
post :set, params: { token: 'PASSWORD' }
33+
post :set, params: { password: 'PASSWORD' }
3434
expect(response).to have_http_status(:ok)
3535

3636
get :token_exists
@@ -41,23 +41,23 @@
4141
end
4242

4343
describe "set" do
44-
it "requires old_token after initial set" do
45-
post :set, params: { token: 'PASSWORD' }
44+
it "requires old_password after initial set" do
45+
post :set, params: { password: 'PASSWORD' }
4646
expect(response).to have_http_status(:ok)
4747

48-
post :set, params: { token: 'PASSWORD2' }
48+
post :set, params: { password: 'PASSWORD2' }
4949
expect(response).to have_http_status(:error)
5050
json = JSON.parse(response.body, allow_nan: true, create_additions: true)
5151
expect(json["status"]).to eql 'error'
52-
expect(json["message"]).to eql 'old_token must not be nil or empty'
52+
expect(json["message"]).to eql 'old_password must not be nil or empty'
5353

54-
post :set, params: { token: 'PASSWORD2', old_token: 'BAD' }
54+
post :set, params: { password: 'PASSWORD2', old_password: 'BAD' }
5555
expect(response).to have_http_status(:error)
5656
json = JSON.parse(response.body, allow_nan: true, create_additions: true)
5757
expect(json["status"]).to eql 'error'
58-
expect(json["message"]).to eql 'old_token incorrect'
58+
expect(json["message"]).to eql 'old_password incorrect'
5959

60-
post :set, params: { token: 'PASSWORD2', old_token: 'PASSWORD' }
60+
post :set, params: { password: 'PASSWORD2', old_password: 'PASSWORD' }
6161
expect(response).to have_http_status(:ok)
6262
end
6363
end
@@ -68,15 +68,23 @@
6868
expect(response).to have_http_status(:unauthorized)
6969
end
7070

71-
it "validates the set token" do
72-
post :set, params: { token: 'PASSWORD' }
71+
it "validates the set password" do
72+
post :set, params: { password: 'PASSWORD' }
7373
expect(response).to have_http_status(:ok)
7474

75-
post :verify, params: { token: 'PASSWORD' }
75+
post :verify, params: { password: 'PASSWORD' }
7676
expect(response).to have_http_status(:ok)
7777

78-
post :verify, params: { token: 'BAD' }
78+
post :verify, params: { password: 'BAD' }
7979
expect(response).to have_http_status(:unauthorized)
8080
end
81+
82+
it "validates the service password" do
83+
post :verify_service, params: { password: 'BAD' }
84+
expect(response).to have_http_status(:unauthorized)
85+
86+
post :verify_service, params: { password: 'openc3service' }
87+
expect(response).to have_http_status(:ok)
88+
end
8189
end
8290
end

openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/Login.vue

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
<v-row>
6565
<v-btn
6666
type="submit"
67-
@click.prevent="() => verifyPassword()"
67+
@click.prevent="() => verify()"
6868
size="large"
6969
color="success"
7070
:disabled="!formValid"
@@ -140,7 +140,7 @@ export default {
140140
},
141141
mounted: function () {
142142
if (localStorage.openc3Token) {
143-
this.verifyPassword(localStorage.openc3Token, true)
143+
this.verify(localStorage.openc3Token, true)
144144
}
145145
},
146146
methods: {
@@ -159,12 +159,12 @@ export default {
159159
window.location = '/'
160160
}
161161
},
162-
verifyPassword: function (token, noAlert) {
163-
token ||= this.password
162+
verify: function (password, noAlert) {
163+
password ||= this.password
164164
this.showAlert = false
165165
Api.post('/openc3-api/auth/verify', {
166166
data: {
167-
token,
167+
password,
168168
},
169169
...this.options,
170170
})
@@ -174,6 +174,11 @@ export default {
174174
.catch((error) => {
175175
if (error?.status === 401) {
176176
this.alert = 'Incorrect password'
177+
} else if (
178+
error?.response?.data?.message === 'invalid password hash'
179+
) {
180+
this.alert =
181+
'Please see the migration guide for upgrading to COSMOS 7 in our docs.'
177182
} else {
178183
this.alert = error.message || 'Something went wrong...'
179184
}
@@ -185,8 +190,8 @@ export default {
185190
this.showAlert = false
186191
Api.post('/openc3-api/auth/set', {
187192
data: {
188-
old_token: this.oldPassword,
189-
token: this.password,
193+
old_password: this.oldPassword,
194+
password: this.password,
190195
},
191196
...this.options,
192197
})

openc3/Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ gem 'rack-cors', '~> 3.0'
1010
gem 'rails', '~> 7.2.0'
1111
gem 'tzinfo-data'
1212
gem 'ruby-termios', '>= 1.0'
13+
gem 'argon2', '~> 2.3', '>= 2.3.2'

openc3/bin/openc3cli

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ require 'redis'
4646
require 'erb'
4747
require 'irb'
4848
require 'irb/completion'
49+
require 'digest'
50+
require 'argon2'
4951

5052
$redis_url = "redis://#{ENV['OPENC3_REDIS_HOSTNAME']}:#{ENV['OPENC3_REDIS_PORT']}"
5153

@@ -869,6 +871,22 @@ def cli_script(args=[])
869871
exit(ret_code)
870872
end
871873

874+
def migrate_password_hash
875+
password = ENV['OPENC3_API_PASSWORD']
876+
argon2_profile = ENV["OPENC3_ARGON2_PROFILE"]&.to_sym || :rfc_9106_low_memory
877+
if password.nil? or password.empty?
878+
abort "OPENC3_API_PASSWORD environment variable is required for password migration"
879+
end
880+
redis = Redis.new(url: $redis_url, username: ENV['OPENC3_REDIS_USERNAME'], password: ENV['OPENC3_REDIS_PASSWORD'])
881+
old_pw_hash = redis.get('OPENC3__TOKEN')
882+
abort "Password hash already migrated" if old_pw_hash.start_with?('$argon2')
883+
abort "OPENC3_API_PASSWORD incorrect" unless old_pw_hash == Digest::SHA256.hexdigest(password)
884+
new_pw_hash = Argon2::Password.create(password, profile: argon2_profile)
885+
redis.set('OPENC3__TOKEN', new_pw_hash)
886+
puts "Password migrated successfully."
887+
exit 0
888+
end
889+
872890
if not ARGV[0].nil? # argument(s) given
873891

874892
# Handle each task
@@ -1331,6 +1349,9 @@ if not ARGV[0].nil? # argument(s) given
13311349
end
13321350
run_migrations(ARGV[1])
13331351

1352+
when 'migratepassword'
1353+
migrate_password_hash()
1354+
13341355
else # Unknown task
13351356
print_usage()
13361357
abort("Unknown task: #{ARGV[0]}")

openc3/lib/openc3/models/auth_model.rb

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,41 @@
2020
# This file may also be used under the terms of a commercial license
2121
# if purchased from OpenC3, Inc.
2222

23-
require 'digest'
23+
require 'argon2'
2424
require 'securerandom'
2525
require 'openc3/utilities/store'
2626

2727
module OpenC3
2828
class AuthModel
29-
PRIMARY_KEY = 'OPENC3__TOKEN'
30-
SESSIONS_KEY = 'OPENC3__SESSIONS'
29+
ARGON2_PROFILE = ENV["OPENC3_ARGON2_PROFILE"]&.to_sym || :rfc_9106_low_memory
3130

32-
TOKEN_CACHE_TIMEOUT = 5
31+
# Redis keys
32+
PRIMARY_KEY = 'OPENC3__TOKEN' # for argon2 password hash
33+
SESSIONS_KEY = 'OPENC3__SESSIONS' # for hash containing session tokens
34+
35+
# The length of time in minutes to keep redis values in memory
36+
PW_HASH_CACHE_TIMEOUT = 5
3337
SESSION_CACHE_TIMEOUT = 5
34-
@@token_cache = nil
35-
@@token_cache_time = nil
38+
39+
# Cached argon2 password hash
40+
@@pw_hash_cache = nil
41+
@@pw_hash_cache_time = nil
42+
43+
# Cached session tokens
3644
@@session_cache = nil
3745
@@session_cache_time = nil
3846

39-
MIN_TOKEN_LENGTH = 8
47+
MIN_PASSWORD_LENGTH = 8
4048

4149
def self.set?(key = PRIMARY_KEY)
4250
Store.exists(key) == 1
4351
end
4452

53+
# Checks whether the provided token is a valid user password, service password, or session token.
54+
# @param token [String] the plaintext password or session token to check (required)
4555
# @param no_password [Boolean] enforces use of a session token or service password (default: true)
56+
# @param service_only [Boolean] enforces use of a service password (default: false)
57+
# @return [Boolean] whether the provided password/token is valid
4658
def self.verify(token, no_password: true, service_only: false)
4759
# Handle a service password - Generally only used by ScriptRunner
4860
# TODO: Replace this with temporary service tokens
@@ -54,55 +66,61 @@ def self.verify(token, no_password: true, service_only: false)
5466
return verify_no_service(token, no_password: no_password)
5567
end
5668

69+
# Checks whether the provided token is a valid user password or session token.
70+
# @param token [String] the plaintext password or session token to check (required)
5771
# @param no_password [Boolean] enforces use of a session token (default: true)
72+
# @return [Boolean] whether the provided password/token is valid
5873
def self.verify_no_service(token, no_password: true)
5974
return false if token.nil? or token.empty?
6075

76+
# Check cached session tokens and password hash
6177
time = Time.now
6278
return true if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
63-
token_hash = hash(token)
64-
return true if @@token_cache and (time - @@token_cache_time) < TOKEN_CACHE_TIMEOUT and @@token_cache == token_hash
79+
unless no_password
80+
return true if @@pw_hash_cache and (time - @@pw_hash_cache_time) < PW_HASH_CACHE_TIMEOUT and Argon2::Password.verify_password(token, @@pw_hash_cache)
81+
end
6582

66-
# Check sessions
83+
# Check stored session tokens
6784
@@session_cache = Store.hgetall(SESSIONS_KEY)
6885
@@session_cache_time = time
6986
return true if @@session_cache[token]
7087

7188
return false if no_password
7289

73-
# Check Direct password
74-
@@token_cache = Store.get(PRIMARY_KEY)
75-
@@token_cache_time = time
76-
return true if @@token_cache == token_hash
77-
78-
return false
90+
# Check stored password hash
91+
pw_hash = Store.get(PRIMARY_KEY)
92+
raise "invalid password hash" unless pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
93+
@@pw_hash_cache = pw_hash
94+
@@pw_hash_cache_time = time
95+
return Argon2::Password.verify_password(token, @@pw_hash_cache)
7996
end
8097

81-
def self.set(token, old_token, key = PRIMARY_KEY)
82-
raise "token must not be nil or empty" if token.nil? or token.empty?
83-
raise "token must be at least 8 characters" if token.length < MIN_TOKEN_LENGTH
98+
def self.set(password, old_password, key = PRIMARY_KEY)
99+
raise "password must not be nil or empty" if password.nil? or password.empty?
100+
raise "password must be at least 8 characters" if password.length < MIN_PASSWORD_LENGTH
84101

85102
if set?(key)
86-
raise "old_token must not be nil or empty" if old_token.nil? or old_token.empty?
87-
raise "old_token incorrect" unless verify_no_service(old_token, no_password: false)
103+
raise "old_password must not be nil or empty" if old_password.nil? or old_password.empty?
104+
raise "old_password incorrect" unless verify_no_service(old_password, no_password: false)
88105
end
89-
Store.set(key, hash(token))
106+
pw_hash = Argon2::Password.create(password, profile: ARGON2_PROFILE)
107+
Store.set(key, pw_hash)
108+
@@pw_hash_cache = nil
109+
@@pw_hash_cache_time = nil
90110
end
91111

112+
# Creates a new session token. DO NOT CALL BEFORE VERIFYING.
92113
def self.generate_session
93114
token = SecureRandom.urlsafe_base64(nil, false)
94115
Store.hset(SESSIONS_KEY, token, Time.now.iso8601)
95116
return token
96117
end
97118

119+
# Terminates every session.
98120
def self.logout
99121
Store.del(SESSIONS_KEY)
100-
@@sessions_cache = nil
101-
@@sessions_cache_time = nil
102-
end
103-
104-
def self.hash(token)
105-
Digest::SHA256.hexdigest token
122+
@@session_cache = nil
123+
@@session_cache_time = nil
106124
end
107125
end
108126
end

0 commit comments

Comments
 (0)