Skip to content

Commit

Permalink
feat: [torrust#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 cb565b4
Show file tree
Hide file tree
Showing 17 changed files with 351 additions and 145 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`;
99 changes: 66 additions & 33 deletions src/core/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [`ExpiringKey`].
#[must_use]
/// It generates a new random 32-char authentication [`ExpiringKey`]
pub fn generate_permanent_key() -> PeerKey {
generate_key(None)
}

/// It generates a new random 32-char authentication [`ExpiringKey`].
///
/// 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);

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

/// It verifies an [`ExpiringKey`]. 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
}
}

/// 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),
}
}
}

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 @@ -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());
}
}
}
6 changes: 3 additions & 3 deletions src/core/databases/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ 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.
///
Expand All @@ -207,7 +207,7 @@ pub trait Database: Sync + Send {
/// # 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> {
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,
},
},
)?;

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> {
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>), _, _>(
"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,
},
}))
}

/// 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> {
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!(),
};

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

0 comments on commit cb565b4

Please sign in to comment.