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

1"""Entry model for KDBX password entries.""" 

2 

3from __future__ import annotations 

4 

5import uuid as uuid_module 

6from dataclasses import dataclass, field 

7from datetime import datetime 

8from typing import TYPE_CHECKING 

9 

10from .times import Times 

11 

12if TYPE_CHECKING: 

13 from ..database import Database 

14 from ..security.totp import TotpCode 

15 from .group import Group 

16 

17 

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) 

29 

30 

31@dataclass 

32class StringField: 

33 """A string field in an entry. 

34 

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 """ 

40 

41 key: str 

42 value: str | None = None 

43 protected: bool = False 

44 

45 

46@dataclass 

47class AutoType: 

48 """AutoType settings for an entry. 

49 

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 """ 

56 

57 enabled: bool = True 

58 sequence: str | None = None 

59 window: str | None = None 

60 obfuscation: int = 0 

61 

62 

63@dataclass 

64class BinaryRef: 

65 """Reference to a binary attachment. 

66 

67 Attributes: 

68 key: Filename of the attachment 

69 ref: Reference ID to the binary in the database 

70 """ 

71 

72 key: str 

73 ref: int 

74 

75 

76@dataclass 

77class Entry: 

78 """A password entry in a KDBX database. 

79 

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. 

83 

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 """ 

99 

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 

113 

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) 

118 

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) 

125 

126 # --- Standard field properties --- 

127 

128 @property 

129 def title(self) -> str | None: 

130 """Get or set entry title.""" 

131 return self.strings.get("Title", StringField("Title")).value 

132 

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 

138 

139 @property 

140 def username(self) -> str | None: 

141 """Get or set entry username.""" 

142 return self.strings.get("UserName", StringField("UserName")).value 

143 

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 

149 

150 @property 

151 def password(self) -> str | None: 

152 """Get or set entry password.""" 

153 return self.strings.get("Password", StringField("Password")).value 

154 

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 

160 

161 @property 

162 def url(self) -> str | None: 

163 """Get or set entry URL.""" 

164 return self.strings.get("URL", StringField("URL")).value 

165 

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 

171 

172 @property 

173 def notes(self) -> str | None: 

174 """Get or set entry notes.""" 

175 return self.strings.get("Notes", StringField("Notes")).value 

176 

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 

182 

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 

187 

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 

193 

194 def totp(self, *, at: datetime | float | None = None) -> TotpCode | None: 

195 """Generate current TOTP code from the entry's otp field. 

196 

197 Supports both standard otpauth:// URIs and KeePassXC legacy format 

198 (TOTP Seed / TOTP Settings custom fields). 

199 

200 Args: 

201 at: Optional timestamp for code generation. Can be a datetime 

202 or Unix timestamp float. Defaults to current time. 

203 

204 Returns: 

205 TotpCode object with code and expiration info, or None if no OTP configured. 

206 

207 Raises: 

208 ValueError: If OTP configuration is invalid 

209 

210 Example: 

211 >>> result = entry.totp() 

212 >>> print(f"Code: {result.code}") 

213 Code: 123456 

214 

215 >>> print(f"Expires in {result.remaining}s") 

216 Expires in 15s 

217 

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 ) 

227 

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 

244 

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 

249 

250 return generate_totp(config, timestamp) 

251 

252 @property 

253 def custom_icon(self) -> uuid_module.UUID | None: 

254 """Get or set custom icon by UUID or name. 

255 

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. 

260 

261 Returns: 

262 UUID of the custom icon, or None if not set 

263 """ 

264 return self.custom_icon_uuid 

265 

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__}") 

284 

285 # --- Custom properties --- 

286 

287 def get_custom_property(self, key: str) -> str | None: 

288 """Get a custom property value. 

289 

290 Args: 

291 key: Property name (must not be a reserved key) 

292 

293 Returns: 

294 Property value, or None if not set 

295 

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 

303 

304 def set_custom_property(self, key: str, value: str, protected: bool = False) -> None: 

305 """Set a custom property. 

306 

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 

311 

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) 

318 

319 def delete_custom_property(self, key: str) -> None: 

320 """Delete a custom property. 

321 

322 Args: 

323 key: Property name to delete 

324 

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] 

334 

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} 

339 

340 # --- Convenience methods --- 

341 

342 @property 

343 def parent(self) -> Group | None: 

344 """Get parent group.""" 

345 return self._parent 

346 

347 @property 

348 def database(self) -> Database | None: 

349 """Get the database this entry belongs to.""" 

350 return self._database 

351 

352 @property 

353 def index(self) -> int: 

