Last active
April 10, 2026 10:38
-
-
Save sedrubal/1b6b07d36850bbbe900eb268624bee0f to your computer and use it in GitHub Desktop.
Create empty gnome keyring database files, change their password (without a running gnome-keyring-daemon) and unlock the gnome-keyring
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Tool for GNOME Keyring (features that are not part of the command line tool gnome-keyring). | |
| See also <https://gist.github.com/sedrubal/1b6b07d36850bbbe900eb268624bee0f> | |
| """ | |
| import argparse | |
| import binascii | |
| import dataclasses | |
| import enum | |
| import hashlib | |
| import io | |
| import logging | |
| import os | |
| import random | |
| import socket | |
| import struct | |
| import sys | |
| import typing | |
| from datetime import datetime, timezone | |
| from getpass import getpass | |
| from pathlib import Path | |
| from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes | |
| #: Exit code when something was changed. | |
| EXIT_CHANGED = 10 | |
| class EncAlgo(enum.IntEnum): | |
| AES = 0 | |
| class HashAlgo(enum.IntEnum): | |
| MD5 = 0 | |
| HEADER = b"GnomeKeyring\n\r\x00\n" | |
| VERSION_MAJOR = 0 | |
| VERSION_MINOR = 0 | |
| HASH_ALGO = HashAlgo.MD5 | |
| class Flags(enum.IntEnum): | |
| """ | |
| See pkcs11/secret-store/gck-secret-binary.c or gnome-keyring/pkcs11/secret-store/gkm-secret-binary.c | |
| """ | |
| # docs says: (flag 0 == lock_on_idle) | |
| # but this seems to be wrong | |
| NO_FLAG = 0 | |
| LOCK_ON_IDLE = 0b01 | |
| LOCK_AFTER = 0b10 | |
| class ItemType(enum.IntEnum): | |
| """TODO""" | |
| UNKNOWN_0 = 0 | |
| UNKNOWN_2 = 2 | |
| class ItemAttrType(enum.IntEnum): | |
| """Type of the attribute value.""" | |
| STRING = 0 | |
| class AclAllowType(enum.IntEnum): | |
| """TODO""" | |
| UNKNOWN = 0 | |
| def derive_key_and_iv( | |
| password: str, salt: bytes, iterations: int, key_len: int, iv_len: int | |
| ) -> tuple[bytes, bytes]: | |
| assert password | |
| assert salt | |
| digest = b"" | |
| data = b"" | |
| while True: | |
| # loop over passes | |
| algo = hashlib.sha256() | |
| if digest: | |
| # in pass > 1: seed the hash with the digest from before | |
| algo.update(digest) | |
| algo.update(password.encode()) | |
| algo.update(salt) | |
| digest = algo.digest() | |
| for _ in range(1, iterations): | |
| algo = hashlib.sha256() | |
| algo.update(digest) | |
| digest = algo.digest() | |
| data += digest | |
| if len(data) >= key_len + iv_len: | |
| return data[:key_len], data[key_len : key_len + iv_len] | |
| def md5hash(data: bytes) -> bytes: | |
| md5 = hashlib.md5() | |
| md5.update(data) | |
| return md5.digest() | |
| def encrypt_data( | |
| plain_data: bytes, password: str, salt: bytes, iterations: int | |
| ) -> bytes: | |
| key, iv = derive_key_and_iv( | |
| password=password, | |
| salt=salt, | |
| iterations=iterations, | |
| key_len=algorithms.AES128.key_size // 8, | |
| iv_len=algorithms.AES128.key_size // 8, | |
| ) | |
| cipher = Cipher(algorithms.AES128(key), modes.CBC(iv)) | |
| encryptor = cipher.encryptor() | |
| encrypted_data = encryptor.update(plain_data) + encryptor.finalize() | |
| return encrypted_data | |
| EMPTY_STRING_LEN = 0xFF_FF_FF_FF | |
| def parse_string(file: typing.BinaryIO) -> str: | |
| """ | |
| Parse a string. | |
| uint32 + bytes, no padding, NULL is encoded as 0xffffffff | |
| """ | |
| str_len = struct.unpack(">I", file.read(4))[0] | |
| if str_len == EMPTY_STRING_LEN: | |
| return "" | |
| return struct.unpack(f"{str_len}s", file.read(str_len))[0].decode("utf-8") | |
| def parse_datetime(file: typing.BinaryIO) -> datetime: | |
| """ | |
| Parse a datetime. | |
| time_t: 2 * guint32 | |
| """ | |
| return datetime.fromtimestamp(struct.unpack(">Q", file.read(8))[0]) | |
| def dump_string(value: str, file: typing.BinaryIO) -> None: | |
| """Dump a string into a file.""" | |
| if not value: | |
| file.write(struct.pack(">I", EMPTY_STRING_LEN)) | |
| return | |
| file.write(struct.pack(f">I{len(value)}s", len(value), value.encode())) | |
| def dump_datetime(value: datetime, file: typing.BinaryIO) -> None: | |
| """Dump a datetime into a file.""" | |
| file.write(struct.pack(">Q", int(value.timestamp()))) | |
| @dataclasses.dataclass(frozen=True, kw_only=True, slots=True) | |
| class ItemInfo: | |
| @dataclasses.dataclass(frozen=True, kw_only=True, slots=True) | |
| class Attribute: | |
| name: str | |
| attr_type: ItemAttrType | |
| attr_hash: int | str | |
| @classmethod | |
| def parse(cls, file: typing.BinaryIO) -> typing.Self: | |
| name = parse_string(file) | |
| attr_type = ItemAttrType(struct.unpack(">I", file.read(4))[0]) | |
| if attr_type == ItemAttrType.STRING: | |
| attr_hash = parse_string(file) | |
| else: | |
| attr_hash = struct.unpack(">I", file.read(4))[0] | |
| return cls(name=name, attr_type=attr_type, attr_hash=attr_hash) | |
| def dump(self, file: typing.BinaryIO) -> None: | |
| dump_string(self.name, file=file) | |
| file.write(struct.pack(">I", self.attr_type)) | |
| if self.attr_type == ItemAttrType.STRING: | |
| assert isinstance(self.attr_hash, str) | |
| dump_string(self.attr_hash, file=file) | |
| else: | |
| assert False, f"Unsupported attribute type {self.attr_type}" | |
| def __str__(self) -> str: | |
| return f"{self.name}={self.attr_hash!r}" | |
| item_id: int | |
| item_type: ItemType | |
| attributes: "list[ItemInfo.Attribute]" | |
| @classmethod | |
| def parse(cls, file: typing.BinaryIO) -> typing.Self: | |
| item_id = struct.unpack(">I", file.read(4))[0] | |
| item_type = ItemType(struct.unpack(">I", file.read(4))[0]) | |
| num_attributes = struct.unpack(">I", file.read(4))[0] | |
| attributes: list[ItemInfo.Attribute] = [] | |
| for _ in range(num_attributes): | |
| attributes.append(ItemInfo.Attribute.parse(file)) | |
| return cls(item_id=item_id, item_type=item_type, attributes=attributes) | |
| def dump(self, file: typing.BinaryIO) -> None: | |
| """Dump into file.""" | |
| file.write( | |
| struct.pack(">III", self.item_id, self.item_type, len(self.attributes)) | |
| ) | |
| for attr in self.attributes: | |
| attr.dump(file=file) | |
| def __str__(self) -> str: | |
| attrs_str = ", ".join(str(attr) for attr in self.attributes) | |
| return f"Item ID: {self.item_id} Item Type: {self.item_type} Attrs: {attrs_str}" | |
| @dataclasses.dataclass(frozen=True, kw_only=True, slots=True) | |
| class Item: | |
| @dataclasses.dataclass(frozen=True, kw_only=True, slots=True) | |
| class Attribute: | |
| name: str | |
| attr_type: ItemAttrType | |
| value: int | str | |
| @classmethod | |
| def parse(cls, file: typing.BinaryIO) -> typing.Self: | |
| name = parse_string(file) | |
| attr_type = ItemAttrType(struct.unpack(">I", file.read(4))[0]) | |
| if attr_type == ItemAttrType.STRING: | |
| value = parse_string(file) | |
| else: | |
| value = struct.unpack(">I", file.read(4))[0] | |
| return cls(name=name, attr_type=attr_type, value=value) | |
| def dump(self, file: typing.BinaryIO) -> None: | |
| dump_string(self.name, file=file) | |
| file.write(struct.pack(">I", self.attr_type)) | |
| if self.attr_type == ItemAttrType.STRING: | |
| assert isinstance(self.value, str) | |
| dump_string(self.value, file=file) | |
| else: | |
| assert False, f"Unsupported attribute type {self.attr_type}" | |
| def __str__(self) -> str: | |
| return f"{self.name}={self.value!r}" | |
| def get_attr_info(self) -> ItemInfo.Attribute: | |
| assert self.attr_type == ItemAttrType.STRING, ( | |
| "Can only generate attr info from string attr type." | |
| ) | |
| assert isinstance(self.value, str) | |
| return ItemInfo.Attribute( | |
| name=self.name, | |
| attr_type=self.attr_type, | |
| attr_hash=binascii.hexlify( | |
| md5hash(self.value.encode("utf-8")) | |
| ).decode(), | |
| ) | |
| @dataclasses.dataclass(frozen=True, kw_only=True, slots=True) | |
| class Acl: | |
| types_allowed: AclAllowType | |
| display_name: str | |
| pathname: str | |
| # reserved_str: str | |
| # reserved_uint: int | |
| @classmethod | |
| def parse(cls, file: typing.BinaryIO) -> typing.Self: | |
| types_allowed = AclAllowType(struct.unpack(">I", file.read(4))[0]) | |
| display_name = parse_string(file) | |
| pathname = parse_string(file) | |
| _reserved_str = parse_string(file) | |
| _reserved_uint = struct.unpack(">I", file.read(4))[0] | |
| return cls( | |
| types_allowed=types_allowed, | |
| display_name=display_name, | |
| pathname=pathname, | |
| ) | |
| def dump(self, file: typing.BinaryIO) -> None: | |
| file.write(struct.pack(">I", self.types_allowed)) | |
| dump_string(self.display_name, file=file) | |
| dump_string(self.pathname, file=file) | |
| dump_string("", file=file) # reserved_str | |
| file.write(struct.pack(">I", 0)) # reserved_uint | |
| display_name: str | |
| secret: str | |
| ctime: datetime | |
| mtime: datetime | |
| # reserved_str: str | |
| # reserved_int2: tuple[int, int, int, int] | |
| attributes: "list[Item.Attribute]" | |
| acls: "list[Item.Acl]" | |
| @classmethod | |
| def parse(cls, file: typing.BinaryIO) -> typing.Self: | |
| display_name = parse_string(file) | |
| secret = parse_string(file) | |
| ctime = parse_datetime(file) | |
| mtime = parse_datetime(file) | |
| _reserved_str = parse_string(file) | |
| _reserved_int2 = [ | |
| struct.unpack(">I", file.read(4))[0], | |
| struct.unpack(">I", file.read(4))[0], | |
| struct.unpack(">I", file.read(4))[0], | |
| struct.unpack(">I", file.read(4))[0], | |
| ] | |
| num_attributes = struct.unpack(">I", file.read(4))[0] | |
| attributes: list[Item.Attribute] = [] | |
| for _ in range(num_attributes): | |
| attributes.append(Item.Attribute.parse(file)) | |
| acl_len = struct.unpack(">I", file.read(4))[0] | |
| acls: list[Item.Acl] = [] | |
| for _ in range(acl_len): | |
| acls.append(Item.Acl.parse(file)) | |
| return cls( | |
| display_name=display_name, | |
| secret=secret, | |
| ctime=ctime, | |
| mtime=mtime, | |
| # reserved_str=reserved_str, | |
| # reserved_int2=reserved_int2, | |
| attributes=attributes, | |
| acls=acls, | |
| ) | |
| def dump(self, file: typing.BinaryIO) -> None: | |
| dump_string(self.display_name, file=file) | |
| dump_string(self.secret, file=file) | |
| dump_datetime(self.ctime, file=file) | |
| dump_datetime(self.mtime, file=file) | |
| dump_string("", file=file) # reserved_str | |
| file.write(struct.pack(">IIII", 0, 0, 0, 0)) # reserved_int | |
| file.write(struct.pack(">I", len(self.attributes))) | |
| for attr in self.attributes: | |
| attr.dump(file=file) | |
| file.write(struct.pack(">I", len(self.acls))) | |
| for acl in self.acls: | |
| acl.dump(file=file) | |
| def __str__(self) -> str: | |
| attrs_str = ", ".join(str(attr) for attr in self.attributes) | |
| acls_str = ", ".join(str(acl) for acl in self.acls) | |
| return f"Display Name: {self.display_name!r} Secret: {self.secret!r} CTime: {self.ctime} MTime: {self.mtime} Attrs: {attrs_str} ACLs: {acls_str}" | |
| @dataclasses.dataclass(frozen=True, kw_only=True, slots=True) | |
| class Vault: | |
| """ | |
| The content of a decrypted vault file. | |
| Docs for the binary format: | |
| - https://wiki.gnome.org/Projects(2f)GnomeKeyring(2f)KeyringFormats(2f)FileFormat.html | |
| - https://github.com/GNOME/gnome-keyring/blob/main/docs/file-format.txt | |
| """ | |
| name: str | |
| mtime: datetime | |
| ctime: datetime | |
| flags: Flags | |
| timeout: int | |
| items: list[Item] | |
| hash_iterations: int | |
| salt: bytes | |
| @classmethod | |
| def parse(cls, file: typing.BinaryIO, password: str) -> typing.Self: | |
| header = struct.unpack("16s", file.read(16))[0] | |
| assert header == HEADER, f"{header!r} != {HEADER!r}" | |
| (version_major, version_minor, enc_algo, hash_algo) = struct.unpack( | |
| "bbbb", file.read(4) | |
| ) | |
| assert (version_major, version_minor, enc_algo, hash_algo) == ( | |
| VERSION_MAJOR, | |
| VERSION_MINOR, | |
| EncAlgo.AES.value, | |
| HASH_ALGO, | |
| ) | |
| name = parse_string(file) | |
| mtime = parse_datetime(file) | |
| ctime = parse_datetime(file) | |
| flags = Flags(struct.unpack(">I", file.read(4))[0]) | |
| timeout = struct.unpack(">I", file.read(4))[0] | |
| hash_iterations = struct.unpack(">I", file.read(4))[0] | |
| salt = struct.unpack("8s", file.read(8))[0] | |
| for _ in range(4): | |
| # reserved 4 * 32bit | |
| _padding = file.read(4) | |
| num_items = struct.unpack(">I", file.read(4))[0] | |
| item_infos = [ItemInfo.parse(file=file) for _ in range(num_items)] | |
| num_encrypted_bytes = struct.unpack(">I", file.read(4))[0] | |
| encrypted_data = struct.unpack( | |
| f"{num_encrypted_bytes}s", file.read(num_encrypted_bytes) | |
| )[0] | |
| eof = file.read() | |
| assert eof == b"", ( | |
| f"File should already be completely read. There was trash at the end: {eof!r}" | |
| ) | |
| plain_data = decrypt_data( | |
| encrypted_data=encrypted_data, | |
| password=password, | |
| salt=salt, | |
| iterations=hash_iterations, | |
| ) | |
| assert len(plain_data) % 16 == 0, ( | |
| "Decrypted data is not zero padded to be a multiple of 16" | |
| ) | |
| decrypted_data_hash = plain_data[:16] | |
| plain_data = plain_data[16:] | |
| assert HASH_ALGO == HashAlgo.MD5 | |
| calculated_data_hash = md5hash(plain_data) | |
| if decrypted_data_hash != calculated_data_hash: | |
| raise ValueError("Verification failed. Is the password correct?") | |
| plain_data_io = io.BytesIO(initial_bytes=plain_data) | |
| items = [Item.parse(file=plain_data_io) for _ in range(num_items)] | |
| for item_info, item in zip(item_infos, items): | |
| assert len(item.attributes) == len(item_info.attributes), ( | |
| f"Amount of item info attributes is not equal to amount of item attributes: {item.attributes=} != {item_info.attributes=}" | |
| ) | |
| for attr, attr_info in zip(item.attributes, item_info.attributes): | |
| calculated_attr_info = attr.get_attr_info() | |
| assert calculated_attr_info == attr_info, ( | |
| f"Item attribute information does not match expected attribute info: {calculated_attr_info!r} != {attr_info!r}" | |
| ) | |
| # zero padding to make even multiple of 16 | |
| real_len_enc_data = plain_data_io.tell() | |
| zero_padding = plain_data_io.read() | |
| assert zero_padding == b"\00" * (plain_data_io.tell() - real_len_enc_data), ( | |
| f"Invalid zero padding at the end of the decrypted data block: {zero_padding!r}" | |
| ) | |
| return cls( | |
| name=name, | |
| mtime=mtime, | |
| ctime=ctime, | |
| flags=flags, | |
| timeout=timeout, | |
| items=items, | |
| hash_iterations=hash_iterations, | |
| salt=salt, | |
| ) | |
| def dump(self, file: typing.BinaryIO, password: str) -> None: | |
| file.write(HEADER) | |
| file.write( | |
| struct.pack( | |
| "bbbb", | |
| VERSION_MAJOR, | |
| VERSION_MINOR, | |
| EncAlgo.AES.value, | |
| HASH_ALGO, | |
| ) | |
| ) | |
| assert len(self.name) <= 32, "Name is too long" | |
| assert self.name, ( | |
| "Name is required. I think, if it is empty, it must be set to 0xffffffff" | |
| ) | |
| dump_string(self.name, file=file) | |
| dump_datetime(self.mtime, file=file) | |
| dump_datetime(self.ctime, file=file) | |
| file.write(struct.pack(">I", self.flags)) | |
| file.write(struct.pack(">I", self.timeout)) | |
| file.write(struct.pack(">I", self.hash_iterations)) | |
| file.write(struct.pack("8s", self.salt)) | |
| for _ in range(4): | |
| # reserved 4 * 32bit | |
| file.write(struct.pack("xxxx")) | |
| file.write(struct.pack(">I", len(self.items))) | |
| for idx, item in enumerate(self.items): | |
| item_info = ItemInfo( | |
| item_id=idx, | |
| item_type=ItemType.UNKNOWN_0, | |
| attributes=[attr.get_attr_info() for attr in item.attributes], | |
| ) | |
| item_info.dump(file=file) | |
| plain_data_io = io.BytesIO(initial_bytes=b"") | |
| for item in self.items: | |
| item.dump(file=plain_data_io) | |
| # zero padding | |
| if plain_data_io.tell() % 16 != 0: | |
| plain_data_io.write(b"\x00" * (16 - (plain_data_io.tell() % 16))) | |
| plain_data = plain_data_io.getvalue() | |
| assert HASH_ALGO == HashAlgo.MD5 | |
| data_hash = md5hash(plain_data) | |
| data_to_encrypt = data_hash + plain_data | |
| encrypted_data = encrypt_data( | |
| plain_data=data_to_encrypt, | |
| password=password, | |
| salt=self.salt, | |
| iterations=self.hash_iterations, | |
| ) | |
| num_encrypted_bytes = len(encrypted_data) | |
| file.write(struct.pack(">I", num_encrypted_bytes)) | |
| file.write(struct.pack(f"{num_encrypted_bytes}s", encrypted_data)) | |
| def decrypt_data( | |
| encrypted_data: bytes, password: str, salt: bytes, iterations: int | |
| ) -> bytes: | |
| key, iv = derive_key_and_iv( | |
| password=password, | |
| salt=salt, | |
| iterations=iterations, | |
| key_len=algorithms.AES128.key_size // 8, | |
| iv_len=algorithms.AES128.key_size // 8, | |
| ) | |
| cipher = Cipher(algorithms.AES128(key), modes.CBC(iv)) | |
| decryptor = cipher.decryptor() | |
| plain_data = decryptor.update(encrypted_data) + decryptor.finalize() | |
| return plain_data | |
| def create_keyring(file_path: Path, vault: Vault, password: str) -> None: | |
| with file_path.open("wb") as file: | |
| vault.dump(file=file, password=password) | |
| def verify_keyring(file_path: Path, password: str) -> None: | |
| """Verify the password and the content of a keyring.""" | |
| with file_path.open("rb") as file: | |
| vault = Vault.parse(file, password=password) | |
| logging.info("Name: %s", vault.name) | |
| logging.info("MTime: %s", vault.mtime) | |
| logging.info("CTime: %s", vault.ctime) | |
| logging.info("Flags: %s", vault.flags) | |
| logging.info("Timeout: %s", vault.timeout) | |
| logging.info("Hash Iterations: %i", vault.hash_iterations) | |
| logging.info("Salt: %s", binascii.hexlify(vault.salt).decode("utf-8")) | |
| for item in vault.items: | |
| logging.info("- %s", item) | |
| logging.info("Looks good") | |
| def change_password(file_path: Path, old_password: str, new_password: str) -> None: | |
| """Change the vault password.""" | |
| with file_path.open("rb") as file: | |
| vault = Vault.parse(file, password=old_password) | |
| with file_path.open("wb") as file: | |
| vault.dump(file, password=new_password) | |
| def force_password(file_path: Path, password: str) -> None: | |
| """Change the vault password.""" | |
| try: | |
| with file_path.open("rb") as file: | |
| vault = Vault.parse(file, password=password) | |
| logging.info("The password matches. Nothing was changed.") | |
| sys.exit(0) | |
| except (ValueError, FileNotFoundError) as err: | |
| if isinstance(err, ValueError): | |
| logging.warning("Password did not match. Creating a new empty vault.") | |
| elif isinstance(err, FileNotFoundError): | |
| logging.warning( | |
| "Wallet %s does not exist. Creating a new empty vault.", file_path | |
| ) | |
| backup_path = file_path.parent / f"{file_path.name}.bak" | |
| if file_path.exists() and not backup_path.exists(): | |
| logging.info(" Backing up old vault to %s", backup_path) | |
| file_path.rename(backup_path) | |
| vault = Vault( | |
| name=file_path.stem, | |
| mtime=datetime.fromtimestamp(0, tz=timezone.utc), | |
| ctime=datetime.now(tz=timezone.utc), | |
| flags=Flags.NO_FLAG, | |
| timeout=0, | |
| hash_iterations=random.randint(3500, 5000), | |
| salt=random.randbytes(8), | |
| items=[], | |
| ) | |
| with file_path.open("wb") as file: | |
| vault.dump(file, password=password) | |
| sys.exit(EXIT_CHANGED) | |
| def set_default_keyring(value: str, default_path: Path) -> None: | |
| """Set the default keyring.""" | |
| known_keyrings = [ | |
| file_path.name[: -len(".keyring")] | |
| for file_path in default_path.parent.glob("*.keyring") | |
| ] | |
| if value not in known_keyrings: | |
| logging.warning( | |
| "Keyring %s can't be found. Setting it anyway. Known keyrings are %s", | |
| ", ".join(known_keyrings), | |
| ) | |
| default_keyring = get_default_keyring(default_path=default_path) | |
| if default_keyring != value: | |
| logging.info( | |
| "Changing the default keyring from %s to %s.", default_keyring, value | |
| ) | |
| with default_path.open("w") as file: | |
| print(value, file=file) | |
| sys.exit(EXIT_CHANGED) | |
| else: | |
| logging.info("Keyring %s already set as default", value) | |
| def get_default_keyring(default_path: Path) -> str | None: | |
| """Get the default keyring.""" | |
| if not default_path.is_file(): | |
| return None | |
| with default_path.open("r") as file: | |
| default_keyring = file.read().strip() | |
| return default_keyring | |
| def get_default_action(default_path: Path) -> None: | |
| """The command line action for getting the default keyring.""" | |
| default_keyring = get_default_keyring(default_path=default_path) | |
| if default_keyring: | |
| print(default_keyring) | |
| else: | |
| logging.warning("No default keyring is currently set.") | |
| def unit_tests(): | |
| """Unit tests^^.""" | |
| key, iv = derive_key_and_iv( | |
| password="booo", | |
| salt=b"\x27\x55\xc6\x12\xfe\x71\xe1\xb8", | |
| iterations=2678, | |
| key_len=algorithms.AES128.key_size // 8, | |
| iv_len=algorithms.AES128.key_size // 8, | |
| ) | |
| assert key == b"\x63\x8a\x25\xe1\x1b\x69\xb9\xcd\x41\x73\x9d\x5f\x20\x43\x03\x65" | |
| assert iv == b"\x35\x40\x47\xe6\x3b\x00\x2e\x66\x84\x22\x71\x20\x67\x2e\x66\xa3" | |
| assert HASH_ALGO == HashAlgo.MD5 | |
| verifier_data = md5hash(b"") | |
| real_verifier = b"\xd4\x1d\x8c\xd9\x8f\x00\xb2\x04\xe9\x80\x09\x98\xec\xf8\x42\x7e" | |
| assert verifier_data == real_verifier | |
| attr = Item.Attribute(name="a name", attr_type=ItemAttrType.STRING, value="a value") | |
| acl = Item.Acl( | |
| types_allowed=AclAllowType.UNKNOWN, | |
| display_name="display-name", | |
| pathname="path/name", | |
| ) | |
| item = Item( | |
| display_name="display name", | |
| secret="secret", | |
| ctime=datetime.fromtimestamp(0), | |
| mtime=datetime.fromtimestamp(0), | |
| attributes=[attr], | |
| acls=[acl], | |
| ) | |
| for item in (attr, attr.get_attr_info(), acl, item): | |
| buffer = io.BytesIO() | |
| item.dump(file=buffer) | |
| buffer.seek(0) | |
| assert item == item.__class__.parse(file=buffer) | |
| logging.info("Passed!") | |
| class ControlOpCode(enum.IntEnum): | |
| """Op codes for the control socket.""" | |
| INITIALIZE = 0 | |
| UNLOCK = 1 | |
| CHANGE = 2 | |
| QUIT = 4 | |
| class ControlResult(enum.IntEnum): | |
| """Result codes for the control sockets.""" | |
| OK = 0 | |
| DENIED = 1 | |
| FAILED = 2 | |
| NO_DAEMON = 3 | |
| def get_control_socket_path() -> Path: | |
| """Find the control socket for gnome keyring.""" | |
| if "GNOME_KEYRING_CONTROL" in os.environ: | |
| socket_path = Path(os.environ["GNOME_KEYRING_CONTROL"]) / "control" | |
| if socket_path.exists() and socket_path.is_socket(): | |
| return socket_path | |
| if "XDG_RUNTIME_DIR" in os.environ: | |
| socket_path = Path(os.environ["XDG_RUNTIME_DIR"]) / "keyring" / "control" | |
| if socket_path.exists() and socket_path.is_socket(): | |
| return socket_path | |
| raise FileNotFoundError("Unable to find control socket.") | |
| def unlock_keyring(socket_path: Path, password: str) -> None: | |
| """ | |
| Unlock the default gnome keyring (communicates with gnome-keyring-daemon via the control socket). | |
| The code is based on | |
| <https://unix.stackexchange.com/a/723048/95359> which points to | |
| <https://codeberg.org/umglurf/gnome-keyring-unlock/raw/branch/main/unlock.py> | |
| """ | |
| sock = socket.socket(family=socket.AF_UNIX, type=socket.SOCK_STREAM) | |
| sock.connect(str(socket_path)) | |
| def recv_int() -> int: | |
| data = sock.recv(4) | |
| return struct.unpack(">I", data)[0] | |
| pw_len = len(password) | |
| # op_len is | |
| # 4 (uint32): bytes packet size | |
| # 4 (uint32): bytes for op code | |
| # 4 (uint32): bytes length of password | |
| # length of password (char): password | |
| op_len = 3 * 4 + pw_len | |
| msg = struct.pack( | |
| f">BIII{pw_len}s", | |
| 0, | |
| op_len, | |
| ControlOpCode.UNLOCK, | |
| pw_len, | |
| password.encode(), | |
| ) | |
| num_sent_bytes = sock.send(msg) | |
| if num_sent_bytes != len(msg): | |
| raise Exception( | |
| "Error communicating with gnome-keyring control: " | |
| f"Tried to send {msg!r}, but could only send {num_sent_bytes} bytes." | |
| ) | |
| response_length = recv_int() | |
| if response_length != 8: | |
| raise Exception("Invalid response length") | |
| result = ControlResult(recv_int()) | |
| sock.close() | |
| match result: | |
| case ControlResult.DENIED: | |
| raise Exception("Unlock denied! Was the password correct?") | |
| case ControlResult.FAILED: | |
| raise Exception("Unlock failed!") | |
| case ControlResult.OK: | |
| logging.info("Successfully unlocked keyring.") | |
| case _: | |
| raise Exception(f"Unexpected gnome-keyring control socket result {result}") | |
| def get_password(prompt: str) -> str: | |
| """Get a password from stdin with fallback, when turning of echo does not work.""" | |
| if sys.stdin.isatty(): | |
| return getpass(prompt=prompt) | |
| else: | |
| return input(prompt) | |
| def parse_args() -> argparse.Namespace: | |
| """Parse command line arguments.""" | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| def password_argument(value: str) -> str: | |
| if value.startswith("file:"): | |
| password_file = Path(value[len("file:") :]) | |
| try: | |
| with password_file.open("r") as file: | |
| return file.read().rstrip("\n\r") | |
| except OSError as err: | |
| raise argparse.ArgumentTypeError( | |
| f"Could not read password file {password_file}: {err}" | |
| ) from err | |
| else: | |
| return value | |
| try: | |
| default_control_socket = get_control_socket_path() | |
| except FileNotFoundError as exc: | |
| default_control_socket = None | |
| logging.debug(str(exc)) | |
| subparsers = parser.add_subparsers( | |
| dest="action", | |
| description="Choose the action you want to execute.", | |
| required=True, | |
| help="the action to execute", | |
| ) | |
| create_subparser = subparsers.add_parser( | |
| name="create", | |
| description="Create a new empty keyring", | |
| help="Create a new empty keyring with a given password.", | |
| ) | |
| create_subparser.add_argument( | |
| "-n", | |
| "--name", | |
| type=str, | |
| action="store", | |
| default=None, | |
| help="The name of the keyring to create (default: derived from the file name)..", | |
| ) | |
| verify_subparser = subparsers.add_parser( | |
| name="verify", | |
| description="Verify the password of a keyring", | |
| help="Verify that the password of a keyring matches.", | |
| ) | |
| change_pw_subparser = subparsers.add_parser( | |
| name="change-password", | |
| description="Change the password of a keyring", | |
| help="Change the password of a keyring.", | |
| ) | |
| unlock_keyring_subparser = subparsers.add_parser( | |
| name="unlock-keyring", | |
| description="Unlock a keyring", | |
| help="Unlock a keyring. Communicates with the gnome-keyring-daemon.", | |
| ) | |
| unlock_keyring_subparser.add_argument( | |
| "-s", | |
| "--socket", | |
| dest="socket_path", | |
| type=Path, | |
| action="store", | |
| default=default_control_socket, | |
| required=default_control_socket is None, | |
| help="The gnome-keyring daemon control socket (default %(default)s).", | |
| ) | |
| force_password_subparser = subparsers.add_parser( | |
| name="force-password", | |
| description="Create a new keyring if the desire password does not match", | |
| help="Check if the desired password matches. If not overwrite the keyring with an empty one. Data might get lost.", | |
| ) | |
| force_password_subparser.add_argument( | |
| "--i-know-what-i-am-doing", | |
| action="store_true", | |
| help="Use this if you are aware, that data might get lost.", | |
| ) | |
| set_default_subparser = subparsers.add_parser( | |
| name="set-default", | |
| description="Set the default keyring", | |
| help="Set the default keyring for a user.", | |
| ) | |
| set_default_subparser.add_argument( | |
| "keyring", | |
| type=str, | |
| help="The name of the default keyring (e.g. 'login').", | |
| ) | |
| get_default_subparser = subparsers.add_parser( | |
| name="get-default", | |
| description="Get the default keyring", | |
| help="Get the default keyring of a user.", | |
| ) | |
| for subparser in (set_default_subparser, get_default_subparser): | |
| subparser.add_argument( | |
| "-f", | |
| "--default-file", | |
| type=Path, | |
| default=Path("~/.local/share/keyrings/default").expanduser(), | |
| help="The path to the file, where the name of the default keyring is saved (default: %(default)s).", | |
| ) | |
| for subparser in ( | |
| create_subparser, | |
| verify_subparser, | |
| change_pw_subparser, | |
| force_password_subparser, | |
| ): | |
| subparser.add_argument( | |
| "file_path", | |
| type=Path, | |
| help="The path to the keyring.", | |
| ) | |
| for subparser, short_arg, long_arg, help in ( | |
| (create_subparser, None, None, None), | |
| (verify_subparser, None, None, None), | |
| (force_password_subparser, None, None, None), | |
| (unlock_keyring_subparser, None, None, None), | |
| ( | |
| change_pw_subparser, | |
| "-op", | |
| "--old-password", | |
| "The old password of the keyring. Use --old-password=file:./path/to/file to load from file. (default: ask).", | |
| ), | |
| ( | |
| change_pw_subparser, | |
| "-np", | |
| "--new-password", | |
| "The new password of the keyring. Use --old-password=file:./path/to/file to load from file. (default: ask).", | |
| ), | |
| ): | |
| subparser.add_argument( | |
| short_arg or "-p", | |
| long_arg or "--password", | |
| type=password_argument, | |
| action="store", | |
| help=help | |
| or "The password for the keyring. Use --password=file:./path/to/file to load from file. (default: ask).", | |
| ) | |
| _test_subparser = subparsers.add_parser( | |
| name="test", | |
| description="Internal unit tests", | |
| help="Internal unit tests / self-checks.", | |
| ) | |
| parser.add_argument( | |
| "-v", | |
| "--verbose", | |
| dest="log_level", | |
| action="store_const", | |
| const=logging.DEBUG, | |
| default=logging.INFO, | |
| help="Log debug messages.", | |
| ) | |
| return parser.parse_args() | |
| def main(): | |
| args = parse_args() | |
| logger = logging.getLogger() | |
| logger.setLevel(args.log_level) | |
| handler = logging.StreamHandler() | |
| handler.setLevel(args.log_level) | |
| logger.addHandler(handler) | |
| match args.action: | |
| case "create": | |
| create_keyring( | |
| file_path=args.file_path, | |
| vault=Vault( | |
| name=args.name or args.file_path.stem, | |
| mtime=datetime.fromtimestamp(0, tz=timezone.utc), | |
| ctime=datetime.now(tz=timezone.utc), | |
| flags=Flags.NO_FLAG, | |
| timeout=0, | |
| hash_iterations=random.randint(3500, 5000), | |
| salt=random.randbytes(8), | |
| items=[], | |
| ), | |
| password=(args.password or get_password("Please enter the password: ")), | |
| ) | |
| case "verify": | |
| verify_keyring( | |
| file_path=args.file_path, | |
| password=(args.password or get_password("Please enter the password: ")), | |
| ) | |
| case "change-password": | |
| change_password( | |
| file_path=args.file_path, | |
| old_password=( | |
| args.old_password or get_password("Please enter the old password: ") | |
| ), | |
| new_password=( | |
| args.new_password or get_password("Please enter the new password: ") | |
| ), | |
| ) | |
| case "force-password": | |
| if not args.i_know_what_i_am_doing: | |
| logger.fatal( | |
| "This will overwrite the current keyring and all secrets might get lost! Are you sure? Use --i-know-what-i-am-doing." | |
| ) | |
| sys.exit(1) | |
| force_password( | |
| file_path=args.file_path, | |
| password=( | |
| args.password or get_password("Please enter the new password: ") | |
| ), | |
| ) | |
| case "unlock-keyring": | |
| unlock_keyring( | |
| socket_path=args.socket_path, | |
| password=args.password or get_password("Please enter the password: "), | |
| ) | |
| case "set-default": | |
| set_default_keyring(args.keyring, default_path=args.default_file) | |
| case "get-default": | |
| get_default_action(default_path=args.default_file) | |
| case "test": | |
| unit_tests() | |
| case _: | |
| assert False | |
| if __name__ == "__main__": | |
| try: | |
| main() | |
| except KeyboardInterrupt: | |
| print() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment