[PATCH] ssh: Add option to present certificates on command line

Bhat, Meghana mebhat at akamai.com
Fri Jul 31 03:44:15 AEST 2015


Hi,

I just created the bug for this patch at this URL:
https://bugzilla.mindrot.org/show_bug.cgi?id=2436

Thanks,
Meghana

From: Damien Miller <djm at mindrot.org<mailto:djm at mindrot.org>>
Date: Wednesday, July 29, 2015 at 8:53 PM
To: Meghana Bhat <mebhat at akamai.com<mailto:mebhat at akamai.com>>
Cc: "openssh-unix-dev at mindrot.org<mailto:openssh-unix-dev at mindrot.org>" <openssh-unix-dev at mindrot.org<mailto:openssh-unix-dev at mindrot.org>>
Subject: Re: [PATCH] ssh: Add option to present certificates on command line

Hi,

Thanks for this. Could I ask you to create a bug at
https://bugzilla.mindrot.org/ and attach your patch there? We're pretty
much closed for the 7.0 release ATM but we'll look at it once we're done.

I guess something similar for ssh-add would make sense too...

-d

On Wed, 29 Jul 2015, Meghana Bhat wrote:

Allow users to specify certificates to be used for authentication on
the command line with the '-z' argument when running ssh. For
successful authentication, the key pair associated with the certificate
must also be presented during the ssh.
Certificates may also be specified in ssh_config as a
CertificateFile.
This option is meant the address the issue mentioned in the following
exchange:
http://lists.mindrot.org/pipermail/openssh-unix-dev/2013-September/031629.html
Patch developed against 6.9p.
---
  readconf.c          |  48 +++++++++++++++++++
  readconf.h          |   6 +++
  regress/Makefile    |   1 +
  regress/ssh-cert.sh | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++
  ssh.1               |  17 +++++++
  ssh.c               |  85 +++++++++++++++++++++++++++++++-
  ssh.h               |   7 +++
  ssh_config.5        |  33 +++++++++++++
  sshconnect2.c       |  47 ++++++++++++++++--
  9 files changed, 375 insertions(+), 5 deletions(-)
  create mode 100644 regress/ssh-cert.sh
