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
25 changes: 25 additions & 0 deletions examples/security-foundations/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.teaql</groupId>
<artifactId>teaql-java-parent</artifactId>
<version>1.550-RELEASE</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>teaql-security-foundations-example</artifactId>
<name>TeaQL security foundations focused example</name>
<dependencies>
<dependency>
<groupId>io.teaql</groupId>
<artifactId>teaql-runtime</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.teaql.examples.security;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;

import io.teaql.core.EntityReferenceTokenException;
import io.teaql.runtime.DefaultUserContext;
import io.teaql.runtime.security.AeadEntityReferenceCodec;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Map;
import org.junit.Test;

public class SecurityFoundationsExampleTest {
@Test
public void userContextProtectsBoundaryReferences() {
byte[] key = new byte[32];
byte[] nonce = new byte[12];
Arrays.fill(key, (byte) 0x22);
Arrays.fill(nonce, (byte) 0x33);
AeadEntityReferenceCodec codec = new AeadEntityReferenceCodec(2, Map.of(2, key))
.withClock(Clock.fixed(Instant.parse("2026-09-26T12:00:00Z"), ZoneOffset.UTC))
.withNonceSource(() -> nonce.clone());
DefaultUserContext context = new DefaultUserContext(null);
context.withEntityReferenceCodec(codec);
String token = context.encodeEntityReference("OrderItem", 42, 7, "edit-order", Duration.ofHours(1));
assertEquals(42, context.decodeEntityReference(token, "OrderItem", "edit-order").id());
assertThrows(EntityReferenceTokenException.class,
() -> context.decodeEntityReference(token, "OrderItem", "view-order"));
System.out.println("PASS Java security foundations: opaque reference is portable and purpose-bound");
}
}
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
<module>teaql-android</module>
<module>teaql-business-id-jdbc</module>
<module>examples/business-id-runtime</module>
<module>examples/security-foundations</module>
<module>teaql-tfp-endpoint</module>
<module>teaql-opentelemetry</module>
<module>examples/conformance/lib</module>
Expand Down
4 changes: 3 additions & 1 deletion scripts/verify-examples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
verification_dir="$(mktemp -d)"
trap 'rm -rf -- "$verification_dir"' EXIT
expected=(business-id-runtime conformance order-management school-management)
expected=(business-id-runtime conformance order-management school-management security-foundations)
mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
if [[ "${actual[*]}" != "${expected[*]}" ]]; then
echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2
Expand All @@ -15,6 +15,8 @@ cd "$repo"
mvn -q -DskipTests install
mvn -q -pl examples/business-id-runtime \
-Dtest=BusinessIdRuntimeExampleTest test
mvn -q -pl examples/security-foundations \
-Dtest=SecurityFoundationsExampleTest test
mvn -q -f examples/conformance/lib/pom.xml install -DskipTests
mvn -q -f examples/conformance/pom.xml spring-boot:run \
-Dspring-boot.run.arguments="--spring.main.web-application-type=none --spring.datasource.url=jdbc:sqlite:$verification_dir/conformance.db"
Expand Down
13 changes: 13 additions & 0 deletions teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.teaql.core;

import java.time.Instant;

/** Verified, boundary-safe identity recovered from an opaque entity reference. */
public record EntityReferenceClaims(
String entityType,
long id,
long version,
Instant issuedAt,
Instant expiresAt,
String purpose,
int keyVersion) {}
10 changes: 10 additions & 0 deletions teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.teaql.core;

import java.time.Duration;

