Skip to content

Commit

Permalink
Merge #983: Overhaul tracker keys: allow permanent keys
Browse files Browse the repository at this point in the history
c5beff5 feat: [#979] permanent keys (Jose Celano)
8d3fe72 chore(deps): [#979] add new cargo dep: serde_with (Jose Celano)

Pull request description:

  This PR introduces a new feature. Tracker keys can be permanent.

  ### How to use the endpoint

  #### Upload a pre-generated and expiring key

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

  #### Upload a pre-generated and permanent key

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

  #### Generate a random and expiring key

  You can omit the key, and the tracker will generate a random one.

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

  #### Generate a random and permanent key

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

  ### NOTES

  - Fields in the json object are mandatory. You cannot omit them. Instead of omitting the field, I've decided to use the `null` value to represent the case where the value is not needed. I prefer to use always the same structure and make it explicit. However, omitting the field could have other advantages, like requiring less maintenance. For example, if we rename a field (breaking change), that would not affect users who are not using that field.
  - I've introduced manual database migrations. The `second_valid` column in the `keys` table is now nullable (for permanent keys).

ACKs for top commit:
  josecelano:
    ACK c5beff5

Tree-SHA512: 3680165af0f6269eab88144aa116c7aa1d3d73d5d1322f3e18ff25e7dff10de7bc6bc3cb34bcf7b36df6f9fb8d50c6cccad4bb1c10e2cecc5abdc6a102d20a8b
  • Loading branch information
josecelano committed Aug 1, 2024
2 parents 1fdf3a8 + c5beff5 commit 680f642
Show file tree
Hide file tree
Showing 22 changed files with 457 additions and 167 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ serde_bencode = "0"
serde_bytes = "0"
serde_json = { version = "1", features = ["preserve_order"] }
serde_repr = "0"
serde_with = { version = "3.9.0", features = ["json"] }
thiserror = "1"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] }
torrust-tracker-clock = { version = "3.0.0-alpha.12-develop", path = "packages/clock" }
Expand Down
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)
}

/// 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);

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.
/// 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
}
}

/// 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 @@ -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
Loading

0 comments on commit 680f642

Please sign in to comment.