29. listopadu 2007

XML Signature - použít nebo ne?

V naší aplikaci přenášíme poměrně velké XML soubory a přenášíme jich více najednou. Proto jsou zazipované v ZIP archivu. Ovšem potřebujeme nějak zabezpečit, aby nebylo možné měnit obsah archivu (tj. nemožnost přidávat či mazat soubory) a dále aby nešlo měnit jednotlivé XML soubory. Navíc tato komunikace neprobíhá mezi naším softwarem, ale mezi naším a cizím. Jak věc vyřešit?

Použít XML Signature (specifikace) nebo ne? Než jsem se pustil do zkoušení hledal jsem na netu a našel jsem pár mailů v konferencích a blogů, že XML Signature implementace nefunguje dobře na "large documents". Už jsem přemýšlel o vlastním řešení, kde bych hashoval XML soubory jako stream bytů, nebo bych vytvořil nějaký vlastní algoritmus pracující přímo nad SAXem, který používáme jak pro čtení tak i pro zápis dokumentů. Ale nakonec jsem si řekl, že to zkusím přímo s XML Signature a šáhl jsem po implementací, která je součástí JDK 6. Pro XML Signature hovoří skutečnosti, že je to standard (nemusím specifikovat jak se podpis vypočítá) a existuje implementace pro skoro všechny rozšířené jazyky.

Napsal jsem tedy velmi jednoduchou třídu XMLSignatureTest (při jejím psaní jsem převážně vycházel z příkladu v XML Digital Signature API Examples):

public class XMLSignatureTest {

protected static final String KEY_ALIAS = "ALIAS";

public static void main(String[] args) {
try {
long milis = System.currentTimeMillis();
if ("sign".equals(args[0])) {
signing(new File(args[1]), new File(args[2]));
} else {
System.out.println(verify(new File(args[1])) ? "Verified - ok" : "Changed");
}
System.out.println("Done in " + (System.currentTimeMillis() - milis) + " ms.");
} catch (Throwable t) {
t.printStackTrace();
}
}

protected static void signing(File xmlFile, File signatureFile) throws Exception {
//načti vstupní XML dokument
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
DocumentBuilder builder = dbf.newDocumentBuilder();
Document doc = builder.parse(new FileInputStream(xmlFile));

//otevři keystore a načti z něj privátní klíč k podpisu hashe
KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(new FileInputStream("keystore"), "XMLSig".toCharArray());
PrivateKey key = (PrivateKey) keyStore.getKey(KEY_ALIAS, "MEkeypwd".toCharArray());

//nadefinuj context podpisu
DOMSignContext dsc = new DOMSignContext(key, doc.getDocumentElement());
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");

//podepiš celý dokument, na canonicalizaci použij metodu INCLUSIV, jako hash použij SHA1 algoritmus
//podpis bude vložen do podepisovaného dokumentu a výsledný hash bude zakryptovát DSA klíčem
Reference ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA1, null),
Collections.singletonList(fac.newTransform(Transform.ENVELOPED, (XMLStructure) null)),
null, null);
SignedInfo si = fac.newSignedInfo(
fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, (XMLStructure) null),
fac.newSignatureMethod(SignatureMethod.DSA_SHA1, null), Collections.singletonList(ref));

//do podpisu jako informaci o použitém klíči vložíme jeho alias
//tj. obě strany se musí domluvit na aliasech a předat si veřejné klíče
KeyInfoFactory kif = fac.getKeyInfoFactory();
KeyName kn = kif.newKeyName(KEY_ALIAS);
KeyInfo ki = kif.newKeyInfo(Collections.singletonList(kn));

//spočítej podpis
XMLSignature signature = fac.newXMLSignature(si, ki);
signature.sign(dsc);

//ulož výsledný dokument do souboru
OutputStream os = new FileOutputStream(signatureFile);
TransformerFactory tf = TransformerFactory.newInstance();
Transformer trans = tf.newTransformer();
trans.transform(new DOMSource(doc), new StreamResult(os));
os.flush();
os.close();
}

protected static boolean verify(File xmlFile) throws Exception {
//načti XML document s vloženým podpisem
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
DocumentBuilder builder = dbf.newDocumentBuilder();
Document doc = builder.parse(new FileInputStream(xmlFile));

//najdi v dokumentu podpis
NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
if (nl.getLength() == 0) {
throw new Exception("Cannot find Signature element");
}

//otevři keystore pro načtení veřejného klíče
KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(new FileInputStream("keystore"), "XMLSig".toCharArray());

//vytvoř validační kontext (node s podpisem a KeySelector pro získání
//klíče podle identifikace v podpisu - v našem případě je to ALIAS)
DOMValidateContext valContext =
new DOMValidateContext(new KeyNameKeySelector(keyStore), nl.item(0));
XMLSignatureFactory factory = XMLSignatureFactory.getInstance("DOM");
XMLSignature signature = factory.unmarshalXMLSignature(valContext);

//ověř podpis
return signature.validate(valContext);
}

