commit bd51aae2e40814ac2ae5801fd9f83f6a4a886fb1
Author: Daniel Barlow <dan@telent.net>
Date:   Fri Aug 23 11:33:24 2024 +0100

    add -U otion to set path to authorized_keys file
    
    based on https://github.com/mkj/dropbear/pull/35
    by Salvador Fandino sfandino@yahoo.com
    
    - Allow authorized keys inside dirs with the sticky bit set
    
    - Add option -U for customizing authorized_keys path
    
    - Updated for dropbear 2024.85 (source files moved to src/)
    
    - allow %u, %d, %n "format specifiers" in pathname so that the user's
      username/homedir/uid can be embedded into the path

diff --git a/Makefile.in b/Makefile.in
index 5ebfca2..686fbfb 100644
--- a/Makefile.in
+++ b/Makefile.in
@@ -51,7 +51,7 @@ COMMONOBJS = $(patsubst %,$(OBJ_DIR)/%,$(_COMMONOBJS))
 _SVROBJS=svr-kex.o svr-auth.o sshpty.o \
 		svr-authpasswd.o svr-authpubkey.o svr-authpubkeyoptions.o svr-session.o svr-service.o \
 		svr-chansession.o svr-runopts.o svr-agentfwd.o svr-main.o svr-x11fwd.o\
-		svr-tcpfwd.o svr-authpam.o
+		svr-tcpfwd.o svr-authpam.o pathexpand.o
 SVROBJS = $(patsubst %,$(OBJ_DIR)/%,$(_SVROBJS))
 
 _CLIOBJS=cli-main.o cli-auth.o cli-authpasswd.o cli-kex.o \
diff --git a/manpages/dropbear.8 b/manpages/dropbear.8
index bdb2ea0..c8d450d 100644
--- a/manpages/dropbear.8
+++ b/manpages/dropbear.8
@@ -29,6 +29,9 @@ or automatically with the '-R' option. See "Host Key Files" below.
 .B \-R
 Generate hostkeys automatically. See "Host Key Files" below.
 .TP
+.B \-U \fIauthorized_keys
+Absolute pathname to file containing authorized user keys. May contain the sequences %d, %n, %u which are expanded to the user's home directory, username and numeric uid respectively. Default '%d/.ssh/authorized_keys'.
+.TP
 .B \-F
 Don't fork into background.
 .TP
