Rotation des clés DKIM

La signature des emails par clé DKIM est une composante importante d'un système de messagerie sain et bien géré. Comme tout système cryptographique fonctionnant sur la base d'un couple clé publique / clé privée, il est nécessaire de renouveler les jeux de clés régulièrement.
En effet, plus longtemps une même clé est utilisée, plus elle a de chance d'être cassée. De la même manière, plus elle est petite, plus elle sera facilement cassée.
Comme toujours, avec la correspondance par email, rien n'est instantané. Contrairement à un serveur web par exemple qui pourrait changer de certificat SSL en quelques minutes et faire immédiatement disparaître l'ancien de la circulation, les clés DKIM doivent changer selon un roulement tuilé : quand la nouvelle clé remplace l'ancienne, cette dernière doit rester disponible encore quelques temps pour permettre la validation des signatures présentes dans des messages encore en cours de livraison.
Ainsi, le besoin de faire tourner régulièrement les clés DKIM tout en garantissant un tuilage, ajouté à la complexité (relative) d'une architecture mixte à base de serveur DNS, serveur de mail, logiciel de signature DKIM, rendent nécessaire la mise en place d'une procédure claire, fiable et automatique.

Évolution de l'utilisation de DKIM mesurée au niveau de l'antispam en entrée de mon serveur de mails

Évolution de l'utilisation de DKIM mesurée au niveau de l'antispam en entrée de mon serveur de mails, entre fin 2005 et début 2015

Pour mes propres besoins, j'ai conçu un script shell utilisable pour faire tourner les clés d'un domaine donné. Ce script s'appuie sur un serveur Bind local : la modification des zones DNS se fait par modification directe des fichiers. Il utilise l'implémentation DKIM de OpenDKIM.
Il serait tout à fait possible de concevoir un script qui utilise un serveur DNS distant ou local via la commande nsupdate(8), mais cette approche laisse la main totalement à Bind pour l'écriture des fichiers de zones. Il fait alors disparaître tous les commentaires, il change l'ordre des enregistrements, etc. Ce n'était pas souhaitable pour moi qui stocke des informations en commentaire dans mes fichiers de zones.

Prérequis pour utiliser ce script

- Être root sur le serveur ;
- Disposer d'un serveur DNS Bind, ou compatible (c'est OpenDKIM qui écrit les enregistrements DNS), maître pour les zones concernées par la rotation de clés ;
- Disposer sur la même machine d'une installation OpenDKIM fonctionnelle ;
- Avoir une version de sed qui supporte l'option -i (in place).

Comportement

Le premier argument obligatoire du script est l'action : renouvellement ("new") ou nettoyage ("clean"). Le second argument obligatoire est le domaine cible et le troisième argument (facultatif) est la longueur des nouvelles clés.
Pour l'action "new" et un domaine donné, le script renouvelle l'ensemble des clés DKIM. Par défaut, il génère des clés de la même longueur que celles qu'il remplace, et les sélecteurs sont nommés en `date "+%Y%m"` suivi d'un tiret et des 8 premiers caractères retournés par la commande uuidgen(1). Si une longueur de clé est imposée en argument, l'ensemble des clés du domaine utilisera cette nouvelle valeur. Pour l'action "clean" et un domaine donné, le script vidange les anciennes clés.
Pour assurer un tuilage d'une semaine par exemple, il faut donc le jour J lancer le script avec "new", et à J+7 lancer le script avec "clean" sur le même domaine. Ainsi, avant le jour J, la clé N est utilisée pour signer les messages. À partir du jour J, la clé N+1 est utilisée pour signer les messages, mais la clé N est encore disponible pour vérifier de vieux messages qui seraient encore en circulation. À partir de J+7, l'ancienne clé N disparaît.

Syntaxe des fichiers de zone DNS

Deux contraintes ici :
- Le numéro de série de la zone doit être seul sur sa ligne et suivi du commentaire " ; Serial" pour que le script puisse facilement le localiser et l'incrémenter.
- Les enregistrements DNS des clés DKIM doivent être en inclusion dans le fichier de la zone et résider respectivement dans le fichier dkim-active.${DOMAIN} pour les clés actives et dans le fichier dkim-retired.${DOMAIN} pour les clés inactives. Cela donne pour le fichier de zone patpro.net :

...
$INCLUDE "/etc/namedb/master/dkim-active.patpro.net"
$INCLUDE "/etc/namedb/master/dkim-retired.patpro.net"
...

Ainsi, le script peut très facilement déplacer les clés actives vers le fichier dkim-retired quand de nouvelles clés sont fabriquées et intégrées à dkim-active.

OpenDKIM

Les clés doivent être rangées par domaine, dans des répertoires qui portent le nom du domaine. Cela donne chez moi :

opendkim/patpro.net/201501-62e79011.private
opendkim/patpro.net/201501-62e79011.txt
opendkim/patpro.net/201501-82a3edc5.private
opendkim/patpro.net/201501-82a3edc5.txt
opendkim/proniewski.net/201502-e47af46c.private
opendkim/proniewski.net/201502-e47af46c.txt

Les clés utilisées doivent être référencées dans un fichier KeyTable unique que le script analyse pour retrouver les sélecteurs à remplacer, et réécrit avec les nouveaux sélecteurs.

Utilisation

crontab pour renouvellement automatique mensuel :

0 0 1 * *    /usr/local/sbin/rotate-dkim.sh new patpro.net
0 0 8 * *    /usr/local/sbin/rotate-dkim.sh clean patpro.net

ligne de commande pour changer la taille des clés d'un domaine :

rotate-dkim.sh new proniewski.net 2048

Corpus Scripti

