Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • nounous/cranspasswords
  • detraz/cranspasswords
  • dstan/cranspasswords
  • serrano/cranspasswords
  • bde/bdepasswords
  • esum/cranspasswords
  • bombar/cranspasswords
7 results
Show changes
Commits on Source (167)
clientconfig.py
serverconfig.py
*.pyc
# Byte-compiled / optimized / DLL files
__pycache__
*.py[cod]
*$py.class
*.swp
*.egg-info
_build
*.mo
.pybuild
# Dossier contenant les mots de passe
# Debian package
debian/cranspasswords.substvars
debian/cranspasswords
debian/debhelper-build-stamp
debian/files
*.debhelper
# Virtualenvs
.tox
venv
# Editors configuration
.ipynb_checkpoints
.spyderproject
.spyproject
.ropeproject
.idea
.vscode
# Configuration files
./clientconfig.py
./serverconfig.py
db
stages:
- quality-assurance
- build
linters:
image: python:3.7
stage: quality-assurance
before_script:
- pip install tox
script:
- tox -e linters
# Be nice to new contributors, but please use `tox` before commit
allow_failure: true
build-deb:
image: debian:buster
stage: build
before_script:
- apt-get update && apt-get -y --no-install-recommends install build-essential debmake dh-python debhelper python3-all python3-setuptools
script:
- dpkg-buildpackage
- mkdir build && cp ../*.deb build/
artifacts:
paths:
- build/*.deb
......@@ -3,36 +3,37 @@ Explique les modifications entre les versions, surtout si on casse
la backward-compatibility.
== Disclaimer ==
== Overview ==
cranspasswords va maintenant posséder des branches.
cranspasswords possède plusieurs branches.
* master : branche à utiliser en production.
* dev : ce qui est dedans a des chances d'être cassé.
Plein d'essais ou de trucs cools, mais si ça marche pas c'est normal.
* master : ça marche, mais c'est pas forcément backward-compatible.
* 0.1, 0.2,… : ça marche, ça n'intègre plus de nouvelles fonctionnalités,
seulement d'éventuels bugfix, et c'est backward-compatible.
* 0.1, 0.2,… : anciennes versions (si vieux serveur),
ça n'intègre plus de nouvelles fonctionnalités, seulement d'éventuels bugfix.
=== 0.3.0 ===
Si vous ne voulez pas réfléchir : passez sur la branche 0.1
Permet d'afficher le secret dans un qrcode.
== version 0.1 ==
1ère version de cranspasswords une fois passé à ce système de branches.
=== 0.2.0 ===
Les version ci-dessous sont basées sur la version 0.1 et introduisent
des modifications non backward-compatibles.
=== 0.1.1 ===
''Pour voir cette version, git show 0.1.1''
La configuration du client a changé de format,
il faut donc repartir du `clientconfig.example.ini`.
La bash_complétion a changé pour ne plus hardcoder le nom cranspasswords
Les nouveaux clients utilisent Paramiko pour réaliser la connexion SSH.
Cela permet de garder une unique session SSH ouverte.
Pour récupérer la bash_complétion comme avant :
* sourcer bash_completion et non plus cranspasswords_bash_completion
* exécuter quelque part "complete -F _cranspasswords_completion cranspasswords"
Le module logging de Python est maintenant utilisé.
Vous pouvez augmenter sa verbosité avec `-vv`.
=== 0.1.2 ===
''Pour voir cette version, git show 0.1.2''
Il faut maintenant Python 3 avec pip pour installer cranspassword.
Le fichier à exécuter n'est plus cranspasswords.py mais client.py
=== 0.1.5 ===
''Pour voir cette version, git show 0.1.5''
Les nouveaux clients s'attendent à ce que le serveur renvoie le rôle spécial
"whoami".
=== 0.1.2-5 ===
''Pour voir cette version, git show 0.1.2-5''
......@@ -42,13 +43,32 @@ Le fichier à exécuter n'est plus cranspasswords.py mais client.py
Le path du script sur le serveur n'est plus /root/cranspasswords/server.py
mais /usr/local/bin/cranspasswords-server
=== 0.1.4 ===
''Pour voir cette version, git show 0.1.4''
Les fonctions serveurs getfile et putfile au singulier ne sont plus garanties de
fonctionner.
=== 0.1.3 ===
''Pour voir cette version, git show 0.1.3''
Les fichiers de mdp ne sont plus stockés dans /root/cranspasswords/db/
mais /var/lib/cranspasswords/db/.
=== 0.1.4 ===
''Pour voir cette version, git show 0.1.4''
=== 0.1.2 ===
''Pour voir cette version, git show 0.1.2''
Le fichier à exécuter n'est plus cranspasswords.py mais client.py
=== 0.1.1 ===
''Pour voir cette version, git show 0.1.1''
La bash_complétion a changé pour ne plus hardcoder le nom cranspasswords
Pour récupérer la bash_complétion comme avant :
* sourcer bash_completion et non plus cranspasswords_bash_completion
* exécuter quelque part "complete -F _cranspasswords_completion cranspasswords"
== version 0.1 ==
Première version de cranspasswords une fois passé aux systèmes de branches.
Les fonctions serveurs getfile et putfile au singulier ne sont plus garanties de fonctionner.
This diff is collapsed.
include MANIFEST.in
include LICENSE
# Nom de commande originel. Ne surtout pas changer cette variable
cmd_original_name=cranspasswords
# Nom de la commande, par défaut cranspasswords
# Vous pouvez la modifier
cmd_name=cranspasswords
# Expression régulière et son remplacement, pour le renommage
before=cmd_name = '${cmd_original_name}'
after=cmd_name = '${cmd_name}'
before2=cmd_name=${cmd_original_name}
after2=cmd_name=${cmd_name}
# Path du sudoer-file utilisé pour autoriser l'accès au script serveur
sudoer_file_path=/etc/sudoers.d/${cmd_name}
# Groupe qui aura le droit de lire les fichiers de mot de passe
# (indépendamment de pouvoir les déchiffrer)
sudoer_group=respbats
build:
@echo "Pour installer ${cmd_name} :"
@echo "Exécutez make install pour installer le client pour vous."
@echo "Exécutez sudo make install-server pour installer le serveur sur la machine."
rename:
@echo "Modification des variables pour renommer '${cmd_original_name}' en '${cmd_name}'"
@sed -i "s/^${before}$$/${after}/" serverconfig.example.py clientconfig.example.py
@sed -i "s/^${before2}$$/${after2}/" server
rerename:
@echo "Remise en place des variables passées à '${cmd_name}' en leur valeur de départ '${cmd_original_name}'"
@sed -i "s/^${after}$$/${before}/" serverconfig.example.py clientconfig.example.py
@sed -i "s/^${after2}$$/${before2}/" server
install:
@if [ "${cmd_name}" != "${cmd_original_name}" ]; then make --quiet rename; fi
install -d -m 0755 ~/bin
install -m 0755 client.py ~/bin/${cmd_name}
install -d -m 0755 ~/.config/${cmd_name}
install -m 0644 clientconfig.example.py ~/.config/${cmd_name}
@if [ "${cmd_name}" != "${cmd_original_name}" ]; then make --quiet rerename; fi
install-server:
@echo "Création du sudoer-file."
@echo "# Autorisation locale d'éxécution de ${cmd_name}" > ${sudoer_file_path}
@echo " %${sudoer_group} ALL=(root) NOPASSWD: /usr/local/bin/${cmd_name}-server" >> ${sudoer_file_path}
install -g root -o root -m 0755 server.py /usr/local/bin/${cmd_name}-server
install -d -g root -o root -m 0755 /etc/${cmd_name}/
install -g root -o root -m 0644 serverconfig.example.py /etc/${cmd_name}/serverconfig.py
install -d -m 700 /var/lib/${cmd_name}/db/
Bienvenue sur l'outil de gestion de mot de passe du Cr@ns.
Ce dépôt git contient à la fois le programme client (à utiliser sur votre
ordinateur) et le serveur.
== Nom de la commande ==
Si vous voulez appeler votre commande autrement que "cranspasswords",
c'est possible. Il faut pour cela changer la variable cmd_name dans le Makefile
avant de lancer make install ou make install-server.
== Installation et configuration du client ==
* Copiez le dépôt git sur votre machine :
$ git clone git://git.crans.org/git/cranspasswords.git
* Si ce n'est déjà fait, indiquer votre clé publique sur gest_crans
* Lancez make install
* Assurez-vous d'avoir ~/bin dans votre $PATH
* Exécutez cranspasswords test pour voir si vous arrivez à récupérer le mot de passe de test
== Installation et configuration du serveur ==
* Copiez le dépôt git sur le serveur :
$ git clone git://git.crans.org/git/cranspasswords.git
* Lancez sudo make install-server
* Il va installer un suoder-file, si vous voulez paramétrer
le groupe qui aura les accès en lecture aux fichiers de mot de passe,
changez la variable sudoer_group au début de Makefile.
La possibilité de lire les fichiers est indépendante de la capacité
à les déchiffrer.
* Éditez /etc/cranspasswords/serverconfig.py pour qu'il soit conforme
à vos désirs.
== Complétion ==
* Pour avoir la bash-complétion, dans votre .bashrc :
* Sourcez le fichier bash_completion présent dans le dépôt
* exécutez "complete -F _cranspasswords_completion <nom de votre commande>"
cPasswords
==========
cPasswords is a group password manager develop by the
`CRANS <https://www.crans.org>`__.
Client installation
-------------------
Add you GPG fingerprint and your SSH key to the cpassword server.
For the Crans, you can do this on the intranet.
Debian-based distribution
~~~~~~~~~~~~~~~~~~~~~~~~~
- Install some requirements,
``apt install git python3 python3-polib python3-paramiko python3-pyperclip python3-gpg python3-pip libgpgme-dev``.
- Clone the code,
``git clone https://gitlab.crans.org/nounous/cranspasswords.git && cd cranspasswords``,
- Launch ``pip3 install --user .``,
- Try ``cranspasswords test`` to decrypt a test password.
Nix-based distribution
~~~~~~~~~~~~~~~~~~~~~~
Refer to `shell.nix <shell.nix>`__ to get a Nix environment with cpasswords.
In a Python Virtualenv
~~~~~~~~~~~~~~~~~~~~~~
Make sure you have ``git``, ``gettext`` and Python ``venv`` module.
**Please do not compile ``gpg`` Python module with libgpgme.
It is much cleaner to installer ``python3-gpg``
that will be kept in sync with your ``gpg``.**
- Clone the code,
``git clone https://gitlab.crans.org/nounous/cranspasswords.git && cd cranspasswords``,
- Create the virtualenv,
``python3 -m venv venv --system-site-packages && source venv/bin/activate``
- Install with ``pip install .``
Server installation
-------------------
- Clone the code,
``git clone git@gitlab.crans.org:nounous/cranspasswords.git``,
- Launch ``pip3 install --user .``,
- Add a sudoers rule to enable users from ``sudoers_group`` to launch
cpasswords,
``%sudoers_group ALL=(root) NOPASSWD: /usr/local/bin/cranspasswords-server``
- Copy and adapt ``docs/serverconfig.example.py`` to
``/etc/cranspasswords/serverconfig.py``.
- Create ``/var/lib/cranspasswords/db/`` with owner root and mod 700
(root only).
How to
------
Develop
~~~~~~~
With a Python virtualenv,
.. code:: bash
python3 -m venv venv
. venv/bin/activate
pip install -e .
Then you will be able to launch the client with ``cpasswords``
or the server with ``cpasswords-server``.
Update recipients
~~~~~~~~~~~~~~~~~
If you added a recipient in a role, you should use ``--recrypt`` to recrypt
all files.
Change command name
~~~~~~~~~~~~~~~~~~~
If you wish to rename the command from ``cranspasswords`` to something
else, define ``COMMAND_NAME`` environment variable before executing
``pip install`` during the installation of the client or server.
Import cPasswords
~~~~~~~~~~~~~~~~~
To import ``client.py`` from another script, you may need to specify
where is his configuration :
::
export CRANSPASSWORDS_CLIENT_CONFIG_DIR=/path/to/config/dir/
Build Debian package
~~~~~~~~~~~~~~~~~~~~
You need to install ``debmake dh-python debhelper python3-all``.
Then in the repository root you can run ``dpkg-buildpackage``.
The build files will be in ``../``.
Install completion
------------------
With bash, you can add to ``~/.bashrc`` :
.. code:: bash
source <this repo path>/docs/bash_completion
complete -F _cranspasswords_completion <command name>
Troubleshoutings
----------------
To troubleshot, you ``-vv`` option to activate debug.
Sudo asks for a password
~~~~~~~~~~~~~~~~~~~~~~~~
""“sudo: sorry, a password is required to run sudo”""
Check sudoers file on server side.
Cpasswords is not trusting many people
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Please make sure your trustdb is up to date with ``gpg --update-trustdb``.
You need to have physically met and sign at least some people from the server
keyring.
This diff is collapsed.
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
""" Configuration du client cranspasswords """
import os
#: Pour override le nom si vous voulez renommer la commande
cmd_name = 'cranspasswords'
#: Path du binaire ssh sur la machine client
ssh_path = '/usr/bin/ssh'
#: Path du script ``cmd_name``-server sur le serveur
server_path = '/usr/local/bin/%s-server' % (cmd_name,)
#: Commande à exécuter sur le serveur après y être entré en ssh
distant_cmd = "sudo %s" % (server_path,)
#: Username utilisé pour se loguer sur le serveur.
#: Par défaut, prend la valeur de l'username sur le client,
#: il faut donc le remplacer pour ceux qui n'ont pas le même username
#: sur le client et le serveur.
username = os.getenv('USER')
#: Liste des serveurs sur lesquels ont peut récupérer des mots de passe.
#:
#: Sans précision du paramètre --server, la clé ``'default'`` sera utilisée.
#:
#: * ``'server_cmd'`` : La commande exécutée sur le client pour appeler
#: le script sur le serveur distant.
#: * ``'user'``: L'username sur le serveur
servers = {
'default': {
'server_cmd': [ssh_path, 'vert.adm.crans.org', distant_cmd],
'user' : username
},
'ovh': {
'server_cmd': [ssh_path, 'ovh.crans.org', distant_cmd],
'user' : username
}
}
import os
if os.environ.get("CRYPTOGRAPHY_OPENSSL_NO_LEGACY") is None:
os.environ["CRYPTOGRAPHY_OPENSSL_NO_LEGACY"]="1"
This diff is collapsed.
"""
GnuPG abstraction layer
Copyright (C) 2010-2020 Cr@ns <roots@crans.org>
Authors : Daniel Stan <daniel.stan@crans.org>
Vincent Le Gallic <legallic@crans.org>
Alexandre Iooss <erdnaxe@crans.org>
SPDX-License-Identifier: GPL-3.0-or-later
"""
import subprocess
import logging
import gpg
# Local logger
log = logging.getLogger(__name__)
def decrypt(ciphertext: str):
"""
Return decrypted content
"""
log.info("Decrypting using GnuPG")
with gpg.Context() as c:
plaintext, _, _ = c.decrypt(ciphertext.encode("utf-8"))
return plaintext.decode("utf-8")
def encrypt(content: str, keys: []) -> str:
"""
Return encrypted content for keys
"""
log.info("Encrypting using GnuPG")
with gpg.Context() as c:
c.armor = True
cipher, _, _ = c.encrypt(content.encode("utf-8"), keys)
return cipher.decode("utf-8")
def receive_key(fpr: str):
"""
Download key from fingerprint
"""
full_command = ['gpg', '--recv-keys', fpr]
log.info("Running `%s`" % " ".join(full_command))
return subprocess.run(full_command)
def check_key_validity(key, email: str) -> bool:
"""
Check key identities email and trust level
Return true if can be trusted and we can encrypt
"""
log.info("Checking %s key with email %s" % (key.fpr, email))
if not key.can_encrypt:
log.debug("Cannot encrypt for key %s" % key.fpr)
return False
for uid in key.uids:
if email == uid.email and not uid.revoked and not uid.invalid \
and uid.validity >= gpg.constants.validity.FULL:
return True
# no trusted valid uid were found
log.debug("No trusted valid uid were found for this key")
return False
def get_key_from_fingerprint(fpr):
"""
Get GnuPG key by fingerprint
"""
log.info("Getting key corresponding to %s" % fpr)
with gpg.Context() as c:
return c.get_key(fpr)
"""
Small gettext wrapper
Copyright (C) 2010-2020 Cr@ns <roots@crans.org>
Authors : Daniel Stan <daniel.stan@crans.org>
Vincent Le Gallic <legallic@crans.org>
Alexandre Iooss <erdnaxe@crans.org>
SPDX-License-Identifier: GPL-3.0-or-later
"""
import gettext
import platform
try:
import importlib.resources as importlib_resources
except ImportError:
# In PY<3.7 fall-back to backported `importlib_resources`.
import importlib_resources
# Load locale
if platform.uname().system == 'Linux':
with importlib_resources.path("cpasswords", "locale") as mo_path:
gettext.bindtextdomain('messages', mo_path)
gettext.textdomain('messages')
_ = gettext.gettext
else:
# Do not support translation under MacOS and Windows
def _(s):
return s
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-04-16 10:01+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: cpasswords/client.py:47
#, python-format
msgid ""
"%s/clientconfig.ini could not be found or read.\n"
"Please copy `docs/clientconfig.example.ini` from the source repository and "
"customize."
msgstr ""
"%s/clientconfig.ini n'a pas été trouvé.\n"
"Veuillez copier `docs/clientconfig.example.ini` à partir des sources et le "
"personnaliser."
#: cpasswords/client.py:176
#, python-format
msgid "key correponding to fingerprint %s and mail %s not found"
msgstr "clé correspondante à l'emprunte %s et au mail %s non trouvée"
#: cpasswords/client.py:179
msgid "Do you want to try to import it now ?"
msgstr "Voulez-vous tenter de l'importer maintenant ?"
#: cpasswords/client.py:186 cpasswords/client.py:190
#, python-format
msgid "Dropping key from %s"
msgstr "On drop la clé de %s, (V) (;,,;) (V) drop the key"
#: cpasswords/client.py:311
msgid "Available files:"
msgstr "Liste des fichiers disponibles :"
#: cpasswords/client.py:316
#, python-format
msgid "--Mes roles: %s"
msgstr ""
#: cpasswords/client.py:323
msgid "Fichier corrompus :"
msgstr "Corrumpted files:"
#: cpasswords/client.py:335
msgid "Listing available roles and their users"
msgstr "Liste des rôles disponibles et leurs utilisateurs"
#: cpasswords/client.py:348
msgid "List of configured servers"
msgstr "Liste des serveurs configurés"
#: cpasswords/client.py:350 cpasswords/client.py:351
msgid "undefined"
msgstr "non défini"
#: cpasswords/client.py:362
msgid ""
"Appuyez sur Entrée pour récupérer le contenu précédent du presse papier."
msgstr ""
#: cpasswords/client.py:414
msgid "La clé a été mise dans l'agent ssh"
msgstr ""
#: cpasswords/client.py:469
msgid "File not found on server"
msgstr "Fichier non trouvé sur le server"
#: cpasswords/client.py:575
msgid ""
"Vérification que les clés sont valides (uid correspondant au login) et de "
"confiance."
msgstr ""
#: cpasswords/client.py:629
#, python-format
msgid ""
"Vous vous apprêtez à rechiffrer les fichiers suivants :\n"
"%s"
msgstr ""
#: cpasswords/client.py:647
msgid "Aucun fichier n'a besoin d'être rechiffré"
msgstr ""
#: cpasswords/client.py:688
msgid "You need to provide a filename with this command"
msgstr "Un nom de fichier est nécessaire avec cette commande"
#: cpasswords/client.py:697
msgid "Group passwords manager based on GPG."
msgstr "Gestion de mots de passe partagés grâce à GPG."
#: cpasswords/client.py:703
msgid "name of file to show or edit"
msgstr "nom du fichier à lire ou éditer"
#: cpasswords/client.py:709
msgid "verbose mode, multiple -v options increase verbosity"
msgstr "mode verbeux, multiplier l'option -v pour augmenter"
#: cpasswords/client.py:715
msgid "silent mode: hide errors, overrides verbosity"
msgstr "mode silencieux: cache les erreurs, ignore la verbosité"
#: cpasswords/client.py:721
msgid "select another server than DEFAULT"
msgstr "sélectionne un autre server que DEFAULT"
#: cpasswords/client.py:728
msgid "do not try to store password in clipboard"
msgstr "n'essaie pas de stocker le mot de passe dans le presse papier"
#: cpasswords/client.py:734
msgid "do not prompt confirmation"
msgstr "ne pas demander confirmation"
#: cpasswords/client.py:741
msgid "need --force, drop untrusted keys without confirmation."
msgstr ""
"avec --force, ignore les clés en lesquels on n'a pas confiance sans "
"confirmation."
#: cpasswords/client.py:747
msgid ""
"specify for which roles to crypt (default to all roles, or do not change if "
"editing)"
msgstr ""
"spécifier pour quels rôles chiffrer (défaut à tous les rôles, ou ne les "
"change pas si édition)"
#: cpasswords/client.py:759
msgid "read file (default)"
msgstr "lit le fichier (défaut)"
#: cpasswords/client.py:766
msgid "edit (or create) file"
msgstr "édite (ou crée) le fichier"
#: cpasswords/client.py:773
msgid "erase file"
msgstr "efface le fichier"
#: cpasswords/client.py:780
msgid "list files"
msgstr "liste les fichiers"
#: cpasswords/client.py:786
msgid "restore corrumpted files"
msgstr "restaure les fichiers corrompus"
#: cpasswords/client.py:793
msgid "check keys"
msgstr "vérifie les clés"
#: cpasswords/client.py:800
msgid "update keys"
msgstr "met à jour les clés"
#: cpasswords/client.py:807
msgid "list existing roles"
msgstr "liste les rôles existants"
#: cpasswords/client.py:814
msgid "list servers"
msgstr "liste les serveurs"
#: cpasswords/client.py:821
msgid "recrypt all files having a role listed in --roles"
msgstr "rechiffre tous les fichiers ayant un rôle listé dans --roles"
#: cpasswords/remote.py:35
msgid "An error occured during SSH connection, debug with -vv"
msgstr "Une erreur s'est produite pendant la connexion SSH, débogguez avec -vv"
#: cpasswords/remote.py:59
#, python-format
msgid "Running command `%s`"
msgstr "Lancement de la commande `%s`"
#: cpasswords/remote.py:64
#, python-format
msgid "Writing to stdin: %s"
msgstr "Écriture dans stdin: %s"
#: cpasswords/remote.py:74
#, python-format
msgid "Wrong server return code %s, error is %s"
msgstr "Mauvaise code de retour serveur %s, l'erreur est %s"
#: cpasswords/remote.py:81
msgid "Error while parsing JSON"
msgstr "Erreur lors du parsing du JSON"
#: cpasswords/remote.py:84
#, python-format
msgid "Server returned %s"
msgstr "Le serveur a renvoyé %s"
"""
Utils to run commands on remote server
Copyright (C) 2010-2020 Cr@ns <roots@crans.org>
Authors : Daniel Stan <daniel.stan@crans.org>
Vincent Le Gallic <legallic@crans.org>
Alexandre Iooss <erdnaxe@crans.org>
SPDX-License-Identifier: GPL-3.0-or-later
"""
import base64
import json
import logging
import re
from functools import lru_cache
from getpass import getpass, getuser
from hashlib import sha1, sha256
from pathlib import Path
from dns import flags, resolver
from paramiko.client import MissingHostKeyPolicy, SSHClient
from paramiko.config import SSHConfig
from paramiko.ssh_exception import (
AuthenticationException,
PasswordRequiredException,
SSHException,
)
from .locale import _
# Local logger
log = logging.getLogger(__name__)
AUTHENTICITY_MSG = """
The authenticity of host '{host}' can't be established.
{ktype} key fingerprint is SHA256:{kfp}.{dnsfp}
Are you sure you want to continue connecting (yes/no)?
"""
_key_algorithms = {
"ssh-rsa": "1",
"ssh-dss": "2",
"ecdsa-sha2-nistp256": "3",
"ecdsa-sha2-nistp384": "3",
"ecdsa-sha2-nistp521": "3",
"ssh-ed25519": "4",
}
_hash_funcs = {
"1": sha1,
"2": sha256,
}
class AskUserOrDNSPolicy(MissingHostKeyPolicy):
"""
Policy for automatically trusting DNSSEC authenticated fingerprint
or asking the user for unknown hostname & key. This is used by `.SSHClient`
"""
def missing_host_key(self, client, hostname, key):
kfp256 = sha256(key.asbytes()).digest()
kfp = base64.b64encode(kfp256).decode("utf-8").replace("=", "")
ktype = key.get_name().upper().replace("SSH-", "")
kalg = _key_algorithms.get(key.get_name())
kres = resolver.Resolver()
kres.use_edns(True, flags.DO, 1280)
dnssec = False
found = False
dnsfp = ""
try:
kans = kres.query(hostname, "SSHFP")
if kans.response.flags & flags.AD:
dnssec = True
for rdata in kans:
try:
alg, fptype, fp = rdata.to_text().split()
except ValueError: # Invalid SSHFP record format, don't care.
pass
if alg != kalg:
continue
if fptype not in _hash_funcs:
continue
expected = _hash_funcs.get(fptype)(key.asbytes()).hexdigest()
if expected == fp:
found = True
break
except resolver.NoAnswer: # Can't find SSHFP for this host.
pass
if found and dnssec:
log.debug(
"Authentic {} host key for {} found in DNS".format(
key.get_name(), hostname
)
)
return
if found:
dnsfp = "\nMatching host key fingerprint found in DNS."
inp = input(
AUTHENTICITY_MSG.format(host=hostname, ktype=ktype, kfp=kfp, dnsfp=dnsfp)
)
if inp not in ["yes", "y", ""]:
log.debug("Rejecting {} host key for {}: {}".format(ktype, hostname, kfp))
raise SSHException(
"Connection to {!r} rejected by the user".format(hostname)
)
log.debug("Accepting {} host key for {}: {}".format(ktype, hostname, kfp256))
@lru_cache()
def create_ssh_client(host, password=None):
"""
Create a SSH client with paramiko module
"""
# Create SSH client with system host keys and agent
client = SSHClient()
client.set_missing_host_key_policy(AskUserOrDNSPolicy)
# Load config file and use the right username
# As of June 2020, paramiko doesn't support `Include` directive ...
# See https://github.com/paramiko/paramiko/issues/1609
config_path = Path.home().joinpath(".ssh/config")
# Match anything right after `Include` directive in a non commented line.
include_regex = re.compile(r"^(?!#).*(?<=Include )([^\s]+)")
config_files = [config_path]
with open(config_path) as cpath:
for line in cpath.readlines():
m = include_regex.match(line)
if m:
config_files.append(Path(m.group(1)))
username = None
for conf in config_files:
try:
config = SSHConfig()
config.parse(conf.expanduser().open())
except FileNotFoundError:
continue
username = config.lookup(host).get("user")
if username:
break
if not username:
username = getuser()
log.debug(f"Will connect to {host} as {username}")
# Load system private keys
client.load_system_host_keys()
# Connect
try:
client.connect(host, username=username, password=password)
except PasswordRequiredException:
password = getpass("SSH password: ")
return create_ssh_client(host, password)
except AuthenticationException:
log.error(_("SSH authentication failed."))
exit(1)
except SSHException:
log.error(_("An error occured during SSH connection, debug with -vv"))
raise
return client
def remote_command(options, command, arg=None, stdin_contents=None):
"""
Execute remote command and return output
"""
if "host" not in options.serverdata:
log.error("Missing parameter `host` in active server configuration")
exit(1)
client = create_ssh_client(str(options.serverdata['host']))
# Build command
if "remote_cmd" not in options.serverdata:
log.error("Missing parameter `remote_cmd` in active server configuration")
exit(1)
remote_cmd = options.serverdata['remote_cmd'] + " " + command
if arg:
remote_cmd += " " + arg
# Run command and timeout after 10s
log.info(_("Running command `%s`") % remote_cmd)
stdin, stdout, stderr = client.exec_command(remote_cmd, timeout=10)
# Write
if stdin_contents is not None:
log.info(_("Writing to stdin: %s") % stdin_contents)
stdin.write(json.dumps(stdin_contents))
stdin.flush()
# If the server is not expected to exit, then exit now
if stdin_contents:
return
# Return code == 0 if success
ret = stdout.channel.recv_exit_status()
if ret != 0:
err = ""
if stderr.channel.recv_stderr_ready():
err = stderr.read()
log.error(_("Wrong server return code %s, error is %s") % (ret, err))
exit(ret)
# Decode directly read buffer
try:
answer = json.load(stdout)
except ValueError:
log.error(_("Error while parsing JSON"))
exit(42)
log.debug(_("Server returned %s") % answer)
return answer
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
#!python
"""Serveur pour cranspasswords"""
"""
Group password manager server
from __future__ import print_function
Copyright (C) 2010-2020 Cr@ns <roots@crans.org>
Authors : Daniel Stan <daniel.stan@crans.org>
Vincent Le Gallic <legallic@crans.org>
Alexandre Iooss <erdnaxe@crans.org>
SPDX-License-Identifier: GPL-3.0-or-later
"""
import glob
import os
import pwd
import sys
import json
import smtplib
import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import socket
import logging
from smtplib import SMTP
from email.message import EmailMessage
# Même problème que pour le client, il faut bootstraper le nom de la commande
# Pour accéder à la config
cmd_name = os.path.split(sys.argv[0])[1].replace("-server", "")
sys.path.append("/etc/%s/" % (cmd_name,))
import serverconfig
# Configuration loading
# Guess name as we do not have config
bootstrap_cmd_name = os.path.split(sys.argv[0])[1].replace("-server", "")
default_config_path = "/etc/" + bootstrap_cmd_name
config_path = os.getenv(
"CRANSPASSWORDS_SERVER_CONFIG_DIR", default_config_path)
try:
sys.path.append(config_path)
import serverconfig
except ModuleNotFoundError:
# If config could not be imported, display an error if required
# Do not use logger as it has not been initialized yet
print("%s/serverconfig.py could not be found or read.\n"
"Please copy `docs/serverconfig.example.py` from the source "
"repository and customize." % config_path)
exit(1)
# Local logger
log = logging.getLogger(__name__)
# Get user name that launch the server
MYUID = pwd.getpwuid(os.getuid())[0]
if MYUID == 'root':
MYUID = os.environ['SUDO_USER']
def validate(roles, mode='r'):
"""Vérifie que l'appelant appartient bien aux roles précisés
Si mode mode='w', recherche un rôle en écriture
......@@ -32,61 +54,180 @@ def validate(roles, mode='r'):
for role in roles:
if mode == 'w':
role += '-w'
if serverconfig.ROLES.has_key(role) and MYUID in serverconfig.ROLES[role]:
if role in serverconfig.ROLES.keys() and MYUID in serverconfig.ROLES[role]:
return True
return False
def getpath(filename, backup=False):
"""Récupère le chemin du fichier ``filename``"""
return os.path.join(serverconfig.STORE, '%s.%s' % (filename, 'bak' if backup else 'json'))
def writefile(filename, contents):
"""Écrit le fichier avec les bons droits UNIX"""
os.umask(0077)
os.umask(0o077)
f = open(filename, 'w')
f.write(contents.encode("utf-8"))
f.write(contents)
f.close()
class ServerCommand(object):
"""
Une instance est un décorateur pour la fonction servant de commande
externe du même nom"""
#: nom de la commande
name = None
#: fonction wrappée
decorated = None
#: (static) dictionnaire name => fonction
by_name = {}
#: rajoute un argument en fin de fonction à partir de stdin (si standalone)
stdin_input = False
#: Est-ce que ceci a besoin d'écrire ?
write = False
def __init__(self, name, stdin_input=False, write=False):
"""
* ``name`` nom de l'action telle qu'appelée par le client
* ``stdin_input`` si True, stdin sera lu en mode non-keepalive, et
remplira le dernier argument de la commande.
* ``write`` s'agit-il d'une commande en écriture ?
"""
self.name = name
self.stdin_input = stdin_input
self.write = write
ServerCommand.by_name[name] = self
def __call__(self, fun):
self.decorated = fun
return fun
# Fonction exposées par le serveur
@ServerCommand('keep-alive')
def keepalive():
""" Commande permettant de réaliser un tunnel json (un datagramme par ligne)
Un message entre le client et le serveur consiste en l'échange de dico
Message du client: {'action': "nom_de_l'action",
'args': liste_arguments_passes_a_la_fonction}
Réponse du serveur: {'status': 'ok',
'content': retour_de_la_fonction,
}
"""
for line in iter(sys.stdin.readline, ''):
data = json.loads(line.rstrip())
try:
# Une action du protocole = de l'ascii
action = data['action'].encode('ascii')
content = ServerCommand.by_name[action].decorated(*data['args'])
status = u'ok'
except Exception as e:
status = u'error'
content = repr(e)
out = {
'status': status,
'content': content,
}
print(json.dumps(out))
sys.stdout.flush()
@ServerCommand('listroles')
def listroles():
"""Liste des roles existant et de leurs membres"""
return serverconfig.ROLES
"""Liste des roles existant et de leurs membres.
Renvoie également un rôle particulier ``"whoami"``, contenant l'username
de l'utilisateur qui s'est connecté."""
d = serverconfig.ROLES
if "whoami" in d.keys():
raise ValueError('La rôle "whoami" ne devrait pas exister')
d["whoami"] = MYUID
return d
@ServerCommand('listkeys')
def listkeys():
"""Liste les usernames et les (mail, fingerprint) correspondants"""
return serverconfig.KEYS
@ServerCommand('listfiles')
def listfiles():
"""Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder"""
os.chdir(serverconfig.STORE)
filenames = glob.glob('*.json')
files = {}
for filename in filenames:
file_dict = json.loads(open(filename).read())
files[filename[:-5]] = file_dict["roles"]
fname = filename[:-5]
files[fname] = file_dict["roles"]
return files
@ServerCommand('restorefiles')
def restorefiles():
"""Si un fichier a été corrompu, on restore son dernier backup valide"""
os.chdir(serverconfig.STORE)
filenames = glob.glob('*.json')
files = {}
for filename in filenames:
file_dict = json.loads(open(filename).read())
if not ('-----BEGIN PGP MESSAGE-----' in file_dict["contents"]):
fname = filename[:-5]
with open(fname+'.bak') as f:
line = f.readline()
backup = ''
while not (line == ''):
try:
line_dict = json.loads(line)
if ('-----BEGIN PGP MESSAGE-----' in line_dict["contents"]):
backup = line
except Exception:
pass
line = f.readline()
if not (backup == ''):
files[fname] = 'restored'
with open(fname+'.json', 'w') as f2:
f2.write(backup)
else:
files[fname] = 'not restored'
return files
@ServerCommand('getfile')
def getfile(filename):
"""Récupère le fichier ``filename``"""
filepath = getpath(filename)
try:
obj = json.loads(open(filepath).read())
if not validate(obj['roles']):
return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
return [False, u"Vous n'avez pas les droits de lecture sur le fichier %s." % filename]
obj["filename"] = filename
return [True, obj]
except IOError:
return [False, u"Le fichier %s n'existe pas." % filename]
def getfiles():
@ServerCommand('getfiles', stdin_input=True)
def getfiles(filenames):
"""Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin"""
stdin = sys.stdin.read()
filenames = json.loads(stdin)
return [getfile(f) for f in filenames]
# TODO ça n'a rien à faire là, à placer plus haut dans le code
def _putfile(filename, roles, contents):
"""Écrit ``contents`` avec les roles ``roles`` dans le fichier ``filename``"""
"""Écrit ``contents`` avec les roles ``roles`` dans le fichier ``filename``
"""
gotit, old = getfile(filename)
if not gotit:
old = u"[Création du fichier]"
......@@ -95,19 +236,25 @@ def _putfile(filename, roles, contents):
oldroles = old['roles']
if not validate(oldroles, 'w'):
return [False, u"Vous n'avez pas le droit d'écriture sur %s." % filename]
corps = u"Le fichier %s a été modifié par %s." % (filename, MYUID)
backup(corps, filename, old)
notification(u"Modification de %s" % filename, corps, filename, old)
notification(u"Modification", filename, MYUID)
filepath = getpath(filename)
writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
data = {'filename': filename, 'roles': roles, 'contents': contents}
for client in _list_to_replicate(data):
client.put_file(data)
return [True, u"Modification effectuée."]
def putfile(filename):
@ServerCommand('putfile', stdin_input=True, write=True)
def putfile(filename, parsed_stdin):
"""Écrit le fichier ``filename`` avec les données reçues sur stdin."""
stdin = sys.stdin.read()
parsed_stdin = json.loads(stdin)
try:
roles = parsed_stdin['roles']
contents = parsed_stdin['contents']
......@@ -115,10 +262,11 @@ def putfile(filename):
return [False, u"Entrée invalide"]
return _putfile(filename, roles, contents)
def putfiles():
"""Écrit plusieurs fichiers. Lit les filenames sur l'entrée standard avec le reste."""
stdin = sys.stdin.read()
parsed_stdin = json.loads(stdin)
@ServerCommand('putfiles', stdin_input=True, write=True)
def putfiles(parsed_stdin):
"""Écrit plusieurs fichiers. Lit les filenames sur l'entrée standard avec le
reste."""
results = []
for fichier in parsed_stdin:
try:
......@@ -132,85 +280,98 @@ def putfiles():
return results
@ServerCommand('rmfile', write=True)
def rmfile(filename):
"""Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
gotit, old = getfile(filename)
if not gotit:
return old # contient le message d'erreur
return old # contient le message d'erreur
roles = old['roles']
if validate(roles, 'w'):
corps = u"Le fichier %s a été supprimé par %s." % (filename, MYUID)
backup(corps, filename, old)
notification(u"Suppression de %s" % filename, corps, filename, old)
notification(u"Suppression", filename, MYUID)
os.remove(getpath(filename))
else:
return u"Vous n'avez pas les droits d'écriture sur le fichier %s." % filename
return u"Suppression effectuée"
# TODO monter plus haut
def backup(corps, fname, old):
"""Backupe l'ancienne version du fichier"""
os.umask(0o077)
back = open(getpath(fname, backup=True), 'a')
back.write(json.dumps(old))
back.write('\n')
back.write((u'* %s: %s\n' % (str(datetime.datetime.now()), corps)).encode("utf-8"))
back.write(f"* {datetime.datetime.now()}: {corps}\n")
back.close()
def notification(subject, corps, fname, old):
"""Envoie par mail une notification de changement de fichier"""
conn = smtplib.SMTP('localhost')
frommail = serverconfig.CRANSP_MAIL
tomail = serverconfig.DEST_MAIL
msg = MIMEMultipart(_charset="utf-8")
msg['Subject'] = subject
msg['X-Mailer'] = serverconfig.cmd_name.decode()
msg['From'] = serverconfig.CRANSP_MAIL
msg['To'] = serverconfig.DEST_MAIL
msg.preamble = u"%s report" % (serverconfig.cmd_name.decode(),)
info = MIMEText(corps +
u"\nLa version précédente a été sauvegardée." +
u"\n\n-- \nCranspasswords.py", _charset="utf-8")
msg.attach(info)
conn.sendmail(frommail, tomail, msg.as_string())
conn.quit()
WRITE_COMMANDS = ["putfile", "rmfile"]
if __name__ == "__main__":
argv = sys.argv[1:]
if len(argv) not in [1, 2]:
sys.exit(1)
command = argv[0]
if serverconfig.READONLY and command in WRITE_COMMANDS:
def _list_to_replicate(data):
"""Renvoie une liste d'options clients sur lesquels appliquer relancer
la procédure (pour réplication auto)"""
# roles = data.get('roles', [])
# backups = getattr(serverconfig, 'BACKUP_ROLES', {})
# servers = getattr(serverconfig, 'BACKUP_SERVERS', {})
# configs = set(name for role in roles for name in backups.get(role, []))
# return [clientlib.Client(servers[name]) for name in configs]
return [] # FIXME
_notif_todo = []
def notification(action, fname, actor):
"""Enregistre une notification"""
_notif_todo.append((action, fname, actor))
def notification_mail(notifications):
"""
Send notifications by mail
"""
# Build message
actions = set(task[1] for task in notifications)
msg = EmailMessage()
liste = "\r\n".join(" * %s de %s par %s" % task for task in notifications)
hostname = socket.gethostname()
msg['From'] = f"{ serverconfig.FROM_MAIL }"
msg['To'] = f"{ serverconfig.TO_MAIL }"
msg['Subject'] = "Modification de la base ({})".format(', '.join(actions))
msg['X-Mailer'] = f"{ serverconfig.cmd_name }"
msg.set_content(
f"Des modifications ont été faites:\r\n"
f"{ liste }\r\n"
f"Des sauvegardes ont été réalisées.\r\n"
f"-- \r\n{ serverconfig.cmd_name } sur { hostname }"
)
# Send
with SMTP(serverconfig.SMTP_HOST) as s:
s.send_message(msg)
def main():
argv = sys.argv[0:]
command_name = argv[1]
command = ServerCommand.by_name[command_name]
if serverconfig.READONLY and command.write:
raise IOError("Ce serveur est read-only.")
filename = None
try:
filename = argv[1]
except IndexError:
pass
answer = None
if command == "listroles":
answer = listroles()
elif command == "listkeys":
answer = listkeys()
elif command == "listfiles":
answer = listfiles()
elif command == "getfiles":
answer = getfiles()
elif command == "putfiles":
answer = putfiles()
else:
if not filename:
print("filename nécessaire pour cette opération", file=sys.stderr)
sys.exit(1)
if command == "getfile":
answer = getfile(filename)
elif command == "putfile":
answer = putfile(filename)
elif command == "rmfile":
answer = rmfile(filename)
else:
sys.exit(1)
if not answer is None:
args = argv[2:]
if command.stdin_input:
args.append(json.loads(sys.stdin.read()))
answer = command.decorated(*args)
if answer is not None:
print(json.dumps(answer))
if _notif_todo:
# if notifications, then send email
notification_mail(_notif_todo)
if __name__ == "__main__":
main()
cranspasswords for Debian
Install cpasswords client and server as "cranspasswords".
-- Alexandre Iooss <erdnaxe@crans.org> Wed, 15 Apr 2020 13:00:11 +0200
cranspasswords (0.2.0) unstable; urgency=low
* Initial release.
-- Alexandre Iooss <erdnaxe@crans.org> Wed, 15 Apr 2020 13:00:11 +0200
11