1#! /usr/local/bin/python3.8
2#
3#    ssh-multiadd - add multiple ssh keys, maybe some with the same passphrase
4#    Copyright (C) 2001-2002  Matthew Mueller <donut@azstarnet.com>
5#
6#    This program is free software; you can redistribute it and/or modify
7#    it under the terms of the GNU General Public License as published by
8#    the Free Software Foundation; either version 2 of the License, or
9#    (at your option) any later version.
10#
11#    This program is distributed in the hope that it will be useful,
12#    but WITHOUT ANY WARRANTY; without even the implied warranty of
13#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14#    GNU General Public License for more details.
15#
16#    You should have received a copy of the GNU General Public License
17#    along with this program; if not, write to the Free Software
18#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19
20import os,errno,pty,sys,getopt,re,commands
21
22def pathfind(pl, path=os.environ.get('PATH',os.defpath).split(os.pathsep), notfound=None, pathadd=[]):
23	if type(pl)==type(''): pl = (pl,)
24	for a in [os.path.join(d,p) for p in pl for d in path+pathadd]:
25		if os.path.exists(a):
26			return a
27	# if we don't find it, return with no explicit path so that we get a useful
28	# "file not found" error rather than some "bad operand for +" if we
29	# returned (and tried to use) None
30	return notfound is None and pl[0] or notfound
31
32# default configuration:
33keys = ('identity', 'id_dsa')
34ssh = pathfind('ssh')
35sshadd = pathfind('ssh-add')
36sshaskpass = os.environ.get('SSH_ASKPASS', pathfind(('ssh-askpass','x11-ssh-askpass','ssh-askpass2','ssh-askpass1','gnome-ssh-askpass'),pathadd=[os.path.join(os.sep,'usr','lib','ssh')]))
37sshdir = pathfind(('.ssh','.ssh2'), [os.path.expanduser('~')], '')
38useaskpass = 'auto'
39verbose = 0
40listidentities = 0
41sshgetfingerprint = -1
42
43# user configuration:
44conffile = os.path.join(os.path.expanduser('~'),'.ssh-multiadd.rc.py')
45if os.path.exists(conffile):
46	execfile(conffile)
47
48def p(s,nl=1,outf=sys.stdout):
49	outf.write(s)
50	if nl: outf.write('\n')
51def pverbose(s,nl=1):
52	if verbose>0: p(s,nl)
53def pinfo(s,nl=1):
54	if verbose>=0: p(s,nl)
55def perror(s,nl=1):
56	if verbose>=-1: p(s,nl,outf=sys.stderr)
57
58
59def ptyopen(blah,args):
60	"kinda like popen..."
61	pverbose('ptyopen: '+blah+' '+str(args))
62	pid,fd=pty.fork()
63	if pid==0:
64		os.execv(blah,[blah]+args)
65	else:
66		return fd
67def writen(fd, data):
68	"Write all the data to a descriptor."
69	while data != '':
70		n = os.write(fd, data)
71		data = data[n:]
72
73def exitstatus_str(st):
74	if os.WIFSTOPPED(st):
75		return 'stopped: sig %i'%os.WSTOPSIG(st)
76	if os.WIFSIGNALED(st):
77		return 'killed: sig %i'%os.WTERMSIG(st)
78	if os.WIFEXITED(st):
79		return 'exit status: %i'%os.WEXITSTATUS(st)
80	return '(??: %i)'%st
81
82def getoutput(cmd):
83	pverbose('running %r...'%cmd)
84	st,p=commands.getstatusoutput(cmd)
85	if not os.WIFEXITED(st):
86		perror('%r %s (%s)'%(cmd,exitstatus_str(st),p))
87		sys.exit(1)
88	return p
89
90def getoutput_checkstatus(cmd):
91	pverbose('running %r...'%cmd)
92	st,p=commands.getstatusoutput(cmd)
93	if st:
94		perror('%r %s (%s)'%(cmd,exitstatus_str(st),p))
95		sys.exit(1)
96	return p
97
98def askpass(prompt=''):
99	p=''
100	while p=='':
101		if useaskpass=='yes' or (useaskpass=='auto' and not sys.stdin.isatty()):
102			p=getoutput_checkstatus(sshaskpass + (prompt and ' '+`prompt` or ''))
103		else:
104			import getpass
105			p=getpass.getpass(prompt or 'enter pass: ')
106	return p
107
108version='1.3.2'
109
110def printusage(err=0):
111	def isdef(c): return c and ' (default)' or ''
112	perror('Usage: ssh-multiadd [opts] [keyfiles (%s)]'%', '.join(keys))
113	perror(' -a <a>    use ssh-askpass (auto)')
114	perror(' -A <p>    ssh-askpass (%s)'%sshaskpass)
115	perror(' -s <p>    ssh-add (%s)'%sshadd)
116	perror(' -S <p>    ssh (%s)'%ssh)
117	perror(' -d <d>    dir to look for keyfiles (%s)'%sshdir)
118	perror(' -f        force all keys to be added'+isdef(sshgetfingerprint==None))
119	perror(' -l        lists all identities represented by the agent when done'+isdef(listidentities==1))
120	perror(' -L        lists differences in represented identities when done'+isdef(listidentities==2))
121	perror(" --nolist  disable -l/-L"+isdef(listidentities==0))
122	perror(' -h/--help show help')
123	perror(' --version show version')
124	sys.exit(err)
125def printhelp():
126	perror('ssh-multiadd v%s - Copyright (C) 2001-2002 Matthew Mueller - GPL license'%version)
127	printusage()
128
129def main(argv):
130	try:
131		optlist, args = getopt.getopt(argv, 'a:A:s:d:S:flLh?', ['help','version','debug','nolist'])
132	except getopt.error, a:
133		perror("ssh-multiadd: %s"%a)
134		printusage(1)
135
136	global useaskpass,sshaskpass,sshadd,sshdir,verbose,listidentities,keys,sshgetfingerprint,ssh
137	#prevopt=''
138	for o,a in optlist:
139		if o=='-a':
140			if a in ('yes','no','auto'):
141				useaskpass=a
142			else:
143				perror("invalid -a arg '%s'"%a)
144				printusage(1)
145		elif o=='-A':
146			sshaskpass=a
147		elif o=='-s':
148			sshadd=a
149		elif o=='-d':
150			sshdir=a
151		elif o=='-S':
152			ssh=a
153		elif o=='-f':
154			sshgetfingerprint=None
155		elif o=='-l':
156			listidentities=1
157		elif o=='-L':
158			listidentities=2
159		elif o=='--nolist':
160			listidentities=0
161		elif o=='--debug':
162			verbose=1
163		elif o=='-h' or o=='-?' or o=='--help':
164			printhelp()
165		elif o=='--version':
166			print version
167			sys.exit(0)
168
169	keys = [os.path.join(sshdir,d) for d in args or keys]
170
171	if sshgetfingerprint == -1:
172		sver = getoutput_checkstatus(ssh + ' -V').splitlines()[0]
173		if sver.find('OpenSSH')>=0:
174			sdir = os.path.split(ssh)[0]
175			sshgetfingerprint = (os.path.join(sdir,'ssh-keygen -l -f "%s"'), '(\S+\s+\S+)')
176		elif sver.find('SSH Version 1')>=0:
177			sshgetfingerprint = (pathfind('cat')+' "%s.pub"', '(\S+\s+\S+\s+\S+)')
178		elif sver.find('SSH Version 2')>=0:
179			sshgetfingerprint = (pathfind('basename')+' "%s"', '(.+)') #kludge since sshv2's output is minimal
180		else:
181			sshgetfingerprint = None
182		pverbose('ssh ver %s, sshgetfingerprint=%s'%(sver,sshgetfingerprint))
183
184	if listidentities==2 or sshgetfingerprint:
185		pre_identities=getoutput(sshadd + ' -l').split(os.linesep)
186		pverbose('pre_identities=%s'%(pre_identities))
187	if sshgetfingerprint:
188		for k in keys[:]:
189			fp = getoutput_checkstatus(sshgetfingerprint[0]%k)
190			pverbose('%s = %s'%(k,fp))
191			x = re.match(sshgetfingerprint[1],fp)
192			kid = x.group(1)
193			for pid in pre_identities:
194				if pid.find(kid)>=0:
195					pinfo('key %s already loaded'%(k))
196					#pinfo('key %s(%s) already loaded (%s)'%(k,kid,pid))
197					keys.remove(k)
198					break
199		if not keys:
200			pinfo('all keys loaded already, exiting')
201			return
202
203#we have to use a pty since ssh-add will always call ssh_askpass if its stdin is not a tty.
204	fd=ptyopen(sshadd, keys)
205	s=''
206	passphrases=[]
207	readded=re.compile(r"added: (.*)$",re.M|re.I)#commercial ssh2 doesn't say this at all. oh well.
208	rebadpass=re.compile(r"Bad passphrase",re.I)
209	#commercial ssh don't say enter passphrase for ..., so get it from the need
210	reneed=re.compile(r"Need passphrase for (.*)",re.I)
211	reenterpass=re.compile(r"Enter passphrase(?: for (.*?)):? ",re.I)
212	while 1:
213		try:
214			r=os.read(fd,1024)
215		except OSError, err:
216			if err[0]==errno.EAGAIN or err[0]==errno.EINTR:
217				continue #dunno if these can happen here, the docs don't say.. be safe. :)
218			if err[0]==errno.EIO:
219				break #seems to return oserror on eof :)
220			raise
221		if not len(r):
222			break
223			perror('hm, I wonder why we are here')
224			continue
225		pverbose('read: {'+r+'}')
226		s += r
227		while 1: #mulitple added messages coulde be in the same read if no passphrase used..
228			x=readded.search(s)
229			if not x:
230				break
231			pinfo('added: %s'%x.group(1))
232			#pverbose('s before: {'+s+'}')
233			s=s[:x.start(0)]+s[x.end(0):] #cut the added part out so we don't see it again
234			#pverbose('s after: {'+s+'}')
235
236		x=reneed.search(s)
237		if x:
238			curkey=x.group(1)
239		x=reenterpass.search(s)
240		if x:
241			curpass=0
242			if x.group(1):
243				curkey=x.group(1)
244			curaskextra=''
245			askedyet=0
246		else:
247			x=rebadpass.search(s)
248			if not x:
249				continue
250			if askedyet and not curaskextra: #if we haven't asked yet for this key, any bad pass messages will just be from trying previous passphrases.
251				curaskextra='Bad Pass. '
252		if curpass>=len(passphrases):
253			passphrases.append(askpass('%sEnter passphrase for %s: '%(curaskextra,curkey)))
254			askedyet=1
255		writen(fd,passphrases[curpass]+'\n')
256		curpass+=1
257		pverbose('<pass>')
258		s=''
259	os.close(fd)
260	w=os.wait()
261	if w[1]:
262		perror('%s[%i] %s'%(sshadd,w[0],exitstatus_str(w[1])))
263		x=re.search('(.*)$',s) #. does not match \n without re.DOTALL
264		if x:
265			perror('last line of output was: %s'%x.group(1)) #we only want to print the last line in case a passphrase was in that output somewhere... and just because its cleaner :)
266		sys.exit(1)
267	pverbose('wait: '+str(w))
268
269	if listidentities==2:
270		excl=re.compile('agent has (no|[0-9]+) ',re.I)
271		post_identities=getoutput(sshadd + ' -l').split(os.linesep)
272		added=[x for x in post_identities if x not in pre_identities and not excl.search(x)]
273		delled=[x for x in pre_identities if x not in post_identities and not excl.search(x)]
274		if added or delled:
275			for x in added: print '+'+x
276			for x in delled: print '-'+x
277		else:
278			pverbose("no change in agent's identities")
279	elif listidentities:
280		os.system(sshadd + ' -l')
281
282if __name__=='__main__':
283	try:
284		main(sys.argv[1:])
285	except KeyboardInterrupt:
286		# in debug mode print the traceback, otherwise exit nicely on ^C
287		if verbose>0: raise
288