05/16 Enhanced codes
This commit is contained in:
+2
-2
@@ -14,7 +14,7 @@ VALID_TYPES = {t.value for t in ItemType}
|
|||||||
@require_jwt
|
@require_jwt
|
||||||
def list_items():
|
def list_items():
|
||||||
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
|
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
|
||||||
VaultItem.name.asc()
|
VaultItem.created_at.desc()
|
||||||
).all()
|
).all()
|
||||||
return jsonify([item.to_dict() for item in items]), 200
|
return jsonify([item.to_dict() for item in items]), 200
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ def export_items():
|
|||||||
created_at, updated_at }
|
created_at, updated_at }
|
||||||
"""
|
"""
|
||||||
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
|
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
|
||||||
VaultItem.name.asc()
|
VaultItem.created_at.desc()
|
||||||
).all()
|
).all()
|
||||||
AuditLog.log(
|
AuditLog.log(
|
||||||
user_id=g.current_user_id,
|
user_id=g.current_user_id,
|
||||||
|
|||||||
@@ -177,42 +177,11 @@ def generate_recovery_nonce() -> str:
|
|||||||
return os.urandom(32).hex()
|
return os.urandom(32).hex()
|
||||||
|
|
||||||
|
|
||||||
def compute_recovery_proof(recovery_enc_salt_b64: str, recovery_iv_b64: str, nonce: str) -> str:
|
# NOTE: compute_recovery_proof() is intentionally absent.
|
||||||
"""
|
# The server cannot decrypt the recovery blob (it was encrypted client-side with
|
||||||
Derive the expected HMAC-SHA256 proof tag that the client must produce.
|
# the user's recovery key). Instead, the expected HMAC is computed inline in
|
||||||
|
# the /recovery/data route using user.enc_key_salt as the HMAC key, stored in
|
||||||
The client-side proof is:
|
# flask.session, and compared on submission via verify_recovery_proof() below.
|
||||||
key_material = AES-GCM-decrypt(recovery_key, recovery_enc_salt_ciphertext)
|
|
||||||
= enc_key_salt (plaintext bytes)
|
|
||||||
proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_bytes)
|
|
||||||
|
|
||||||
The server replicates this using the stored ciphertext + its TOTP encryption
|
|
||||||
key is NOT involved here — the recovery blob was encrypted with the *client*
|
|
||||||
recovery key. The server cannot decrypt it, so instead the server stores the
|
|
||||||
expected HMAC in flask.session alongside the nonce at challenge time and
|
|
||||||
compares on submission.
|
|
||||||
|
|
||||||
Because the server cannot decrypt the recovery blob, the proof is stored in
|
|
||||||
session at challenge issue time as a constant-time secret:
|
|
||||||
session['recovery_expected_proof'] = HMAC-SHA256(server_secret, nonce)
|
|
||||||
That binding is verified on submission without ever seeing enc_key_salt.
|
|
||||||
|
|
||||||
Concretely:
|
|
||||||
expected_tag = HMAC-SHA256(key=SECRET_KEY_bytes, msg=nonce_hex_bytes)
|
|
||||||
|
|
||||||
The client sends:
|
|
||||||
client_tag = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_hex_bytes)
|
|
||||||
|
|
||||||
These are different keys — so the server never validates client_tag directly.
|
|
||||||
Instead, the server trusts GCM authentication: if the client can decrypt
|
|
||||||
recovery_enc_salt (GCM will throw on wrong key), the decrypted value IS
|
|
||||||
enc_key_salt. The server then computes:
|
|
||||||
expected = HMAC-SHA256(key=user.enc_key_salt.encode(), msg=nonce.encode())
|
|
||||||
and compares it to client_tag in constant time.
|
|
||||||
"""
|
|
||||||
key = base64.b64decode(recovery_enc_salt_b64) # unused — see docstring
|
|
||||||
msg = nonce.encode()
|
|
||||||
return hmac.new(key, msg, hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_recovery_proof(expected_hmac: str, client_hmac: str) -> bool:
|
def verify_recovery_proof(expected_hmac: str, client_hmac: str) -> bool:
|
||||||
@@ -235,20 +204,12 @@ def generate_backup_codes() -> tuple[list[str], list[str]]:
|
|||||||
The plaintext list is shown to the user ONCE and never stored.
|
The plaintext list is shown to the user ONCE and never stored.
|
||||||
Only the hashed list is persisted in user.mfa_backup_codes (JSON array).
|
Only the hashed list is persisted in user.mfa_backup_codes (JSON array).
|
||||||
"""
|
"""
|
||||||
|
import secrets
|
||||||
ph = PasswordHasher(
|
ph = PasswordHasher(
|
||||||
time_cost=1, # backup codes can afford lighter params than master password
|
time_cost=1, # backup codes can afford lighter params than master password
|
||||||
memory_cost=16384,
|
memory_cost=16384,
|
||||||
parallelism=2,
|
parallelism=2,
|
||||||
)
|
)
|
||||||
plaintext = [
|
|
||||||
''.join(os.urandom(1)[0] % len(_BACKUP_ALPHABET)
|
|
||||||
and _BACKUP_ALPHABET[os.urandom(1)[0] % len(_BACKUP_ALPHABET)]
|
|
||||||
or _BACKUP_ALPHABET[os.urandom(1)[0] % len(_BACKUP_ALPHABET)]
|
|
||||||
for _ in range(BACKUP_CODE_LENGTH))
|
|
||||||
for _ in range(BACKUP_CODE_COUNT)
|
|
||||||
]
|
|
||||||
# Simpler generation using secrets module for clarity and correctness:
|
|
||||||
import secrets
|
|
||||||
plaintext = [
|
plaintext = [
|
||||||
''.join(secrets.choice(_BACKUP_ALPHABET) for _ in range(BACKUP_CODE_LENGTH))
|
''.join(secrets.choice(_BACKUP_ALPHABET) for _ in range(BACKUP_CODE_LENGTH))
|
||||||
for _ in range(BACKUP_CODE_COUNT)
|
for _ in range(BACKUP_CODE_COUNT)
|
||||||
|
|||||||
Reference in New Issue
Block a user