diff --git a/readconf.c b/readconf.c
index f1c860b..b34213d 100644
--- a/readconf.c
+++ b/readconf.c
@@ -135,6 +135,7 @@ typedef enum {
   oPasswordAuthentication, oRSAAuthentication,
   oChallengeResponseAuthentication, oXAuthLocation,
   oIdentityFile, oHostName, oPort, oCipher, oRemoteForward, oLocalForward,
+ oCertificateFile,
   oUser, oEscapeChar, oRhostsRSAAuthentication, oProxyCommand,
   oGlobalKnownHostsFile, oUserKnownHostsFile, oConnectionAttempts,
   oBatchMode, oCheckHostIP, oStrictHostKeyChecking, oCompression,
@@ -202,6 +203,7 @@ static struct {
   { "identityfile", oIdentityFile },
   { "identityfile2", oIdentityFile }, /* obsolete */
   { "identitiesonly", oIdentitiesOnly },
+ { "certificatefile", oCertificateFile },
   { "hostname", oHostName },
   { "hostkeyalias", oHostKeyAlias },
   { "proxycommand", oProxyCommand },
@@ -366,6 +368,37 @@ clear_forwardings(Options *options)
  }

  void
+add_certificate_file(Options *options, const char *dir, const char *filename,
+    int userprovided)
+{
+ char *path;
+ int i;
+
+ if (options->num_certificate_files >= SSH_MAX_CERTIFICATE_FILES)
+ fatal("Too many certificate files specified (max %d)",
+     SSH_MAX_CERTIFICATE_FILES);
+
+ if (dir == NULL) /* no dir, filename is absolute */
+ path = xstrdup(filename);
+ else
+ (void)xasprintf(&path, "%.100s%.100s", dir, filename);
+
+ /* Avoid registering duplicates */
+ for (i = 0; i < options->num_certificate_files; i++) {
+ if (options->certificate_file_userprovided[i] == userprovided &&
+     strcmp(options->certificate_files[i], path) == 0) {
+ debug2("%s: ignoring duplicate key %s", __func__, path);
+ free(path);
+ return;
+ }
+ }
+
+ options->certificate_file_userprovided[options->num_certificate_files] =
+     userprovided;
+ options->certificate_files[options->num_certificate_files++] = path;
+}
+
+void
  add_identity_file(Options *options, const char *dir, const char *filename,
      int userprovided)
  {
@@ -981,6 +1014,20 @@ parse_time:
   }
   break;

+ case oCertificateFile:
+ arg = strdelim(&s);
+ if (!arg || *arg == '\0')
+ fatal("%.200s line %d: Missing argument.", filename, linenum);
+ if (*activep) {
+ intptr = &options->num_certificate_files;
+ if (*intptr >= SSH_MAX_CERTIFICATE_FILES)
+ fatal("%.200s line %d: Too many identity files specified (max %d).",
+     filename, linenum, SSH_MAX_CERTIFICATE_FILES);
+ add_certificate_file(options, NULL,
+     arg, flags & SSHCONF_USERCONF);
+ }
+ break;
+
   case oXAuthLocation:
   charptr=&options->xauth_location;
   goto parse_string;
@@ -1625,6 +1672,7 @@ initialize_options(Options * options)
   options->hostkeyalgorithms = NULL;
   options->protocol = SSH_PROTO_UNKNOWN;
   options->num_identity_files = 0;
+ options->num_certificate_files = 0;
   options->hostname = NULL;
   options->host_key_alias = NULL;
   options->proxy_command = NULL;
diff --git a/readconf.h b/readconf.h
index bb2d552..f839016 100644
--- a/readconf.h
+++ b/readconf.h
@@ -94,6 +94,11 @@ typedef struct {
   char   *identity_files[SSH_MAX_IDENTITY_FILES];
   int    identity_file_userprovided[SSH_MAX_IDENTITY_FILES];
   struct sshkey *identity_keys[SSH_MAX_IDENTITY_FILES];
+
+ int num_certificate_files; /* Number of extra certificates for ssh. */
+ char *certificate_files[SSH_MAX_CERTIFICATE_FILES];
+ int certificate_file_userprovided[SSH_MAX_CERTIFICATE_FILES];
+ struct sshkey *certificates[SSH_MAX_CERTIFICATE_FILES];

   /* Local TCP/IP forward requests. */
   int     num_local_forwards;
@@ -194,5 +199,6 @@ void dump_client_config(Options *o, const char *host);
  void add_local_forward(Options *, const struct Forward *);
  void add_remote_forward(Options *, const struct Forward *);
  void add_identity_file(Options *, const char *, const char *, int);
+void add_certificate_file(Options *, const char *, const char *, int);

  #endif /* READCONF_H */
diff --git a/regress/Makefile b/regress/Makefile
index cba83f4..67455a8 100644
--- a/regress/Makefile
+++ b/regress/Makefile
@@ -74,6 +74,7 @@ LTESTS= connect \
   hostkey-agent \
   keygen-knownhosts \
   hostkey-rotate \
+ ssh-cert \
   principals-command


diff --git a/regress/ssh-cert.sh b/regress/ssh-cert.sh
new file mode 100644
index 0000000..152278b
--- /dev/null
+++ b/regress/ssh-cert.sh
@@ -0,0 +1,136 @@
+# $OpenBSD: multicert.sh,v 1.1 2014/12/22 08:06:03 djm Exp $
+# Placed in the Public Domain.
+
+tid="ssh with certificates"
+
+rm -f $OBJ/user_ca_key* $OBJ/user_key*
+rm -f $OBJ/cert_user_key*
+
+# Create a CA key
+${SSHKEYGEN} -q -N '' -t ed25519 -f $OBJ/user_ca_key1 ||\
+ fatal "ssh-keygen failed"
+${SSHKEYGEN} -q -N '' -t ed25519  -f $OBJ/user_ca_key2 ||\
+ fatal "ssh-keygen failed"
+
+# Make some keys and certificates.
+${SSHKEYGEN} -q -N '' -t ed25519 -f $OBJ/user_key1 || \
+ fatal "ssh-keygen failed"
+${SSHKEYGEN} -q -N '' -t ed25519 -f $OBJ/user_key2 || \
+ fatal "ssh-keygen failed"
+# Move the certificate to a different address to better control
+# when it is offered.
+${SSHKEYGEN} -q -s $OBJ/user_ca_key1 -I "regress user key for $USER" \
+ -z $$ -n ${USER} $OBJ/user_key1 ||
+ fail "couldn't sign user_key1 with user_ca_key1"
+mv $OBJ/user_key1-cert.pub $OBJ/cert_user_key1_1.pub
+${SSHKEYGEN} -q -s $OBJ/user_ca_key2 -I "regress user key for $USER" \
+ -z $$ -n ${USER} $OBJ/user_key1 ||
+ fail "couldn't sign user_key1 with user_ca_key2"
+mv $OBJ/user_key1-cert.pub $OBJ/cert_user_key1_2.pub
+
+trace 'try with identity files'
+opts="-F $OBJ/ssh_proxy -oIdentitiesOnly=yes"
+opts2="$opts -i $OBJ/user_key1 -i $OBJ/user_key2"
+echo "cert-authority $(cat $OBJ/user_ca_key1.pub)" > $OBJ/authorized_keys_$USER
+
+for p in ${SSH_PROTOCOLS}; do
+ # Just keys should fail
+ ${SSH} $opts2 somehost exit 5$p
+ r=$?
+ if [ $r -eq 5$p ]; then
+ fail "ssh succeeded with no certs in protocol $p"
+ fi
+
+ # Keys with untrusted cert should fail.
+ opts3="$opts2 -z $OBJ/cert_user_key1_2.pub"
+ ${SSH} $opts3 somehost exit 5$p
+ r=$?
+ if [ $r -eq 5$p ]; then
+ fail "ssh succeeded with bad cert in protocol $p"
+ fi
+
+ # Good cert with bad key should fail.
+ opts3="$opts -i $OBJ/user_key2 -z $OBJ/cert_user_key1_1.pub"
+ ${SSH} $opts3 somehost exit 5$p
+ r=$?
+ if [ $r -eq 5$p ]; then
+ fail "ssh succeeded with no matching key in protocol $p"
+ fi
+
+ # Keys with one trusted cert, should succeed.
+ opts3="$opts2 -z $OBJ/cert_user_key1_1.pub"
+ ${SSH} $opts3 somehost exit 5$p
+ r=$?
+ if [ $r -ne 5$p ]; then
+ fail "ssh failed with trusted cert and key in protocol $p"
+ fi
+
+ # Multiple certs and keys, with one trusted cert, should succeed.
+ opts3="$opts2 -z $OBJ/cert_user_key1_2.pub -z $OBJ/cert_user_key1_1.pub"
+ ${SSH} $opts3 somehost exit 5$p
+ r=$?
+ if [ $r -ne 5$p ]; then
+ fail "ssh failed with multiple certs in protocol $p"
+ fi
+
+ #Keys with trusted certificate specified in config options, should succeed.
+ opts3="$opts2 -oCertificateFile=$OBJ/cert_user_key1_1.pub"
+ ${SSH} $opts3 somehost exit 5$p
+ r=$?
+ if [ $r -ne 5$p ]; then
+ fail "ssh failed with trusted cert in config in protocol $p"
+ fi
+done
+
+#next, using an agent in combination with the keys
+SSH_AUTH_SOCK=/nonexistent ${SSHADD} -l > /dev/null 2>&1
+if [ $? -ne 2 ]; then
+ fatal "ssh-add -l did not fail with exit code 2"
+fi
+
+trace "start agent"
+eval `${SSHAGENT} -s` > /dev/null
+r=$?
+if [ $r -ne 0 ]; then
+ fatal "could not start ssh-agent: exit code $r"
+fi
+
+# add private keys to agent
+${SSHADD} -k $OBJ/user_key2 > /dev/null 2>&1
+if [ $? -ne 0 ]; then
+ fatal "ssh-add did not succeed with exit code 0"
+fi
+${SSHADD} -k $OBJ/user_key1 > /dev/null 2>&1
+if [ $? -ne 0 ]; then
+ fatal "ssh-add did not succeed with exit code 0"
+fi
+
+# try ssh with the agent and certificates
+# note: ssh agent only uses certificates in protocol 2
+opts="-F $OBJ/ssh_proxy"
+# with no certificates, shoud fail
+${SSH} -2 $opts somehost exit 52
+if [ $? -eq 52 ]; then
+ fail "ssh connect with agent in protocol 2 succeeded with no cert"
+fi
+
+#with an untrusted certificate, should fail
+opts="$opts -z $OBJ/cert_user_key1_2.pub"
+${SSH} -2 $opts somehost exit 52
+if [ $? -eq 52 ]; then
+ fail "ssh connect with agent in protocol 2 succeeded with bad cert"
+fi
+
+#with an additional trusted certificate, should succeed
+opts="$opts -z $OBJ/cert_user_key1_1.pub"
+${SSH} -2 $opts somehost exit 52
+if [ $? -ne 52 ]; then
+ fail "ssh connect with agent in protocol 2 failed with good cert"
+fi
+
+trace "kill agent"
+${SSHAGENT} -k > /dev/null
+
+#cleanup
+rm -f $OBJ/user_ca_key* $OBJ/user_key*
+rm -f $OBJ/cert_user_key*
diff --git a/ssh.1 b/ssh.1
index 2ea0a20..76a9459 100644
--- a/ssh.1
+++ b/ssh.1
@@ -63,6 +63,7 @@
  .Op Fl S Ar ctl_path
  .Op Fl W Ar host : Ns Ar port
  .Op Fl w Ar local_tun Ns Op : Ns Ar remote_tun
+.Op Fl z Ar certificate_file
  .Oo Ar user Ns @ Oc Ns Ar hostname
  .Op Ar command
  .Ek
@@ -468,6 +469,7 @@ For full details of the options listed below, and their possible values, see
  .It CanonicalizeHostname
  .It CanonicalizeMaxDots
  .It CanonicalizePermittedCNAMEs
+.It CertificateFile
  .It ChallengeResponseAuthentication
  .It CheckHostIP
  .It Cipher
@@ -768,6 +770,21 @@ Send log information using the
  .Xr syslog 3
  system module.
  By default this information is sent to stderr.
+.It Fl z Ar certificate_file
+Selects a file from which certificate information is loaded for public
+key authentication. For the certificate to be signed, the private key
+corresponding to
+.Ar certificate_file
+must also be provided for authentication, whether through
+.Xr ssh_agent 1 .
+or through an
+.Ar identity_file
+specified on the command line or in configuration files.
+Certificate files may also be specified on a per-host basis in
+the configuration file. It is possible to have multiple
+.Fl z
+options (and multiple certificates specified in
+configuration files).
  .El
  .Pp
  .Nm
diff --git a/ssh.c b/ssh.c
index 3239108..e01790a 100644
--- a/ssh.c
+++ b/ssh.c
@@ -207,7 +207,8 @@ usage(void)
  "           [-O ctl_cmd] [-o option] [-p port]\n"
  "           [-Q cipher | cipher-auth | mac | kex | key]\n"
  "           [-R address] [-S ctl_path] [-W host:port]\n"
-"           [-w local_tun[:remote_tun]] [user@]hostname [command]\n"
+"           [-w local_tun[:remote_tun]] [-z certificate_file]\n"
+"           [user@]hostname [command]\n"
   );
   exit(255);
  }