/** Provider SPI for boundary-safe entity references. */
public interface EntityReferenceCodec {
String encode(String entityType, long id, long version, String purpose, Duration lifetime);

EntityReferenceClaims decode(String token, String expectedEntityType, String purpose);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.teaql.core;

/** Stable fail-closed error; deliberately does not reveal why validation failed. */
public final class EntityReferenceTokenException extends TeaQLRuntimeException {
private final String code;

public EntityReferenceTokenException(String code) {
super(code);
this.code = code;
}

public String code() {
return code;
}
}
96 changes: 96 additions & 0 deletions teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package io.teaql.core;

import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;

/** Portable wire helpers shared by the runtime codec and the explicit development bypass. */
public final class EntityReferenceWire {
public static final String ENCRYPTED_PREFIX = "tqr1.";
public static final String RAW_PREFIX = "tqr0.";
public static final String ASSOCIATED_DATA = "teaql.entity-reference.v1";
public static final String UNSAFE_ENVIRONMENT = "TEAQL_UNSAFE_RAW_ENTITY_REFERENCES";
public static final String UNSAFE_ACKNOWLEDGEMENT =
"I_UNDERSTAND_RAW_ENTITY_IDS_ARE_VISIBLE_FOR_LOCAL_DEVELOPMENT_ONLY";

private EntityReferenceWire() {}

public static byte[] encodeClaims(EntityReferenceClaims claims) {
try {
ByteArrayOutputStream output = new ByteArrayOutputStream();
writeText(output, claims.entityType());
output.write(ByteBuffer.allocate(32)
.putLong(claims.id())
.putLong(claims.version())
.putLong(claims.issuedAt().getEpochSecond())
.putLong(claims.expiresAt().getEpochSecond())
.array());
writeText(output, claims.purpose());
return output.toByteArray();
} catch (EntityReferenceTokenException exception) {
throw exception;
} catch (Exception exception) {
throw invalid();
}
}

public static EntityReferenceClaims decodeClaims(byte[] data) {
try {
ByteBuffer input = ByteBuffer.wrap(data);
String entityType = readText(input);
if (input.remaining() < 32) throw invalid();
long id = input.getLong();
long version = input.getLong();
long issuedAt = input.getLong();
long expiresAt = input.getLong();
String purpose = readText(input);
if (input.hasRemaining() || entityType.isEmpty() || id <= 0) throw invalid();
return new EntityReferenceClaims(
entityType, id, version, Instant.ofEpochSecond(issuedAt),
Instant.ofEpochSecond(expiresAt), purpose, 0);
} catch (EntityReferenceTokenException exception) {
throw exception;
} catch (Exception exception) {
throw invalid();
}
}

public static String base64Url(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}

public static byte[] base64UrlDecode(String text) {
try {
return Base64.getUrlDecoder().decode(text);
} catch (IllegalArgumentException exception) {
throw invalid();
}
}

public static boolean unsafeRawReferencesEnabled() {
return UNSAFE_ACKNOWLEDGEMENT.equals(System.getenv(UNSAFE_ENVIRONMENT));
}

public static EntityReferenceTokenException invalid() {
return new EntityReferenceTokenException("ENTITY_REFERENCE_INVALID");
}

private static void writeText(ByteArrayOutputStream output, String value) {
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
if (bytes.length > 0xffff) throw invalid();
output.write((bytes.length >>> 8) & 0xff);
output.write(bytes.length & 0xff);
output.writeBytes(bytes);
}

private static String readText(ByteBuffer input) {
if (input.remaining() < 2) throw invalid();
int length = Short.toUnsignedInt(input.getShort());
if (input.remaining() < length) throw invalid();
byte[] bytes = new byte[length];
input.get(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
}
46 changes: 46 additions & 0 deletions teaql-core/src/main/java/io/teaql/core/UserContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import io.teaql.core.businessid.BusinessClock;
import io.teaql.core.businessid.BusinessIdSchemaContributor;
import io.teaql.core.businessid.BusinessIdService;
import java.time.Duration;
import java.time.Instant;

public interface UserContext extends OptNullBasicTypeFromObjectGetter<String> {

Expand All @@ -19,6 +21,50 @@ public interface UserContext extends OptNullBasicTypeFromObjectGetter<String> {
String TEAQL_ACTIVE_ROOT = "teaql.active.root";
String TEAQL_FIX_EVIDENCE_CURRENT = "teaql.fix.evidence.current";
String TEAQL_FIX_EVIDENCE_LAST = "teaql.fix.evidence.last";
String TEAQL_ENTITY_REFERENCE_CODEC = EntityReferenceCodec.class.getName();

default UserContext withEntityReferenceCodec(EntityReferenceCodec codec) {
if (codec == null) throw new IllegalArgumentException("codec must not be null");
putAttribute(TEAQL_ENTITY_REFERENCE_CODEC, codec);
return this;
}

default String encodeEntityReference(
String entityType, long id, long version, String purpose, Duration lifetime) {
EntityReferenceCodec codec = getAttribute(TEAQL_ENTITY_REFERENCE_CODEC, EntityReferenceCodec.class);
if (codec == null) codec = capability(EntityReferenceCodec.class);
if (codec != null) return codec.encode(entityType, id, version, purpose, lifetime);
if (!EntityReferenceWire.unsafeRawReferencesEnabled()) {
throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED");
}
if (entityType == null || entityType.isBlank() || id <= 0 || lifetime == null
|| lifetime.isZero() || lifetime.isNegative()) throw EntityReferenceWire.invalid();
Instant now = Instant.now();
EntityReferenceClaims claims = new EntityReferenceClaims(
entityType, id, version, now, now.plus(lifetime), purpose == null ? "" : purpose, 0);
return EntityReferenceWire.RAW_PREFIX
+ EntityReferenceWire.base64Url(EntityReferenceWire.encodeClaims(claims));
}

default EntityReferenceClaims decodeEntityReference(
String token, String expectedEntityType, String purpose) {
EntityReferenceCodec codec = getAttribute(TEAQL_ENTITY_REFERENCE_CODEC, EntityReferenceCodec.class);
if (codec == null) codec = capability(EntityReferenceCodec.class);
if (codec != null) return codec.decode(token, expectedEntityType, purpose);
if (!EntityReferenceWire.unsafeRawReferencesEnabled()) {
throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED");
}
if (token == null || !token.startsWith(EntityReferenceWire.RAW_PREFIX)) {
throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED");
}
EntityReferenceClaims claims = EntityReferenceWire.decodeClaims(EntityReferenceWire.base64UrlDecode(
token.substring(EntityReferenceWire.RAW_PREFIX.length())));
Instant now = Instant.now();
if (!claims.expiresAt().isAfter(now) || claims.issuedAt().isAfter(now.plusSeconds(60))
|| !claims.entityType().equals(expectedEntityType)
|| !claims.purpose().equals(purpose)) throw EntityReferenceWire.invalid();
return claims;
}

default void beginFixEvidence() {
putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, new java.util.ArrayList<FixEvidence>());
Expand Down
Loading
Loading