Coverage for pass_import/managers/onepassword.py: 95%

98 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 re 

8from pass_import.core import register_managers, register_detecters 

9from pass_import.formats.csv import CSV 

10from pass_import.formats.json import JSON 

11 

12 

13class OnePasswordCSV(CSV): 

14 """Importer for 1password 6 in CSV format.""" 

15 name = '1password' 

16 default = False 

17 version = '6' 

18 default = False 

19 url = 'https://1password.com' 

20 hexport = 'See this guide: https://support.1password.com/export' 

21 himport = 'pass import 1password file.csv' 

22 keys = { 

23 'title': 'Title', 

24 'password': 'Password', 

25 'login': 'Username', 

26 'url': 'URL', 

27 'comments': 'Notes', 

28 'group': 'Type' 

29 } 

30 

31 

32class OnePassword4CSV(CSV): 

33 """Importer for 1password 4 in CSV format.""" 

34 name = '1password' 

35 default = False 

36 version = '4' 

37 only = True 

38 url = 'https://1password.com' 

39 hexport = 'See this guide: https://support.1password.com/export' 

40 himport = 'pass import 1password file.csv' 

41 keys = { 

42 'title': 'title', 

43 'password': 'password', 

44 'login': 'username', 

45 'url': 'url', 

46 'comments': 'notes' 

47 } 

48 

49 

50class OnePassword8CSV(CSV): 

51 """Importer for 1password 8 in CSV format.""" 

52 name = '1password' 

53 version = '8' 

54 url = 'https://1password.com' 

55 hexport = 'See this guide: https://support.1password.com/export' 

56 himport = 'pass import 1password file.csv' 

57 keys = { 

58 'title': 'Title', 

59 'url': 'Url', 

60 'login': 'Username', 

61 'password': 'Password', 

62 'otpauth': 'OTPAuth', 

63 'favorite': 'Favorite', 

64 'archived': 'Archived', 

65 'tags': 'Tags', 

66 'comments': 'Notes' 

67 } 

68 

69 

70class OnePassword4PIF(JSON): 

71 """Importer for 1password 4 in PIF format. 

72 

73 :param list ignore: List of key in the PIF file to not try to import. 

74 

75 """ 

76 name = '1password' 

77 format = '1pif' 

78 default = False 

79 version = '4' 

80 url = 'https://1password.com' 

81 hexport = 'See this guide: https://support.1password.com/export' 

82 himport = 'pass import 1password file.1pif' 

83 encoding = 'utf-8-sig' 

84 ignore = {'keyID', 'typeName', 'uuid', 'openContents', 'URLs'} 

85 keys = { 

86 'title': 'title', 

87 'password': 'password', 

88 'login': 'username', 

89 'url': 'location', 

90 'comments': 'notesPlain', 

91 'group': 'folderUuid', 

92 'tags': 'tags' 

93 } 

94 

95 # Import methods 

96 

97 @staticmethod 

98 def pif2json(file): 

99 """Convert 1pif to json: https://github.com/eblin/1passpwnedcheck.""" 

100 data = file.read() 

101 cleaned = re.sub(r'(?m)^\*\*\*.*\*\*\*\s+', '', data) 

102 cleaned = cleaned.split('\n') 

103 # On 1Password v7.9.11 (macOS), 1PIF export produces 1 extra empty line 

104 cleaned = [v for v in cleaned if len(v) > 0] 

105 cleaned = ','.join(cleaned).rstrip(',') 

106 cleaned = f'[{cleaned}]' 

107 # JSON string with eventual special characters are encoded properly 

108 # eg: NUL, TAB 

109 cleaned = json.dumps(json.loads(cleaned, strict=False)) 

110 return json.loads(cleaned) 

111 

112 def parse(self): 

113 """Parse PIF based file.""" 

114 jsons = self.pif2json(self.file) 

115 keys = self.invkeys() 

116 folders = {} 

117 for item in jsons: 

118 if item.get('typeName', '') == 'system.folder.Regular': 

119 key = item.get('uuid', '') 

120 folders[key] = { 

121 'group': item.get('title', ''), 

122 'parent': item.get('folderUuid', '') 

123 } 

124 

125 elif item.get('typeName', '') == 'webforms.WebForm': 

126 if item.get('trashed', False): 

127 continue 

128 entry = {} 

129 scontent = item.pop('secureContents', {}) 

130 fields = scontent.pop('fields', []) 

131 for field in fields: 

132 name = field.get('name', '') 

133 designation = field.get('designation', '') 

134 jsonkey = name or designation 

135 key = keys.get(jsonkey, jsonkey) 

136 entry[key] = field.get('value', '') 

137 

138 sections = scontent.get('sections', []) 

139 for section in sections: 

140 for field in section.get('fields', []): 

141 value = field.get('v', '') 

142 if value.startswith('otpauth://'): 

143 entry['otpauth'] = value 

144 

145 item.update(scontent) 

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

147 if key not in self.ignore: 

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

149 

150 tags = [] 

151 if 'openContents' in item: 

152 open_contents = item['openContents'] 

153 tags = open_contents.get('tags', []) 

154 

155 entry['tags'] = tags 

156 self.data.append(entry) 

157 self._sortgroup(folders) 

158 

159 # Format recognition method 

160 

161 def is_format(self): 

162 """Return True if the file is a 1PIF file.""" 

163 try: 

164 self.jsons = self.pif2json(self.file) 

165 except (json.decoder.JSONDecodeError, UnicodeDecodeError): 

166 return False 

167 return True 

168 

169 def checkheader(self, header, only=False): 

170 """No header check is needed.""" 

171 return True 

172 

173 

174register_managers(OnePassword8CSV, 

175 OnePasswordCSV, OnePassword4CSV, OnePassword4PIF) 

176register_detecters(OnePassword4PIF)