@@ -215,6 +216,7 @@ usage(void)
  static int ssh_session(void);
  static int ssh_session2(void);
  static void load_public_identity_files(void);
+static void load_certificate_files(void);
  static void main_sigchld_handler(int);

  /* from muxclient.c */
@@ -595,7 +597,7 @@ main(int ac, char **av)

   again:
   while ((opt = getopt(ac, av, "1246ab:c:e:fgi:kl:m:no:p:qstvx"
-     "ACD:E:F:GI:KL:MNO:PQ:R:S:TVw:W:XYy")) != -1) {
+     "ACD:E:F:GI:KL:MNO:PQ:R:S:TVw:W:XYyz:")) != -1) {
   switch (opt) {
   case '1':
   options.protocol = SSH_PROTO_1;
@@ -906,6 +908,9 @@ main(int ac, char **av)
   case 'F':
   config = optarg;
   break;
+ case 'z':
+ add_certificate_file(&options, NULL, optarg, 1);
+ break;
   default:
   usage();
   }
@@ -1013,6 +1018,9 @@ main(int ac, char **av)
   options.hostname = xstrdup(host);
   }

+ /* If the user has specified certificate(s), load it now. */
+ load_certificate_files();
+
   /* If canonicalization requested then try to apply it */
   lowercase(host);
   if (options.canonicalize_hostname != SSH_CANONICALISE_NO)
