Skip to content

Commit 8dc8285

Browse files
authored
feat(types): enhance crypto column types and add TOTP/OTP storage (#758)
## Summary Enhance the at-rest cryptographic column types and add two new types — an encrypted TOTP shared-secret type and a hashed, self-expiring one-time-code type. ## What changed ### `EncryptedString` / PGCrypto - **Corrected `PGCryptoBackend`** to run encryption/decryption server-side via SQLAlchemy `bind_expression`/`column_expression`: writes wrap as `armor(pgp_sym_encrypt(:v, key))`, reads as `pgp_sym_decrypt(dearmor(col), key)`. ASCII-armored ciphertext stays in a `String` column, so the column type is unchanged. **Verified interchangeable with `FernetBackend`** on PostgreSQL — same API, same round-trip for value/None/empty/unicode, ciphertext at rest. - Wired `cryptography` through the optionals facade (`CRYPTOGRAPHY_INSTALLED`); `FernetBackend` raises `MissingDependencyError` when it's absent. Importing `advanced_alchemy.types` never eagerly imports the optional crypto libraries — guards fire at construction time. - **Deprecated the random default key**: constructing without an explicit `key` now emits a `DeprecationWarning` (the random default changes per process restart → undecryptable rows). Non-breaking. - Cache the derived cipher for static keys; callable keys still re-resolve. - `__repr__` renders `type(self).__name__` so `EncryptedText` reconstructs as `EncryptedText` (not `EncryptedString`) in autogenerated migrations. ### `PasswordHash` - Added `needs_rehash` to all three backends and `HashedPassword.verify_and_update` for transparent rehash-on-login. - Facade guards for argon2/passlib/pwdlib; default `length` 128 → 255; narrowed `Argon2Hasher.verify` (no bare `except`). ### New types - **`TOTPSecret`** — base32 TOTP secret encrypted at rest, returns a pyotp-backed `TOTPProvider` (`now`/`verify`/`provisioning_uri`); `generate_totp_secret` helper. `key` is required (no deprecated default). - **`OneTimeCode`** — a transient code hashed in a **JSON column that also tracks expiry, single-use redemption, and wrong-guess lockout**, so the whole lifecycle lives in one column: - `OneTimeCode(backend, ttl_seconds=None, max_attempts=3)` stores `{hash, expires_at, used_at, attempts}`. - `HashedOneTimeCode` exposes `is_expired`/`is_used`/`is_locked`; `verify` succeeds only while the code is redeemable; `redeem(code)` verifies and returns the updated value to persist (marks used on success, records a failed attempt otherwise). - Single-use is always enforced; `max_attempts` (default 3) locks after that many wrong guesses; `ttl_seconds` expires it. State persists by assigning the redeemed value back and committing. - `generate_one_time_code(length=6, digits_only=True)` helper. - Both new types are registered in the Alembic mako templates and reconstruct faithfully. ### Docs & packaging - New `cryptography` and `pyotp` optional-dependency extras. - Usage docs for key management, PGCrypto requirements, password hashing, TOTP, and the one-time-code redeem lifecycle; README feature entries; a single changelog entry in the `1.11.0` block. All new docs examples execute under Sybil. ## Backward compatibility - No change to Fernet ciphertext format or key derivation. - The default-key change is non-breaking (warning only). - `compare_value`/`compare_expression` retained (documented as an advanced extension point), not removed.
1 parent 5e69914 commit 8dc8285

28 files changed

Lines changed: 2007 additions & 389 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
- id: unasyncd
2323
additional_dependencies: ["ruff"]
2424
- repo: https://github.com/charliermarsh/ruff-pre-commit
25-
rev: "v0.15.14"
25+
rev: "v0.15.15"
2626
hooks:
2727
# Run the linter.
2828
- id: ruff

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ offering:
4747
- Integrated support for UUID6 and UUID7 ([`uuid-utils`](https://github.com/aminalaee/uuid-utils) when installed, otherwise native in Python 3.14+, with UUID4 fallback on older versions).
4848
- Integrated support for Nano ID using [`fastnanoid`](https://github.com/oliverlambson/fastnanoid) (install with the `nanoid` extra)
4949
- Custom encrypted text type with multiple backend support including [`pgcrypto`](https://www.postgresql.org/docs/current/pgcrypto.html) for PostgreSQL and the Fernet implementation from [`cryptography`](https://cryptography.io/en/latest/) for other databases
50-
- Custom password hashing type with multiple backend support including [`Argon2`](https://github.com/P-H-C/phc-winner-argon2), [`Passlib`](https://passlib.readthedocs.io/en/stable/), and [`Pwdlib`](https://pwdlib.readthedocs.io/en/stable/) with automatic salt generation
50+
- Custom password hashing type with multiple backend support including [`Argon2`](https://github.com/P-H-C/phc-winner-argon2), [`Passlib`](https://passlib.readthedocs.io/en/stable/), and [`Pwdlib`](https://pwdlib.readthedocs.io/en/stable/) with automatic salt generation and rehash-on-verify for transparent hash upgrades on login
51+
- Custom TOTP shared-secret type that stores an authenticator seed encrypted at rest and verifies time-based one-time passwords via [`pyotp`](https://github.com/pyauth/pyotp) (install with the `pyotp` extra)
52+
- Custom one-time-code type that stores email/SMS verification codes hashed in JSON with built-in expiry, single-use redemption, attempt lockout, and a secure code generator
5153
- Pre-configured base classes with audit columns UUID or Big Integer primary keys and
5254
a [sentinel column](https://docs.sqlalchemy.org/en/20/core/connections.html#configuring-sentinel-columns).
5355
- Synchronous and asynchronous repositories featuring:

advanced_alchemy/_typing.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,18 @@
2525
# ---------------------------------------------------------------------------
2626
# Feature-detection flags (use ``find_spec`` to avoid eagerly importing).
2727
# ---------------------------------------------------------------------------
28+
ARGON2_INSTALLED = find_spec("argon2") is not None
2829
ATTRS_INSTALLED = find_spec("attrs") is not None
2930
CATTRS_INSTALLED = find_spec("cattrs") is not None
31+
CRYPTOGRAPHY_INSTALLED = find_spec("cryptography") is not None
3032
LITESTAR_INSTALLED = find_spec("litestar") is not None
3133
MSGSPEC_INSTALLED = find_spec("msgspec") is not None
3234
NUMPY_INSTALLED = find_spec("numpy") is not None
3335
ORJSON_INSTALLED = find_spec("orjson") is not None
36+
PASSLIB_INSTALLED = find_spec("passlib") is not None
37+
PWDLIB_INSTALLED = find_spec("pwdlib") is not None
3438
PYDANTIC_INSTALLED = find_spec("pydantic") is not None
39+
PYOTP_INSTALLED = find_spec("pyotp") is not None
3540
SQLMODEL_INSTALLED = find_spec("sqlmodel") is not None
3641

3742

@@ -375,14 +380,19 @@ class EmptyEnum(enum.Enum):
375380

376381

377382
__all__ = (
383+
"ARGON2_INSTALLED",
378384
"ATTRS_INSTALLED",
379385
"ATTRS_NOTHING_STUB",
380386
"CATTRS_INSTALLED",
387+
"CRYPTOGRAPHY_INSTALLED",
381388
"LITESTAR_INSTALLED",
382389
"MSGSPEC_INSTALLED",
383390
"NUMPY_INSTALLED",
384391
"ORJSON_INSTALLED",
392+
"PASSLIB_INSTALLED",
393+
"PWDLIB_INSTALLED",
385394
"PYDANTIC_INSTALLED",
395+
"PYOTP_INSTALLED",
386396
"SQLMODEL_INSTALLED",
387397
"UNSET",
388398
"UNSET_STUB",

advanced_alchemy/alembic/templates/asyncio/script.py.mako

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,22 @@ Create Date: ${create_date}
77
"""
88

99
import warnings
10-
from typing import TYPE_CHECKING, Any
10+
from typing import TYPE_CHECKING
1111

1212
import sqlalchemy as sa
1313
from alembic import op
14-
from advanced_alchemy.types import Bool, EncryptedString, EncryptedText, GUID, JsonB, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
14+
from advanced_alchemy.types import Bool, EncryptedString, EncryptedText, GUID, JsonB, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend, TOTPSecret, OneTimeCode
1515
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
16+
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
17+
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
18+
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
1619
from sqlalchemy import Text # noqa: F401
1720
${imports if imports else ""}
18-
try:
19-
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
20-
except ImportError:
21-
Argon2Hasher = Any # type: ignore
22-
try:
23-
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
24-
except ImportError:
25-
PasslibHasher = Any # type: ignore
26-
try:
27-
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
28-
except ImportError:
29-
PwdlibHasher = Any # type: ignore
3021

3122
if TYPE_CHECKING:
3223
from collections.abc import Sequence
3324

34-
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
25+
__all__ = ("downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades")
3526

3627
sa.GUID = GUID
3728
sa.Bool = Bool
@@ -47,6 +38,8 @@ sa.PasslibHasher = PasslibHasher
4738
sa.PwdlibHasher = PwdlibHasher
4839
sa.FernetBackend = FernetBackend
4940
sa.PGCryptoBackend = PGCryptoBackend
41+
sa.TOTPSecret = TOTPSecret
42+
sa.OneTimeCode = OneTimeCode
5043

5144
# revision identifiers, used by Alembic.
5245
revision = ${repr(up_revision)}

advanced_alchemy/alembic/templates/sync/script.py.mako

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,22 @@ Create Date: ${create_date}
77
"""
88

99
import warnings
10-
from typing import TYPE_CHECKING, Any
10+
from typing import TYPE_CHECKING
1111

1212
import sqlalchemy as sa
1313
from alembic import op
14-
from advanced_alchemy.types import Bool, EncryptedString, EncryptedText, GUID, JsonB, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
14+
from advanced_alchemy.types import Bool, EncryptedString, EncryptedText, GUID, JsonB, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend, TOTPSecret, OneTimeCode
1515
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
16+
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
17+
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
18+
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
1619
from sqlalchemy import Text # noqa: F401
1720
${imports if imports else ""}
18-
try:
19-
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
20-
except ImportError:
21-
Argon2Hasher = Any # type: ignore
22-
try:
23-
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
24-
except ImportError:
25-
PasslibHasher = Any # type: ignore
26-
try:
27-
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
28-
except ImportError:
29-
PwdlibHasher = Any # type: ignore
3021

3122
if TYPE_CHECKING:
3223
from collections.abc import Sequence
3324

34-
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
25+
__all__ = ("downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades")
3526

3627
sa.GUID = GUID
3728
sa.Bool = Bool
@@ -47,6 +38,8 @@ sa.PasslibHasher = PasslibHasher
4738
sa.PwdlibHasher = PwdlibHasher
4839
sa.FernetBackend = FernetBackend
4940
sa.PGCryptoBackend = PGCryptoBackend
41+
sa.TOTPSecret = TOTPSecret
42+
sa.OneTimeCode = OneTimeCode
5043

5144
# revision identifiers, used by Alembic.
5245
revision = ${repr(up_revision)}

advanced_alchemy/types/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""SQLAlchemy custom types for use with the ORM."""
22

3-
from advanced_alchemy.types import encrypted_string, file_object, password_hash
3+
from advanced_alchemy.types import encrypted_string, file_object, password_hash, totp
44
from advanced_alchemy.types.boolean import Bool
55
from advanced_alchemy.types.datetime import DateTimeUTC
66
from advanced_alchemy.types.encrypted_string import (
@@ -23,6 +23,12 @@
2323
from advanced_alchemy.types.json import ORA_JSONB, JsonB
2424
from advanced_alchemy.types.mutables import MutableList
2525
from advanced_alchemy.types.password_hash.base import HashedPassword, PasswordHash
26+
from advanced_alchemy.types.password_hash.one_time_code import (
27+
HashedOneTimeCode,
28+
OneTimeCode,
29+
generate_one_time_code,
30+
)
31+
from advanced_alchemy.types.totp import TOTPProvider, TOTPSecret, generate_totp_secret
2632
from advanced_alchemy.types.vector import Vector
2733

2834
__all__ = (
@@ -39,17 +45,24 @@
3945
"FernetBackend",
4046
"FileObject",
4147
"FileObjectList",
48+
"HashedOneTimeCode",
4249
"HashedPassword",
4350
"JsonB",
4451
"MutableList",
52+
"OneTimeCode",
4553
"PasswordHash",
4654
"StorageBackend",
4755
"StorageBackendT",
4856
"StorageRegistry",
4957
"StoredObject",
58+
"TOTPProvider",
59+
"TOTPSecret",
5060
"Vector",
5161
"encrypted_string",
5262
"file_object",
63+
"generate_one_time_code",
64+
"generate_totp_secret",
5365
"password_hash",
5466
"storages",
67+
"totp",
5568
)

0 commit comments

Comments
 (0)