Skip to content

Commit

Permalink
feat: [#979] permanent keys
Browse files Browse the repository at this point in the history
This commit adds a new feature. It allow creating permanent keys (keys
that do not expire).

THis is an example for making a request to the endpoint using curl:

```console
curl -X POST http://localhost:1212/api/v1/keys?token=MyAccessToken \
     -H "Content-Type: application/json" \
     -d '{
     	   "key": null,
           "seconds_valid": null
         }'
```

NOTICE: both the `key` and the `seconds_valid` fields can be null.

- If `key` is `null` a new random key will be generated. You can use an
  string with a pre-generated key like `Xc1L4PbQJSFGlrgSRZl8wxSFAuMa2110`. That will allow users to migrate to the Torrust Tracker wihtout forcing the users to re-start downloading/seeding with new keys.
- If `seconds_valid` is `null` the key will be permanent. Otherwise it
  will expire after the seconds specified in this value.
  • Loading branch information
josecelano committed Jul 31, 2024
1 parent 8d3fe72 commit c5beff5
Show file tree
Hide file tree
Showing 20 changed files with 455 additions and 167 deletions.
5 changes: 5 additions & 0 deletions migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Database Migrations

We don't support automatic migrations yet. The tracker creates all the needed tables when it starts. The SQL sentences are hardcoded in each database driver.

The migrations in this folder were introduced to add some new changes (permanent keys) and to allow users to migrate to the new version. In the future, we will remove the hardcoded SQL and start using a Rust crate for database migrations. For the time being, if you are using the initial schema described in the migration `20240730183000_torrust_tracker_create_all_tables.sql` you will need to run all the subsequent migrations manually.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
CREATE TABLE
IF NOT EXISTS whitelist (
id integer PRIMARY KEY AUTO_INCREMENT,
info_hash VARCHAR(40) NOT NULL UNIQUE
);

CREATE TABLE
IF NOT EXISTS torrents (
id integer PRIMARY KEY AUTO_INCREMENT,
info_hash VARCHAR(40) NOT NULL UNIQUE,
completed INTEGER DEFAULT 0 NOT NULL
);

CREATE TABLE
IF NOT EXISTS `keys` (
`id` INT NOT NULL AUTO_INCREMENT,
`key` VARCHAR(32) NOT NULL,
`valid_until` INT (10) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE (`key`)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `keys` CHANGE `valid_until` `valid_until` INT (10);
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
CREATE TABLE
IF NOT EXISTS whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
info_hash TEXT NOT NULL UNIQUE
);

CREATE TABLE
IF NOT EXISTS torrents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
info_hash TEXT NOT NULL UNIQUE,
completed INTEGER DEFAULT 0 NOT NULL
);

CREATE TABLE
IF NOT EXISTS keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
valid_until INTEGER NOT NULL
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE
IF NOT EXISTS keys_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
valid_until INTEGER
);

INSERT INTO keys_new SELECT * FROM `keys`;

DROP TABLE `keys`;

ALTER TABLE keys_new RENAME TO `keys`;
107 changes: 70 additions & 37 deletions src/core/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! Tracker keys are tokens used to authenticate the tracker clients when the tracker runs
//! in `private` or `private_listed` modes.
//!
//! There are services to [`generate`] and [`verify`] authentication keys.
//! There are services to [`generate_key`] and [`verify_key`] authentication keys.
//!
//! Authentication keys are used only by [`HTTP`](crate::servers::http) trackers. All keys have an expiration time, that means
//! they are only valid during a period of time. After that time the expiring key will no longer be valid.
Expand All @@ -19,7 +19,7 @@
//! /// Random 32-char string. For example: `YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ`
//! pub key: Key,
//! /// Timestamp, the key will be no longer valid after this timestamp
//! pub valid_until: DurationSinceUnixEpoch,
//! pub valid_until: Option<DurationSinceUnixEpoch>,
//! }
//! ```
//!
Expand All @@ -29,11 +29,11 @@
//! use torrust_tracker::core::auth;
//! use std::time::Duration;
//!
//! let expiring_key = auth::generate(Duration::new(9999, 0));
//! let expiring_key = auth::generate_key(Some(Duration::new(9999, 0)));
//!
//! // And you can later verify it with:
//!
//! assert!(auth::verify(&expiring_key).is_ok());
//! assert!(auth::verify_key(&expiring_key).is_ok());
//! ```

use std::panic::Location;
Expand All @@ -55,63 +55,96 @@ use tracing::debug;
use crate::shared::bit_torrent::common::AUTH_KEY_LENGTH;
use crate::CurrentClock;

