Coverage for pass_import/managers/lastpass.py: 94%

127 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-02-26 12:11 +0000

1# -*- encoding: utf-8 -*- 

2# pass import - Passwords importer swiss army knife 

3# Copyright (C) 2017-2024 Alexandre PUJOL <alexandre@pujol.io>. 

4# 

5 

6import json 

7import os 

8 

9from pass_import.core import register_managers 

10from pass_import.errors import FormatError, PMError 

11from pass_import.formats.cli import CLI 

12from pass_import.formats.csv import CSV 

13from pass_import.tools import getpassword 

14 

15 

16class LastpassCLI(CLI): 

17 """Importer & Exporter for Lastpass using lpass. 

18 

19 Binary attachments are not supported by Lastpass 

20 The Lastpass login is given either by the prefix or by the configuration 

21 file under the lastpass.login entry. 

22 

23 Example: 

24 ------- 

25 .. code-block:: yml 

26 

27 lastpass: 

28 login: <your email addresss> 

29 

30 """ 

31 name = 'lastpass' 

32 command = 'lpass' 

33 url = 'https://www.lastpass.com' 

34 himport = 'pass import lastpass <login>' 

35 keys = { 

36 'title': 'name', 

37 'login': 'username', 

38 'url': 'url', 

39 'comments': 'note', 

40 'group': 'group' 

41 } 

42 

43 def __init__(self, prefix=None, settings=None): 

44 self._opt = [] 

45 self.sep = '\\' 

46 

47 settings = {} if settings is None else settings 

48 conf = settings.get('lastpass', {}) 

49 prefix = conf.get('login', prefix) 

50 

51 super().__init__(prefix, settings) 

52 self._setenv('LPASS_HOME') 

53 self._setenv('LPASS_AUTO_SYNC_TIME') 

54 self._setenv('LPASS_AGENT_TIMEOUT') 

55 self._setenv('LPASS_AGENT_DISABLE') 

56 self._setenv('LPASS_PINENTRY') 

57 self._setenv('LPASS_DISABLE_PINENTRY', value='1') 

58 self._setenv('LPASS_ASKPASS') 

59 self._setenv('LPASS_CLIPBOARD_COMMAND') 

60 

61 def _path(self, path, rep=os.sep): 

62 r"""Lpass is not consitent with / and '\\'. Replace them by os.sep.""" 

63 return path.replace('/', rep).replace(self.sep, rep) 

64 

65 def sync(self): 

66 """Force a synchronization of the local cache with the servers.""" 

67 self._command(['sync', '--color=never']) 

68 

69 def list(self, path=''): 

70 """List the paths in the password store repository. 

71 

72 :param str path: Root path to the password repository to list. 

73 :return list: Return a list of unique ID in the store. 

74 

75 """ 

76 uids = [] 

77 path = path.replace(self.sep, os.sep) 

78 

79 arg = ['ls', '--format=%ai|%aN', '--sync=now', '--color=never'] 

80 data = self._command(arg).split('\n') 

81 for line in data: 

82 if '|' in line: 

83 uid, group = line.split('|', 1) 

84 group = group.replace(self.sep, os.sep) 

85 if not group.endswith(os.sep) and path in group: 

86 uids.append(uid) 

87 return uids 

88 

89 # Import methods 

90 

91 def show(self, uid): 

92 """Decrypt a lastpass entry and read the credentials. 

93 

94 lpass do not show the same data with the --json option and without. 

95 To retrieve the full entry, both --json and --format option need to 

96 be used. 

97 

98 :param str uid: UniqueID to the password entry to decrypt. 

99 :return dict: Return a dictionary with of the password entry. 

100 

101 """ 

102 entry = {} 

103 

104 # lpass show --json 

105 ignores = {'fullname'} 

106 keys = self.invkeys() 

107 jsons = self._command(['show', '--json', uid]) 

108 item = json.loads(jsons).pop() 

109 for key, value in item.items(): 

110 if key not in ignores: 

111 entry[keys.get(key, key)] = value 

112 entry['group'] = self._path(item['group']) 

113 

114 # lpass show --format 

115 ignores = {'Username', 'Password', 'URL'} 

116 arg = ['show', '--color=never', 

117 "--format=%fn|%fv", '--color=never', uid] 

118 data = self._command(arg).split('\n') 

