Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions dashjoin-core/src/main/java/org/dashjoin/function/Hmac.java
Original file line number Diff line number Diff line change
@@ -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<Object> {

static final Set<String> 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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions dashjoin-core/src/test/java/org/dashjoin/function/HmacTest.java
Original file line number Diff line number Diff line change
@@ -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<String> 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")));
}
}
27 changes: 27 additions & 0 deletions dashjoin-docs/docs/appendix-jsonata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this must go in /dashjoin-base/dashjoin-docs/llms/jsonata.json
also, we need to add this to Action.ts to make the function available on the client (proxied to backend if client jsonata engine)

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
```
Expand Down Expand Up @@ -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
```
Expand Down
1 change: 1 addition & 0 deletions dashjoin-docs/docs/jsonata-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions dashjoin-docs/llms/jsonata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Loading