diff --git a/src/pathexpand.c b/src/pathexpand.c
new file mode 100644
index 0000000..2028733
--- /dev/null
+++ b/src/pathexpand.c
@@ -0,0 +1,132 @@
+#include <limits.h>
+#include <string.h>
+#include <stdio.h>
+
+#ifdef TEST_PATHEXPAND
+
+/* to run tests:
+  gcc -Wall -o pathexpand -D TEST_PATHEXPAND=1 src/pathexpand.c && ./pathexpand
+*/
+
+char * pathexpand(char *relfilename);
+
+
+#define m_malloc(c) malloc(c)
+#define m_strdup(c) strdup(c)
+
+struct session {
+    struct AuthState {
+	char * pw_dir;
+	char * pw_name;
+	uid_t pw_uid;
+    } authstate;
+};
+
+struct session ses = {
+    .authstate = {
+	.pw_dir = "/home/dan",
+	.pw_name = "dan",
+	.pw_uid = 12345,
+    }
+};
+
+int exit_status = 0;
+
+int expect_expansion(char * input, char * expected) {
+    char *actual = pathexpand(input);
+    if(strcmp(actual, expected) != 0) {
+	printf("expected %s for %s, got %s\n", expected, input, actual);
+	exit_status++;
+    }
+    free(actual);
+    return exit_status;
+}
+
+int main(int argc, char *argv[]) {
+    for(int i = 1; i < argc; i++) {
+	char *actual =  pathexpand(argv[i]);
+	printf("%s => %s\n", argv[i], pathexpand(argv[i]));
+	free(actual);
+    }
+
+    /* a string without % is unaltered */
+    expect_expansion("hello", "hello");
+
+    /* discards single trailing % */
+    expect_expansion("hello%", "hello");
+
+    /* %% is transformed to % */
+    expect_expansion("hello%%", "hello%");
+    expect_expansion("hello%%goodbye", "hello%goodbye");
+
+    /* %u is transformed to uid */
+    expect_expansion("/run/user/%u/authorized_keys", "/run/user/12345/authorized_keys");
+    /* % sequences work when at start of string */
+    expect_expansion("%u/authorized_keys", "12345/authorized_keys");
+
+    /* %d expands to home directory */
+    expect_expansion("%d/.ssh", "/home/dan/.ssh");
+
+    /* %n expands to username */
+    expect_expansion("/tmp/%n/.ssh", "/tmp/dan/.ssh");
+
+    /* unrecognised specifiers are discarded */
+    expect_expansion("/hi/%q/.ssh", "/hi//.ssh");
+
+    exit(exit_status);
+}
+
+#else
+#include "session.h"
+#include "debug.h"
+#endif
+#define NUMLEN(c) strlen(#c)
+
+char * pathexpand(char *relfilename)
+{
+    char * filename;
+    int len;
+
+    len = strlen(relfilename);
+    for(char *p = relfilename; p; p = strchr(p, '%')) {
+	switch(*(p+1)) {
+	case 'd': len += strlen(ses.authstate.pw_dir); break;
+	case 'n': len += strlen(ses.authstate.pw_name); break;
+	case 'u': len += NUMLEN(INT_MAX); break;
+	}
+	if(*(p+1) == '\0') break;
+	p=p+2;
+    }
+    filename = m_malloc(len+1);
+    filename[0] = '\0';
+
+    char *start = relfilename;
+    char *out = filename;
+    char *p = relfilename;
+    do {
+	p = strchrnul(start, '%');
+	strncat(out, start, p - start);
+
+	if(*p == '\0') break;
+
+	switch(*(p+1)) {
+	case '\0':
+	    p++; break;
+	case 'd':
+	    strcat(out, ses.authstate.pw_dir); break;
+	case 'n':
+	    strcat(out, ses.authstate.pw_name); break;
+	case 'u':
+	    snprintf(out + strlen(out),
+		     NUMLEN(INT_MAX),
+		     "%d",
+		     ses.authstate.pw_uid);
+	    break;
+	case '%':
+	    strcat(out, "%"); break;
+	}
+	start = p+2;
+    }
+    while (*p);
+    return filename;		/* caller must free */
+}
diff --git a/src/runopts.h b/src/runopts.h
index 1c88b5c..707008f 100644
--- a/src/runopts.h
+++ b/src/runopts.h
@@ -128,7 +128,8 @@ typedef struct svr_runopts {
 	char * pidfile;
 
 	char * forced_command;
-	char* interface;
+	char * authkeysfile;
+	char * interface;
 
 #if DROPBEAR_PLUGIN
 	/* malloced */
diff --git a/src/svr-authpubkey.c b/src/svr-authpubkey.c
index 5d298cb..54502f4 100644
--- a/src/svr-authpubkey.c
+++ b/src/svr-authpubkey.c
@@ -73,7 +73,7 @@
 
 static int checkpubkey(const char* keyalgo, unsigned int keyalgolen,
 		const unsigned char* keyblob, unsigned int keybloblen);
-static int checkpubkeyperms(void);
+static int checkpubkeyperms(char *filename, char *base);
 static void send_msg_userauth_pk_ok(const char* sigalgo, unsigned int sigalgolen,
 		const unsigned char* keyblob, unsigned int keybloblen);
 static int checkfileperm(char * filename);
@@ -431,6 +431,7 @@ out:
 	return ret;
 }
 
+extern char *pathexpand(char *input);
 
 /* Checks whether a specified publickey (and associated algorithm) is an
  * acceptable key for authentication */
@@ -458,19 +459,12 @@ static int checkpubkey(const char* keyalgo, unsigned int keyalgolen,
 		dropbear_exit("Failed to set euid");
 	}
 #endif
+	filename = pathexpand(svr_opts.authkeysfile);
+
 	/* check file permissions, also whether file exists */