119 data.pop() 

120 data.pop(0) 

121 for line in data: 

122 if '|' in line: 

123 key, value = line.split('|', 1) 

124 if key not in ignores: 

125 entry[key] = value 

126 

127 # Special cleanup 

128 if entry.get('url', '') == 'http://': 

129 entry['url'] = '' 

130 return entry 

131 

132 def parse(self): 

133 """Parse Lastpass repository using lpass.""" 

134 uniqueids = self.list(self.root) 

135 if not uniqueids: 

136 raise FormatError('empty password store.') 

137 

138 for uniqueid in uniqueids: 

139 entry = self.show(uniqueid) 

140 self.data.append(entry) 

141 

142 # Export methods 

143 

144 def remove(self, uid): 

145 """Move an entry to the Trash.""" 

146 arg = ['rm', '--color=never', uid] 

147 self._command(arg) 

148 

149 def insert(self, entry): 

150 """Insert a password entry into lastpass using lpass.""" 

151 path = os.path.join(self.root, entry['path']) 

152 entry['group'] = os.path.dirname(path) 

153 entry['title'] = os.path.basename(path) 

154 path = entry['group'].replace(os.sep, self.sep) + '/' + entry['title'] 

155 

156 # Remove entries with the same name. 

157 uids = self.list(path) 

158 if uids: 

159 if not self.force: 

160 raise PMError(f"An entry already exists for {path}.") 

161 for uid in uids: 

162 self.remove(uid) 

163 

164 # Insert the entry into lastpass 

165 seen = {'path', 'title', 'group'} 

166 exportkeys = { 

167 'title': 'Name', 

168 'password': 'Password', 

169 'login': 'Username', 

170 'url': 'URL', 

171 'comments': 'Notes', 

172 } 

173 data = '' 

174 for key in self.keyslist: 

175 if key in seen: 

176 continue 

177 if key in entry: 

178 data += f"{exportkeys.get(key, key)}: {entry[key]}\n" 

179 seen.add(key) 

180 

181 if self.all: 

182 for key, value in entry.items(): 

183 if key in seen: 

184 continue 

185 data += f"{key}: {value}\n" 

186 

187 arg = ['add', '--sync=now', '--non-interactive', '--color=never', path] 

188 self._command(arg, data) 

189 

190 # Context manager methods 

191 

192 def open(self): 

193 """Sign in to your Lastpass account.""" 

194 if self.prefix == '': 

195 raise PMError("Your Lastpass username is empty") 

196 

197 status = [self._binary, 'status', '--quiet'] 

198 res, _, _ = self._call(status) 

199 if res: 

200 login = ['login', '--trust', '--color=never', self.prefix] 

201 password = getpassword('Lastpass') 

202 res = self._command(login, password) 

203 

204 def close(self): 

205 """Synchronise and sign out of your Lastpass account.""" 

206 self.sync() 

207 

208 

209class LastpassCSV(CSV): 

210 """Importer for Lastpass in CSV format.""" 

211 name = 'lastpass' 

212 default = False 

213 url = 'https://www.lastpass.com' 

214 hexport = 'More Options > Advanced > Export' 

215 keys = { 

216 'title': 'name', 

217 'password': 'password', 

218 'login': 'username', 

219 'url': 'url', 

220 'comments': 'extra', 

221 'group': 'grouping' 

222 } 

223 

224 def parse(self): 

225 """Parse Lastpass CSV file.""" 

226 super().parse() 

227 for entry in self.data: 

228 if 'group' in entry and entry['group'] is None: 

229 # LastPass will truncate everything after `$` in a 

230 # secure note entry when exporting as a CSV, including 

231 # any closing ", leaving the file in a corrupt 

232 # state. Triggering this is likely a symptom of such a 

233 # corrupted export. 

234 # 

235 # Likewise, it also has problems exporting single 

236 # quotes in the password field, causing all data prior 

237 # to the single quote (including the url field, etc.) 

238 # to be truncated, leading to the parser thinking the 

239 # path field wasn't included, and incorrectly 

240 # resulting in a value of None. 

241 raise FormatError(f'Invalid group in entry:\n{entry}.') 

242 entry['group'] = entry.get('group', '').replace('\\', os.sep) 

243 

244 

245register_managers(LastpassCLI, LastpassCSV)