Skip to content

Commit

Permalink
feat: initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
alvarolopez committed Oct 15, 2024
1 parent fbdaa28 commit 447d9f1
Show file tree
Hide file tree
Showing 7 changed files with 302 additions and 1 deletion.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ repository = "https://github.com/ai4os/ai4-api-keys"
"Bug Tracker" = "https://github.com/ai4os/ai4-api-keys/issues"

[tool.poetry.scripts]

ai4-api-keys = "ai4_api_keys.cli:app"

[tool.poetry.dependencies]
python = "^3.12"
cryptography = "^43.0.1"
typer = "^0.12.5"

[tool.poetry.group.dev.dependencies]
tox = "^4.21.2"
Expand Down
33 changes: 33 additions & 0 deletions src/ai4_api_keys/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Command line interface for the ai4-api-keys package."""

import typer

import ai4_api_keys
import ai4_api_keys.keys
import ai4_api_keys.fernet

app = typer.Typer(help="AI4 API Keys management CLI.")
app.add_typer(ai4_api_keys.fernet.app, name="fernet")
app.add_typer(ai4_api_keys.keys.app, name="keys")


def version_callback(value: bool):
"""Return the version for the --version option."""
if value:
typer.echo(ai4_api_keys.extract_version())
raise typer.Exit()


@app.callback()
def version(
version: bool = typer.Option(
None,
"--version",
"-v",
callback=version_callback,
is_eager=True,
help="Print the version and exit",
)
):
"""Show version and exit."""
pass
63 changes: 63 additions & 0 deletions src/ai4_api_keys/fernet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Manage AI4 Fernet signing keys."""

from typing_extensions import Annotated
from typing import Optional

import cryptography.fernet
import typer

app = typer.Typer(help="AI4 Fernet keys management CLI.")


@app.command(name="generate")
def generate_cli(
output: Annotated[
Optional[str],
typer.Option("--output", "-o", help="Output file for the generated key."),
] = None,
) -> None:
"""Generate a new Fernet key."""
key = generate()
if output is None:
typer.echo(key.decode())
else:
with open(output, "wb") as key_file:
key_file.write(key)


def generate() -> bytes:
"""Generate a new Fernet key."""
key = cryptography.fernet.Fernet.generate_key()
return key


@app.command(name="encrypt")
def encrypt_cli(
key: str = typer.Argument(..., help="The Fernet key to use."),
data: str = typer.Argument(..., help="The data to encrypt."),
) -> None:
"""Encrypt data using a Fernet key (CLI)."""
typer.echo(encrypt(key, data))


def encrypt(key: str, data: str) -> str:
"""Encrypt data using a Fernet key."""
fernet = cryptography.fernet.Fernet(key.encode())
encrypted_data = fernet.encrypt(data.encode())
return encrypted_data.decode()


@app.command(name="decrypt")
def decrypt_cli(
key: str = typer.Argument(..., help="The Fernet key to use."),
data: str = typer.Argument(..., help="The data to decrypt."),
) -> None:
"""Decrypt data using a Fernet key (CLI)."""
typer.echo(decrypt(key, data))


