/*
* Copyright 2008 The Apache Software Foundation or its licensors, as
* applicable.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* A licence was granted to the ASF by Florian Sager on 30 November 2008
*/
package de.agitos.dkim;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.ArrayList;
import java.util.Date;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.Map.Entry;
import javax.mail.MessagingException;
import com.sun.mail.util.CRLFOutputStream;
/*
* Main class providing a signature according to DKIM RFC 4871.
*
* @author Florian Sager, http://www.agitos.de, 15.10.2008
*/
public class DKIMSigner {
private static String DKIMSIGNATUREHEADER = "DKIM-Signature";
private static int MAXHEADERLENGTH = 67;
private static ArrayList<String> minimumHeadersToSign = new ArrayList<String>();
static {
minimumHeadersToSign.add("From");
}
private String[] defaultHeadersToSign = new String[]{
"Content-Description","Content-ID","Content-Type","Content-Transfer-Encoding","Cc",
"Date","From","In-Reply-To","List-Subscribe","List-Post","List-Owner","List-Id",
"List-Archive","List-Help","List-Unsubscribe","MIME-Version","Message-ID","Resent-Sender",
"Resent-Cc","Resent-Date","Resent-To","Reply-To","References","Resent-Message-ID",
"Resent-From","Sender","Subject","To"};
private SigningAlgorithm signingAlgorithm = SigningAlgorithm.SHA256withRSA; // use rsa-sha256 by default, see RFC 4871
private Signature signatureService;
private MessageDigest messageDigest;
private String signingDomain;
private String selector;
private String identity = null;
private boolean lengthParam = false;
private boolean zParam = false;
private Canonicalization headerCanonicalization = Canonicalization.RELAXED;
private Canonicalization bodyCanonicalization = Canonicalization.SIMPLE;
private PrivateKey privkey;
public DKIMSigner(String signingDomain, String selector, PrivateKey privkey) throws Exception {
initDKIMSigner(signingDomain, selector, privkey);
}
public DKIMSigner(String signingDomain, String selector, String privkeyFilename) throws Exception {
File privKeyFile = new File(privkeyFilename);
// read private key DER file
DataInputStream dis = new DataInputStream(new FileInputStream(privKeyFile));
byte[] privKeyBytes = new byte[(int) privKeyFile.length()];
dis.read(privKeyBytes);
dis.close();
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// decode private key
PKCS8EncodedKeySpec privSpec = new PKCS8EncodedKeySpec(privKeyBytes);
RSAPrivateKey privKey = (RSAPrivateKey) keyFactory.generatePrivate(privSpec);
initDKIMSigner(signingDomain, selector, privKey);
}
private void initDKIMSigner(String signingDomain, String selector, PrivateKey privkey) throws DKIMSignerException {
if (!DKIMUtil.isValidDomain(signingDomain)) {
throw new DKIMSignerException(signingDomain+" is an invalid signing domain");
}
this.signingDomain = signingDomain;
this.selector = selector.trim();
this.privkey = privkey;
this.setSigningAlgorithm(this.signingAlgorithm);
}
public String getIdentity() {
return identity;
}
public void setIdentity(String identity) throws DKIMSignerException {
if (identity!=null) {
identity = identity.trim();
if (!identity.endsWith("@"+signingDomain) && !identity.endsWith("."+signingDomain)) {
throw new DKIMSignerException("The domain part of "+identity+" has to be "+signingDomain+" or its subdomain");
}
}
this.identity = identity;
}
public Canonicalization getBodyCanonicalization() {
return bodyCanonicalization;
}
public void setBodyCanonicalization(Canonicalization bodyCanonicalization) throws DKIMSignerException {
this.bodyCanonicalization = bodyCanonicalization;
}
public Canonicalization getHeaderCanonicalization() {
return headerCanonicalization;
}
public void setHeaderCanonicalization(Canonicalization headerCanonicalization) throws DKIMSignerException {
this.headerCanonicalization = headerCanonicalization;
}
public String[] getDefaultHeadersToSign() {
return defaultHeadersToSign;
}
public void addHeaderToSign(String header) {
if (header==null || "".equals(header)) return;
int len = this.defaultHeadersToSign.length;
String[] headersToSign = new String[len+1];
for (int i=0; i<len; i++) {
if (header.equals(this.defaultHeadersToSign[i])) {
return;
}
headersToSign[i] = this.defaultHeadersToSign[i];
}
headersToSign[len] = header;
this.defaultHeadersToSign = headersToSign;
}
public void removeHeaderToSign(String header) {
if (header==null || "".equals(header)) return;
int len = this.defaultHeadersToSign.length;
if (len==0) return;
String[] headersToSign = new String[len-1];
int found = 0;
for (int i=0; i<len-1; i++) {
if (header.equals(this.defaultHeadersToSign[i+found])) {
found = 1;
}
headersToSign[i] = this.defaultHeadersToSign[i+found];
}
this.defaultHeadersToSign = headersToSign;
}
public void setLengthParam(boolean lengthParam) {
this.lengthParam = lengthParam;
}
public boolean getLengthParam() {
return lengthParam;
}
public boolean isZParam() {
return zParam;
}
public void setZParam(boolean param) {
zParam = param;
}
public SigningAlgorithm getSigningAlgorithm() {
return signingAlgorithm;
}
public void setSigningAlgorithm(SigningAlgorithm signingAlgorithm) throws DKIMSignerException {
try {
this.messageDigest = MessageDigest.getInstance(signingAlgorithm.getJavaHashNotation());
} catch (NoSuchAlgorithmException nsae) {
throw new DKIMSignerException("The hashing algorithm "+signingAlgorithm.getJavaHashNotation()+" is not known by the JVM", nsae);
}
try {
this.signatureService = Signature.getInstance(signingAlgorithm.getJavaSecNotation());
} catch (NoSuchAlgorithmException nsae) {
throw new DKIMSignerException("The signing algorithm "+signingAlgorithm.getJavaSecNotation()+" is not known by the JVM", nsae);
}
try {
this.signatureService.initSign(privkey);
} catch (InvalidKeyException ike) {
throw new DKIMSignerException("The provided private key is invalid", ike);
}
this.signingAlgorithm = signingAlgorithm;
}
private String serializeDKIMSignature(Map<String, String> dkimSignature) {
Set<Entry<String, String>> entries = dkimSignature.entrySet();
StringBuffer buf = new StringBuffer(), fbuf;
int pos = 0;
Iterator<Entry<String, String>> iter = entries.iterator();
while (iter.hasNext()) {
Entry<String, String> entry = iter.next();
// buf.append(entry.getKey()).append("=").append(entry.getValue()).append(";\t");
fbuf = new StringBuffer();
fbuf.append(entry.getKey()).append("=").append(entry.getValue()).append(";");
if (pos + fbuf.length() + 1 > MAXHEADERLENGTH) {
pos = fbuf.length();
// line folding : this doesn't work "sometimes" --> maybe someone likes to debug this
/* int i = 0;
while (i<pos) {
if (fbuf.substring(i).length()>MAXHEADERLENGTH) {
buf.append("\r\n\t").append(fbuf.substring(i, i+MAXHEADERLENGTH));
i += MAXHEADERLENGTH;
} else {
buf.append("\r\n\t").append(fbuf.substring(i));
pos -= i;
break;
}
} */
buf.append("\r\n\t").append(fbuf);
} else {
buf.append(" ").append(fbuf);
pos += fbuf.length() + 1;
}
}
buf.append("\r\n\tb=");
return buf.toString().trim();
}
private String foldSignedSignature(String s, int offset) {
int i = 0;
StringBuffer buf = new StringBuffer();
while (true) {
if (offset > 0 && s.substring(i).length()>MAXHEADERLENGTH - offset) {
buf.append(s.substring(i, i + MAXHEADERLENGTH - offset));
i += MAXHEADERLENGTH - offset;
offset = 0;
} else if (s.substring(i).length()>MAXHEADERLENGTH) {
buf.append("\r\n\t").append(s.substring(i, i + MAXHEADERLENGTH));
i += MAXHEADERLENGTH;
} else {
buf.append("\r\n\t").append(s.substring(i));
break;
}
}
return buf.toString();
}
public String sign(SMTPDKIMMessage message) throws DKIMSignerException, MessagingException {
Map<String, String> dkimSignature = new LinkedHashMap<String, String>();
dkimSignature.put("v", "1");
dkimSignature.put("a", this.signingAlgorithm.getRfc4871Notation());
dkimSignature.put("q", "dns/txt");
dkimSignature.put("c", getHeaderCanonicalization().getType()+"/"+getBodyCanonicalization().getType());
dkimSignature.put("t", ((long) new Date().getTime() / 1000)+"");
dkimSignature.put("s", this.selector);
dkimSignature.put("d", this.signingDomain);
// set identity inside signature
if (identity!=null) {
dkimSignature.put("i", DKIMUtil.QuotedPrintable(identity));
}
// process header
ArrayList assureHeaders = (ArrayList) minimumHeadersToSign.clone();
// intersect defaultHeadersToSign with available headers
StringBuffer headerList = new StringBuffer();
StringBuffer headerContent = new StringBuffer();
StringBuffer zParamString = new StringBuffer();
Enumeration headerLines = message.getMatchingHeaderLines(defaultHeadersToSign);
while (headerLines.hasMoreElements()) {
String header = (String) headerLines.nextElement();
String[] headerParts = DKIMUtil.splitHeader(header);
headerList.append(headerParts[0]).append(":");
headerContent.append(this.headerCanonicalization.canonicalizeHeader(headerParts[0], headerParts[1])).append("\r\n");
assureHeaders.remove(headerParts[0]);
// add optional z= header list, DKIM-Quoted-Printable
if (this.zParam) {
zParamString.append(headerParts[0]).append(":").append(DKIMUtil.QuotedPrintable(headerParts[1].trim()).replace("|", "=7C")).append("|");
}
}
if (!assureHeaders.isEmpty()) {
throw new DKIMSignerException("Could not find the header fields "+DKIMUtil.concatArray(assureHeaders, ", ")+" for signing");
}
dkimSignature.put("h", headerList.substring(0, headerList.length()-1));
if (this.zParam) {
String zParamTemp = zParamString.toString();
dkimSignature.put("z", zParamTemp.substring(0, zParamTemp.length()-1));
}
// process body
String body = message.getEncodedBody();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
CRLFOutputStream crlfos = new CRLFOutputStream(baos);
try {
crlfos.write(body.getBytes());
} catch (IOException e) {
throw new DKIMSignerException("The body conversion to MIME canonical CRLF line terminator failed", e);
}
body = baos.toString();
try {
body = this.bodyCanonicalization.canonicalizeBody(body);
} catch (IOException ioe) {
throw new DKIMSignerException("The body canonicalization failed", ioe);
}
if (this.lengthParam) {
dkimSignature.put("l", body.length()+"");
}
// calculate and encode body hash
dkimSignature.put("bh", DKIMUtil.base64Encode(this.messageDigest.digest(body.getBytes())));
// create signature
String serializedSignature = serializeDKIMSignature(dkimSignature);
byte[] signedSignature;
try {
signatureService.update(headerContent.append(this.headerCanonicalization.canonicalizeHeader(DKIMSIGNATUREHEADER, " "+serializedSignature)).toString().getBytes());
signedSignature = signatureService.sign();
} catch (SignatureException se) {
throw new DKIMSignerException("The signing operation by Java security failed", se);
}
return DKIMSIGNATUREHEADER + ": " + serializedSignature+foldSignedSignature(DKIMUtil.base64Encode(signedSignature), 3);
}
}