#!/usr/local/bin/bash
# rotate DKIM keys
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# settings
ACTION=${1}
DOMAIN=${2}
NBITS=${3}; NBITS=$(( $NBITS+0 )) # force number
CHROOT=/var/db/opendkim
GENKEY=/usr/local/sbin/opendkim-genkey
KeyTable=/usr/local/etc/opendkim/KeyTable
ZONEROOT=/etc/namedb/master
USERGROUP=root:opendkim
ARCHIVE=/tmp/
MAXKEY=4096
MINKEY=512

onerror() {
case "$1" in
	"args")
		echo "usage: $(basename $0) new|clean domain.name [bits]"; exit 1
		;;
	"chroot")
		echo "\$CHROOT must exist and be a directory"; exit 1
		;;
	"genkey")
		echo "opendkim-genkey not found or not executable"; exit 1
		;;
	"ktable")
		echo "KeyTable not found"; exit 1
		;;
	"zoneroot")
		echo "\$ZONEROOT must exist and be a directory"; exit 1
		;;
	"usergroup")
		echo "User and group must exist"; exit 1
		;;
	"archive")
		echo "\$ARCHIVE must exist and be a directory"; exit 1
		;;
	"nbits")
		echo "Bits length must be greater than $MINKEY, and should not be greater than $MAXKEY"; exit 1
		;;
esac
}