def decrypt(key: str, data: str) -> str:
"""Decrypt data using a Fernet key."""
fernet = cryptography.fernet.Fernet(key.encode())
decrypted_data = fernet.decrypt(data.encode())
return decrypted_data.decode()
130 changes: 130 additions & 0 deletions src/ai4_api_keys/keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Module to generate API keys."""

import enum
import json
import pathlib
import secrets
from typing_extensions import Annotated
from typing import Optional

import typer

from ai4_api_keys import fernet


app = typer.Typer(help="AI4 API Keys management CLI.")


class APILevels(str, enum.Enum):
"""Levels of API keys."""

GOLD = "gold"
SILVER = "silver"
BRONZE = "bronze"
PLATINUM = "platinum"


@app.command(name="create")
def create_cli(
key_file: Annotated[
Optional[pathlib.Path],
typer.Option("--key-file", "-k", help="Read fernet key from a file."),
] = None,
key: Annotated[
Optional[str], typer.Option("--key", "-K", help="Use a specific fernet key.")
] = None,
level: Annotated[
APILevels,
typer.Option("--level", "-l", help="The level of the API key."),
] = APILevels.BRONZE,
scope: Annotated[
str, typer.Option("--scope", "-s", help="The scope of the API key.")
] = "ai4eosc",
) -> None:
"""Create a new API key (CLI)."""
if key_file and key:
raise typer.BadParameter("Cannot use both --key-file and --key.")

if key_file is not None:
with open(key_file, "r") as f:
key = f.read().strip()

if key is None:
raise typer.BadParameter("Either --key-file or --key must be provided.")

typer.echo(create(key, scope, level))


def create(key: str, scope: str, level: APILevels) -> str:
"""Create a new API key.
:param key: The Fernet key to use.
:param scope: The scope of the API key.
:param level: The level of the API key.
:return: The new API key.
"""

message = {
"nonce": secrets.token_hex(8),
"scope": scope,
"level": level.value,
}

return fernet.encrypt(key, json.dumps(message))


@app.command(name="validate")
def validate_cli(
key_file: Annotated[
Optional[pathlib.Path],
typer.Option("--key-file", "-k", help="Read fernet key from a file."),
] = None,
key: Annotated[
Optional[str], typer.Option("--key", "-K", help="Use a specific fernet key.")
] = None,
quiet: Annotated[
bool, typer.Option("--quiet", "-q", help="Do not print the result.")
] = False,
scope: str = typer.Argument("ai4eosc", help="The scope of the API key."),
api_key: str = typer.Argument(..., help="The API key to validate."),
) -> None:
"""validate an API key (CLI)."""
if key_file and key:
raise typer.BadParameter("Cannot use both --key-file and --key.")

if key_file is not None:
with open(key_file, "r") as f:
key = f.read().strip()

if key is None:
raise typer.BadParameter("Either --key-file or --key must be provided.")

valid = validate(key, api_key, scope)

if valid:
if not quiet:
typer.echo("API key is valid.")
else:
if not quiet:
typer.echo("API key is invalid.")
raise typer.Exit(code=1)


def validate(key: str, api_key: str, scope: str) -> bool:
"""validate an API key.
:param key: The Fernet key to use.
:param api_key: The API key to validate.
:param scope: The scope of the API key.
:return: Whether the API key is valid.
"""

try:
decrypted = fernet.decrypt(key, api_key)
except Exception:
return False

message = json.loads(decrypted)
if message["scope"] != scope:
return False
return True
Empty file.
27 changes: 27 additions & 0 deletions src/ai4_api_keys/tests/test_fernet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import pytest

from ai4_api_keys import fernet


@pytest.fixture
def key():
"""Return a Fernet key."""
return "D5muj8QWVvkzDLRA0iPITxjGGYF22U2AA6eopxIWX2M="


def test_generate_key():
"""Test the fernet.generate function."""
key = fernet.generate()
assert key
assert len(key) == 44


def test_encrypt_decrypt(key):
"""Test the fernet.encrypt and fernet.decrypt functions."""

data = "Hello, World!"
encrypted_data = fernet.encrypt(key, data)
assert encrypted_data

decrypted_data = fernet.decrypt(key, encrypted_data)
assert decrypted_data == data
47 changes: 47 additions & 0 deletions src/ai4_api_keys/tests/test_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import pytest

from ai4_api_keys import keys


@pytest.fixture
def key():
"""Return a Fernet key."""
return "D5muj8QWVvkzDLRA0iPITxjGGYF22U2AA6eopxIWX2M="


def test_create_key(key):
"""Test the fernet.create function."""

scope = "ai4eosc"
level = keys.APILevels.BRONZE

new_key = keys.create(key, scope, level)
assert new_key

assert keys.validate(key, new_key, scope)


@pytest.fixture()
def valid_api_key():
"""Return a valid API key."""
return ("gAAAAABnDh1srDPSh7F3R8f3dhNVR1_t8pGX_pOc8RZRq0j_0UWIluMjxttgieXdfihMUChb"
"smz5ByfRw4K3t_N8Nhp_pbsi7KbBFw9H-AK7qqMRAZvef527SEkHP-j0S8TLYoE93WD_PkqQI"
"Oe4N0ShUnd8wHrSrI1QzOBlsWnzmPv3lkUV0uI=")


def test_validate_key(key, valid_api_key):
scope = "ai4eosc"

assert keys.validate(key, valid_api_key, scope)


def test_validate_key_invalid(key):
scope = "ai4eosc"

assert not keys.validate(key, "invalid_key", scope)


def test_validate_key_invalid_scope(key, valid_api_key):
scope = "invalid_scope"

assert not keys.validate(key, valid_api_key, scope)

0 comments on commit 447d9f1

Please sign in to comment.