-	if (checkpubkeyperms() == DROPBEAR_FAILURE) {
-		TRACE(("bad authorized_keys permissions, or file doesn't exist"))
+	if (checkpubkeyperms(filename, ses.authstate.pw_dir) == DROPBEAR_FAILURE) {
+		TRACE(("bad authorized keys permissions on %s, or file doesn't exist", filename))
 	} else {
-		/* we don't need to check pw and pw_dir for validity, since
-		 * its been done in checkpubkeyperms. */
-		len = strlen(ses.authstate.pw_dir);
-		/* allocate max required pathname storage,
-		 * = path + "/.ssh/authorized_keys" + '\0' = pathlen + 22 */
-		filename = m_malloc(len + 22);
-		snprintf(filename, len + 22, "%s/.ssh/authorized_keys",
-					ses.authstate.pw_dir);
-
 		authfile = fopen(filename, "r");
 		if (!authfile) {
 			TRACE(("checkpubkey: failed opening %s: %s", filename, strerror(errno)))
@@ -486,7 +480,7 @@ static int checkpubkey(const char* keyalgo, unsigned int keyalgolen,
 	if (authfile == NULL) {
 		goto out;
 	}
-	TRACE(("checkpubkey: opened authorized_keys OK"))
+	TRACE(("checkpubkey: opened %s OK", filename))
 
 	line = buf_new(MAX_AUTHKEYS_LINE);
 	line_num = 0;
@@ -524,53 +518,47 @@ out:
 
 /* Returns DROPBEAR_SUCCESS if file permissions for pubkeys are ok,
  * DROPBEAR_FAILURE otherwise.
- * Checks that the user's homedir, ~/.ssh, and
- * ~/.ssh/authorized_keys are all owned by either root or the user, and are
+ * Checks filename and its parent directories recursively until the
+ * base directory (usually ~/) or one of its ancestors (up to /) is
+ * reached.
+ * The files and directories must be all owned by root or the user, and be
  * g-w, o-w */
-static int checkpubkeyperms() {
-
-	char* filename = NULL;
+static int checkpubkeyperms(char *filename, char *base) {
+	char* path = NULL;
 	int ret = DROPBEAR_FAILURE;
 	unsigned int len;
 
-	TRACE(("enter checkpubkeyperms"))
-
-	if (ses.authstate.pw_dir == NULL) {
-		goto out;
-	}
+	TRACE(("enter checkpubkeyperms(%s, %s)", filename, base))
 
-	if ((len = strlen(ses.authstate.pw_dir)) == 0) {
+	if ((base == NULL) || (base[0] != '/') ||
+	    (filename == NULL) || (filename[0] != '/')) {
+		/* both filename and base must be absolute paths */
 		goto out;
 	}
 
-	/* allocate max required pathname storage,
-	 * = path + "/.ssh/authorized_keys" + '\0' = pathlen + 22 */
-	len += 22;
-	filename = m_malloc(len);
-	strlcpy(filename, ses.authstate.pw_dir, len);
+	len = strlen(filename);
+	path = m_strdup(filename);
 
-	/* check ~ */
-	if (checkfileperm(filename) != DROPBEAR_SUCCESS) {
-		goto out;
-	}
-
-	/* check ~/.ssh */
-	strlcat(filename, "/.ssh", len);
-	if (checkfileperm(filename) != DROPBEAR_SUCCESS) {
-		goto out;
-	}
+	while (checkfileperm(len ? path : "/") == DROPBEAR_SUCCESS) {
+		/* check if we are on base trail and if this is the
+		 * case, return success */
+		if ((strncmp(base, path, len) == 0) &&
+		    (!len || (base[len] == '\0') || (base[len] == '/'))) {
+			ret = DROPBEAR_SUCCESS;
+			break;
+		}
 
-	/* now check ~/.ssh/authorized_keys */
-	strlcat(filename, "/authorized_keys", len);
-	if (checkfileperm(filename) != DROPBEAR_SUCCESS) {
-		goto out;
+		/* look for parent directory */
+		while (--len) {
+			if (path[len] == '/') {
+				path[len] = '\0';
+				break;
+			}
+		}
 	}
 
-	/* file looks ok, return success */
-	ret = DROPBEAR_SUCCESS;
-
 out:
-	m_free(filename);
+	m_free(path);
 
 	TRACE(("leave checkpubkeyperms"))
 	return ret;
@@ -596,7 +584,9 @@ static int checkfileperm(char * filename) {
 		TRACE(("wrong ownership"))
 	}
 	/* check permissions - don't want group or others +w */
-	if (filestat.st_mode & (S_IWGRP | S_IWOTH)) {
+	/* (unless sticky dir, which is allowed) */
+	if ((filestat.st_mode & (S_IWGRP | S_IWOTH)) &&
+	    !(S_ISDIR(filestat.st_mode) && (filestat.st_mode & S_ISVTX))) {
 		badperm = 1;
 		TRACE(("wrong perms"))
 	}
diff --git a/src/svr-runopts.c b/src/svr-runopts.c
index c4f83c1..faddfa2 100644
--- a/src/svr-runopts.c
+++ b/src/svr-runopts.c
@@ -147,6 +147,7 @@ void svr_getopts(int argc, char ** argv) {
 	char* maxauthtries_arg = NULL;
 	char* reexec_fd_arg = NULL;
 	char* keyfile = NULL;
+	char* authkeysfile = NULL;
 	char c;
 #if DROPBEAR_PLUGIN
         char* pubkey_plugin = NULL;
@@ -173,6 +174,8 @@ void svr_getopts(int argc, char ** argv) {
 	svr_opts.hostkey = NULL;
 	svr_opts.delay_hostkey = 0;
 	svr_opts.pidfile = expand_homedir_path(DROPBEAR_PIDFILE);
+	svr_opts.authkeysfile = "%d/.ssh/authorized_keys";
+
 #if DROPBEAR_SVR_LOCALANYFWD
 	svr_opts.nolocaltcp = 0;
 #endif
@@ -322,6 +325,9 @@ void svr_getopts(int argc, char ** argv) {
 				case 'u':
 					/* backwards compatibility with old urandom option */
 					break;
+				case 'U':
+					next = &authkeysfile;
+					break;
 #if DROPBEAR_PLUGIN
                                 case 'A':
                                         next = &pubkey_plugin;
@@ -372,6 +378,10 @@ void svr_getopts(int argc, char ** argv) {
 				addhostkey(keyfile);
 				keyfile = NULL;
 			}
+			if (authkeysfile) {
+				svr_opts.authkeysfile = m_strdup(authkeysfile);
+				authkeysfile = NULL;
+			}
 		}
 	}