Compare commits
6 commits
Author | SHA1 | Date | |
---|---|---|---|
65137b99ac | |||
7f36eafc11 | |||
fc77407379 | |||
0644d260de | |||
8641f7c2ba | |||
6804360352 |
7 changed files with 86 additions and 6 deletions
|
@ -1,6 +1,7 @@
|
||||||
# Pssecret server
|
# Pssecret server
|
||||||
|
|
||||||
[](https://github.com/psf/black)
|
[](https://github.com/psf/black)
|
||||||
|
[](https://pypi.org/project/pssecret-server/)
|
||||||
|
|
||||||
Pssecret is self-hosted service to share secrets (like passwords) with somebody
|
Pssecret is self-hosted service to share secrets (like passwords) with somebody
|
||||||
over the network, but don't want them to appear in chats, unencrypted e-mails, etc.
|
over the network, but don't want them to appear in chats, unencrypted e-mails, etc.
|
||||||
|
@ -50,6 +51,7 @@ Available configuration options:
|
||||||
--uds TEXT Bind to a UNIX domain socket.
|
--uds TEXT Bind to a UNIX domain socket.
|
||||||
--workers INTEGER Number of worker processes. Defaults to the
|
--workers INTEGER Number of worker processes. Defaults to the
|
||||||
$WEB_CONCURRENCY environment variable if available, or 1.
|
$WEB_CONCURRENCY environment variable if available, or 1.
|
||||||
|
--version Show the version and exit.
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
@ -21,5 +23,6 @@ import uvicorn
|
||||||
),
|
),
|
||||||
type=int,
|
type=int,
|
||||||
)
|
)
|
||||||
|
@click.version_option(version("pssecret_server"))
|
||||||
def cli(**kwargs) -> None:
|
def cli(**kwargs) -> None:
|
||||||
uvicorn.run("pssecret_server.main:app", **kwargs)
|
uvicorn.run("pssecret_server.main:app", **kwargs)
|
||||||
|
|
|
@ -8,7 +8,7 @@ from redis.asyncio import Redis
|
||||||
from pssecret_server.fernet import get_fernet
|
from pssecret_server.fernet import get_fernet
|
||||||
from pssecret_server.models import Secret, SecretSaveResult
|
from pssecret_server.models import Secret, SecretSaveResult
|
||||||
from pssecret_server.redis_db import get_redis
|
from pssecret_server.redis_db import get_redis
|
||||||
from pssecret_server.utils import decrypt_secret, encrypt_secret, save_secret
|
from pssecret_server.utils import decrypt_secret, encrypt_secret, getdel, save_secret
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ async def set_secret(
|
||||||
async def get_secret(
|
async def get_secret(
|
||||||
secret_key: str, redis: RedisDep, fernet: FernetDep
|
secret_key: str, redis: RedisDep, fernet: FernetDep
|
||||||
) -> dict[str, bytes]:
|
) -> dict[str, bytes]:
|
||||||
data: bytes | None = await redis.getdel(secret_key)
|
data: bytes | None = await getdel(redis, secret_key)
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
from functools import lru_cache
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
from redis.exceptions import ResponseError
|
||||||
|
from redis.typing import ResponseT
|
||||||
|
|
||||||
from pssecret_server.models import Secret
|
from pssecret_server.models import Secret
|
||||||
|
|
||||||
|
@ -30,3 +33,35 @@ async def save_secret(data: Secret, redis: Redis) -> str:
|
||||||
await redis.setex(new_key, 60 * 60 * 24, data.data)
|
await redis.setex(new_key, 60 * 60 * 24, data.data)
|
||||||
|
|
||||||
return new_key
|
return new_key
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
async def _is_getdel_available(redis: Redis) -> bool:
|
||||||
|
"""Checks the availability of GETDEL command on the Redis server instance
|
||||||
|
|
||||||
|
GETDEL is not available in Redis prior to version 6.2
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await redis.getdel("test:getdel:availability")
|
||||||
|
except ResponseError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def getdel(redis: Redis, key: str) -> ResponseT:
|
||||||
|
"""Gets the value of key and deletes the key
|
||||||
|
|
||||||
|
Depending on the capabilities of Redis server this function
|
||||||
|
will either call GETDEL command, either first call GETSET with empty string
|
||||||
|
and DEL right after that.
|
||||||
|
"""
|
||||||
|
result: ResponseT
|
||||||
|
|
||||||
|
if await _is_getdel_available(redis):
|
||||||
|
result = await redis.getdel(key)
|
||||||
|
else:
|
||||||
|
result = await redis.getset(key, "")
|
||||||
|
await redis.delete(key)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "pssecret-server"
|
name = "pssecret-server"
|
||||||
version = "1.0.0"
|
version = "1.1.2"
|
||||||
description = "API service for secrets sharing over network"
|
description = "API service for secrets sharing over network"
|
||||||
authors = ["Ivan Golikov <root@ivnglkv.me>"]
|
authors = ["Ivan Golikov <root@ivnglkv.me>"]
|
||||||
license = "BSD-3-Clause"
|
license = "BSD-3-Clause"
|
||||||
|
@ -53,3 +53,6 @@ reportUnusedCallResult = "none"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from pssecret_server.utils import get_new_key, save_secret
|
from pssecret_server.utils import get_new_key, getdel, save_secret
|
||||||
|
|
||||||
from ..factories import SecretFactory
|
from ..factories import SecretFactory
|
||||||
|
|
||||||
|
@ -33,3 +34,22 @@ async def test_save_secret_data(redis_server: Redis) -> None:
|
||||||
|
|
||||||
assert redis_data is not None
|
assert redis_data is not None
|
||||||
assert redis_data.decode() == secret.data
|
assert redis_data.decode() == secret.data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("getdel_available", [True, False])
|
||||||
|
@patch("pssecret_server.utils._is_getdel_available", new_callable=AsyncMock)
|
||||||
|
async def test_getdel(
|
||||||
|
mock_is_getdel_available: AsyncMock,
|
||||||
|
getdel_available: bool,
|
||||||
|
redis_server: Redis,
|
||||||
|
) -> None:
|
||||||
|
mock_is_getdel_available.return_value = getdel_available
|
||||||
|
|
||||||
|
test_value = "test_data"
|
||||||
|
test_key = "test_key"
|
||||||
|
await redis_server.set(test_key, test_value)
|
||||||
|
|
||||||
|
result = await getdel(redis_server, test_key)
|
||||||
|
|
||||||
|
assert result.decode() == test_value # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
assert not await redis_server.exists(test_key)
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from redis.exceptions import ResponseError
|
||||||
|
|
||||||
from pssecret_server.utils import decrypt_secret, encrypt_secret
|
from pssecret_server.utils import _is_getdel_available, decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
from ..factories import SecretFactory
|
from ..factories import SecretFactory
|
||||||
|
|
||||||
|
@ -29,3 +32,17 @@ def test_secret_is_not_decryptable_by_random_key(fernet: Fernet):
|
||||||
|
|
||||||
with pytest.raises(InvalidToken):
|
with pytest.raises(InvalidToken):
|
||||||
decrypt_secret(encrypted_secret.data.encode(), random_fernet)
|
decrypt_secret(encrypted_secret.data.encode(), random_fernet)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("getdel_effect", "expected_result"), [(None, True), (ResponseError, False)]
|
||||||
|
)
|
||||||
|
async def test_is_getdel_available(
|
||||||
|
getdel_effect: ResponseError | None, expected_result: bool
|
||||||
|
):
|
||||||
|
redis = AsyncMock()
|
||||||
|
redis.getdel.side_effect = getdel_effect # pyright: ignore[reportAny]
|
||||||
|
|
||||||
|
result = await _is_getdel_available(redis)
|
||||||
|
|
||||||
|
assert result is expected_result
|
||||||
|
|
Loading…
Reference in a new issue