Skip to content

Commit 6a170e4

Browse files
joewizclaude
andcommitted
[feature] Add crypto:hash for EXPath spec completeness
Implements crypto:hash($data, $algorithm) and crypto:hash($data, $algorithm, $encoding) per the EXPath Cryptographic Module spec. Accepts string, binary, and node input. Supports MD5, SHA-1, SHA-256, SHA-384, SHA-512 with base64 (default) or hex output. For new code, fn:hash() (XQuery 4.0) or util:hash() are preferred. crypto:hash is provided for backward compatibility and cross-engine portability (BaseX also implements it). 11 new tests (46 total), CI smoke test added. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c82bf16 commit 6a170e4

File tree

5 files changed

+305
-17
lines changed

5 files changed

+305
-17
lines changed

.github/workflows/exist.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ jobs:
5757
sleep 5
5858
5959
# Smoke test: verify module loads and functions work
60+
- name: Test crypto:hash with known SHA-256 vector
61+
run: |
62+
result=$(curl -sf -u admin: -H "Content-Type: application/xml" --data '
63+
<query xmlns="http://exist.sourceforge.net/NS/exist">
64+
<text><![CDATA[
65+
import module namespace crypto = "http://expath.org/ns/crypto";
66+
crypto:hash("test", "SHA-256", "hex")
67+
]]></text>
68+
</query>' "http://localhost:8080/exist/rest/db")
69+
echo "$result"
70+
echo "$result" | grep -q "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" || (echo "FAIL: SHA-256 hash mismatch" && exit 1)
71+
6072
- name: Test crypto:hmac with SHA256 hex output
6173
run: |
6274
result=$(curl -sf -u admin: -H "Content-Type: application/xml" --data '

README.md

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# EXPath Crypto Module for eXist-db
22

3-
A standalone XAR package implementing the [EXPath Cryptographic Module](http://expath.org/spec/crypto) for eXist-db 7.0+. Provides HMAC authentication, symmetric encryption/decryption, and XML digital signatures using Java's built-in JCE (Java Cryptography Extension) — no external dependencies.
3+
A standalone XAR package implementing the [EXPath Cryptographic Module](http://expath.org/spec/crypto) for eXist-db 7.0+. Provides cryptographic hashing, HMAC authentication, symmetric encryption/decryption, and XML digital signatures using Java's built-in JCE (Java Cryptography Extension) — no external dependencies.
44

55
## Install
66

@@ -14,6 +14,8 @@ xst package install exist-crypto-1.0.0.xar
1414

1515
| Function | Description |
1616
|----------|-------------|
17+
| `crypto:hash($data, $algorithm)` | Cryptographic hash with Base64 output |
18+
| `crypto:hash($data, $algorithm, $encoding)` | Cryptographic hash with specified encoding (`base64` or `hex`) |
1719
| `crypto:hmac($data, $key, $algorithm)` | HMAC with Base64 output |
1820
| `crypto:hmac($data, $key, $algorithm, $encoding)` | HMAC with specified encoding (`base64` or `hex`) |
1921
| `crypto:encrypt($data, $type, $key, $algorithm)` | Symmetric encryption (AES or DES) |
@@ -23,6 +25,22 @@ xst package install exist-crypto-1.0.0.xar
2325

2426
**Module namespace:** `http://expath.org/ns/crypto`
2527

28+
### Hash
29+
30+
```xquery
31+
import module namespace crypto = "http://expath.org/ns/crypto";
32+
33+
(: Base64 output (default) :)
34+
crypto:hash("data", "SHA-256")
35+
36+
(: Hex output :)
37+
crypto:hash("data", "SHA-256", "hex")
38+
```
39+
40+
**Algorithms:** MD5, SHA-1, SHA-256, SHA-384, SHA-512
41+
42+
For new code, prefer `fn:hash()` (XQuery 4.0) or `util:hash()`. `crypto:hash` is provided for backward compatibility with the EXPath spec and cross-engine portability (BaseX also implements it).
43+
2644
### HMAC
2745

2846
```xquery
@@ -74,7 +92,7 @@ This package is a clean-room replacement for the legacy [`expath-crypto-module`]
7492
| **Java version** | Java 8+ | Java 21+ |
7593
| **Dependencies** | External `crypto-java` library (ro.kuberam) | Zero — uses Java's built-in JCE |
7694
| **Parent POM** | `exist-apps-parent` 1.11.0 | `exist-apps-parent` 2.0.0 |
77-
| **`crypto:hash`** | Yes (2–3 arity) | No (use XQuery 4.0's built-in `fn:hash` instead) |
95+
| **`crypto:hash`** | Yes (2–3 arity) | Yes (2–3 arity, compatible) |
7896

7997
### Upgrade steps
8098

@@ -104,7 +122,7 @@ The module namespace is identical, so **import statements require no changes**:
104122
import module namespace crypto = "http://expath.org/ns/crypto";
105123
```
106124

107-
Calls to `crypto:hmac` and `crypto:validate-signature` are compatible as-is for the most common usage patterns.
125+
Calls to `crypto:hash`, `crypto:hmac`, and `crypto:validate-signature` are compatible as-is for the most common usage patterns.
108126

109127
#### `crypto:hmac` — return type changed
110128

@@ -144,23 +162,16 @@ The 6-argument form is compatible. The XPath subsetting (7-arg), certificate (8-
144162

145163
**If you used XPath subsetting or certificates:** These features are not yet available. File an issue if you need them.
146164

147-
#### `crypto:hash` — removed
148-
149-
The legacy module provided `crypto:hash($data, $algorithm)` for basic hashing. This function is not included because eXist-db's XQuery 4.0 Functions branch provides the standard `fn:hash()`:
150-
151-
```xquery
152-
(: Legacy :)
153-
crypto:hash("data", "SHA-256")
165+
#### `crypto:hash` — return type changed
154166

155-
(: New — use fn:hash from XQuery 4.0 :)
156-
fn:hash("data", "SHA-256")
157-
```
167+
| | Legacy | New |
168+
|---|---|---|
169+
| **Parameter types** | `$data as item()*` | `$data as item()` |
170+
| **Return type** | `xs:byte*` | `xs:string` |
158171

159-
If you are on an eXist-db version without `fn:hash`, you can use `util:hash()` which has been available since eXist-db 4.x:
172+
The legacy module returned a byte sequence. The new module returns a string (Base64 or hex encoded). The function signatures and algorithm names are otherwise compatible.
160173

161-
```xquery
162-
util:hash("data", "SHA-256")
163-
```
174+
For new code, prefer `fn:hash()` (XQuery 4.0) or `util:hash()`. `crypto:hash` is provided for backward compatibility with the EXPath Cryptographic Module specification and cross-engine portability (BaseX also implements it).
164175

165176
### Package identity comparison
166177

src/main/java/org/exist/xquery/modules/crypto/CryptoModule.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ public class CryptoModule extends AbstractInternalModule {
5454
EncryptFunction.FS_ENCRYPT),
5555
functionDefs(GenerateSignatureFunction.class,
5656
GenerateSignatureFunction.FS_GENERATE_SIGNATURE),
57+
functionDefs(HashFunction.class,
58+
HashFunction.FS_HASH),
5759
functionDefs(HmacFunction.class,
5860
HmacFunction.FS_HMAC),
5961
functionDefs(ValidateSignatureFunction.class,
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* eXist-db Open Source Native XML Database
3+
* Copyright (C) 2001 The eXist-db Authors
4+
*
5+
* info@exist-db.org
6+
* http://www.exist-db.org
7+
*
8+
* This library is free software; you can redistribute it and/or
9+
* modify it under the terms of the GNU Lesser General Public
10+
* License as published by the Free Software Foundation; either
11+
* version 2.1 of the License, or (at your option) any later version.
12+
*
13+
* This library is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16+
* Lesser General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Lesser General Public
19+
* License along with this library; if not, write to the Free Software
20+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
21+
*/
22+
package org.exist.xquery.modules.crypto;
23+
24+
import java.io.IOException;
25+
import java.io.InputStream;
26+
import java.nio.charset.StandardCharsets;
27+
import java.security.MessageDigest;
28+
import java.security.NoSuchAlgorithmException;
29+
import java.util.Base64;
30+
import java.util.HexFormat;
31+
32+
import org.exist.xquery.BasicFunction;
33+
import org.exist.xquery.FunctionSignature;
34+
import org.exist.xquery.XPathException;
35+
import org.exist.xquery.XQueryContext;
36+
import org.exist.xquery.value.BinaryValue;
37+
import org.exist.xquery.value.Item;
38+
import org.exist.xquery.value.NodeValue;
39+
import org.exist.xquery.value.Sequence;
40+
import org.exist.xquery.value.StringValue;
41+
import org.exist.xquery.value.Type;
42+
43+
import static org.exist.xquery.FunctionDSL.*;
44+
45+
/**
46+
* Implements {@code crypto:hash()} — cryptographic hashing.
47+
*
48+
* <p>Two arities:</p>
49+
* <ul>
50+
* <li>{@code crypto:hash($data, $algorithm)} — Base64 output (default)</li>
51+
* <li>{@code crypto:hash($data, $algorithm, $encoding)} — hex or base64</li>
52+
* </ul>
53+
*
54+
* <p>Accepts string, binary, and node input. Nodes are serialized to their
55+
* string value before hashing.</p>
56+
*
57+
* <p>For new code, prefer {@code fn:hash()} (XQuery 4.0) or {@code util:hash()}.
58+
* {@code crypto:hash} is provided for backward compatibility with the EXPath
59+
* Cryptographic Module specification and cross-engine portability (BaseX also
60+
* implements it).</p>
61+
*
62+
* <p>Supported algorithms: MD5, SHA-1, SHA-256, SHA-384, SHA-512.</p>
63+
*/
64+
public class HashFunction extends BasicFunction {
65+
66+
private static final String FS_HASH_NAME = "hash";
67+
68+
public static final FunctionSignature[] FS_HASH = functionSignatures(
69+
CryptoModule.qname(FS_HASH_NAME),
70+
"Computes the hash of the given data using the specified algorithm. " +
71+
"For new code, prefer fn:hash() (XQuery 4.0) or util:hash(). " +
72+
"crypto:hash is provided for backward compatibility and cross-engine portability.",
73+
returns(Type.STRING, "the hash value as a base64 or hex string"),
74+
arities(
75+
arity(
76+
param("data", Type.ITEM, "The data to hash " +
77+
"(xs:string, xs:base64Binary, xs:hexBinary, or node)."),
78+
param("algorithm", Type.STRING,
79+
"The hash algorithm: MD5, SHA-1, SHA-256, SHA-384, or SHA-512.")
80+
),
81+
arity(
82+
param("data", Type.ITEM, "The data to hash " +
83+
"(xs:string, xs:base64Binary, xs:hexBinary, or node)."),
84+
param("algorithm", Type.STRING,
85+
"The hash algorithm: MD5, SHA-1, SHA-256, SHA-384, or SHA-512."),
86+
param("encoding", Type.STRING,
87+
"The output encoding: 'base64' (default) or 'hex'.")
88+
)
89+
)
90+
);
91+
92+
public HashFunction(final XQueryContext context, final FunctionSignature signature) {
93+
super(context, signature);
94+
}
95+
96+
@Override
97+
public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
98+
if (args[0].isEmpty()) {
99+
return Sequence.EMPTY_SEQUENCE;
100+
}
101+
102+
final Item data = args[0].itemAt(0);
103+
final String algorithm = args[1].getStringValue();
104+
final String encoding = getArgumentCount() == 3 ? args[2].getStringValue() : "base64";
105+
106+
final String jceAlgorithm = toJceAlgorithm(algorithm);
107+
108+
try {
109+
final MessageDigest digest = MessageDigest.getInstance(jceAlgorithm);
110+
final byte[] input = itemToBytes(data);
111+
final byte[] hash = digest.digest(input);
112+
113+
final String encoded;
114+
if ("hex".equalsIgnoreCase(encoding)) {
115+
encoded = HexFormat.of().formatHex(hash);
116+
} else if ("base64".equalsIgnoreCase(encoding)) {
117+
encoded = Base64.getEncoder().encodeToString(hash);
118+
} else {
119+
throw new XPathException(this,
120+
"Unsupported encoding: " + encoding + ". Use 'base64' or 'hex'.");
121+
}
122+
123+
return new StringValue(this, encoded);
124+
} catch (final NoSuchAlgorithmException e) {
125+
throw new XPathException(this, "Unsupported hash algorithm: " + algorithm, e);
126+
}
127+
}
128+
129+
/**
130+
* Converts an XQuery item to a byte array for hashing.
131+
*/
132+
private byte[] itemToBytes(final Item item) throws XPathException {
133+
return switch (item.getType()) {
134+
case Type.BASE64_BINARY, Type.HEX_BINARY -> {
135+
try (final InputStream is = ((BinaryValue) item).getInputStream()) {
136+
yield is.readAllBytes();
137+
} catch (final IOException e) {
138+
throw new XPathException(this, "Failed to read binary data: " + e.getMessage(), e);
139+
}
140+
}
141+
default -> item.getStringValue().getBytes(StandardCharsets.UTF_8);
142+
};
143+
}
144+
145+
/**
146+
* Normalizes the user-supplied algorithm name to a JCE MessageDigest identifier.
147+
* Accepts both hyphenated (SHA-256) and bare (SHA256) forms.
148+
*/
149+
private String toJceAlgorithm(final String algorithm) throws XPathException {
150+
return switch (algorithm.toUpperCase().replace("-", "")) {
151+
case "MD5" -> "MD5";
152+
case "SHA1" -> "SHA-1";
153+
case "SHA256" -> "SHA-256";
154+
case "SHA384" -> "SHA-384";
155+
case "SHA512" -> "SHA-512";
156+
default -> throw new XPathException(this,
157+
"Unsupported hash algorithm: " + algorithm +
158+
". Supported: MD5, SHA-1, SHA-256, SHA-384, SHA-512.");
159+
};
160+
}
161+
}

src/test/java/org/exist/xquery/modules/crypto/CryptoModuleTest.java

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,108 @@ public void validateTamperedSignatureFails() throws XMLDBException {
194194
"false", result.getResource(0).getContent().toString());
195195
}
196196

197+
// ===== crypto:hash tests =====
198+
199+
@Test
200+
public void hashSha256Base64() throws XMLDBException {
201+
final ResourceSet result = existEmbeddedServer.executeQuery(
202+
CRYPTO_IMPORT + "crypto:hash('test', 'SHA-256')");
203+
final String hash = result.getResource(0).getContent().toString();
204+
assertTrue("SHA-256 base64 hash should be non-empty", hash.length() > 0);
205+
// SHA-256 base64 is always 44 characters (32 bytes -> 44 base64 chars with padding)
206+
assertEquals("SHA-256 base64 should be 44 chars", 44, hash.length());
207+
}
208+
209+
@Test
210+
public void hashSha256Hex() throws XMLDBException {
211+
final ResourceSet result = existEmbeddedServer.executeQuery(
212+
CRYPTO_IMPORT + "crypto:hash('test', 'SHA-256', 'hex')");
213+
final String hash = result.getResource(0).getContent().toString();
214+
assertEquals("SHA-256 hex should be 64 chars", 64, hash.length());
215+
assertTrue("Should be hex chars only", hash.matches("[0-9a-f]+"));
216+
// Known test vector: SHA-256("test") = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
217+
assertEquals("SHA-256 of 'test' should match known vector",
218+
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", hash);
219+
}
220+
221+
@Test
222+
public void hashMd5Hex() throws XMLDBException {
223+
final ResourceSet result = existEmbeddedServer.executeQuery(
224+
CRYPTO_IMPORT + "crypto:hash('test', 'MD5', 'hex')");
225+
assertEquals("MD5 hex should be 32 chars", 32,
226+
result.getResource(0).getContent().toString().length());
227+
}
228+
229+
@Test
230+
public void hashSha1Hex() throws XMLDBException {
231+
final ResourceSet result = existEmbeddedServer.executeQuery(
232+
CRYPTO_IMPORT + "crypto:hash('test', 'SHA-1', 'hex')");
233+
assertEquals("SHA-1 hex should be 40 chars", 40,
234+
result.getResource(0).getContent().toString().length());
235+
}
236+
237+
@Test
238+
public void hashSha384Hex() throws XMLDBException {
239+
final ResourceSet result = existEmbeddedServer.executeQuery(
240+
CRYPTO_IMPORT + "crypto:hash('test', 'SHA-384', 'hex')");
241+
assertEquals("SHA-384 hex should be 96 chars", 96,
242+
result.getResource(0).getContent().toString().length());
243+
}
244+
245+
@Test
246+
public void hashSha512Hex() throws XMLDBException {
247+
final ResourceSet result = existEmbeddedServer.executeQuery(
248+
CRYPTO_IMPORT + "crypto:hash('test', 'SHA-512', 'hex')");
249+
assertEquals("SHA-512 hex should be 128 chars", 128,
250+
result.getResource(0).getContent().toString().length());
251+
}
252+
253+
@Test
254+
public void hashAcceptsBareAlgorithmName() throws XMLDBException {
255+
// Accept "SHA256" without hyphen
256+
final ResourceSet result = existEmbeddedServer.executeQuery(
257+
CRYPTO_IMPORT + "crypto:hash('test', 'SHA256', 'hex')");
258+
assertEquals("SHA256 (no hyphen) should work same as SHA-256",
259+
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
260+
result.getResource(0).getContent().toString());
261+
}
262+
263+
@Test
264+
public void hashDefaultEncodingIsBase64() throws XMLDBException {
265+
final ResourceSet explicit = existEmbeddedServer.executeQuery(
266+
CRYPTO_IMPORT + "crypto:hash('data', 'SHA-256', 'base64')");
267+
final ResourceSet defaultEnc = existEmbeddedServer.executeQuery(
268+
CRYPTO_IMPORT + "crypto:hash('data', 'SHA-256')");
269+
assertEquals("Default encoding should be base64",
270+
explicit.getResource(0).getContent().toString(),
271+
defaultEnc.getResource(0).getContent().toString());
272+
}
273+
274+
@Test
275+
public void hashNodeInput() throws XMLDBException {
276+
// Hashing a node uses its string value
277+
final ResourceSet result = existEmbeddedServer.executeQuery(
278+
CRYPTO_IMPORT + "crypto:hash(<data>test</data>, 'SHA-256', 'hex')");
279+
assertEquals("Hashing node should use its string value",
280+
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
281+
result.getResource(0).getContent().toString());
282+
}
283+
284+
@Test
285+
public void hashEmptyStringProducesResult() throws XMLDBException {
286+
final ResourceSet result = existEmbeddedServer.executeQuery(
287+
CRYPTO_IMPORT + "crypto:hash('', 'SHA-256', 'hex')");
288+
assertEquals("SHA-256 of empty string should be e3b0c44...",
289+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
290+
result.getResource(0).getContent().toString());
291+
}
292+
293+
@Test(expected = XMLDBException.class)
294+
public void hashUnsupportedAlgorithmThrows() throws XMLDBException {
295+
existEmbeddedServer.executeQuery(
296+
CRYPTO_IMPORT + "crypto:hash('test', 'INVALID')");
297+
}
298+
197299
// ===== HMAC known test vectors =====
198300

199301
@Test

0 commit comments

Comments
 (0)