# Checks
[ $# -ge 2 ] || onerror "args"
[ ${ACTION} = "new" -o ${ACTION} = "clean" ] || onerror "args"
[ -d ${CHROOT} ] || onerror "chroot"
[ -d ${ZONEROOT} ] || onerror "zoneroot"
[ -d ${ARCHIVE} ] || onerror "archive"
[ -x ${GENKEY} ] || onerror "genkey"
[ -f ${KeyTable} ] || onerror "ktable"
pw usershow ${USERGROUP/:*/} >/dev/null 2>&1 || onerror "usergroup"
pw groupshow ${USERGROUP/*:/} >/dev/null 2>&1 || onerror "usergroup"
if [ $NBITS -ne 0 ]; then
	[ $NBITS -gt $MINKEY ] || onerror "nbits"
	[ $NBITS -le $MAXKEY ] || onerror "nbits"
fi


archive() {
	echo "Putting away files for old DKIM selector ${1}"
	mv -v ${CHROOT}/${DOMAIN}/${1}.* ${ARCHIVE}
}

if [ ${ACTION} = "new" ]; then
	# Keytable: update SELECTOR
	# structure: 
	# arbitrary-name signing-domain:selector:keypath
	# for each line matching DOMAIN: 
	# - get SELECTOR. 
	# - key path derived from CHROOT/SELECTOR

	# get SELECTOR for DOMAIN:
	SELECTORLIST=( $(awk '/^[^#].* '${DOMAIN}':/ {split($2,a,":"); print a[2]}' ${KeyTable}) )

	# for each SELECTOR we must:
	# - destroy/archive private key 
	# - create a new SELECTOR + key + DNS
	# - rewrite KeyTable
	echo ""
	echo "Selector list for domain $DOMAIN has ${#SELECTORLIST[*]} member-s, lets renew each one:"
	for OLDSELECTOR in ${SELECTORLIST[@]}; do
		if [ $NBITS -ne 0 ]; then
			echo "Bits length set on command line to ${NBITS}, overriding current private key bits length"
			BITS=${NBITS}
		else
			echo -n "Get bits length for private key ${OLDSELECTOR}.private: "
			BITS=$(openssl rsa -in ${CHROOT}/${DOMAIN}/${OLDSELECTOR}.private -text -noout | awk '/Private-Key/ {gsub(/[^0-9]/,"",$2); print $2}')
			echo "$BITS"
		fi		
		echo -n "Create new selector: "
		UUID=$(uuidgen)
		SELECTOR="$(date "+%Y%m")-${UUID/-*/}"
		echo "${SELECTOR}"
		echo "Create new private key with ${SELECTOR}:"
		${GENKEY} -v -b ${BITS} -d ${DOMAIN} -D ${CHROOT}/${DOMAIN} -r -s ${SELECTOR}
		# permission adjustments:
		chown ${USERGROUP} ${CHROOT}/${DOMAIN}/${SELECTOR}.*
		chmod u=r,g=r ${CHROOT}/${DOMAIN}/${SELECTOR}.*
		ls -l ${CHROOT}/${DOMAIN}/${SELECTOR}.*
		echo -n "Update KeyTable: "
		sed -i .bkp -e 's/'$OLDSELECTOR'/'${SELECTOR}'/g' ${KeyTable}
		[ $? -eq 0 ] && echo "OK"
		archive ${OLDSELECTOR}
	done
	echo ""
	echo "End of selector renewal."
	# every SELECTOR for DOMAIN is updated, now deal with DNS
fi

echo ""
# calculate new serial for zone $DOMAIN
echo -n "Get old DNS zone serial for ${DOMAIN} and increment: "
NEWSERIAL=$(date +%Y%m%d%I)
OLDSERIAL=$(awk '/ ; Serial/ {print $1}' ${ZONEROOT}/${DOMAIN})
while [ $OLDSERIAL -ge $NEWSERIAL ]; do
	let NEWSERIAL=(NEWSERIAL+1)
done
echo "$OLDSERIAL -> $NEWSERIAL"
#
# Update SERIAL of DNS zone.
echo -n "Change serial in zone file: "
sed -i .bkp -e 's/'$OLDSERIAL' ; Serial/'${NEWSERIAL}' ; Serial/g' ${ZONEROOT}/${DOMAIN}
[ $? -eq 0 ] && echo "OK"

if [ ${ACTION} = "new" ]; then
	echo -n "Move old public keys to dkim-retired.${DOMAIN}: "
	cat ${ZONEROOT}/dkim-active.${DOMAIN} > ${ZONEROOT}/dkim-retired.${DOMAIN}
	[ $? -eq 0 ] && echo "OK"
	echo -n "Copy new public keys to dkim-active.${DOMAIN}: "
	cat ${CHROOT}/${DOMAIN}/*.txt > ${ZONEROOT}/dkim-active.${DOMAIN}
	[ $? -eq 0 ] && echo "OK"
elif [ ${ACTION} = "clean" ]; then
	echo -n "Clean old public keys of dkim-retired.${DOMAIN}: "
	echo "" > ${ZONEROOT}/dkim-retired.${DOMAIN}
	[ $? -eq 0 ] && echo "OK"
fi

echo ""
echo "Everything looks good, lets refresh world:"
named-checkzone ${DOMAIN} ${ZONEROOT}/${DOMAIN}
service named restart
[ ${ACTION} = "new" ] && service milter-opendkim restart

Les commentaires dans le code sont suffisants pour expliquer chaque étape du déroulement du script.

Bibliographie

DKIM
M3AAWG DKIM Key Rotation Best Common Practices (PDF)

Création de mots de passe en ligne de commande

keychainLa création de nouveaux mots de passe est une activité assez peu passionnante, d'autant que dans certains contextes on peut être amené à répéter l'opération plusieurs fois par jour. Plutôt que d'avoir à réfléchir - et donc de produire un résultat influencé par les travers de l'esprit humain - il est préférable de s'en remettre totalement à la machine.
L'aléa est bien meilleur, et le résultat est obtenu bien plus facilement. Il est bien sûr question ici de vrais mots de passe : large palette de caractères, longueur correcte, totalement aléatoires.

Voici deux fonctions assez simples qui trouveront leur place tout naturellement dans votre .bashrc ou .bash_profile sur *BSD (FreeBSD, Mac OS X, etc.). La seule condition étant de disposer des commandes jot et rs déjà présentées dans les articles "jot et seq, créer des séquences en ligne de commande" et surtout "Les bons mots de passe…".

Cette première fonction crée 5 mots de passe aléatoires dont les caractères sont pris entre les codes ASCII 32 (espace) et 126 (tilde). Par défaut, ces mots de passe font 25 caractères de long, mais la commande génère en fait un tirage de 500 caractères, ce qui permet de fabriquer 5 mots de passe de 100 chiffres, lettres, symboles en indiquant la longueur souhaitée comme paramètre. L'astuce ici tient dans l'utilisation de la tabulation comme séparateur en sortie de jot, ce qui permet d'avoir l'espace présent dans les mots de passe en sortie.

mdp-complexe() {
jot -s $'\t' -r -c 500 32 126 | rs -c$'\t' -g0 0 ${1-25} | head -5
}

Exemple d'utilisation :

$ mdp-complexe 
fnc\Y}02PSc6}{;0`T}LOeN?\
5W Y1Y8!o<VlTA2vuE}SU)g?]
'kGhRa=u|hJW#;6aDd[A&UR_/
n|$_f3S?[`pO2":+0e<;aTi\v
.nU\i.PhbPE5TfuXY)g+DiN~g

En imposant la longueur :

$ mdp-complexe 50
9Tu1StsL $Gk@K<X\R)xKv{JXYe|g>L^2T;|*GMASee@HX.Epj
T%2 N;VvLFVi]s>_~xpo^~vD8b2.Fb$02ay9sLgjW#,TQ>8JAO
7EyWUC)Cqm2F,72mIf$#vh3<sTx%j>*v't{Fump=Fb5cJeZir$
RfS638d~a.T#StF.t3%Z*`RG)/?#; H?liH2DielVvs#4IBp0a
G`OyIOzpYtt5Sh!92/zdp7^C98r.OtIV8Fk_ilYCvVI6~e@17T

Cette seconde fonction est basée sur la première, mais un filtrage est imposé à la sortie de jot pour ne garder qu'un sous-ensemble des caractères générés. Ici, les caractères a-z, A-Z, 0-9, _, / et - sont conservés. L'approche est relativement simple : il suffit d'ajouter à la liste entre [ ] les caractères que l'on souhaite conserver dans les mots de passe :

mdp-simple() {
jot -s $'\t' -r -c 500 32 126 | sed "s,[^a-zA-Z0-9_/-]"$'\t'",,g"|\
 rs -c$'\t' -g0 0 ${1-25} | head -5
}

Ce qui donne :

$ mdp-simple 
y0q/EKoZKN9ZqGGhr1triaB0l
VMFUNYNpopAu9NleSv0BxuuTh
W07eE4ef1A99yAl0UmP6EHo6_
9X4CWx7qgML6qxn9Y0nxhONpr
lw_eY4DYY414mrllNxe/FSUfM

Stockez ensuite le mot de passe choisi dans votre trousseau de mots de passe sécurisé.

Créer un trou noir DNS

Même si la question a déjà été largement traitée, je livre ici ma propre version d'un trou noir DNS. Le DNS blackhole, en anglais dans le texte, permet par exemple de s'assurer (dans une certaine mesure) que les utilisateurs de votre réseau ne peuvent pas se connecter trop facilement à des sites web vecteurs d'infection.
Le fonctionnement est simple : vous avez un réseau local, chez vous ou dans votre entreprise, et vous mettez à disposition de vos utilisateurs un serveur DHCP pour que les équipements qui se connectent puissent obtenir une adresse IP. Je pars du principe que sur ce réseau local, vous disposez aussi d'un serveur DNS. Un utilisateur se connecte avec votre réseau : le serveur DHCP lui donne une adresse IP pour que la nouvelle machine puisse parler sur le réseau, et fourni aussi l'adresse de votre serveur DNS. C'est donc par ce serveur que vous maitrisez que la machine de l'utilisateur va convertir les noms de domaine (www.patpro.net) en adresse IP (193.30.227.216).
Comme vous avez la maîtrise du serveur DNS, vous avez la maîtrise de la résolution des noms de domaine. Vous pouvez donc décider de bloquer la résolution de certains noms de sites web qui posent problème (gros pourvoyeurs de malware, régies publicitaires, facebook, etc.).

Je vais me baser ici sur la liste de malwaredomains.com qui regroupe environ 20000 noms de domaine qui sont, ou ont été, impliqués dans la distribution de malware (en général en infectant le visiteur imprudent). Il s'agit donc pour moi de défendre mon réseau en protégeant les utilisateurs.

Les informations sont largement inspirées - voire pompées - de la prose anglophone de Paul.

Pré-requis : un serveur DNS BIND que vous maîtrisez, et un réseau avec serveur DHCP qui indique l'adresse IP du DNS, de sorte que les clients qui se connectent utiliseront par défaut le serveur DNS sus-mentionné. Il faut bash, curl, et jot (sur linux il faut remplacer jot par seq).

Obtenir la liste des zones DNS à mettre en trou noir.

Préparer le lieu de stockage :

mkdir /etc/namedb/blackhole

J'utilise le script suivant, à lancer en root via une crontab (une fois par semaine suffit) :

#!/usr/local/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
ZONEFILE=/etc/namedb/blackhole/spywaredomains.zones
MIRRORS[1]="http://mirror1.malwaredomains.com/files/spywaredomains.zones"
MIRRORS[2]="http://mirror2.malwaredomains.com/files/spywaredomains.zones"
MIRRORS[3]="http://dns-bh.sagadc.org/spywaredomains.zones"

# fonctionne sur BSD uniquement, pour linux il faut
# remplacer par `seq`
CHOOSE=$(jot -r 1 1 ${#MIRRORS[@]})

if [[ "$(/usr/local/bin/curl ${MIRRORS[CHOOSE]} -z ${ZONEFILE} -o ${ZONEFILE}_new -s -L -w %{http_code})" == "200" ]]; then
	rm -f ${ZONEFILE}_old
	mv ${ZONEFILE} ${ZONEFILE}_old
	mv ${ZONEFILE}_new ${ZONEFILE}
	named-checkconf -z | grep -v ": loaded serial"
	if [ ${PIPESTATUS[0]} -eq 0 ]; then
		service named restart
	else
		echo "Error loading new zone file, reversing..."
		mv ${ZONEFILE} ${ZONEFILE}_invalid_$(date "+%Y%m%d")
		mv ${ZONEFILE}_old ${ZONEFILE}
	fi
fi

C'est loin d'être foolproof, mais ça fait le travail. Les options de curl permettent de ne télécharger le fichier que si il a été modifié depuis votre dernière mise à jour. le script choisit un miroir au hasard au moment de faire le comparatif ce qui évite de charger toujours le même serveur.

Configurer BIND pour gérer les zones téléchargées.

Il faut ajouter dans la configuration du serveur DNS une directive d'inclusion du fichier téléchargé ci-dessus. Il suffit pour cela d'ajouter la ligne suivante à la fin du fichier /etc/named/named.conf :

 include "/etc/namedb/blackhole/spywaredomains.zones";

Le fichier spywaredomains.zones fait pointer chaque zone vers un unique fichier de définition qu'il convient de créer et de renseigner : /etc/namedb/blockeddomain.hosts.
Ce fichier contient une définition minimaliste de zone DNS BIND. Faites-la pointer vers un serveur web existant, cela vous permettra d'afficher à vos utilisateurs une page web d'explication :

$TTL    86400   ; one day
@       IN     SOA    NOM-DE-VOTRE-SERV-DNS. NOM-DE-VOTRE-SERV-DNS. (
	1
	28800   ; refresh  8 hours
	7200    ; retry    2 hours
	864000  ; expire  10 days
	86400 ) ; min ttl  1 day

	NS     NOM-DE-VOTRE-SERV-DNS.

	       A       IP-DE-VOTRE-SERVEUR-WEB
*	IN     A       IP-DE-VOTRE-SERVEUR-WEB

Si vous ne souhaitez pas informer vos utilisateurs, remplacez simplement IP-DE-VOTRE-SERVEUR-WEB par 127.0.0.1. Mais je recommande tout de même la publication d'une page web d'information sur la quelle les utilisateurs pourront arriver si ils tentent de joindre un domaine présent dans la liste noire.

Avec ça, votre serveur DNS est transformé en trou noir pour l'ensemble des domaines répertoriés chez malwaredomains.com. En analysant les logs de votre serveur web, vous pourrez aussi déterminer rapidement qui sur votre réseau est infecté ou risque de l'être.

Références.

edit : correction d'une coquille dans le script

S’envoyer des SMS multilignes via l’API de free-mobile

Free vient de mettre à disposition de ses abonnés Free-mobile un moyen simple de générer via une API accessible en ligne des SMS à soi-même. L'utilisation est globalement très simple et tient presque toutes ses promesses (actuellement il ne semble pas possible de faire du POST, seul le GET fonctionne).
Il suffit aux intéressés de se rendre sur leur interface de gestion de compte Free-mobile, dans la section "Mes options", et d'y activer (vers le bas de la page) l'option ad-hoc. Une fois l'option activée, Free vous gratifie d'un mot de passe dédié à cet usage, et vous renseigne un peu sur le fonctionnement du service :

free-mobile-notification

Une fois l'option activée, l'utilisation se résume à une requête HTTP de cette forme :

curl 'https://smsapi.free-mobile.fr/sendmsg?user=***&pass=***&msg=coucou'

Vous recevez alors sur votre téléphone Free-mobile le SMS contenant le message "coucou".

La création d'un message multiligne est un peu plus délicate. La plupart des plateforme Unix/Linux envoie en guise de séparateur de ligne un \n qui se traduit par %0a une fois "url-encodé". Malheureusement, l'API n'accepte pas ce %0a et le message sera tronqué à la première occurrence.
Par contre, le séparateur de ligne \r, encodé en %0d est bien accepté par l'API Free-mobile. Il faut donc, avant d'envoyer un message sur plusieurs lignes, traduire les \n en \r.

J'ai conçu un script shell pour mes besoins personnels. Il prend ses données en entrée standard, par exemple via un pipe :

echo "mon message" | sms.sh

Ce qui est plus souple pour moi dans bien des situations, que de devoir invoquer le script et de lui servir le message comme argument, par exemple :

autresms.sh mon message

Voici donc le code de mon script qui prend les messages via stdin, et qui converti les \n en \r. Notez que le SMS à l'arrivée ne contient pas de saut de ligne, donc prévoyez des espaces en fin de ligne si vous ne voulez pas que la fin de ligne soit collée au début de la ligne suivante.

#!/usr/local/bin/bash  
#
# push notification via smsapi.free-mobile.fr
#
LOGGEROPT="-p security.notice -t SMS"
REPLOGG=1
REPMAIL=1
MAILTO=root
USR="****"
PSW="****"
URL="https://smsapi.free-mobile.fr/sendmsg"
CURL=/usr/local/bin/curl

report() {
        [ ${REPLOGG} -eq 1 ] && echo $@ | logger ${LOGGEROPT}
        [ ${REPMAIL} -eq 1 ] && echo $@ | mail -s "SMS $@" ${MAILTO}
}

eval $(${CURL} --insecure -G --write-out "c=%{http_code} u=%{url_effective}" \
        -o /dev/null -L -d user=${USR} -d pass=${PSW} --data-urlencode msg="$(cat|tr '\n' '\r')" ${URL} 2>/dev/null | tr '&' ';')

echo ${c} ${u}\&pass=***\&msg=${msg} | logger ${LOGGEROPT}

case ${c} in
        "200")
                exit 0
                ;;
        "400")
                report "parameter missing" ; exit 400
                ;;
        "402")
                report "too many SMS..." ; exit 402
                ;;
        "403")
                report "service unavailable for user, or wrong credentials" ; exit 403
                ;;
        "500")
                report "server error, try later" ; exit 500
                ;;
        *)
                report "unexpected result" ; exit 600
                ;;
esac

Utilisation :

echo "mon message 
sur plusieurs 
lignes" | sms.sh

Il reste à faire :
- ajouter l'enregistrement dans les log système de chaque utilisation du script sms.sh
- ajouter la gestion des codes de retour HTTP autres que 200.

Attention
Pour bénéficier d'une gestion d'erreur et d'une trace dans les log de la machine j'ai utilisé une grosse ruse très sale avec eval. Cela permet de créer des variables à la volée à partir de données fournie par l'utilisateur. C'est éminemment risqué car tout ce est qui généré par c=%{http_code} u=%{url_effective} sera évalué sans distinction (avec potentiellement des exécutions de code).
Pour un système de monitoring c'est un risque négligeable : les messages sont connus et fixés par le logiciel qui les génère, sans interaction avec l'utilisateur. Pour une utilisation dans tout autre mécanisme la prudence est requise.

Les bons mots de passe…

jtr-crossword-10-donation_design Donner des conseils en matière de mot de passe, c'est délicat. Ça implique en général de croire un minimum dans le système de protection par mot de passe. Et comme toute la sécurité en général, le mot de passe est un compromis, et comme tout compromis, il est donc faillible. Je crois personnellement de moins en moins au principe du mot de passe pour protéger des informations vraiment vitales. Sans doute suis-je encouragé dans cette impression par des démonstrations récentes de casse de mots de passe complexes-mais-humainement-utilisables.
Je ne prendrai qu'un exemple : des mots de passe aussi complexes en apparence que "momof3g8kids" ou "qeadzcwrsfxv1331" peuvent être cassés en quelques heures.
En fait, dès l'instant où vous enregistrez un mot de passe sur un site web vous n'avez aucune maitrise (ni information en général) sur la qualité du stockage de ce mot de passe. Souvent, mais pas toujours, le mot de passe est chiffré, ou hashé. Mais rien ne vous garantit que cette protection est suffisante. Compte tenu de la puissance des machines et logiciels actuels, je doute sincèrement qu'elle le soit.
La probabilité pour qu'on casse votre mot de passe par des tentatives d'authentification successives sur votre compte hotmail est extrêmement faible. Par contre, celle qu'un pirate vole la base de mot de passe du forum d'équitation auquel vous êtes inscrit n'est pas négligeable (piratage d'un serveur de jeu en ligne de RockYou : plus de 32 millions de mots de passe aspirés - stockés en clair...).
Une fois la base de mots de passe dérobée, le pirate n'a plus qu'à s'y attaquer, si ils ne sont pas directement stockés en clair bien sûr. Quelques heures après en général, il aura cassé un échantillon non négligeable de mots de passe, dont chacun est associé à des données intéressantes telles que login, adresse email, etc.
À partir de là, le pirate va pouvoir tester ces mots de passe / login / emails pour se connecter à des sites comme facebook, gmail, hotmail, apple store, etc.
En 2010 je conseillais d'utiliser des mots de passe par type de risques, ce qui évitait d'en avoir un différent pour chaque site tout en cloisonnant un peu les choses. Depuis, je suis un peu plus désabusé. Avoir une grosse poignée de bons mots de passe ne semble plus suffisant. Je me garderai aussi à présent de donner quelque conseil que ce soit tant le problème a pris une ampleur hors de tout contrôle (PRISM, etc.).

Voici ce que je fais depuis quelques temps, vous n'êtes pas obligé de faire pareil :

- une adresse email différente pour chaque site sur le quel je m'inscris
- un mot de passe différent à chaque fois
- un mot de passe long, aléatoire et donc impossible à retenir

Le tout est stocké dans une application locale de type keychain. Ce trousseau n'est pas sauvegardé dans le cloud (mais il est tout de même sauvegardé, hein). Le trousseau est protégé par un mot de passe fiable que j'ai dans la tête et qui n'est pas utilisé ailleurs.

Mes mots de passe ressemblent à ça désormais :

xwMxTgX7g@*Gd&BW31dlc`ME<
j?UI?@CQe`=p5D(-w<q{FUOWb
&X52&/*.U4Otm/rH?oT-VhTIn
$kUT5_XP\ncw9cYM_\8NHbIm*
...

Des chaînes aléatoires de 25 caractères, choisis parmi 93 lettres, chiffres et symboles. Autant dire qu'avec ça, j'ai bon espoir que mon mot de passe se trouve dans les derniers à être craqués lors d'une attaque. En prime comme je n'utilise chacun que pour un site unique, la valeur ajoutée de la casse de ce mot de passe est très faible.

Si vous êtes sur BSD (Mac OS X, FreeBSD…) vous pouvez utiliser la commande jot pour créer autant de mots de passe que vous souhaitez :

jot -r -c 200 33 126 | rs -g0 0 25

-r : random
-c 200 : 200 caractères
33 126 : du "!" au "~" dans la table ASCII (man ascii)
rs -g0 0 25 : re-formate en ligne de 25 caractères

Avec ça, je vais bien tenir 2 ou 3 ans avant de devoir allonger les chaînes…

Munin plugins for CRM114

I'm using CRM114-based SpamAssassin plugin for spam filtering at work, and at home. Client-side, I'm able to check CRM114 contribution by a simple look at headers of an email message. But that won't tell you how CRM114 is behaving on the server side. The main concern on the server, is to check the two "databases" spam.css and nonspam.css.
I've chosen to monitor both average packing density and documents learned metrics for both files. The first one goes from 0 to 1. When its value reaches 0.9 and beyond, you must make sure your antispam filtering does not become sluggish.
The second one represents the number of emails from which CRM114 has been trained. Basically, CRM114 is only trained from its mistakes : if an email is flagged as spam by mistake, you can train CRM114 to learn it as nonspam, but if an email is properly flagged as spam, you can't train CRM114 to learn it as spam.

I've designed two Munin plugins, they run on FreeBSD but portage to another UNIX is just a matter of path.

Monitor "average packing density" (crm114_packingdensity): shows how much your CRM114 bases are encumbered

#!/usr/local/bin/bash
#
# Parameters:
#
# 	config   (required)
# 	autoconf (optional - used by munin-config)
#
# Magick markers (optional - used by munin-config and som installation
# scripts):
#%# family=auto
#%# capabilities=autoconf

export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# config
if [ "$1" = "config" ]; then
    echo 'graph_title CRM114 css file stat average packing density'
    echo 'graph_vlabel Packing density'
    echo 'graph_category ANTISPAM'
    echo 'graph_args --upper-limit 1 --lower-limit 0'
    echo 'graph_info This graph shows the average packing density for CRM114 css files'
    echo 'spam.label spam'
    echo 'nonspam.label nonspam'
    exit 0
fi

cssutil -r -b /var/amavis/.crm114/spam.css | awk '/Average packing density/ {print "spam.value "$5}'
cssutil -r -b /var/amavis/.crm114/nonspam.css | awk '/Average packing density/ {print "nonspam.value "$5}'

Monitor "documents learned" (crm114_documentslearned): shows how many emails CRM114 has learned from

#!/usr/local/bin/bash
#
# Parameters:
#
# 	config   (required)
# 	autoconf (optional - used by munin-config)
#
# Magick markers (optional - used by munin-config and som installation
# scripts):
#%# family=auto
#%# capabilities=autoconf

export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# config
if [ "$1" = "config" ]; then
    echo 'graph_title CRM114 css file stat documents learned'
    echo 'graph_vlabel Documents learned'
    echo 'graph_category ANTISPAM'
    echo 'graph_args --lower-limit 0'
    echo 'graph_info This graph shows the documents learned count for CRM114 css files'
    echo 'spam.label spam'
    echo 'nonspam.label nonspam'
    exit 0
fi

cssutil -r -b /var/amavis/.crm114/spam.css | awk '/Documents learned/ {print "spam.value "$4}'
cssutil -r -b /var/amavis/.crm114/nonspam.css | awk '/Documents learned/ {print "nonspam.value "$4}'

Both plugins need read access to spam.css and nonspam.css, depending on your setup it will require special privileges. You might want to add in /usr/local/etc/munin/plugin-conf.d/plugins.conf:

[crm114_*]
user root

Running source dedicated server on FreeBSD 9.x

steam logo © steamI've covered this subject in french back in 2010, but things have evolved, and installing scrds on FreeBSD is not as straightforward as it used to be. Prerequisites are the same, you must first install the linux compatibility layer:

# As root, load the module & make sure it will be loaded after reboot:
kldload /boot/kernel/linux.ko
echo 'linux_enable="YES"' >> /etc/rc.conf

Then, and only after loading the linux module, install linux_base:

portinstall -PP linux_base-f10
(or portinstall linux_base-f10 if the command above fails)

Add linproc to /etc/fstab,

linproc         /compat/linux/proc      linprocfs       rw 0 0

and mount it:

mount -a

Install linux-steam:

portinstall linux-steam

Add a steamuser user (or whatever name you want). The user must be unprivileged, and must not be able to log into the machine. Set its home to /usr/local/steam and its shell to /usr/sbin/nologin.
Then, update the client linux-steam:

chown -R steamuser /usr/local/steam
cd /usr/local/steam
sudo -u steamuser ./steam
sudo -u steamuser ./steam (yes, do it twice, to make sure it's up to date)

Back in past, you would have used the steam client to install and update games, but people at steam thought it was way too easy. So they made it more complicated. Now you have to install a dedicated tool in order to install games and keep them updated: steamcmd.
Point your browser to the SteamCMD wiki page at Valvesoftware and read it. Then, download the linux version and extract in a dedicated directory (/usr/games for example).

cd /usr/games
fetch http://media.steampowered.com/client/steamcmd_linux.tar.gz
tar -xzf steamcmd_linux.tar.gz

By default, the steamcmd command will maintain your game library into /usr/local/steam/Steam/SteamApps, you might want to create a soft link in order to put your game library somewhere else (the force_install_dir option of steamcmd would not work on my server). At least, change owners for /usr/games/SteamCMD directory, to make sure steamuser can update its content:

mkdir /usr/local/steam/Steam
mkdir /usr/games/SteamApps
ln -s /usr/games/SteamApps /usr/local/steam/Steam/SteamApps
chown steamuser /usr/games/SteamApps
chown -R steamuser /usr/games/SteamCMD

Then you might have to change shebangs in SteamCMD/steam.sh and SteamCMD/steamcmd.sh to use your own bash (probably /usr/local/bin/bash instead of /bin/bash). After what you can launch steamcmd.sh:

cd /usr/games/SteamCMD
sudo -u steamuser ./steamcmd.sh

The program should auto-update, and present you with a Steam> prompt.
Then, you must login. For L4D2, and most games, you can login anonymously:

login anonymous

Choose your game from the list on the wiki, and use its ID to install/update:

app_update 222860 validate

It's possible to automate SteamCMD, for daily update of your games. For example you can create a shell script like this one:

#!/usr/local/bin/bash
cd /usr/games/SteamCMD || exit 1
/usr/local/bin/sudo -u steamuser ./steamcmd.sh +login anonymous +app_update 222860 validate +quit

Running your game server does not change much from my previous post. The path of game folder is the only important modification. I've created a shell script to launch L4D2 server:

#!/usr/local/bin/bash
ROOT="/patpro/games/SteamApps/common/Left 4 Dead 2 Dedicated Server"
SUDO="/usr/local/bin/sudo -u steamuser"
SCREEN=/usr/local/bin/screen
NICE="/usr/bin/nice -n -5"
STEAMRUNARGS="-ip PUT-YOUR-IP-ADDRESS-HERE -fps_max 0 -sys_ticrate 1000"

cd "${ROOT}" || exit 1
${SCREEN} ${NICE} ${SUDO} ./srcds_run ${STEAMRUNARGS}

Open TCP and UDP ports 26901 and 27015 in your firewall, and edit SteamApps/common/Left 4 Dead 2 Dedicated Server/left4dead2/cfg/server.cfg to tweak your settings.

Happy gaming!

Track mpm-itk problems with truss

Some background

I've some security needs on a shared hosting web server at work and I've ended up installing Apache-mpm-itk in place of my old vanilla Apache server. MPM-ITK is a piece of software (a set of patches in fact) you apply onto Apache source code to change it's natural behavior.
Out of the box, Apache spawns a handful of children httpd belonging to user www:www or whatever your config is using. Only the parent httpd belongs to root.
Hence, every single httpd must be able to read (and sometimes write) web site files. Now imagine you exploit a vulnerability into a php CMS, successfully injecting a php shell. Now through this php shell, you are www on the server, you can do everything www can, and it's bad, because you can even hack the other web sites of the server that have no known vulnerability.
With MPM-ITK, Apache spawns a handfull of master processes running as root, and accordingly to your config files, each httpd serving a particular virtual host or directory will switch from root to a user:group of your choice. So, one httpd process currently serving files from web site "foo" cannot access file from web site "bar": an attacker exploiting a vulnerability of one particular web site won't be able to hack every other web site on the server.

More background

That's a world you could dream of. In real world, that's not so simple. In particular, you'll start having troubles as soon as you make use of fancy features, especially when you fail to provide a dedicated virtual host per user.
On the shared server we host about 35 vhosts for 250 web sites, and we can't afford to provide every user with his dedicated vhost. The result is a given virtual host with a default value for the fallback user:group (say www:www), and each web site configured via Directory to use a different dedicated user.

When a client GET a resource (web page, img, css...) it generally keeps the connection opened with the httpd process. And it can happen that a resource linked from a web page sits into another directory, belonging to another user. The httpd process has already switched from root to user1 to serve the web page, it can't switch to user2 to serve the linked image from user2's directory. So Apache drops the connection, spawns a new httpd process, switches to user2, and serves the requested resource.
When it happens, you can read things like this into your Apache error log:

[warn] (itkmpm: pid=38130 uid=1002, gid=80) itk_post_perdir_config(): 
initgroups(www, 80): Operation not permitted
[warn] Couldn't set uid/gid/priority, closing connection.

That's perfectly "legal" behavior, don't be afraid, unless you read hundreds of new warning every minute.
If you host various web sites, belonging to various users, into the same vhost, you're likely to see many of these triggered by the /favicon.ico request.

Where it just breaks

When things are getting ugly is the moment a user tries to use one of your available mod_auth* variant to add some user authentication (think .htaccess). Remember, I host many web sites in a single vhost, each one into its own directory with its own user:group.

Suddenly every single visitor trying to access the protected directory or subdirectory is disconnected. Their http client reports something like this:

the server unexpectedly dropped the connection...

and nothing else is available. The error, server-side, is the same initgroups error as above, and it does not help at all. How would you solve this? truss is your friend.

Where I fix it

One thing I love about FreeBSD is the availability of many powerful tools out of the box. When I need to track down a strange software behavior, I feel very comfortable on FreeBSD (it doesn't mean I'm skilled). truss is one of my favorites, it's simple, straightforward and powerful.
What you need to use truss is the PID of your target. With Apache + MPM-ITK, processes won't stay around very long, and you can't tell which one you will connect to in advance. So the first step is to buy yourself some precious seconds so that you can get the PID of your target before the httpd process dies. Remember, it dies as soon as the .htaccess file is parsed. Being in production, I could not just kill everything and play alone with the server, so I choose another way. I've created a php script that would run for few seconds before ending. Server side, I've prepared a shell command that would install the .htaccess file I need to test, and start truss while grabbing the PID of my target. On FreeBSD, something like this should do the trick:

cd /path/to/user1/web/site
mv .htaccess_inactive .htaccess && truss -p $(ps auxw|awk '/^user1/ {print $2}')

First http GET request, the .htaccess file is not present, an httpd process switches from root to user1, starts serving the php script. I launch my command server-side: it puts .htaccess in place, gets the PID of my httpd process, and starts truss.
The php script ends and returns its result, client-side I refresh immediately (second GET request), so that I stay on the same httpd process. My client is disconnected as soon as the httpd process has parsed the .htaccess file. At this point, truss should already be dead. I've the complete trace of the event. The best is to read the trace backward from the point where httpd process issue an error about changing UID or GID:

01: setgroups(0x3,0x80a8ff000,0x14,0x3,0x566bc0,0x32008) 
    ERR#1 'Operation not permitted'
02: getgid()					 = 80 (0x50)
03: getuid()					 = 8872 (0x22a8)
04: getpid()					 = 52942 (0xcece)
05: gettimeofday({1364591872.453335 },0x0)		 = 0 (0x0)
06: write(2,"[Fri Mar 29 22:17:52 2013] [warn"...,142) = 142 (0x8e)
07: gettimeofday({1364591872.453583 },0x0)		 = 0 (0x0)
08: write(2,"[Fri Mar 29 22:17:52 2013] [warn"...,85) = 85 (0x55)
09: gettimeofday({1364591872.453814 },0x0)		 = 0 (0x0)
10: shutdown(51,SHUT_WR)				 = 0 (0x0)

Line 01 is the one I'm looking for: the httpd process is trying to change groups and fails, line 02 to 05 it's gathering data for the log entry, line 06 it's writing the error to the log file. 07 & 08: same deal for the second line of log.

From that point in time, moving up shows that it tried to access an out-of-directory resource, and that resource is an html error page! Of course, it makes sense, and it's an hard slap on the head (RTFM!).

01: stat("/user/user1/public_html/bench.php",{ 
    mode=-rw-r--r-- ,inode=4121,size=7427,blksize=7680 }) = 0 (0x0)
02: open("/user/user1/public_html/.htaccess",0x100000,00) = 53 (0x35)
03: fstat(53,{ mode=-rw-r--r-- ,inode=4225,size=128,blksize=4096 }) = 0 (0x0)
04: read(53,"AuthType Basic\nAuthName "Admin "...,4096) = 128 (0x80)
05: read(53,0x80a8efd88,4096)			 = 0 (0x0)
06: close(53)					 = 0 (0x0)
07: open("/user/user1/public_html/bench.php/.htaccess",0x100000,00) 
    ERR#20 'Not a directory'
08: getuid()					 = 8872 (0x22a8)
09: getgid()					 = 80 (0x50)
10: stat("/usr/local/www/apache22/error/HTTP_UNAUTHORIZED.html.var",{ 
    mode=-rw-r--r-- ,inode=454787,size=13557,blksize=16384 }) = 0 (0x0)
11: lstat("/usr/local/www/apache22/error/HTTP_UNAUTHORIZED.html.var",{ 
    mode=-rw-r--r-- ,inode=454787,size=13557,blksize=16384 }) = 0 (0x0)
12: getuid()					 = 8872 (0x22a8)
13: setgid(0x50,0x805d43d94,0x64,0x800644767,0x101010101010101,0x808080808080
    8080) = 0 (0x0)

line 13 shows the beginning of setgid process, and 10/11 shows the culprit. Up from here is the regular processing of the .htaccess file.

RTFM

When you use mod_auth* to present visitors with authentication, the server issues an error, and most of the time, this error is sent to the client with a dedicated header, and a dedicated html document (think "404"). When the error is about authentication (error 401), most clients hide the html part, and present the user with an authentication popup.
But the html part is almost always a physical file somewhere in the server directory tree. And it's this particular file the httpd process was trying to reach, issuing an initgroups command, and dying for not being allowed to switch users.
I've found in my Apache config the definition of ErrorDocument:

ErrorDocument 400 /error/HTTP_BAD_REQUEST.html.var
ErrorDocument 401 /error/HTTP_UNAUTHORIZED.html.var
ErrorDocument 403 /error/HTTP_FORBIDDEN.html.var
...

and replaced them all by a file-less equivalent, so Apache won't have any error file to read and will just send a plain ASCII error body (it saves bandwidth too):

ErrorDocument 400 "400 HTTP_BAD_REQUEST"
ErrorDocument 401 "401 HTTP_UNAUTHORIZED"
ErrorDocument 403 "403 HTTP_FORBIDDEN"
...

I've restarted Apache, and authentication from mod_auth* started to work as usual.
Same approach applies to almost any mpm-itk problem when it's related to a connection loss with Couldn't set uid/gid/priority, closing connection error log. You locate the resource that makes your server fail, and you find a way to fix the issue.