1 | /**************************************************************** |
2 | * Licensed to the Apache Software Foundation (ASF) under one * |
3 | * or more contributor license agreements. See the NOTICE file * |
4 | * distributed with this work for additional information * |
5 | * regarding copyright ownership. The ASF licenses this file * |
6 | * to you under the Apache License, Version 2.0 (the * |
7 | * "License"); you may not use this file except in compliance * |
8 | * with the License. You may obtain a copy of the License at * |
9 | * * |
10 | * http://www.apache.org/licenses/LICENSE-2.0 * |
11 | * * |
12 | * Unless required by applicable law or agreed to in writing, * |
13 | * software distributed under the License is distributed on an * |
14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * |
15 | * KIND, either express or implied. See the License for the * |
16 | * specific language governing permissions and limitations * |
17 | * under the License. * |
18 | ****************************************************************/ |
19 | |
20 | package org.apache.james.jdkim; |
21 | |
22 | import java.io.IOException; |
23 | import java.io.InputStream; |
24 | import java.io.OutputStream; |
25 | import java.security.InvalidKeyException; |
26 | import java.security.NoSuchAlgorithmException; |
27 | import java.security.Signature; |
28 | import java.security.SignatureException; |
29 | import java.util.Arrays; |
30 | import java.util.HashMap; |
31 | import java.util.Hashtable; |
32 | import java.util.Iterator; |
33 | import java.util.LinkedList; |
34 | import java.util.List; |
35 | import java.util.Map; |
36 | |
37 | import org.apache.james.jdkim.api.BodyHasher; |
38 | import org.apache.james.jdkim.api.Headers; |
39 | import org.apache.james.jdkim.api.PublicKeyRecord; |
40 | import org.apache.james.jdkim.api.PublicKeyRecordRetriever; |
41 | import org.apache.james.jdkim.api.SignatureRecord; |
42 | import org.apache.james.jdkim.canon.CompoundOutputStream; |
43 | import org.apache.james.jdkim.exceptions.FailException; |
44 | import org.apache.james.jdkim.exceptions.PermFailException; |
45 | import org.apache.james.jdkim.exceptions.TempFailException; |
46 | import org.apache.james.jdkim.impl.BodyHasherImpl; |
47 | import org.apache.james.jdkim.impl.DNSPublicKeyRecordRetriever; |
48 | import org.apache.james.jdkim.impl.Message; |
49 | import org.apache.james.jdkim.impl.MultiplexingPublicKeyRecordRetriever; |
50 | import org.apache.james.jdkim.tagvalue.PublicKeyRecordImpl; |
51 | import org.apache.james.jdkim.tagvalue.SignatureRecordImpl; |
52 | import org.apache.james.mime4j.MimeException; |
53 | |
54 | public class DKIMVerifier extends DKIMCommon { |
55 | |
56 | private PublicKeyRecordRetriever publicKeyRecordRetriever; |
57 | |
58 | public DKIMVerifier() { |
59 | this.publicKeyRecordRetriever = new MultiplexingPublicKeyRecordRetriever( |
60 | "dns", new DNSPublicKeyRecordRetriever()); |
61 | } |
62 | |
63 | public DKIMVerifier(PublicKeyRecordRetriever publicKeyRecordRetriever) { |
64 | this.publicKeyRecordRetriever = publicKeyRecordRetriever; |
65 | } |
66 | |
67 | protected PublicKeyRecord newPublicKeyRecord(String record) { |
68 | return new PublicKeyRecordImpl(record); |
69 | } |
70 | |
71 | public SignatureRecord newSignatureRecord(String record) { |
72 | return new SignatureRecordImpl(record); |
73 | } |
74 | |
75 | public BodyHasher newBodyHasher(SignatureRecord signRecord) |
76 | throws PermFailException { |
77 | return new BodyHasherImpl(signRecord); |
78 | } |
79 | |
80 | protected PublicKeyRecordRetriever getPublicKeyRecordRetriever() |
81 | throws PermFailException { |
82 | return publicKeyRecordRetriever; |
83 | } |
84 | |
85 | public PublicKeyRecord publicKeySelector(List records) |
86 | throws PermFailException { |
87 | String lastError = null; |
88 | if (records == null || records.size() == 0) { |
89 | lastError = "no key for signature"; |
90 | } else { |
91 | for (Iterator i = records.iterator(); i.hasNext();) { |
92 | String record = (String) i.next(); |
93 | try { |
94 | PublicKeyRecord pk = newPublicKeyRecord(record); |
95 | pk.validate(); |
96 | // we expect a single valid record, otherwise the result |
97 | // is unpredictable. |
98 | // in case of multiple valid records we use the first one. |
99 | return pk; |
100 | } catch (IllegalStateException e) { |
101 | // do this at last. |
102 | lastError = "invalid key for signature: " + e.getMessage(); |
103 | } |
104 | } |
105 | } |
106 | // return PERMFAIL ($error). |
107 | throw new PermFailException(lastError); |
108 | } |
109 | |
110 | /** |
111 | * asserts applicability of a signature record the a public key record. |
112 | * throws an |
113 | * |
114 | * @param pkr public key record |
115 | * @param sign signature record |
116 | * @throws PermFailException when the keys are not applicable |
117 | */ |
118 | public static void apply(PublicKeyRecord pkr, SignatureRecord sign) throws PermFailException { |
119 | if (!pkr.getGranularityPattern().matcher(sign.getIdentityLocalPart()) |
120 | .matches()) { |
121 | throw new PermFailException("inapplicable key identity local=" |
122 | + sign.getIdentityLocalPart() + " Pattern: " |
123 | + pkr.getGranularityPattern().pattern()); |
124 | } |
125 | |
126 | if (!pkr.isHashMethodSupported(sign.getHashMethod())) { |
127 | throw new PermFailException("inappropriate hash for a=" |
128 | + sign.getHashKeyType() + "/" + sign.getHashMethod()); |
129 | } |
130 | if (!pkr.isKeyTypeSupported(sign.getHashKeyType())) { |
131 | throw new PermFailException("inappropriate key type for a=" |
132 | + sign.getHashKeyType() + "/" + sign.getHashMethod()); |
133 | } |
134 | |
135 | if (pkr.isDenySubdomains()) { |
136 | if (!sign.getIdentity().toString().toLowerCase().endsWith( |
137 | ("@" + sign.getDToken()).toLowerCase())) { |
138 | throw new PermFailException( |
139 | "AUID in subdomain of SDID is not allowed by the public key record."); |
140 | } |
141 | } |
142 | |
143 | } |
144 | |
145 | /** |
146 | * Iterates through signature's declared lookup method |
147 | * |
148 | * @param sign |
149 | * the signature record |
150 | * @return an "applicable" PublicKeyRecord |
151 | * @throws TempFailException |
152 | * @throws PermFailException |
153 | */ |
154 | public PublicKeyRecord publicRecordLookup(SignatureRecord sign) |
155 | throws TempFailException, PermFailException { |
156 | // System.out.println(sign); |
157 | PublicKeyRecord key = null; |
158 | TempFailException lastTempFailure = null; |
159 | PermFailException lastPermFailure = null; |
160 | for (Iterator rlm = sign.getRecordLookupMethods().iterator(); key == null |
161 | && rlm.hasNext();) { |
162 | String method = (String) rlm.next(); |
163 | try { |
164 | PublicKeyRecordRetriever pkrr = getPublicKeyRecordRetriever(); |
165 | List records = pkrr.getRecords(method, sign.getSelector() |
166 | .toString(), sign.getDToken().toString()); |
167 | PublicKeyRecord tempKey = publicKeySelector(records); |
168 | // checks wether the key is applicable to the signature |
169 | // TODO check with the IETF group to understand if this is the |
170 | // right thing to do. |
171 | // TODO loggin |
172 | apply(tempKey, sign); |
173 | key = tempKey; |
174 | } catch (TempFailException tf) { |
175 | lastTempFailure = tf; |
176 | } catch (PermFailException pf) { |
177 | lastPermFailure = pf; |
178 | } |
179 | } |
180 | if (key == null) { |
181 | if (lastTempFailure != null) |
182 | throw lastTempFailure; |
183 | else if (lastPermFailure != null) |
184 | throw lastPermFailure; |
185 | // this is unexpected because the publicKeySelector always returns |
186 | // null or exception |
187 | else |
188 | throw new PermFailException( |
189 | "no key for signature [unexpected condition]"); |
190 | } |
191 | return key; |
192 | } |
193 | |
194 | /** |
195 | * Verifies all of the DKIM-Signature records declared in the supplied input |
196 | * stream |
197 | * |
198 | * @param is |
199 | * inputStream |
200 | * @return a list of verified signature records. |
201 | * @throws IOException |
202 | * @throws FailException |
203 | * if no signature can be verified |
204 | */ |
205 | public List/* SignatureRecord */verify(InputStream is) throws IOException, |
206 | FailException { |
207 | Message message; |
208 | try { |
209 | message = new Message(is); |
210 | return verify(message, message.getBodyInputStream()); |
211 | } catch (MimeException e1) { |
212 | throw new PermFailException("Mime parsing exception: " |
213 | + e1.getMessage(), e1); |
214 | } finally { |
215 | is.close(); |
216 | } |
217 | } |
218 | |
219 | /** |
220 | * Verifies all of the DKIM-Signature records declared in the Headers |
221 | * object. |
222 | * |
223 | * @param messageHeaders |
224 | * parsed headers |
225 | * @param bodyInputStream |
226 | * input stream for the body. |
227 | * @return a list of verified signature records |
228 | * @throws IOException |
229 | * @throws FailException |
230 | * if no signature can be verified |
231 | */ |
232 | public List/* SignatureRecord */verify(Headers messageHeaders, |
233 | InputStream bodyInputStream) throws IOException, FailException { |
234 | // System.out.println(message.getFields("DKIM-Signature")); |
235 | List fields = messageHeaders.getFields("DKIM-Signature"); |
236 | // if (fields.size() > 1) throw new RuntimeException("here we are!"); |
237 | if (fields.size() == 0) { |
238 | throw new PermFailException("DKIM-Signature field not found"); |
239 | } |
240 | |
241 | // For each DKIM-signature we prepare an hashjob. |
242 | // We calculate all hashes concurrently so to read |
243 | // the inputstream only once. |
244 | Map/* String, BodyHashJob */bodyHashJobs = new HashMap(); |
245 | List/* OutputStream */outputStreams = new LinkedList(); |
246 | Map/* String, Exception */signatureExceptions = new Hashtable(); |
247 | for (Iterator i = fields.iterator(); i.hasNext();) { |
248 | String signatureField = (String) i.next(); |
249 | try { |
250 | int pos = signatureField.indexOf(':'); |
251 | if (pos > 0) { |
252 | String v = signatureField.substring(pos + 1, signatureField |
253 | .length()); |
254 | SignatureRecord signatureRecord; |
255 | try { |
256 | signatureRecord = newSignatureRecord(v); |
257 | // validate |
258 | signatureRecord.validate(); |
259 | } catch (IllegalStateException e) { |
260 | throw new PermFailException(e.getMessage()); |
261 | } |
262 | |
263 | // TODO here we could check more parameters for |
264 | // validation before running a network operation like the |
265 | // dns lookup. |
266 | // e.g: the canonicalization method could be checked now. |
267 | PublicKeyRecord publicKeyRecord = publicRecordLookup(signatureRecord); |
268 | |
269 | List signedHeadersList = signatureRecord.getHeaders(); |
270 | |
271 | byte[] decoded = signatureRecord.getSignature(); |
272 | signatureVerify(messageHeaders, signatureRecord, decoded, |
273 | publicKeyRecord, signedHeadersList); |
274 | |
275 | // we track all canonicalizations+limit+bodyHash we |
276 | // see so to be able to check all of them in a single |
277 | // stream run. |
278 | BodyHasher bhj = newBodyHasher(signatureRecord); |
279 | |
280 | bodyHashJobs.put(signatureField, bhj); |
281 | outputStreams.add(bhj.getOutputStream()); |
282 | |
283 | } else { |
284 | throw new PermFailException( |
285 | "unexpected bad signature field"); |
286 | } |
287 | } catch (TempFailException e) { |
288 | signatureExceptions.put(signatureField, e); |
289 | } catch (PermFailException e) { |
290 | signatureExceptions.put(signatureField, e); |
291 | } catch (InvalidKeyException e) { |
292 | signatureExceptions.put(signatureField, new PermFailException(e |
293 | .getMessage(), e)); |
294 | } catch (NoSuchAlgorithmException e) { |
295 | signatureExceptions.put(signatureField, new PermFailException(e |
296 | .getMessage(), e)); |
297 | } catch (SignatureException e) { |
298 | signatureExceptions.put(signatureField, new PermFailException(e |
299 | .getMessage(), e)); |
300 | } |
301 | } |
302 | |
303 | OutputStream o; |
304 | if (bodyHashJobs.size() == 0) { |
305 | throw prepareException(signatureExceptions); |
306 | } else if (bodyHashJobs.size() == 1) { |
307 | o = ((BodyHasher) bodyHashJobs.values().iterator().next()) |
308 | .getOutputStream(); |
309 | } else { |
310 | o = new CompoundOutputStream(outputStreams); |
311 | } |
312 | |
313 | // simultaneous computation of all the hashes. |
314 | DKIMCommon.streamCopy(bodyInputStream, o); |
315 | |
316 | List/* SignatureRecord */verifiedSignatures = new LinkedList(); |
317 | for (Iterator i = bodyHashJobs.values().iterator(); i.hasNext();) { |
318 | BodyHasher bhj = (BodyHasher) i.next(); |
319 | |
320 | byte[] computedHash = bhj.getDigest(); |
321 | byte[] expectedBodyHash = bhj.getSignatureRecord().getBodyHash(); |
322 | |
323 | if (!Arrays.equals(expectedBodyHash, computedHash)) { |
324 | signatureExceptions |
325 | .put( |
326 | "DKIM-Signature:"+bhj.getSignatureRecord().toString(), |
327 | new PermFailException( |
328 | "Computed bodyhash is different from the expected one")); |
329 | } else { |
330 | verifiedSignatures.add(bhj.getSignatureRecord()); |
331 | } |
332 | } |
333 | |
334 | if (verifiedSignatures.size() == 0) { |
335 | throw prepareException(signatureExceptions); |
336 | } else { |
337 | // TODO list good and bad signatures. |
338 | // remove system out. |
339 | for (Iterator i = signatureExceptions.keySet().iterator(); i |
340 | .hasNext();) { |
341 | String f = (String) i.next(); |
342 | System.out.println("DKIM-Error: " |
343 | + ((FailException) signatureExceptions.get(f)) |
344 | .getMessage() + " FIELD: " + f); |
345 | } |
346 | for (Iterator i = verifiedSignatures.iterator(); i.hasNext();) { |
347 | SignatureRecord sr = (SignatureRecord) i.next(); |
348 | System.out.println("DKIM-Pass: " + sr); |
349 | } |
350 | return verifiedSignatures; |
351 | } |
352 | |
353 | } |
354 | |
355 | private FailException prepareException(Map signatureExceptions) { |
356 | if (signatureExceptions.size() == 1) { |
357 | return (FailException) signatureExceptions.values().iterator() |
358 | .next(); |
359 | } else { |
360 | // TODO loops signatureExceptions to give a more complete |
361 | // response, using nested exception or a compound exception. |
362 | // System.out.println(signatureExceptions); |
363 | return new PermFailException("found " + signatureExceptions.size() |
364 | + " invalid signatures"); |
365 | } |
366 | } |
367 | |
368 | private void signatureVerify(Headers h, SignatureRecord sign, |
369 | byte[] decoded, PublicKeyRecord key, List headers) |
370 | throws NoSuchAlgorithmException, InvalidKeyException, |
371 | SignatureException, PermFailException { |
372 | |
373 | Signature signature = Signature.getInstance(sign.getHashMethod() |
374 | .toString().toUpperCase() |
375 | + "with" + sign.getHashKeyType().toString().toUpperCase()); |
376 | signature.initVerify(key.getPublicKey()); |
377 | |
378 | signatureCheck(h, sign, headers, signature); |
379 | |
380 | if (!signature.verify(decoded)) |
381 | throw new PermFailException("Header signature does not verify"); |
382 | } |
383 | |
384 | } |