@@ -1353,6 +1361,13 @@ main(int ac, char **av)
   }
   }

+ for (i = 0; i < options.num_certificate_files; i++) {
+ free(options.certificate_files[i]);
+ options.certificate_files[i] = NULL;
+ }
+
+
+
   exit_status = compat20 ? ssh_session2() : ssh_session();
   packet_close();

@@ -1938,6 +1953,72 @@ ssh_session2(void)
       options.escape_char : SSH_ESCAPECHAR_NONE, id);
  }

+/* Load certificate file(s) specified in options. */
+static void
+load_certificate_files(void)
+{
+ char *filename, *cp, thishost[NI_MAXHOST];
+ char *pwdir = NULL, *pwname = NULL;
+ struct passwd *pw;
+ int i, n_ids;
+ struct sshkey *cert;
+ char *certificate_files[SSH_MAX_CERTIFICATE_FILES];
+ struct sshkey *certificates[SSH_MAX_CERTIFICATE_FILES];
+
+ n_ids = 0;
+ memset(certificate_files, 0, sizeof(certificate_files));
+ memset(certificates, 0, sizeof(certificates));
+
+ if ((pw = getpwuid(original_real_uid)) == NULL)
+ fatal("load_certificate_files: getpwuid failed");
+ pwname = xstrdup(pw->pw_name);
+ pwdir = xstrdup(pw->pw_dir);
+ if (gethostname(thishost, sizeof(thishost)) == -1)
+ fatal("load_certificate_files: gethostname: %s",
+     strerror(errno));
+
+ if (options.num_certificate_files > SSH_MAX_CERTIFICATE_FILES)
+ fatal("load_certificate_files: too many certificates");
+ for (i = 0; i < options.num_certificate_files; i++) {
+ cp = tilde_expand_filename(options.certificate_files[i],
+     original_real_uid);
+ filename = percent_expand(cp, "d", pwdir,
+     "u", pwname, "l", thishost, "h", host,
+     "r", options.user, (char *)NULL);
+ free(cp);
+
+ cert = key_load_public(filename, NULL);
+ debug("certificate file %s type %d", filename,
+     cert ? cert->type : -1);
+ free(options.certificate_files[i]);
+ if (cert == NULL) {
+ free(filename);
+ continue;
+ }
+ if (!key_is_cert(cert)) {
+ debug("%s: key %s type %s is not a certificate",
+     __func__, filename, key_type(cert));
+ key_free(cert);
+ free(filename);
+ continue;
+ }
+
+ certificate_files[n_ids] = filename;
+ certificates[n_ids] = cert;
+ ++n_ids;
+ }
+ options.num_certificate_files = n_ids;
+ memcpy(options.certificate_files, certificate_files, sizeof(certificate_files));
+ memcpy(options.certificates, certificates, sizeof(certificates));
+
+ explicit_bzero(pwname, strlen(pwname));
+ free(pwname);
+ explicit_bzero(pwdir, strlen(pwdir));
+ free(pwdir);
+}
+
+
+
  static void
  load_public_identity_files(void)
  {
diff --git a/ssh.h b/ssh.h
index 4f8da5c..8fb7ba3 100644
--- a/ssh.h
+++ b/ssh.h
@@ -19,6 +19,13 @@
  #define SSH_DEFAULT_PORT 22

  /*
+ * Maximum number of certificate files that can be specified
+ * in configuration files or on the command line.
+ */
+#define SSH_MAX_CERTIFICATE_FILES 100
+
+
+/*
   * Maximum number of RSA authentication identity files that can be specified
   * in configuration files or on the command line.
   */
diff --git a/ssh_config.5 b/ssh_config.5
index e514398..17741b7 100644
--- a/ssh_config.5
+++ b/ssh_config.5
@@ -325,6 +325,34 @@ to be canonicalized to names in the
  or
  .Dq *.c.example.com
  domains.
+.It Cm CertificateFile
+Specifies a file from which the user's certificate is read.
+A corresponding private key must be provided separately in order
+to use this certificate.
+.Xr ssh 1
+will attempt to use private keys provided as identity files
+or in the agent for such authentication.
+.Pp
+The file name may use the tilde
+syntax to refer to a user's home directory or one of the following
+escape characters:
+.Ql %d
+(local user's home directory),
+.Ql %u
+(local user name),
+.Ql %l
+(local host name),
+.Ql %h
+(remote host name) or
+.Ql %r
+(remote user name).
+.Pp
+It is possible to have multiple certificate files specified in
+configuration files; these certificates will be tried in sequence.
+Multiple
+.Cm CertificateFile
+directives will add to the list of certificates used for
+authentication.
  .It Cm ChallengeResponseAuthentication
  Specifies whether to use challenge-response authentication.
  The argument to this keyword must be
@@ -911,6 +939,11 @@ differs from that of other configuration directives).
  may be used in conjunction with
  .Cm IdentitiesOnly
  to select which identities in an agent are offered during authentication.
+.Cm IdentityFile
+may also be used in conjunction with
+.Cm CertificateFile
+in order to provide any certificate also needed for authentication with
+the identity.
  .It Cm IgnoreUnknown
  Specifies a pattern-list of unknown options to be ignored if they are
  encountered in configuration parsing.
diff --git a/sshconnect2.c b/sshconnect2.c
index 34dbf9a..fb24b5e 100644
--- a/sshconnect2.c
+++ b/sshconnect2.c
@@ -1016,6 +1016,7 @@ sign_and_send_pubkey(Authctxt *authctxt, Identity *id)
   u_int skip = 0;
   int ret = -1;
   int have_sig = 1;
+ int i;
   char *fp;

   if ((fp = sshkey_fingerprint(id->key, options.fingerprint_hash,
@@ -1053,6 +1054,33 @@ sign_and_send_pubkey(Authctxt *authctxt, Identity *id)
   }
   buffer_put_string(&b, blob, bloblen);

+ /* If the key is an input certificate, sign its private key instead.
+ * If no such private key exists, return failure and continue with
+ * other methods of authentication.
+ * Else, just continue with the normal signing process. */
+ if (key_is_cert(id->key)) {
+ for (i = 0; i < options.num_certificate_files; i++) {
+ if (key_equal(id->key, options.certificates[i])) {
+ Identity *id2;
+ int matched = 0;
+ TAILQ_FOREACH(id2, &authctxt->keys, next) {
+ if (sshkey_equal_public(id->key, id2->key) &&
+     id->key->type != id2->key->type) {
+ id = id2;
+ matched = 1;
+ break;
+ }
+ }
+ if (!matched) {
+ free(blob);
+ buffer_free(&b);
+ return 0;
+ }
+ break;
+ }
+ }
+ }
+
   /* generate signature */
   ret = identity_sign(id, &signature, &slen,
       buffer_ptr(&b), buffer_len(&b), datafellows);
@@ -1189,9 +1217,11 @@ load_identity_file(char *filename, int userprovided)

  /*
   * try keys in the following order:
- * 1. agent keys that are found in the config file
- * 2. other agent keys
- * 3. keys that are only listed in the config file
+ * 1. certificates listed in the config file
+ * 2. other input certificates
+ * 3. agent keys that are found in the config file
+ * 4. other agent keys
+ * 5. keys that are only listed in the config file
   */
  static void
  pubkey_prepare(Authctxt *authctxt)
@@ -1245,6 +1275,17 @@ pubkey_prepare(Authctxt *authctxt)
   free(id);
   }
   }
+ /* list of certificates specified by user */
+ for (i = 0; i < options.num_certificate_files; i++) {
+ key = options.certificates[i];
+ if (!key_is_cert(key))
+ continue;
+ id = xcalloc(1, sizeof(*id));
+ id->key = key;
+ id->filename = xstrdup(options.certificate_files[i]);
+ id->userprovided = options.certificate_file_userprovided[i];
+ TAILQ_INSERT_TAIL(preferred, id, next);
+ }
   /* list of keys supported by the agent */
   if ((r = ssh_get_authentication_socket(&agent_fd)) != 0) {
   if (r != SSH_ERR_AGENT_NOT_PRESENT)
--
1.9.1
_______________________________________________
openssh-unix-dev mailing list
openssh-unix-dev at mindrot.org<mailto:openssh-unix-dev at mindrot.org>
https://lists.mindrot.org/mailman/listinfo/openssh-unix-dev



More information about the openssh-unix-dev mailing list