Skip to content

Commit 07d42e9

Browse files
committed
Auto merge of #2637 - rust-lang:sg-security-advisory-2020-07-14, r=jtgeibel
Generate API tokens with a secure RNG, store hashed Note: The contents of this PR have already been deployed to production, as this was a security issue. The code in this PR is the minimum number of changes needed to rebase what was deployed today onto master. Unfortunately, since this was done in parallel with some refactoring to the errors module, this means some of the added code doesn't look like the rest of the code base. However, master currently does not reflect what is in production, so any cleanup should come as a followup PR. The purpose of this is mainly to get master in sync with prod after going through bors. This addresses a security issue with the way crates.io generates API tokens. Prior to this commit, they were generated using the PostgreSQL `random` function, which is not a secure PRNG. Additionally, the tokens were stored in plain text, which would give an attacker who managed to compromise our database access to all user's API tokens. This commit addresses both changes. An advisory about the problem was posted at https://blog.rust-lang.org/2020/07/14/crates-io-security-advisory.html The tokens are now generated using the OS's random number generator, which maps to C's `getrandom` function, which is secure and unpredictable. The tokens are hashed using sha256. The choice to use a fast hashing function such as this one instead of one typically used for passwords (such as bcrypt or argon2) was intentional. Unlike passwords, our API tokens are known to be 32 characters and are truly random, giving us 192 bits of entropy. This means that even with a fast hashing function, actually finding a token from that hash before the death of human civilization is infeasible. Additionally, unlike passwords, API tokens need to be checked on every request where they're used, instead of once at sign in. This means that a slower hashing function would put significantly more load on our server than they would when used for passwords. We opted to use sha256 instead of bcrypt with a lower cost due to the mandatory salt in bcrypt. If we salt the values before hashing them, the tokens can no longer directly be used to identify themselves, and we would need to include another identifier in the token given to the user. While this is feasible, it leads to a very obtuse looking token, and more complex code. r? @jtgeibel
2 parents 9d05ba3 + 380be53 commit 07d42e9

File tree

21 files changed

+246
-55
lines changed

21 files changed

+246
-55
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE api_tokens ALTER COLUMN token SET DEFAULT random_string(32);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE api_tokens ALTER COLUMN token DROP DEFAULT;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CREATE INDEX api_tokens_token_idx ON api_tokens (token);
2+
ALTER TABLE api_tokens ADD CONSTRAINT api_tokens_token_key UNIQUE (token);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE api_tokens DROP CONSTRAINT api_tokens_token_key;
2+
DROP INDEX api_tokens_token_idx;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE api_tokens
2+
ALTER COLUMN token
3+
TYPE text USING encode(token, 'escape');
4+
DROP INDEX api_tokens_token_idx;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
2+
ALTER TABLE api_tokens
3+
ALTER COLUMN token
4+
TYPE bytea USING decode(token, 'escape');
5+
CREATE UNIQUE INDEX ON api_tokens (token);

script/ci/prune-cache.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ du -hs target/debug
1212

1313
crate_name="cargo-registry"
1414
test_name="all"
15-
bin_names="background-worker delete-crate delete-version enqueue-job monitor populate render-readmes server test-pagerduty transfer-crates"
15+
bin_names="background-worker delete-crate delete-version enqueue-job monitor populate render-readmes server test-pagerduty transfer-crates verify-token"
1616