//tato třída slouží k nalezení veřejného klíče použitelného k odkryptování podpisu
private static class KeyNameKeySelector extends KeySelector {
protected KeyStore keyStore;

public KeyNameKeySelector(KeyStore keyStore) {
super();
this.keyStore = keyStore;
}

//tato metoda je volána knihovnou
public KeySelectorResult select(KeyInfo keyInfo, KeySelector.Purpose purpose,
AlgorithmMethod method, XMLCryptoContext context) throws KeySelectorException {

if (keyInfo == null) {
throw new KeySelectorException("Null KeyInfo object!");
}
SignatureMethod sm = (SignatureMethod) method;
List list = keyInfo.getContent();

for (int i = 0; i < list.size(); i++) {
XMLStructure xmlStructure = (XMLStructure) list.get(i);
//my hledáme klíč podle jména (aliasu)
if (xmlStructure instanceof KeyName) {
try {
//natáhnem veřejný klíč z keystoru
Certificate certificate = keyStore.getCertificate(((KeyName) xmlStructure).getName());
PublicKey pk = certificate.getPublicKey();
//ověříme, že v podpisu je stejný algoritmus jako používá klíč
if (algEquals(sm.getAlgorithm(), pk.getAlgorithm())) {
return new SimpleKeySelectorResult(pk);
}
} catch (KeyStoreException kse) {
throw new KeySelectorException(kse);
}
}
}
throw new KeySelectorException("No KeyValue element found!");
}

//pouze porovnává algoritmy
static boolean algEquals(String algURI, String algName) {
if (algName.equalsIgnoreCase("DSA")
&& algURI.equalsIgnoreCase(SignatureMethod.DSA_SHA1)) {
return true;
} else if (algName.equalsIgnoreCase("RSA")
&& algURI.equalsIgnoreCase(SignatureMethod.RSA_SHA1)) {
return true;
} else {
return false;
}
}

//jenom holder pro klíč
private static class SimpleKeySelectorResult implements KeySelectorResult {
private Key pk;

SimpleKeySelectorResult(Key pk) {
this.pk = pk;
}

public Key getKey() {
return pk;
}
}

}

}
Úpravy spočívají pouze v použití key-storu pro získání klíču a nepřenášení veřejného klíče v podpisu, ale pouze jeho aliasu.

A nyní k samotným testům. Zajímal jsem se o dva faktory: rychlost a peměťová náročnost.

Takže nejprve jsem test prováděl na XML dokumentu o velikosti 6,5 kB. Výpočet podpisu i ověření proběhlo i s nejmenším možným heapem, tj. 2M (1M nestačilo ani na nastartování JDKčka) a výpočet podpisu a serializace trvala 0,6 s. Co se verifikace podpisu týče, došel jsem k úplně stejnému číslu.

Následně jsem přitvrdil a použil jsem soubor o velikost 4,1 MB. Minimální heap, nad kterým jsem se dobral výsledku a nedostav pouze OOM exception byl 53MB a výpočet podpisu včetně serializace výsledku trval 10s, ověření podpisu trvalo pouze 4,8s a dokonce potřebovalo minimálně pouze 47MB. Při použití heapu o velikosti 400MB jsem se u podpisu dostal na čas 4,8s a u ověření na 2,8s.

Závěr

V podstatě jsem byl velmi mile překvapen, jak vše hladce a na první pokus proběhlo a spíš jsem hledal skulinu, jak XML Signature použít. Z prvu mi vadilo, že je nutné použít DOM, protože používáme SAX (a použít Transaformer se vstupem SAXSource a s výstupem DOMResult se mi nechtělo). Pak jsem si uvědomil, že bych stejně nemohl výpočet podpisu zařadit přímo do zpracování dokumentu, protož zpracování může trvat i dost dlouho (třeba i desítky minut). Souborů se může zpracovávat více najednou. Pokud by každý, nedejbože, byl rovnou podepisován a vytváření podpisu by si slízlo 50MB heapu, pak bych měl na serveru pamět jenom pro podepisování.

Nakonec jsem se rozhodl, že prvním krokem zpracování bude načtení dokumentu pomocí DOM a ověření podpisu. Následně teprve, pokud bude vše v pořádku, budu dokument načítat pomocí SAX (do budoucna stejně přejdeme na StAX) a dokument budu zpracovávat a zároveň budu generovat výstup (také pomocí SAX) a ten budu serializovat do souboru. Na závěr výstupní soubor načtu pomocí DOM a vygeneruju podpis.

V neposlední řadě, tak trochu pod čarou, zmíním canonicalizaci, která funguje opravdu pěkně, přidávání mezer, nových prázdných řádků, přehazování pořadí atributů neovlivní platnost podpisu.

Žádné komentáře: