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..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 ``` @@ -511,6 +520,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 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') +``` +Sample output: +```json +"Wl0zx3E1Yv0GvQ7rM4lyYH8lGBn6Ivr6rSOtcJ0QTlo=" +``` ### incoming get all records from all databases that point to northwind EMPLOYEE 2 ``` @@ -999,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/docs/jsonata-reference.md b/dashjoin-docs/docs/jsonata-reference.md index c2e56025a..a1c1a17df 100644 --- a/dashjoin-docs/docs/jsonata-reference.md +++ b/dashjoin-docs/docs/jsonata-reference.md @@ -68,6 +68,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) 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 +