1717
normalized_crate_name=${crate_name//-/_}
1818
rm -vf target/debug/$normalized_crate_name-*

src/bin/verify-token.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Look up a username by API token. Used by staff to verify someone's identity
2+
// by having an API token given. If an error occurs, including being unable to
3+
// find a user with that API token, the error will be displayed.
4+
5+
use cargo_registry::{db, models::User, util::errors::AppResult};
6+
use std::env;
7+
8+
fn main() -> AppResult<()> {
9+
let conn = db::connect_now()?;
10+
let token = env::args().nth(1).expect("API token argument required");
11+
let user = User::find_by_api_token(&conn, &token)?;
12+
println!("The token belongs to user {}", user.gh_login);
13+
Ok(())
14+
}

src/controllers/util.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ use super::prelude::*;
22

33
use crate::middleware::current_user::TrustedUserId;
44
use crate::models::{ApiToken, User};
5-
use crate::util::errors::{forbidden, internal, AppResult, ChainError};
5+
use crate::util::errors::{
6+
forbidden, internal, AppError, AppResult, ChainError, InsecurelyGeneratedTokenRevoked,
7+
};
68

79
#[derive(Debug)]
810
pub struct AuthenticatedUser {
@@ -42,8 +44,13 @@ impl<'a> UserAuthenticationExt for dyn RequestExt + 'a {
4244
user_id: token.user_id,
4345
token_id: Some(token.id),
4446
})
45-
.chain_error(|| internal("invalid token"))
46-
.chain_error(forbidden)
47+
.map_err(|e| {
48+
if e.is::<InsecurelyGeneratedTokenRevoked>() {
49+
e
50+
} else {
51+
e.chain(internal("invalid token")).chain(forbidden())
52+
}
53+
})
4754
} else {
4855
// Unable to authenticate the user
4956
Err(internal("no cookie session or auth header found")).chain_error(forbidden)

src/models.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub use self::krate::{Crate, CrateVersions, NewCrate, RecentCrateDownloads};
1111
pub use self::owner::{CrateOwner, Owner, OwnerKind};
1212
pub use self::rights::Rights;
1313
pub use self::team::{NewTeam, Team};
14-
pub use self::token::ApiToken;
14+
pub use self::token::{ApiToken, CreatedApiToken};
1515
pub use self::user::{NewUser, User};
1616
pub use self::version::{NewVersion, Version};
1717

src/models/token.rs

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
use chrono::NaiveDateTime;
22
use diesel::prelude::*;
3+
use diesel::sql_types::{Bytea, Text};
34

45
use crate::models::User;
56
use crate::schema::api_tokens;
7+
use crate::util::errors::{AppResult, InsecurelyGeneratedTokenRevoked};
68
use crate::util::rfc3339;
79
use crate::views::EncodableApiTokenWithToken;
810

11+
const TOKEN_LENGTH: usize = 32;
12+
const TOKEN_PREFIX: &str = "cio"; // Crates.IO
13+
914
/// The model representing a row in the `api_tokens` database table.
1015
#[derive(Clone, Debug, PartialEq, Eq, Identifiable, Queryable, Associations, Serialize)]
1116
#[belongs_to(User)]
1217
pub struct ApiToken {
1318
pub id: i32,
1419
#[serde(skip)]
1520
pub user_id: i32,
21+
// Nothing should ever access the plaintext token.
1622
#[serde(skip)]
17-
pub token: String,
23+
token: Vec<u8>,
1824
pub name: String,
1925
#[serde(with = "rfc3339")]
2026
pub created_at: NaiveDateTime,
@@ -24,36 +30,41 @@ pub struct ApiToken {
2430
pub revoked: bool,
2531
}
2632

33+
diesel::sql_function! {
34+
fn digest(input: Text, method: Text) -> Bytea;
35+
}
36+
2737
impl ApiToken {
2838
/// Generates a new named API token for a user
29-
pub fn insert(conn: &PgConnection, user_id: i32, name: &str) -> QueryResult<ApiToken> {
30-
diesel::insert_into(api_tokens::table)
31-
.values((api_tokens::user_id.eq(user_id), api_tokens::name.eq(name)))
32-
.get_result::<ApiToken>(conn)
33-
}
39+
pub fn insert(conn: &PgConnection, user_id: i32, name: &str) -> AppResult<CreatedApiToken> {
40+
let plaintext = format!(
41+
"{}{}",
42+
TOKEN_PREFIX,
43+
crate::util::generate_secure_alphanumeric_string(TOKEN_LENGTH)
44+
);
3445

35-
/// Converts this `ApiToken` model into an `EncodableApiToken` including
36-
/// the actual token value for JSON serialization. This should only be
37-
/// used when initially creating a new token to minimize the chance of
38-
/// token leaks.
39-
pub fn encodable_with_token(self) -> EncodableApiTokenWithToken {
40-
EncodableApiTokenWithToken {
41-
id: self.id,
42-
name: self.name,
43-
token: self.token,
44-
revoked: self.revoked,
45-
created_at: self.created_at,
46-
last_used_at: self.last_used_at,
47-
}
46+
let model = diesel::insert_into(api_tokens::table)
47+
.values((
48+
api_tokens::user_id.eq(user_id),
49+
api_tokens::name.eq(name),
50+
api_tokens::token.eq(digest(&plaintext, "sha256")),
51+
))
52+
.get_result::<ApiToken>(conn)?;
53+
54+
Ok(CreatedApiToken { plaintext, model })
4855
}
4956

50-
pub fn find_by_api_token(conn: &PgConnection, token_: &str) -> QueryResult<ApiToken> {
51-
use crate::schema::api_tokens::dsl::{api_tokens, last_used_at, revoked, token};
57+
pub fn find_by_api_token(conn: &PgConnection, token_: &str) -> AppResult<ApiToken> {
58+
use crate::schema::api_tokens::dsl::*;
5259
use diesel::{dsl::now, update};
5360

61+
if !token_.starts_with(TOKEN_PREFIX) {
62+
return Err(InsecurelyGeneratedTokenRevoked::boxed());
63+
}
64+
5465
let tokens = api_tokens
55-
.filter(token.eq(token_))
56-
.filter(revoked.eq(false));
66+
.filter(revoked.eq(false))
67+
.filter(token.eq(digest(token_, "sha256")));
5768

5869
// If the database is in read only mode, we can't update last_used_at.
5970
// Try updating in a new transaction, if that fails, fall back to reading
@@ -63,6 +74,37 @@ impl ApiToken {
6374
.get_result::<ApiToken>(conn)
6475
})
6576
.or_else(|_| tokens.first(conn))
77+
.map_err(Into::into)
78+
}
79+
}
80+
81+
pub struct CreatedApiToken {
82+
pub model: ApiToken,
83+
pub plaintext: String,
84+
}
85+
86+
impl CreatedApiToken {
87+
/// Converts this `CreatedApiToken` into an `EncodableApiToken` including
88+
/// the actual token value for JSON serialization.
89+
pub fn encodable_with_token(self) -> EncodableApiTokenWithToken {
90+
EncodableApiTokenWithToken {
91+
id: self.model.id,
92+
name: self.model.name,
93+
token: self.plaintext,
94+
revoked: self.model.revoked,
95+
created_at: self.model.created_at,
96+
last_used_at: self.model.last_used_at,
97+
}
98+
}
99+
}
100+
101+
// Use a custom implementation of Debug to hide the plaintext token.
102+
impl std::fmt::Debug for CreatedApiToken {
103+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104+
f.debug_struct("CreatedApiToken")
105+
.field("model", &self.model)
106+
.field("plaintext", &"(sensitive)")
107+
.finish()
66108
}
67109
}
68110

@@ -71,12 +113,20 @@ mod tests {
71113
use super::*;
72114
use chrono::NaiveDate;
73115

116+
#[test]
117+
fn test_token_constants() {
118+
// Changing this by itself will implicitly revoke all existing tokens.
119+
// If this test needs to be change, make sure you're handling tokens
120+
// with the old prefix or that you wanted to revoke them.
121+
assert_eq!("cio", TOKEN_PREFIX);
122+
}
123+
74124
#[test]
75125
fn api_token_serializes_to_rfc3339() {
76126
let tok = ApiToken {
77127
id: 12345,
78128
user_id: 23456,
79-
token: "".to_string(),
129+
token: Vec::new(),
80130
revoked: false,
81131
name: "".to_string(),
82132
created_at: NaiveDate::from_ymd(2017, 1, 6).and_hms(14, 23, 11),

src/models/user.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ impl User {
110110
}
111111

112112
/// Queries the database for a user with a certain `api_token` value.
113-
pub fn find_by_api_token(conn: &PgConnection, token: &str) -> QueryResult<User> {
113+
pub fn find_by_api_token(conn: &PgConnection, token: &str) -> AppResult<User> {
114114
let api_token = ApiToken::find_by_api_token(conn, token)?;
115115

116-
Self::find(conn, api_token.user_id)
116+
Ok(Self::find(conn, api_token.user_id)?)
117117
}
118118

119119
pub fn owning(krate: &Crate, conn: &PgConnection) -> QueryResult<Vec<Owner>> {

src/schema.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ table! {
2222
user_id -> Int4,
2323
/// The `token` column of the `api_tokens` table.
2424
///
25-
/// Its SQL type is `Varchar`.
25+
/// Its SQL type is `Bytea`.
2626
///
2727
/// (Automatically generated by Diesel.)
28-
token -> Varchar,
28+
token -> Bytea,
2929
/// The `name` column of the `api_tokens` table.
3030
///
3131
/// Its SQL type is `Varchar`.

src/tests/authentication.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ fn anonymous_user_unauthorized() {
3636
fn token_auth_cannot_find_token() {
3737
let (app, anon) = TestApp::init().empty();
3838
let mut request = anon.request_builder(Method::GET, URL);
39-
request.header(header::AUTHORIZATION, "fake-token");
39+
request.header(header::AUTHORIZATION, "cio1tkfake-token");
4040

4141
let (status, body) = into_parts(call(&app, request));
4242
assert_eq!(status, StatusCode::FORBIDDEN);

src/tests/krate.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,7 @@ fn new_wrong_token() {
725725
// Try to publish with the wrong token (by changing the token in the database)
726726
app.db(|conn| {
727727
diesel::update(api_tokens::table)
728-
.set(api_tokens::token.eq("bad"))
728+
.set(api_tokens::token.eq(b"bad" as &[u8]))
729729
.execute(conn)
730730
.unwrap();
731731
});

src/tests/token.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
use crate::{user::UserShowPrivateResponse, util::StatusCode, RequestHelper, TestApp};
1+
use crate::{
2+
user::UserShowPrivateResponse,
3+
util::{header, StatusCode},
4+
RequestHelper, TestApp,
5+
};
26
use cargo_registry::{
37
models::ApiToken,
48
schema::api_tokens,
@@ -67,7 +71,10 @@ fn list_tokens() {
6771
.into_iter()
6872
.map(|t| t.name)
6973
.collect::<HashSet<_>>(),
70-
tokens.into_iter().map(|t| t.name).collect::<HashSet<_>>()
74+
tokens
75+
.into_iter()
76+
.map(|t| t.model.name)
77+
.collect::<HashSet<_>>()
7178
);
7279
}
7380

@@ -88,7 +95,7 @@ fn list_tokens_exclude_revoked() {
8895

8996
// Revoke the first token.
9097
let _json: RevokedResponse = user
91-
.delete(&format!("/api/v1/me/tokens/{}", tokens[0].id))
98+
.delete(&format!("/api/v1/me/tokens/{}", tokens[0].model.id))
9299
.good();
93100

94101
// Check that we now have one less token being listed.
@@ -97,7 +104,7 @@ fn list_tokens_exclude_revoked() {
97104
assert!(json
98105
.api_tokens
99106
.iter()
100-
.find(|token| token.name == tokens[0].name)
107+
.find(|token| token.name == tokens[0].model.name)
101108
.is_none());
102109
}
103110

@@ -167,7 +174,6 @@ fn create_token_success() {
167174
let tokens = app.db(|conn| t!(ApiToken::belonging_to(user.as_model()).load::<ApiToken>(conn)));
168175
assert_eq!(tokens.len(), 1);
169176
assert_eq!(tokens[0].name, "bar");
170-
assert_eq!(tokens[0].token, json.api_token.token);
171177
assert_eq!(tokens[0].revoked, false);
172178
assert_eq!(tokens[0].last_used_at, None);
173179
}
@@ -297,3 +303,17 @@ fn using_token_updates_last_used_at() {
297303
// based on the start of the database transaction so it doesn't work in
298304
// this test framework.
299305
}
306+
307+
#[test]
308+
fn old_tokens_give_specific_error_message() {
309+
let url = "/api/v1/me";
310+
let (_, anon) = TestApp::init().empty();
311+
312+
let mut request = anon.get_request(url);
313+
request.header(header::AUTHORIZATION, "oldtoken");
314+
let json = anon
315+
.run::<()>(request)
316+
.bad_with_status(StatusCode::UNAUTHORIZED);
317+
318+
assert_contains!(json.errors[0].detail, "revoked");
319+
}

src/tests/user.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ fn user_total_downloads_no_crates() {
355355
fn updating_existing_user_doesnt_change_api_token() {
356356
let (app, _, user, token) = TestApp::init().with_token();
357357
let gh_id = user.as_model().gh_id;
358-
let token = &token.as_model().token;
358+
let token = token.plaintext();
359359

360360
let user = app.db(|conn| {
361361
// Reuse gh_id but use new gh_login and gh_access_token

0 commit comments

Comments
 (0)