04/16 Upload codebase
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.dialects.mysql import INTEGER
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class EmergencyAccess(db.Model):
|
||||
"""
|
||||
Emergency access grant from a vault owner (grantor) to a trusted contact (grantee).
|
||||
|
||||
Status flow:
|
||||
invited → grantee calls /accept → accepted
|
||||
accepted → grantor calls /provide → ready (enc_vault stored)
|
||||
ready → grantee calls /request → pending (wait timer starts)
|
||||
pending → grantor calls /deny → ready (reset, grantee can request again)
|
||||
pending (wait_days elapsed) → grantable (grantee fetches vault)
|
||||
|
||||
Zero-knowledge: enc_vault is a JSON array of vault items re-encrypted by the grantor
|
||||
using the ECDH shared secret (grantor private key + grantee public key).
|
||||
"""
|
||||
__tablename__ = 'emergency_access'
|
||||
|
||||
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
|
||||
grantor_id = db.Column(
|
||||
INTEGER(unsigned=True),
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
grantee_email = db.Column(db.String(255), nullable=False)
|
||||
grantee_id = db.Column(
|
||||
INTEGER(unsigned=True),
|
||||
db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
wait_days = db.Column(db.Integer, default=7, nullable=False)
|
||||
# invited | accepted | ready | pending | denied
|
||||
status = db.Column(db.String(20), default='invited', nullable=False)
|
||||
request_initiated_at = db.Column(db.DateTime, nullable=True)
|
||||
# JSON string: [{ id, name, item_type, enc_data, iv }, ...]
|
||||
enc_vault = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
@property
|
||||
def wait_elapsed(self):
|
||||
"""True if the wait period has passed since the access request."""
|
||||
if self.status != 'pending' or not self.request_initiated_at:
|
||||
return False
|
||||
return datetime.utcnow() >= self.request_initiated_at + timedelta(days=self.wait_days)
|
||||
|
||||
def to_dict(self, grantor_email=None):
|
||||
return {
|
||||
'id': self.id,
|
||||
'grantor_id': self.grantor_id,
|
||||
'grantor_email': grantor_email,
|
||||
'grantee_email': self.grantee_email,
|
||||
'grantee_id': self.grantee_id,
|
||||
'wait_days': self.wait_days,
|
||||
'status': self.status,
|
||||
'wait_elapsed': self.wait_elapsed,
|
||||
'request_initiated_at': (
|
||||
self.request_initiated_at.isoformat() if self.request_initiated_at else None
|
||||
),
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy.dialects.mysql import INTEGER
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class Folder(db.Model):
|
||||
__tablename__ = 'folders'
|
||||
|
||||
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
|
||||
user_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
|
||||
vault_items = db.relationship('VaultItem', backref='folder', lazy='dynamic')
|
||||
|
||||
def to_dict(self):
|
||||
return {'id': self.id, 'name': self.name}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Folder {self.name}>'
|
||||
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.dialects.mysql import INTEGER
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class SharedItem(db.Model):
|
||||
"""
|
||||
A vault item shared by one user with another.
|
||||
|
||||
Zero-knowledge: enc_data is re-encrypted by the SENDER using the ECDH shared
|
||||
secret derived from sender's private key + recipient's P-256 public key.
|
||||
The server stores only the ciphertext — it cannot decrypt it.
|
||||
"""
|
||||
__tablename__ = 'shared_items'
|
||||
|
||||
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
|
||||
item_id = db.Column(
|
||||
INTEGER(unsigned=True),
|
||||
db.ForeignKey('vault_items.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
owner_id = db.Column(
|
||||
INTEGER(unsigned=True),
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
recipient_email = db.Column(db.String(255), nullable=False)
|
||||
recipient_id = db.Column(
|
||||
INTEGER(unsigned=True),
|
||||
db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
# Plaintext metadata — not sensitive, used for display before decryption
|
||||
item_name = db.Column(db.String(255), nullable=False)
|
||||
item_type = db.Column(db.String(20), nullable=False, default='password')
|
||||
# ECDH-encrypted payload
|
||||
enc_data = db.Column(db.Text, nullable=False)
|
||||
iv = db.Column(db.String(64), nullable=False)
|
||||
|
||||
accepted = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'item_id': self.item_id,
|
||||
'owner_id': self.owner_id,
|
||||
'recipient_email': self.recipient_email,
|
||||
'recipient_id': self.recipient_id,
|
||||
'item_name': self.item_name,
|
||||
'item_type': self.item_type,
|
||||
'enc_data': self.enc_data,
|
||||
'iv': self.iv,
|
||||
'accepted': self.accepted,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.dialects.mysql import INTEGER
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class TokenBlacklist(db.Model):
|
||||
"""Revoked JWT identifiers. Access and refresh tokens are added on logout."""
|
||||
__tablename__ = 'token_blacklist'
|
||||
|
||||
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
|
||||
jti = db.Column(db.String(36), unique=True, nullable=False, index=True)
|
||||
user_id = db.Column(INTEGER(unsigned=True), nullable=False)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
@classmethod
|
||||
def is_blacklisted(cls, jti: str) -> bool:
|
||||
entry = cls.query.filter_by(jti=jti).first()
|
||||
if not entry:
|
||||
return False
|
||||
# Automatically ignore expired entries (they can be cleaned up later)
|
||||
return entry.expires_at > datetime.utcnow()
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Delete entries that have already expired — call occasionally to keep table small."""
|
||||
cls.query.filter(cls.expires_at <= datetime.utcnow()).delete()
|
||||
db.session.commit()
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import datetime
|
||||
from flask_login import UserMixin
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||
from sqlalchemy.dialects.mysql import INTEGER
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
# master_hash: Argon2id hash of the client-derived PBKDF2 auth_hash
|
||||
# The raw master password is NEVER sent to or stored on the server.
|
||||
master_hash = db.Column(db.String(255), nullable=False)
|
||||
# enc_key_salt: random 16-byte salt (base64) generated at registration.
|
||||
# Returned to the client on login so it can re-derive the AES-256-GCM vault key.
|
||||
# The server never uses this for decryption — it is opaque to us.
|
||||
enc_key_salt = db.Column(db.String(64), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login = db.Column(db.DateTime, nullable=True)
|
||||
# TOTP / MFA
|
||||
totp_secret = db.Column(db.String(64), nullable=True)
|
||||
totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||
# ECDH P-256 sharing keypair
|
||||
# Public key: raw uncompressed point (65 bytes), base64-encoded, stored plaintext
|
||||
sharing_public_key = db.Column(db.Text, nullable=True)
|
||||
# Private key: JWK, AES-256-GCM encrypted with the user's vault key
|
||||
sharing_private_key_enc = db.Column(db.Text, nullable=True)
|
||||
sharing_private_key_iv = db.Column(db.String(64), nullable=True)
|
||||
|
||||
folders = db.relationship('Folder', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
|
||||
vault_items = db.relationship('VaultItem', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def check_password(self, auth_hash: str) -> bool:
|
||||
ph = PasswordHasher()
|
||||
try:
|
||||
return ph.verify(self.master_hash, auth_hash)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.email}>'
|
||||
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
from sqlalchemy.dialects.mysql import INTEGER
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class ItemType(str, enum.Enum):
|
||||
PASSWORD = 'password'
|
||||
NOTE = 'note'
|
||||
ADDRESS = 'address'
|
||||
CARD = 'card'
|
||||
BANK = 'bank'
|
||||
SSN = 'ssn'
|
||||
PASSKEY = 'passkey'
|
||||
|
||||
|
||||
class VaultItem(db.Model):
|
||||
__tablename__ = 'vault_items'
|
||||
|
||||
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
|
||||
user_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
folder_id = db.Column(INTEGER(unsigned=True), db.ForeignKey('folders.id', ondelete='SET NULL'), nullable=True)
|
||||
item_type = db.Column(db.String(20), nullable=False, default=ItemType.PASSWORD.value)
|
||||
# name is stored in plaintext for display in the vault list.
|
||||
# All other sensitive fields (username, password, URL, notes, etc.)
|
||||
# are inside enc_data and are encrypted client-side with AES-256-GCM.
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
enc_data = db.Column(db.Text, nullable=False) # base64-encoded AES-256-GCM ciphertext
|
||||
iv = db.Column(db.String(64), nullable=False) # base64-encoded 12-byte GCM nonce
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
def to_dict(self):
|
||||
# item_type is stored as a plain string; handle both str and enum safely
|
||||
item_type = self.item_type
|
||||
if isinstance(item_type, ItemType):
|
||||
item_type = item_type.value
|
||||
return {
|
||||
'id': self.id,
|
||||
'item_type': item_type,
|
||||
'name': self.name,
|
||||
'folder_id': self.folder_id,
|
||||
'enc_data': self.enc_data,
|
||||
'iv': self.iv,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<VaultItem {self.name}>'
|
||||
Reference in New Issue
Block a user