Coverage for src / kdbxtool / models / entry.py: 94%
261 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-19 21:22 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-19 21:22 +0000
1"""Entry model for KDBX password entries."""
3from __future__ import annotations
5import uuid as uuid_module
6from dataclasses import dataclass, field
7from datetime import datetime
8from typing import TYPE_CHECKING
10from .times import Times
12if TYPE_CHECKING:
13 from ..database import Database
14 from ..security.totp import TotpCode
15 from .group import Group
18# Fields that have special handling and shouldn't be treated as custom properties
19RESERVED_KEYS = frozenset(
20 {
21 "Title",
22 "UserName",
23 "Password",
24 "URL",
25 "Notes",
26 "otp",
27 }
28)
31@dataclass
32class StringField:
33 """A string field in an entry.
35 Attributes:
36 key: Field name (e.g., "Title", "UserName", "Password")
37 value: Field value
38 protected: Whether the field should be protected in memory
39 """
41 key: str
42 value: str | None = None
43 protected: bool = False
46@dataclass
47class AutoType:
48 """AutoType settings for an entry.
50 Attributes:
51 enabled: Whether AutoType is enabled for this entry
52 sequence: Default keystroke sequence
53 window: Window filter for AutoType
54 obfuscation: Data transfer obfuscation level (0 = none)
55 """
57 enabled: bool = True
58 sequence: str | None = None
59 window: str | None = None
60 obfuscation: int = 0
63@dataclass
64class BinaryRef:
65 """Reference to a binary attachment.
67 Attributes:
68 key: Filename of the attachment
69 ref: Reference ID to the binary in the database
70 """
72 key: str
73 ref: int
76@dataclass
77class Entry:
78 """A password entry in a KDBX database.
80 Entries store credentials and associated metadata. Each entry has
81 standard fields (title, username, password, url, notes) plus support
82 for custom string fields and binary attachments.
84 Attributes:
85 uuid: Unique identifier for the entry
86 times: Timestamps (creation, modification, access, expiry)
87 icon_id: Icon ID for display (standard icon)
88 custom_icon_uuid: UUID of custom icon (overrides icon_id if set)
89 tags: List of tags for categorization
90 strings: Dictionary of string fields (key -> StringField)
91 binaries: List of binary attachment references
92 autotype: AutoType settings
93 history: List of previous versions of this entry
94 foreground_color: Custom foreground color (hex)
95 background_color: Custom background color (hex)
96 override_url: URL override for AutoType
97 quality_check: Whether to check password quality
98 """
100 uuid: uuid_module.UUID = field(default_factory=uuid_module.uuid4)
101 times: Times = field(default_factory=Times.create_new)
102 icon_id: str = "0"
103 custom_icon_uuid: uuid_module.UUID | None = None
104 tags: list[str] = field(default_factory=list)
105 strings: dict[str, StringField] = field(default_factory=dict)
106 binaries: list[BinaryRef] = field(default_factory=list)
107 autotype: AutoType = field(default_factory=AutoType)
108 history: list[HistoryEntry] = field(default_factory=list)
109 foreground_color: str | None = None
110 background_color: str | None = None
111 override_url: str | None = None
112 quality_check: bool = True
114 # Runtime reference to parent group (not serialized)
115 _parent: Group | None = field(default=None, repr=False, compare=False)
116 # Runtime reference to database (not serialized) - used for icon name resolution
117 _database: Database | None = field(default=None, repr=False, compare=False)
119 def __post_init__(self) -> None:
120 """Initialize default string fields if not present."""
121 for key in ("Title", "UserName", "Password", "URL", "Notes"):
122 if key not in self.strings:
123 protected = key == "Password"
124 self.strings[key] = StringField(key=key, protected=protected)
126 # --- Standard field properties ---
128 @property
129 def title(self) -> str | None:
130 """Get or set entry title."""
131 return self.strings.get("Title", StringField("Title")).value
133 @title.setter
134 def title(self, value: str | None) -> None:
135 if "Title" not in self.strings:
136 self.strings["Title"] = StringField("Title")
137 self.strings["Title"].value = value
139 @property
140 def username(self) -> str | None:
141 """Get or set entry username."""
142 return self.strings.get("UserName", StringField("UserName")).value
144 @username.setter
145 def username(self, value: str | None) -> None:
146 if "UserName" not in self.strings:
147 self.strings["UserName"] = StringField("UserName")
148 self.strings["UserName"].value = value
150 @property
151 def password(self) -> str | None:
152 """Get or set entry password."""
153 return self.strings.get("Password", StringField("Password")).value
155 @password.setter
156 def password(self, value: str | None) -> None:
157 if "Password" not in self.strings:
158 self.strings["Password"] = StringField("Password", protected=True)
159 self.strings["Password"].value = value
161 @property
162 def url(self) -> str | None:
163 """Get or set entry URL."""
164 return self.strings.get("URL", StringField("URL")).value
166 @url.setter
167 def url(self, value: str | None) -> None:
168 if "URL" not in self.strings:
169 self.strings["URL"] = StringField("URL")
170 self.strings["URL"].value = value
172 @property
173 def notes(self) -> str | None:
174 """Get or set entry notes."""
175 return self.strings.get("Notes", StringField("Notes")).value
177 @notes.setter
178 def notes(self, value: str | None) -> None:
179 if "Notes" not in self.strings:
180 self.strings["Notes"] = StringField("Notes")
181 self.strings["Notes"].value = value
183 @property
184 def otp(self) -> str | None:
185 """Get or set OTP secret (TOTP/HOTP)."""
186 return self.strings.get("otp", StringField("otp")).value
188 @otp.setter
189 def otp(self, value: str | None) -> None:
190 if "otp" not in self.strings:
191 self.strings["otp"] = StringField("otp", protected=True)
192 self.strings["otp"].value = value
194 def totp(self, *, at: datetime | float | None = None) -> TotpCode | None:
195 """Generate current TOTP code from the entry's otp field.
197 Supports both standard otpauth:// URIs and KeePassXC legacy format
198 (TOTP Seed / TOTP Settings custom fields).
200 Args:
201 at: Optional timestamp for code generation. Can be a datetime
202 or Unix timestamp float. Defaults to current time.
204 Returns:
205 TotpCode object with code and expiration info, or None if no OTP configured.
207 Raises:
208 ValueError: If OTP configuration is invalid
210 Example:
211 >>> result = entry.totp()
212 >>> print(f"Code: {result.code}")
213 Code: 123456
215 >>> print(f"Expires in {result.remaining}s")
216 Expires in 15s
218 >>> # TotpCode also works as a string
219 >>> print(result)
220 123456
221 """
222 from ..security.totp import (
223 generate_totp,
224 parse_keepassxc_legacy,
225 parse_otpauth_uri,
226 )
228 # Try standard otp field first (otpauth:// URI)
229 otp_value = self.otp
230 if otp_value and otp_value.startswith("otpauth://"):
231 config = parse_otpauth_uri(otp_value)
232 # Try KeePassXC legacy fields
233 elif self.strings.get("TOTP Seed"):
234 seed = self.strings["TOTP Seed"].value
235 if not seed:
236 return None
237 settings = None
238 if self.strings.get("TOTP Settings"):
239 settings = self.strings["TOTP Settings"].value
240 config = parse_keepassxc_legacy(seed, settings)
241 else:
242 # No OTP configured
243 return None
245 # Convert datetime to timestamp if needed
246 timestamp: float | None = None
247 if at is not None:
248 timestamp = at.timestamp() if isinstance(at, datetime) else at
250 return generate_totp(config, timestamp)
252 @property
253 def custom_icon(self) -> uuid_module.UUID | None:
254 """Get or set custom icon by UUID or name.
256 When setting, accepts either a UUID or an icon name (string).
257 If a string is provided, it must match exactly one icon name in the
258 database. Requires the entry to be associated with a database for
259 name-based lookup.
261 Returns:
262 UUID of the custom icon, or None if not set
263 """
264 return self.custom_icon_uuid
266 @custom_icon.setter
267 def custom_icon(self, value: uuid_module.UUID | str | None) -> None:
268 if value is None:
269 self.custom_icon_uuid = None
270 elif isinstance(value, uuid_module.UUID):
271 self.custom_icon_uuid = value
272 elif isinstance(value, str):
273 # Look up icon by name
274 if self._database is None:
275 raise ValueError(
276 "Cannot set custom icon by name: entry is not associated with a database"
277 )
278 icon_uuid = self._database.find_custom_icon_by_name(value)
279 if icon_uuid is None:
280 raise ValueError(f"No custom icon found with name: {value}")
281 self.custom_icon_uuid = icon_uuid
282 else:
283 raise TypeError(f"custom_icon must be UUID, str, or None, not {type(value).__name__}")
285 # --- Custom properties ---
287 def get_custom_property(self, key: str) -> str | None:
288 """Get a custom property value.
290 Args:
291 key: Property name (must not be a reserved key)
293 Returns:
294 Property value, or None if not set
296 Raises:
297 ValueError: If key is a reserved key
298 """
299 if key in RESERVED_KEYS:
300 raise ValueError(f"{key} is a reserved key, use the property instead")
301 field = self.strings.get(key)
302 return field.value if field else None
304 def set_custom_property(self, key: str, value: str, protected: bool = False) -> None:
305 """Set a custom property.
307 Args:
308 key: Property name (must not be a reserved key)
309 value: Property value
310 protected: Whether to mark as protected in memory
312 Raises:
313 ValueError: If key is a reserved key
314 """
315 if key in RESERVED_KEYS:
316 raise ValueError(f"{key} is a reserved key, use the property instead")
317 self.strings[key] = StringField(key=key, value=value, protected=protected)
319 def delete_custom_property(self, key: str) -> None:
320 """Delete a custom property.
322 Args:
323 key: Property name to delete
325 Raises:
326 ValueError: If key is a reserved key
327 KeyError: If property doesn't exist
328 """
329 if key in RESERVED_KEYS:
330 raise ValueError(f"{key} is a reserved key")
331 if key not in self.strings:
332 raise KeyError(f"No such property: {key}")
333 del self.strings[key]
335 @property
336 def custom_properties(self) -> dict[str, str | None]:
337 """Get all custom properties as a dictionary."""
338 return {k: v.value for k, v in self.strings.items() if k not in RESERVED_KEYS}
340 # --- Convenience methods ---
342 @property
343 def parent(self) -> Group | None:
344 """Get parent group."""
345 return self._parent
347 @property
348 def database(self) -> Database | None:
349 """Get the database this entry belongs to."""
350 return self._database
352 @property
353 def index(self) -> int:
354 """Get the index of this entry within its parent group.
356 Returns:
357 Zero-based index of this entry in the parent's entries list.
359 Raises:
360 ValueError: If entry has no parent group.
361 """
362 if self._parent is None:
363 raise ValueError("Entry has no parent group")
364 return self._parent.entries.index(self)
366 @property
367 def expired(self) -> bool:
368 """Check if entry has expired."""
369 return self.times.expired
371 def touch(self, modify: bool = False) -> None:
372 """Update access time, optionally modification time."""
373 self.times.touch(modify=modify)
375 def reindex(self, new_index: int) -> None:
376 """Move this entry to a new position within its parent group.
378 Args:
379 new_index: Target position (zero-based). Negative indices are
380 supported (e.g., -1 for last position).
382 Raises:
383 ValueError: If entry has no parent group.
384 IndexError: If new_index is out of range.
385 """
386 if self._parent is None:
387 raise ValueError("Entry has no parent group")
389 entries = self._parent.entries
390 current_index = entries.index(self)
392 # Handle negative indices
393 if new_index < 0:
394 new_index = len(entries) + new_index
396 # Validate bounds
397 if new_index < 0 or new_index >= len(entries):
398 raise IndexError(f"Index {new_index} out of range for {len(entries)} entries")
400 # No-op if already at target position
401 if current_index == new_index:
402 return
404 # Remove from current position and insert at new position
405 entries.pop(current_index)
406 entries.insert(new_index, self)
408 def save_history(self) -> None:
409 """Save current state to history before making changes."""
410 # Create a history entry from current state
411 history_entry = HistoryEntry.from_entry(self)
412 self.history.append(history_entry)
414 def delete_history(
415 self, history_entry: HistoryEntry | None = None, *, all: bool = False
416 ) -> None:
417 """Delete history entries.
419 Either deletes a specific history entry or all history entries.
420 At least one of history_entry or all=True must be specified.
422 Args:
423 history_entry: Specific history entry to delete
424 all: If True, delete all history entries
426 Raises:
427 ValueError: If neither history_entry nor all=True is specified
428 ValueError: If history_entry is not in this entry's history
429 """
430 if history_entry is None and not all:
431 raise ValueError("Must specify history_entry or all=True")
433 if all:
434 self.history.clear()
435 return
437 if history_entry not in self.history:
438 raise ValueError("History entry not found in this entry's history")
440 self.history.remove(history_entry)
442 def clear_history(self) -> None:
443 """Clear all history entries.
445 This is a convenience method equivalent to delete_history(all=True).
446 """
447 self.history.clear()
449 def move_to(self, destination: Group) -> None:
450 """Move this entry to a different group.
452 Removes the entry from its current parent and adds it to the
453 destination group. Updates the location_changed timestamp.
455 Args:
456 destination: Target group to move the entry to
458 Raises:
459 ValueError: If entry has no parent (not yet added to a group)
460 ValueError: If destination is the current parent (no-op would be confusing)
461 """
462 if self._parent is None:
463 raise ValueError("Cannot move entry that has no parent group")
464 if self._parent is destination:
465 raise ValueError("Entry is already in the destination group")
467 # Remove from current parent
468 self._parent.entries.remove(self)
469 old_parent = self._parent
470 self._parent = None
472 # Add to new parent
473 destination.entries.append(self)
474 self._parent = destination
476 # Update timestamps
477 self.times.update_location()
478 old_parent.touch(modify=True)
479 destination.touch(modify=True)
481 # --- Field References ---
483 def ref(self, field: str) -> str:
484 """Create a reference string pointing to a field of this entry.
486 Creates a KeePass field reference string that can be used in other
487 entries to reference values from this entry. References use the
488 entry's UUID for lookup.
490 Args:
491 field: One of 'title', 'username', 'password', 'url', 'notes', or 'uuid'
493 Returns:
494 Field reference string in format {REF:X@I:UUID}
496 Raises:
497 ValueError: If field is not a valid field name
499 Example:
500 >>> main_entry = db.find_entries(title='Main Account', first=True)
501 >>> ref_string = main_entry.ref('password')
502 >>> # Returns '{REF:P@I:...UUID...}'
503 >>> other_entry.password = ref_string
504 """
505 field_codes = {
506 "title": "T",
507 "username": "U",
508 "password": "P",
509 "url": "A",
510 "notes": "N",
511 "uuid": "I",
512 }
513 field_lower = field.lower()
514 if field_lower not in field_codes:
515 valid = ", ".join(sorted(field_codes.keys()))
516 raise ValueError(f"Invalid field '{field}'. Must be one of: {valid}")
518 field_code = field_codes[field_lower]
519 uuid_hex = self.uuid.hex.upper()
520 return f"{{REF:{field_code}@I:{uuid_hex}}}"
522 def deref(self, field: str) -> str | uuid_module.UUID | None:
523 """Resolve any field references in the given field's value.
525 If the field's value contains KeePass field references ({REF:X@Y:Z}),
526 resolves them to the actual values from the referenced entries.
528 Args:
529 field: One of 'title', 'username', 'password', 'url', 'notes'
531 Returns:
532 The resolved value with all references replaced, a UUID if the
533 referenced field is 'uuid', or None if a referenced entry is not found
535 Raises:
536 ValueError: If no database reference is available
538 Example:
539 >>> # If entry.password contains '{REF:P@I:...UUID...}'
540 >>> actual_password = entry.deref('password')
541 """
542 if self._database is None:
543 raise ValueError("Cannot dereference field: entry is not connected to a database")
545 value = getattr(self, field.lower())
546 return self._database.deref(value)
548 def dump(self) -> str:
549 """Return a human-readable summary of the entry for debugging.
551 Returns:
552 Multi-line string with entry details (passwords are masked).
553 """
554 lines = [f'Entry: "{self.title}" ({self.username})']
555 lines.append(f" UUID: {self.uuid}")
556 if self.url:
557 lines.append(f" URL: {self.url}")
558 if self.tags:
559 lines.append(f" Tags: {self.tags}")
560 lines.append(f" Created: {self.times.creation_time}")
561 lines.append(f" Modified: {self.times.last_modification_time}")
562 if self.times.expires:
563 lines.append(f" Expires: {self.times.expiry_time}")
564 custom_count = len(self.custom_properties)
565 if custom_count > 0:
566 lines.append(f" Custom fields: {custom_count}")
567 if self.binaries:
568 lines.append(f" Attachments: {len(self.binaries)}")
569 if self.history:
570 lines.append(f" History: {len(self.history)} versions")
571 return "\n".join(lines)
573 def __str__(self) -> str:
574 return f'Entry: "{self.title}" ({self.username})'
576 def __hash__(self) -> int:
577 return hash(self.uuid)
579 def __eq__(self, other: object) -> bool:
580 if isinstance(other, Entry):
581 return self.uuid == other.uuid
582 return NotImplemented
584 @classmethod
585 def create(
586 cls,
587 title: str | None = None,
588 username: str | None = None,
589 password: str | None = None,
590 url: str | None = None,
591 notes: str | None = None,
592 tags: list[str] | None = None,
593 icon_id: str = "0",
594 expires: bool = False,
595 expiry_time: datetime | None = None,
596 ) -> Entry:
597 """Create a new entry with common fields.
599 Args:
600 title: Entry title
601 username: Username
602 password: Password
603 url: URL
604 notes: Notes
605 tags: List of tags
606 icon_id: Icon ID
607 expires: Whether entry expires
608 expiry_time: Expiration time
610 Returns:
611 New Entry instance
612 """
613 entry = cls(
614 times=Times.create_new(expires=expires, expiry_time=expiry_time),
615 icon_id=icon_id,
616 tags=tags or [],
617 )
618 entry.title = title
619 entry.username = username
620 entry.password = password
621 entry.url = url
622 entry.notes = notes
623 return entry
626@dataclass
627class HistoryEntry(Entry):
628 """A historical version of an entry.
630 History entries are snapshots of an entry at a previous point in time.
631 They share the same UUID as their parent entry.
632 """
634 def __str__(self) -> str:
635 return f'HistoryEntry: "{self.title}" ({self.times.last_modification_time})'
637 def __hash__(self) -> int:
638 # Include mtime since history entries share UUID with parent
639 return hash((self.uuid, self.times.last_modification_time))
641 @classmethod
642 def from_entry(cls, entry: Entry) -> HistoryEntry:
643 """Create a history entry from an existing entry.
645 Args:
646 entry: Entry to create history from
648 Returns:
649 New HistoryEntry with copied data
650 """
651 import copy
653 # Deep copy all fields except history and parent
654 return cls(
655 uuid=entry.uuid,
656 times=copy.deepcopy(entry.times),
657 icon_id=entry.icon_id,
658 custom_icon_uuid=entry.custom_icon_uuid,
659 tags=list(entry.tags),
660 strings=copy.deepcopy(entry.strings),
661 binaries=list(entry.binaries),
662 autotype=copy.deepcopy(entry.autotype),
663 history=[], # History entries don't have history
664 foreground_color=entry.foreground_color,
665 background_color=entry.background_color,
666 override_url=entry.override_url,
667 quality_check=entry.quality_check,
668 _parent=None,
669 )