354 """Get the index of this entry within its parent group. 

355 

356 Returns: 

357 Zero-based index of this entry in the parent's entries list. 

358 

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) 

365 

366 @property 

367 def expired(self) -> bool: 

368 """Check if entry has expired.""" 

369 return self.times.expired 

370 

371 def touch(self, modify: bool = False) -> None: 

372 """Update access time, optionally modification time.""" 

373 self.times.touch(modify=modify) 

374 

375 def reindex(self, new_index: int) -> None: 

376 """Move this entry to a new position within its parent group. 

377 

378 Args: 

379 new_index: Target position (zero-based). Negative indices are 

380 supported (e.g., -1 for last position). 

381 

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") 

388 

389 entries = self._parent.entries 

390 current_index = entries.index(self) 

391 

392 # Handle negative indices 

393 if new_index < 0: 

394 new_index = len(entries) + new_index 

395 

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") 

399 

400 # No-op if already at target position 

401 if current_index == new_index: 

402 return 

403 

404 # Remove from current position and insert at new position 

405 entries.pop(current_index) 

406 entries.insert(new_index, self) 

407 

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) 

413 

414 def delete_history( 

415 self, history_entry: HistoryEntry | None = None, *, all: bool = False 

416 ) -> None: 

417 """Delete history entries. 

418 

419 Either deletes a specific history entry or all history entries. 

420 At least one of history_entry or all=True must be specified. 

421 

422 Args: 

423 history_entry: Specific history entry to delete 

424 all: If True, delete all history entries 

425 

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") 

432 

433 if all: 

434 self.history.clear() 

435 return 

436 

437 if history_entry not in self.history: 

438 raise ValueError("History entry not found in this entry's history") 

439 

440 self.history.remove(history_entry) 

441 

442 def clear_history(self) -> None: 

443 """Clear all history entries. 

444 

445 This is a convenience method equivalent to delete_history(all=True). 

446 """ 

447 self.history.clear() 

448 

449 def move_to(self, destination: Group) -> None: 

450 """Move this entry to a different group. 

451 

452 Removes the entry from its current parent and adds it to the 

453 destination group. Updates the location_changed timestamp. 

454 

455 Args: 

456 destination: Target group to move the entry to 

457 

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") 

466 

467 # Remove from current parent 

468 self._parent.entries.remove(self) 

469 old_parent = self._parent 

470 self._parent = None 

471 

472 # Add to new parent 

473 destination.entries.append(self) 

474 self._parent = destination 

475 

476 # Update timestamps 

477 self.times.update_location() 

478 old_parent.touch(modify=True) 

479 destination.touch(modify=True) 

480 

481 # --- Field References --- 

482 

483 def ref(self, field: str) -> str: 

484 """Create a reference string pointing to a field of this entry. 

485 

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. 

489 

490 Args: 

491 field: One of 'title', 'username', 'password', 'url', 'notes', or 'uuid' 

492 

493 Returns: 

494 Field reference string in format {REF:X@I:UUID} 

495 

496 Raises: 

497 ValueError: If field is not a valid field name 

498 

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}") 

517 

518 field_code = field_codes[field_lower] 

519 uuid_hex = self.uuid.hex.upper() 

520 return f"{{REF:{field_code}@I:{uuid_hex}}}" 

521 

522 def deref(self, field: str) -> str | uuid_module.UUID | None: 

523 """Resolve any field references in the given field's value. 

524 

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. 

527 

528 Args: 

529 field: One of 'title', 'username', 'password', 'url', 'notes' 

530 

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 

534 

535 Raises: 

536 ValueError: If no database reference is available 

537 

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") 

544 

545 value = getattr(self, field.lower()) 

546 return self._database.deref(value) 

547 

548 def dump(self) -> str: 

549 """Return a human-readable summary of the entry for debugging. 

550 

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) 

572 

573 def __str__(self) -> str: 

574 return f'Entry: "{self.title}" ({self.username})' 

575 

576 def __hash__(self) -> int: 

577 return hash(self.uuid) 

578 

579 def __eq__(self, other: object) -> bool: 

580 if isinstance(other, Entry): 

581 return self.uuid == other.uuid 

582 return NotImplemented 

583 

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. 

598 

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 

609 

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 

624 

625 

626@dataclass 

627class HistoryEntry(Entry): 

628 """A historical version of an entry. 

629 

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 """ 

633 

634 def __str__(self) -> str: 

635 return f'HistoryEntry: "{self.title}" ({self.times.last_modification_time})' 

636 

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)) 

640 

641 @classmethod 

642 def from_entry(cls, entry: Entry) -> HistoryEntry: 

643 """Create a history entry from an existing entry. 

644 

645 Args: 

646 entry: Entry to create history from 

647 

648 Returns: 

649 New HistoryEntry with copied data 

650 """ 

651 import copy 

652 

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 )