/// It generates a new permanent random key [`PeerKey`].
#[must_use]
/// It generates a new random 32-char authentication [`ExpiringKey`]
pub fn generate_permanent_key() -> PeerKey {
generate_key(None)
}

Check warning on line 62 in src/core/auth.rs

View check run for this annotation

Codecov / codecov/patch

src/core/auth.rs#L60-L62

Added lines #L60 - L62 were not covered by tests

/// It generates a new random 32-char authentication [`PeerKey`].
///
/// It can be an expiring or permanent key.
///
/// # Panics
///
/// It would panic if the `lifetime: Duration` + Duration is more than `Duration::MAX`.
pub fn generate(lifetime: Duration) -> ExpiringKey {
///
/// # Arguments
///
/// * `lifetime`: if `None` the key will be permanent.
#[must_use]
pub fn generate_key(lifetime: Option<Duration>) -> PeerKey {
let random_id: String = thread_rng()
.sample_iter(&Alphanumeric)
.take(AUTH_KEY_LENGTH)
.map(char::from)
.collect();

debug!("Generated key: {}, valid for: {:?} seconds", random_id, lifetime);
if let Some(lifetime) = lifetime {
debug!("Generated key: {}, valid for: {:?} seconds", random_id, lifetime);

PeerKey {
key: random_id.parse::<Key>().unwrap(),
valid_until: Some(CurrentClock::now_add(&lifetime).unwrap()),
}
} else {
debug!("Generated key: {}, permanent", random_id);

Check warning on line 91 in src/core/auth.rs

View check run for this annotation

Codecov / codecov/patch

src/core/auth.rs#L91

Added line #L91 was not covered by tests

ExpiringKey {
key: random_id.parse::<Key>().unwrap(),
valid_until: CurrentClock::now_add(&lifetime).unwrap(),
PeerKey {
key: random_id.parse::<Key>().unwrap(),
valid_until: None,

Check warning on line 95 in src/core/auth.rs

View check run for this annotation

Codecov / codecov/patch

src/core/auth.rs#L93-L95

Added lines #L93 - L95 were not covered by tests
}
}
}

/// It verifies an [`ExpiringKey`]. It checks if the expiration date has passed.
/// It verifies an [`PeerKey`]. It checks if the expiration date has passed.
/// Permanent keys without duration (`None`) do not expire.
///
/// # Errors
///
/// Will return `Error::KeyExpired` if `auth_key.valid_until` is past the `current_time`.
/// Will return:
///
/// Will return `Error::KeyInvalid` if `auth_key.valid_until` is past the `None`.
pub fn verify(auth_key: &ExpiringKey) -> Result<(), Error> {
/// - `Error::KeyExpired` if `auth_key.valid_until` is past the `current_time`.
/// - `Error::KeyInvalid` if `auth_key.valid_until` is past the `None`.
pub fn verify_key(auth_key: &PeerKey) -> Result<(), Error> {
let current_time: DurationSinceUnixEpoch = CurrentClock::now();

if auth_key.valid_until < current_time {
Err(Error::KeyExpired {
location: Location::caller(),
})
} else {
Ok(())
match auth_key.valid_until {
Some(valid_until) => {
if valid_until < current_time {
Err(Error::KeyExpired {
location: Location::caller(),
})
} else {
Ok(())
}
}
None => Ok(()), // Permanent key

Check warning on line 122 in src/core/auth.rs

View check run for this annotation

Codecov / codecov/patch

src/core/auth.rs#L122

Added line #L122 was not covered by tests
}
}

/// An authentication key which has an expiration time.
/// An authentication key which can potentially have an expiration time.
/// After that time is will automatically become invalid.
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
pub struct ExpiringKey {
pub struct PeerKey {
/// Random 32-char string. For example: `YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ`
pub key: Key,
/// Timestamp, the key will be no longer valid after this timestamp
pub valid_until: DurationSinceUnixEpoch,

/// Timestamp, the key will be no longer valid after this timestamp.
/// If `None` the keys will not expire (permanent key).
pub valid_until: Option<DurationSinceUnixEpoch>,
}

impl std::fmt::Display for ExpiringKey {
impl std::fmt::Display for PeerKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "key: `{}`, valid until `{}`", self.key, self.expiry_time())
match self.expiry_time() {
Some(expire_time) => write!(f, "key: `{}`, valid until `{}`", self.key, expire_time),
None => write!(f, "key: `{}`, permanent", self.key),

Check warning on line 142 in src/core/auth.rs

View check run for this annotation

Codecov / codecov/patch

src/core/auth.rs#L142

Added line #L142 was not covered by tests
}
}
}

impl ExpiringKey {
impl PeerKey {
#[must_use]
pub fn key(&self) -> Key {
self.key.clone()
Expand All @@ -126,8 +159,8 @@ impl ExpiringKey {
/// Will panic when the key timestamp overflows the internal i64 type.
/// (this will naturally happen in 292.5 billion years)
#[must_use]
pub fn expiry_time(&self) -> chrono::DateTime<chrono::Utc> {
convert_from_timestamp_to_datetime_utc(self.valid_until)
pub fn expiry_time(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.valid_until.map(convert_from_timestamp_to_datetime_utc)
}
}

Expand Down Expand Up @@ -194,8 +227,8 @@ impl FromStr for Key {
}
}

/// Verification error. Error returned when an [`ExpiringKey`] cannot be
/// verified with the [`verify(...)`](crate::core::auth::verify) function.
/// Verification error. Error returned when an [`PeerKey`] cannot be
/// verified with the (`crate::core::auth::verify_key`) function.
#[derive(Debug, Error)]
#[allow(dead_code)]
pub enum Error {
Expand Down Expand Up @@ -277,7 +310,7 @@ mod tests {
// Set the time to the current time.
clock::Stopped::local_set_to_unix_epoch();

let expiring_key = auth::generate(Duration::from_secs(0));
let expiring_key = auth::generate_key(Some(Duration::from_secs(0)));

assert_eq!(
expiring_key.to_string(),
Expand All @@ -287,9 +320,9 @@ mod tests {

#[test]
fn should_be_generated_with_a_expiration_time() {
let expiring_key = auth::generate(Duration::new(9999, 0));
let expiring_key = auth::generate_key(Some(Duration::new(9999, 0)));

assert!(auth::verify(&expiring_key).is_ok());
assert!(auth::verify_key(&expiring_key).is_ok());
}

#[test]
Expand All @@ -298,17 +331,17 @@ mod tests {
clock::Stopped::local_set_to_system_time_now();

// Make key that is valid for 19 seconds.
let expiring_key = auth::generate(Duration::from_secs(19));
let expiring_key = auth::generate_key(Some(Duration::from_secs(19)));

// Mock the time has passed 10 sec.
clock::Stopped::local_add(&Duration::from_secs(10)).unwrap();

assert!(auth::verify(&expiring_key).is_ok());
assert!(auth::verify_key(&expiring_key).is_ok());

// Mock the time has passed another 10 sec.
clock::Stopped::local_add(&Duration::from_secs(10)).unwrap();

assert!(auth::verify(&expiring_key).is_err());
assert!(auth::verify_key(&expiring_key).is_err());
}
}
}
8 changes: 4 additions & 4 deletions src/core/databases/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,19 +195,19 @@ pub trait Database: Sync + Send {
/// # Errors
///
/// Will return `Err` if unable to load.
fn load_keys(&self) -> Result<Vec<auth::ExpiringKey>, Error>;
fn load_keys(&self) -> Result<Vec<auth::PeerKey>, Error>;

/// It gets an expiring authentication key from the database.
///
/// It returns `Some(ExpiringKey)` if a [`ExpiringKey`](crate::core::auth::ExpiringKey)
/// It returns `Some(PeerKey)` if a [`PeerKey`](crate::core::auth::PeerKey)
/// with the input [`Key`] exists, `None` otherwise.
///
/// # Context: Authentication Keys
///
/// # Errors
///
/// Will return `Err` if unable to load.
fn get_key_from_keys(&self, key: &Key) -> Result<Option<auth::ExpiringKey>, Error>;
fn get_key_from_keys(&self, key: &Key) -> Result<Option<auth::PeerKey>, Error>;

/// It adds an expiring authentication key to the database.
///
Expand All @@ -216,7 +216,7 @@ pub trait Database: Sync + Send {
/// # Errors
///
/// Will return `Err` if unable to save.
fn add_key_to_keys(&self, auth_key: &auth::ExpiringKey) -> Result<usize, Error>;
fn add_key_to_keys(&self, auth_key: &auth::PeerKey) -> Result<usize, Error>;

/// It removes an expiring authentication key from the database.
///
Expand Down
39 changes: 27 additions & 12 deletions src/core/databases/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ impl Database for Mysql {
CREATE TABLE IF NOT EXISTS `keys` (
`id` INT NOT NULL AUTO_INCREMENT,
`key` VARCHAR({}) NOT NULL,
`valid_until` INT(10) NOT NULL,
`valid_until` INT(10),
PRIMARY KEY (`id`),
UNIQUE (`key`)
);",
Expand Down Expand Up @@ -119,14 +119,20 @@ impl Database for Mysql {
}

/// Refer to [`databases::Database::load_keys`](crate::core::databases::Database::load_keys).
fn load_keys(&self) -> Result<Vec<auth::ExpiringKey>, Error> {
fn load_keys(&self) -> Result<Vec<auth::PeerKey>, Error> {

Check warning on line 122 in src/core/databases/mysql.rs

View check run for this annotation

Codecov / codecov/patch

src/core/databases/mysql.rs#L122

Added line #L122 was not covered by tests
let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?;

let keys = conn.query_map(
"SELECT `key`, valid_until FROM `keys`",
|(key, valid_until): (String, i64)| auth::ExpiringKey {
key: key.parse::<Key>().unwrap(),
valid_until: Duration::from_secs(valid_until.unsigned_abs()),
|(key, valid_until): (String, Option<i64>)| match valid_until {
Some(valid_until) => auth::PeerKey {
key: key.parse::<Key>().unwrap(),
valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())),
},
None => auth::PeerKey {
key: key.parse::<Key>().unwrap(),
valid_until: None,
},

Check warning on line 135 in src/core/databases/mysql.rs

View check run for this annotation

Codecov / codecov/patch

src/core/databases/mysql.rs#L127-L135

Added lines #L127 - L135 were not covered by tests
},
)?;

Expand Down Expand Up @@ -197,28 +203,37 @@ impl Database for Mysql {
}

/// Refer to [`databases::Database::get_key_from_keys`](crate::core::databases::Database::get_key_from_keys).
fn get_key_from_keys(&self, key: &Key) -> Result<Option<auth::ExpiringKey>, Error> {
fn get_key_from_keys(&self, key: &Key) -> Result<Option<auth::PeerKey>, Error> {

Check warning on line 206 in src/core/databases/mysql.rs

View check run for this annotation

Codecov / codecov/patch

src/core/databases/mysql.rs#L206

Added line #L206 was not covered by tests
let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?;

let query = conn.exec_first::<(String, i64), _, _>(
let query = conn.exec_first::<(String, Option<i64>), _, _>(

Check warning on line 209 in src/core/databases/mysql.rs

View check run for this annotation

Codecov / codecov/patch

src/core/databases/mysql.rs#L209

Added line #L209 was not covered by tests
"SELECT `key`, valid_until FROM `keys` WHERE `key` = :key",
params! { "key" => key.to_string() },
);

let key = query?;

Ok(key.map(|(key, expiry)| auth::ExpiringKey {
key: key.parse::<Key>().unwrap(),
valid_until: Duration::from_secs(expiry.unsigned_abs()),
Ok(key.map(|(key, opt_valid_until)| match opt_valid_until {
Some(valid_until) => auth::PeerKey {
key: key.parse::<Key>().unwrap(),
valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())),
},
None => auth::PeerKey {
key: key.parse::<Key>().unwrap(),
valid_until: None,
},

Check warning on line 224 in src/core/databases/mysql.rs

View check run for this annotation

Codecov / codecov/patch

src/core/databases/mysql.rs#L216-L224

Added lines #L216 - L224 were not covered by tests
}))
}

/// Refer to [`databases::Database::add_key_to_keys`](crate::core::databases::Database::add_key_to_keys).
fn add_key_to_keys(&self, auth_key: &auth::ExpiringKey) -> Result<usize, Error> {
fn add_key_to_keys(&self, auth_key: &auth::PeerKey) -> Result<usize, Error> {

Check warning on line 229 in src/core/databases/mysql.rs

View check run for this annotation

Codecov / codecov/patch

src/core/databases/mysql.rs#L229

Added line #L229 was not covered by tests
let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?;

let key = auth_key.key.to_string();
let valid_until = auth_key.valid_until.as_secs().to_string();
let valid_until = match auth_key.valid_until {
Some(valid_until) => valid_until.as_secs().to_string(),
None => todo!(),

Check warning on line 235 in src/core/databases/mysql.rs

View check run for this annotation

Codecov / codecov/patch

src/core/databases/mysql.rs#L233-L235

Added lines #L233 - L235 were not covered by tests
};

conn.exec_drop(
"INSERT INTO `keys` (`key`, valid_until) VALUES (:key, :valid_until)",
Expand Down
Loading

0 comments on commit c5beff5

Please sign in to comment.