From 44e942a1c11310e4606679a82f4b3b258cd76819 Mon Sep 17 00:00:00 2001 From: uw4 Date: Fri, 6 Nov 2026 15:51:04 +0100 Subject: [PATCH 1/2] Add $hmac JSONata function $hmac(keyId, message, algorithm?, encoding?) signs a message with the secret of a Credentials function, without ever returning the secret. The Credentials roles decide who may sign (no roles = admin only), so apps can expose signing to users through an Invoke function with runas. Supports HmacSHA256/384/512 and base64, base64url or hex output. Co-Authored-By: Claude Opus 5.5 --- .../main/java/org/dashjoin/function/Hmac.java | 105 ++++++++++++++++++ .../services/org.dashjoin.function.Function | 1 + .../java/org/dashjoin/function/HmacTest.java | 91 +++++++++++++++ dashjoin-docs/docs/appendix-jsonata.md | 9 ++ dashjoin-docs/docs/developer-reference.md | 1 + 5 files changed, 207 insertions(+) create mode 100644 dashjoin-core/src/main/java/org/dashjoin/function/Hmac.java create mode 100644 dashjoin-core/src/test/java/org/dashjoin/function/HmacTest.java diff --git a/dashjoin-core/src/main/java/org/dashjoin/function/Hmac.java b/dashjoin-core/src/main/java/org/dashjoin/function/Hmac.java new file mode 100644 index 000000000..32ef3d45f --- /dev/null +++ b/dashjoin-core/src/main/java/org/dashjoin/function/Hmac.java @@ -0,0 +1,105 @@ +package org.dashjoin.function; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.dashjoin.service.ACLContainerRequestFilter; + +/** + * $hmac(keyId, message, algorithm?, encoding?) signs message with the secret of the Credentials + * function keyId, e.g. for signed CDN URLs or for checking webhook signatures. + * + * The secret is never returned. Only users with one of the roles of the Credentials function may + * use it (admin only if it has no roles). To let other users sign a fixed kind of message, wrap the + * call in an Invoke function with runas set to such a role. + * + * algorithm: HmacSHA256 (default), HmacSHA384, HmacSHA512. encoding: base64 (default), base64url + * (without padding) or hex. + */ +@SuppressWarnings("rawtypes") +public class Hmac extends AbstractVarArgFunction { + + static final Set ALGORITHMS = Set.of("HmacSHA256", "HmacSHA384", "HmacSHA512"); + + @Override + public Object run(List args) throws Exception { + String keyId = arg(args, 0, null); + String message = arg(args, 1, null); + if (keyId == null || message == null) + throw new IllegalArgumentException("Syntax: $hmac(keyId, message, algorithm?, encoding?)"); + String algorithm = arg(args, 2, "HmacSHA256"); + String encoding = arg(args, 3, "base64"); + + Credentials key = credentials(keyId); + ACLContainerRequestFilter.check(sc, key); + String secret = secret(key); + if (secret == null || secret.isEmpty()) + throw new IllegalArgumentException("$hmac: " + keyId + " has no secret"); + return sign(secret, message, algorithm, encoding); + } + + /** + * loads the key record. Only Credentials functions qualify: other functions may hold passwords + * for other purposes. + */ + protected Credentials credentials(String keyId) throws Exception { + return asCredentials(services.getConfig().getFunction(keyId), keyId); + } + + static Credentials asCredentials(Object function, String keyId) { + if (!(function instanceof Credentials)) + throw new IllegalArgumentException("$hmac: " + keyId + " is not a Credentials function"); + return (Credentials) function; + } + + /** + * the secret: config value dashjoin.function.[ID].password (e.g. an environment variable) or the + * encrypted password stored with the function + */ + protected String secret(Credentials key) throws Exception { + key.init(sc, services, expressionService, readOnly); + return key.password(); + } + + static String sign(String secret, String message, String algorithm, String encoding) + throws Exception { + if (!ALGORITHMS.contains(algorithm)) + throw new IllegalArgumentException("$hmac: unsupported algorithm " + algorithm); + Mac mac = Mac.getInstance(algorithm); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), algorithm)); + byte[] out = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + switch (encoding) { + case "base64": + return Base64.getEncoder().encodeToString(out); + case "base64url": + return Base64.getUrlEncoder().withoutPadding().encodeToString(out); + case "hex": + return HexFormat.of().formatHex(out); + default: + throw new IllegalArgumentException("$hmac: unsupported encoding " + encoding); + } + } + + static String arg(List args, int index, String fallback) { + Object value = index < args.size() ? args.get(index) : null; + if (value == null) + return fallback; + if (!(value instanceof String)) + throw new IllegalArgumentException("$hmac: argument " + (index + 1) + " must be a string"); + return (String) value; + } + + @Override + public String getID() { + return "hmac"; + } + + @Override + public String getType() { + return "read"; + } +} diff --git a/dashjoin-core/src/main/resources/META-INF/services/org.dashjoin.function.Function b/dashjoin-core/src/main/resources/META-INF/services/org.dashjoin.function.Function index 80c929c94..5cac984c4 100644 --- a/dashjoin-core/src/main/resources/META-INF/services/org.dashjoin.function.Function +++ b/dashjoin-core/src/main/resources/META-INF/services/org.dashjoin.function.Function @@ -37,6 +37,7 @@ org.dashjoin.function.Index org.dashjoin.function.IsRecursiveTrigger org.dashjoin.function.MoveField org.dashjoin.function.UUID +org.dashjoin.function.Hmac org.dashjoin.function.Exec org.dashjoin.function.CreateTable org.dashjoin.function.CreateStubs diff --git a/dashjoin-core/src/test/java/org/dashjoin/function/HmacTest.java b/dashjoin-core/src/test/java/org/dashjoin/function/HmacTest.java new file mode 100644 index 000000000..dd36e737a --- /dev/null +++ b/dashjoin-core/src/test/java/org/dashjoin/function/HmacTest.java @@ -0,0 +1,91 @@ +package org.dashjoin.function; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.core.SecurityContext; + +public class HmacTest { + + static final String FOX = "The quick brown fox jumps over the lazy dog"; + + // Well-known HMAC-SHA256("key", FOX) + static final String FOX_HEX = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"; + + static SecurityContext roles(String... roles) { + SecurityContext sc = Mockito.mock(SecurityContext.class); + for (String role : roles) + Mockito.when(sc.isUserInRole(ArgumentMatchers.eq(role))).thenReturn(true); + return sc; + } + + /** $hmac with an in-memory key "key" that the given roles may use */ + static Hmac hmac(SecurityContext sc, List keyRoles) { + Hmac f = new Hmac() { + @Override + protected Credentials credentials(String keyId) { + Assertions.assertEquals("cdnKey", keyId); + Credentials c = new Credentials(); + c.ID = keyId; + c.roles = keyRoles; + return c; + } + + @Override + protected String secret(Credentials key) { + return "key"; + } + }; + f.sc = sc; + return f; + } + + @Test + public void testEncodings() throws Exception { + Hmac f = hmac(roles("signer"), List.of("signer")); + Assertions.assertEquals(FOX_HEX, f.run(Arrays.asList("cdnKey", FOX, null, "hex"))); + Assertions.assertEquals("97yD9DBThCSxMpjmqm+xQ+9NWaFJRhdZl0edvC0aPNg=", + f.run(Arrays.asList("cdnKey", FOX))); + Assertions.assertEquals("97yD9DBThCSxMpjmqm-xQ-9NWaFJRhdZl0edvC0aPNg", + f.run(Arrays.asList("cdnKey", FOX, "HmacSHA256", "base64url"))); + Assertions.assertEquals(128, + ((String) f.run(Arrays.asList("cdnKey", FOX, "HmacSHA512", "hex"))).length()); + } + + @Test + public void testKeyRoles() throws Exception { + // authenticated users may not use a key reserved for "signer" + Assertions.assertThrows(NotAuthorizedException.class, + () -> hmac(roles("authenticated"), List.of("signer")).run(Arrays.asList("cdnKey", FOX))); + // a key without roles is admin only + Assertions.assertThrows(NotAuthorizedException.class, + () -> hmac(roles("signer"), null).run(Arrays.asList("cdnKey", FOX))); + Assertions.assertNotNull(hmac(roles("admin"), null).run(Arrays.asList("cdnKey", FOX))); + } + + @Test + public void testOnlyCredentials() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> Hmac.asCredentials(new Invoke(), "echo")); + Assertions.assertThrows(IllegalArgumentException.class, + () -> Hmac.asCredentials(null, "missing")); + Credentials c = new Credentials(); + Assertions.assertSame(c, Hmac.asCredentials(c, "cdnKey")); + } + + @Test + public void testInvalidArguments() { + Hmac f = hmac(roles("signer"), List.of("signer")); + Assertions.assertThrows(IllegalArgumentException.class, () -> f.run(Arrays.asList("cdnKey"))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> f.run(Arrays.asList("cdnKey", 42))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> f.run(Arrays.asList("cdnKey", FOX, "HmacMD5"))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> f.run(Arrays.asList("cdnKey", FOX, null, "base32"))); + } +} diff --git a/dashjoin-docs/docs/appendix-jsonata.md b/dashjoin-docs/docs/appendix-jsonata.md index e08cbf3c4..7621ce146 100644 --- a/dashjoin-docs/docs/appendix-jsonata.md +++ b/dashjoin-docs/docs/appendix-jsonata.md @@ -511,6 +511,15 @@ Sample output: "diff" : "diff --git a/dashjoin-demo.db b/dashjoin-demo.db\nindex 6fdb4ce..d29b8f1 100644\n--- a/dashjoin-demo.db\n+++ b/dashjoin-demo.db\nBinary files differ\n" } ] ``` +### hmac +signs a message with the secret of a Credentials function, e.g. for signed URLs or for checking webhook signatures. Optional arguments: algorithm (HmacSHA256, HmacSHA384, HmacSHA512) and encoding (base64, base64url, hex). The secret can be set with the config value dashjoin.function.[ID].password (e.g. an environment variable). Only users with one of the roles of the Credentials function may use it (admin only if it has none) +``` +$hmac('cdnSigningKey', '/download/book.pdf1700000000') +``` +Sample output: +```json +"Wl0zx3E1Yv0GvQ7rM4lyYH8lGBn6Ivr6rSOtcJ0QTlo=" +``` ### incoming get all records from all databases that point to northwind EMPLOYEE 2 ``` diff --git a/dashjoin-docs/docs/developer-reference.md b/dashjoin-docs/docs/developer-reference.md index e96788633..945974c54 100644 --- a/dashjoin-docs/docs/developer-reference.md +++ b/dashjoin-docs/docs/developer-reference.md @@ -1240,6 +1240,7 @@ gitCommit| $gitCommit(message, [paths]) | Run git commit and push gitPull| $gitPull() | Run git pull gitRestore| $gitRestore(path) | Revert a change gitStatus| $gitStatus() | Run git status +hmac | $hmac(keyId, message, algorithm?, encoding?) | Signs message with the secret of the Credentials function keyId. algorithm: HmacSHA256 (default), HmacSHA384, HmacSHA512. encoding: base64 (default), base64url, hex. The secret is never returned. Only users with one of the roles of the Credentials function may use it (admin only if it has none). Wrap $hmac in an Invoke function with runas to let other users sign a fixed kind of message incoming| $incoming(database, table, pk1)| [{id: ID of the record where the link originates, pk: ID of the pk column, fk: ID of the fk column}, ...] index | $index() | Generates a unique row index ID isRecursiveTrigger | $isRecursiveTrigger() | true if the current expression is called from a trigger expression (trigger calls trigger) From 4e22c71992287f09ac08c1963dbeb1dece3220d1 Mon Sep 17 00:00:00 2001 From: uw4 Date: Fri, 6 Nov 2026 16:43:12 +0100 Subject: [PATCH 2/2] Document $hmac in llms/jsonata.json and regenerate docs The appendix and llms.txt are generated by LLMs.java. Regenerating also picks up gitCheckout and use, which were in jsonata.json but not yet in the generated files. Co-Authored-By: Claude Opus 5.5 --- dashjoin-docs/docs/appendix-jsonata.md | 20 +++++++++++++++++++- dashjoin-docs/llms/jsonata.json | 7 +++++++ llms.txt | 15 +++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/dashjoin-docs/docs/appendix-jsonata.md b/dashjoin-docs/docs/appendix-jsonata.md index 7621ce146..5050d5fba 100644 --- a/dashjoin-docs/docs/appendix-jsonata.md +++ b/dashjoin-docs/docs/appendix-jsonata.md @@ -471,6 +471,15 @@ Sample output: ```json "x: [1, 2]\ny: test" ``` +### gitCheckout +Checks out and switches the dashjoin app branch +``` +$gitCheckout('mybranch') +``` +Sample output: +```json +null +``` ### gitCommit git commit a set of files ``` @@ -512,7 +521,7 @@ Sample output: } ] ``` ### hmac -signs a message with the secret of a Credentials function, e.g. for signed URLs or for checking webhook signatures. Optional arguments: algorithm (HmacSHA256, HmacSHA384, HmacSHA512) and encoding (base64, base64url, hex). The secret can be set with the config value dashjoin.function.[ID].password (e.g. an environment variable). Only users with one of the roles of the Credentials function may use it (admin only if it has none) +signs a message with the secret of the Credentials function 'cdnSigningKey' (default HmacSHA256, base64). Optional arguments: algorithm (HmacSHA256, HmacSHA384, HmacSHA512) and encoding (base64, base64url, hex). The secret is never returned and can be set with the config value dashjoin.function.[ID].password (e.g. an environment variable). Only users with one of the roles of the Credentials function may use it (admin only if it has none). Wrap $hmac in an Invoke function with runas to let other users sign, e.g. short-lived signed URLs or webhook signature checks ``` $hmac('cdnSigningKey', '/download/book.pdf1700000000') ``` @@ -1008,6 +1017,15 @@ Sample output: ```json true ``` +### use +When used in conjunction with $eval, allows importing a function from the library +``` +$f := $use('f') ~> $eval +``` +Sample output: +```json +"The expression of the library entry" +``` ### uuid Generates a random UUID ``` diff --git a/dashjoin-docs/llms/jsonata.json b/dashjoin-docs/llms/jsonata.json index 1cb741de3..1c49b2ba8 100644 --- a/dashjoin-docs/llms/jsonata.json +++ b/dashjoin-docs/llms/jsonata.json @@ -548,6 +548,13 @@ ] } ], + "hmac": [ + { + "description": "signs a message with the secret of the Credentials function 'cdnSigningKey' (default HmacSHA256, base64). Optional arguments: algorithm (HmacSHA256, HmacSHA384, HmacSHA512) and encoding (base64, base64url, hex). The secret is never returned and can be set with the config value dashjoin.function.[ID].password (e.g. an environment variable). Only users with one of the roles of the Credentials function may use it (admin only if it has none). Wrap $hmac in an Invoke function with runas to let other users sign, e.g. short-lived signed URLs or webhook signature checks", + "code": "$hmac('cdnSigningKey', '/download/book.pdf1700000000')", + "output": "Wl0zx3E1Yv0GvQ7rM4lyYH8lGBn6Ivr6rSOtcJ0QTlo=" + } + ], "gitPull": [ { "description": "Pull the dashjoin app", diff --git a/llms.txt b/llms.txt index 3bd4ced67..8a67eb02d 100644 --- a/llms.txt +++ b/llms.txt @@ -1680,11 +1680,21 @@ DESCRIPTION: return the git status of the dashjoin app LANGUAGE: jsonata CODE: $gitStatus() +TITLE: hmac +DESCRIPTION: signs a message with the secret of the Credentials function 'cdnSigningKey' (default HmacSHA256, base64). Optional arguments: algorithm (HmacSHA256, HmacSHA384, HmacSHA512) and encoding (base64, base64url, hex). The secret is never returned and can be set with the config value dashjoin.function.[ID].password (e.g. an environment variable). Only users with one of the roles of the Credentials function may use it (admin only if it has none). Wrap $hmac in an Invoke function with runas to let other users sign, e.g. short-lived signed URLs or webhook signature checks +LANGUAGE: jsonata +CODE: $hmac('cdnSigningKey', '/download/book.pdf1700000000') + TITLE: gitPull DESCRIPTION: Pull the dashjoin app LANGUAGE: jsonata CODE: $gitPull() +TITLE: gitCheckout +DESCRIPTION: Checks out and switches the dashjoin app branch +LANGUAGE: jsonata +CODE: $gitCheckout('mybranch') + TITLE: gitRestore DESCRIPTION: git restore 'dashjoin-demo.db' LANGUAGE: jsonata @@ -1775,3 +1785,8 @@ DESCRIPTION: validates a JSON input against a JSON schema (type, required, prope LANGUAGE: jsonata CODE: $validate({'required': ['x'], 'properties':{'x': {'type':boolean}}}, {}) +TITLE: use +DESCRIPTION: When used in conjunction with $eval, allows importing a function from the library +LANGUAGE: jsonata +CODE: $f := $use('f') ~> $eval +