1# SecretStorage module for Python
2# Access passwords using the SecretService DBus API
3# Author: Dmitry Shachnev, 2013-2018
4# License: 3-clause BSD, see LICENSE file
5
6"""SecretStorage item contains a *secret*, some *attributes* and a
7*label* visible to user. Editing all these properties and reading the
8secret is possible only when the :doc:`collection <collection>` storing
9the item is unlocked. The collection can be unlocked using collection's
10:meth:`~secretstorage.collection.Collection.unlock` method."""
11
12from typing import Dict, Optional
13from jeepney.io.blocking import DBusConnection
14from secretstorage.defines import SS_PREFIX
15from secretstorage.dhcrypto import Session
16from secretstorage.exceptions import LockedException, PromptDismissedException
17from secretstorage.util import DBusAddressWrapper, \
18 exec_prompt, open_session, format_secret, unlock_objects
19from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
20from cryptography.hazmat.backends import default_backend
21
22ITEM_IFACE = SS_PREFIX + 'Item'
23
24class Item(object):
25	"""Represents a secret item."""
26
27	def __init__(self, connection: DBusConnection,
28	             item_path: str, session: Optional[Session] = None) -> None:
29		self.item_path = item_path
30		self._item = DBusAddressWrapper(item_path, ITEM_IFACE, connection)
31		self._item.get_property('Label')
32		self.session = session
33		self.connection = connection
34
35	def __eq__(self, other: "DBusConnection") -> bool:
36		assert isinstance(other.item_path, str)
37		return self.item_path == other.item_path
38
39	def is_locked(self) -> bool:
40		"""Returns :const:`True` if item is locked, otherwise
41		:const:`False`."""
42		return bool(self._item.get_property('Locked'))
43
44	def ensure_not_locked(self) -> None:
45		"""If collection is locked, raises
46		:exc:`~secretstorage.exceptions.LockedException`."""
47		if self.is_locked():
48			raise LockedException('Item is locked!')
49
50	def unlock(self) -> bool:
51		"""Requests unlocking the item. Usually, this means that the
52		whole collection containing this item will be unlocked.
53
54		Returns a boolean representing whether the prompt has been
55		dismissed; that means :const:`False` on successful unlocking
56		and :const:`True` if it has been dismissed.
57
58		.. versionadded:: 2.1.2
59
60		.. versionchanged:: 3.0
61		   No longer accepts the ``callback`` argument.
62		"""
63		return unlock_objects(self.connection, [self.item_path])
64
65	def get_attributes(self) -> Dict[str, str]:
66		"""Returns item attributes (dictionary)."""
67		attrs = self._item.get_property('Attributes')
68		return dict(attrs)
69
70	def set_attributes(self, attributes: Dict[str, str]) -> None:
71		"""Sets item attributes to `attributes` (dictionary)."""
72		self._item.set_property('Attributes', 'a{ss}', attributes)
73
74	def get_label(self) -> str:
75		"""Returns item label (unicode string)."""
76		label = self._item.get_property('Label')
77		assert isinstance(label, str)
78		return label
79
80	def set_label(self, label: str) -> None:
81		"""Sets item label to `label`."""
82		self.ensure_not_locked()
83		self._item.set_property('Label', 's', label)
84
85	def delete(self) -> None:
86		"""Deletes the item."""
87		self.ensure_not_locked()
88		prompt, = self._item.call('Delete', '')
89		if prompt != "/":
90			dismissed, _result = exec_prompt(self.connection, prompt)
91			if dismissed:
92				raise PromptDismissedException('Prompt dismissed.')
93
94	def get_secret(self) -> bytes:
95		"""Returns item secret (bytestring)."""
96		self.ensure_not_locked()
97		if not self.session:
98			self.session = open_session(self.connection)
99		secret, = self._item.call('GetSecret', 'o', self.session.object_path)
100		if not self.session.encrypted:
101			return bytes(secret[2])
102		assert self.session.aes_key is not None
103		aes = algorithms.AES(self.session.aes_key)
104		aes_iv = bytes(secret[1])
105		decryptor = Cipher(aes, modes.CBC(aes_iv), default_backend()).decryptor()
106		encrypted_secret = secret[2]
107		padded_secret = decryptor.update(bytes(encrypted_secret)) + decryptor.finalize()
108		assert isinstance(padded_secret, bytes)
109		return padded_secret[:-padded_secret[-1]]
110
111	def get_secret_content_type(self) -> str:
112		"""Returns content type of item secret (string)."""
113		self.ensure_not_locked()
114		if not self.session:
115			self.session = open_session(self.connection)
116		secret, = self._item.call('GetSecret', 'o', self.session.object_path)
117		return str(secret[3])
118
119	def set_secret(self, secret: bytes,
120	               content_type: str = 'text/plain') -> None:
121		"""Sets item secret to `secret`. If `content_type` is given,
122		also sets the content type of the secret (``text/plain`` by
123		default)."""
124		self.ensure_not_locked()
125		if not self.session:
126			self.session = open_session(self.connection)
127		_secret = format_secret(self.session, secret, content_type)
128		self._item.call('SetSecret', '(oayays)', _secret)
129
130	def get_created(self) -> int:
131		"""Returns UNIX timestamp (integer) representing the time
132		when the item was created.
133
134		.. versionadded:: 1.1"""
135		created = self._item.get_property('Created')
136		assert isinstance(created, int)
137		return created
138
139	def get_modified(self) -> int:
140		"""Returns UNIX timestamp (integer) representing the time
141		when the item was last modified."""
142		modified = self._item.get_property('Modified')
143		assert isinstance(modified, int)
144		return modified
145