From 70ea0528ac9bfeefad8799d94e604eca8069a08d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 25 Sep 2026 18:23:59 +0800 Subject: [PATCH 1/3] feat: add context-bound round-trip references (#175) --- .../round-trip-reference-runtime/README.md | 20 +++ examples/round-trip-reference-runtime/pom.xml | 25 +++ .../RoundTripReferenceRuntimeExampleTest.java | 160 ++++++++++++++++++ pom.xml | 1 + .../main/java/io/teaql/core/UserContext.java | 24 +++ .../reference/InternalEntityIdentity.java | 16 ++ .../reference/ResolvedRoundTripReference.java | 8 + .../core/reference/RoundTripReference.java | 15 ++ .../reference/RoundTripReferenceCodec.java | 36 ++++ .../RoundTripReferenceErrorCode.java | 10 ++ .../RoundTripReferenceException.java | 20 +++ .../reference/RoundTripReferenceProvider.java | 14 ++ teaql-core/src/main/java/module-info.java | 1 + .../io/teaql/runtime/DefaultUserContext.java | 4 + .../java/io/teaql/runtime/TeaQLRuntime.java | 17 ++ .../AeadRoundTripReferenceProvider.java | 140 +++++++++++++++ .../ContextReferenceBindingProvider.java | 9 + .../DerivedRoundTripReferenceKeyProvider.java | 100 +++++++++++ .../RawRoundTripReferenceProvider.java | 37 ++++ .../RoundTripReferenceConfiguration.java | 41 +++++ .../reference/RoundTripReferenceKey.java | 28 +++ .../RoundTripReferenceKeyProvider.java | 10 ++ .../RoundTripReferenceMasterKeyRing.java | 7 + ...StaticRoundTripReferenceMasterKeyRing.java | 35 ++++ teaql-runtime/src/main/java/module-info.java | 1 + .../RoundTripReferenceConfigurationTest.java | 52 ++++++ 26 files changed, 831 insertions(+) create mode 100644 examples/round-trip-reference-runtime/README.md create mode 100644 examples/round-trip-reference-runtime/pom.xml create mode 100644 examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/RoundTripReference.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceException.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/ContextReferenceBindingProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/DerivedRoundTripReferenceKeyProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/RawRoundTripReferenceProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceMasterKeyRing.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceMasterKeyRing.java create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/reference/RoundTripReferenceConfigurationTest.java diff --git a/examples/round-trip-reference-runtime/README.md b/examples/round-trip-reference-runtime/README.md new file mode 100644 index 00000000..7ed15f46 --- /dev/null +++ b/examples/round-trip-reference-runtime/README.md @@ -0,0 +1,20 @@ +# Round-Trip Reference runtime example + +This example proves that the reusable runtime serialization boundary can be +called by any Web adapter without depending on Spring, JAX-RS, Servlet, or a +specific JSON library. + +It covers governed issue/resolve, opaque context binding, tamper rejection, +cross-context rejection, expected-type validation, key rotation, and the +explicit development-only raw diagnostic representation. + +The core contract receives the complete `UserContext`; it does not define what +the context binding means. The runtime customization supplies opaque binding +bytes. Successfully resolving a reference only restores `(type, id, version)`: +the application must still re-run its current authorization and policy checks. + +Run locally against repository sources: + +```bash +mvn -pl examples/round-trip-reference-runtime -am test +``` diff --git a/examples/round-trip-reference-runtime/pom.xml b/examples/round-trip-reference-runtime/pom.xml new file mode 100644 index 00000000..a46f4920 --- /dev/null +++ b/examples/round-trip-reference-runtime/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + io.teaql + teaql-java-parent + 1.550-RELEASE + ../../pom.xml + + teaql-round-trip-reference-example + TeaQL Round-Trip Reference focused example + + + io.teaql + teaql-runtime + + + junit + junit + test + + + diff --git a/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java b/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java new file mode 100644 index 00000000..8f76d4ad --- /dev/null +++ b/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java @@ -0,0 +1,160 @@ +package io.teaql.examples.reference; + +import io.teaql.core.BaseEntity; +import io.teaql.core.UserContext; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.reference.*; +import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.TeaQLRuntime; +import io.teaql.runtime.reference.*; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.Assert; +import org.junit.Test; + +public class RoundTripReferenceRuntimeExampleTest { + private static final byte[] CURRENT = bytes(1); + private static final byte[] PREVIOUS = bytes(33); + + private static final class OrderItem extends BaseEntity { + OrderItem(long id, long version) { + __internalSet("id", id); + __internalSet("version", version); + } + + @Override public String typeName() { return "OrderItem"; } + } + + @Test + public void frameworkNeutralCodecRoundTripsAndRejectsTransferOrTampering() { + RoundTripReferenceMasterKeyRing ring = new StaticRoundTripReferenceMasterKeyRing( + new RoundTripReferenceKey("k2", CURRENT), + new RoundTripReferenceKey("k1", PREVIOUS)); + ContextReferenceBindingProvider binding = context -> { + String value = (String) context.getAttribute("example.reference.binding"); + return value == null ? null : value.getBytes(StandardCharsets.UTF_8); + }; + AeadRoundTripReferenceProvider provider = new AeadRoundTripReferenceProvider( + new DerivedRoundTripReferenceKeyProvider(ring, binding, "reference-example")); + + UserContext alice = context(provider, "job-task:alice:42"); + RoundTripReference wire = alice.referenceFor(new OrderItem(8172, 3)); + RoundTripReference secondWire = alice.referenceFor(new OrderItem(8172, 3)); + Assert.assertTrue(wire.value().startsWith("tqr1.k2.")); + Assert.assertFalse(wire.value().contains("8172")); + Assert.assertNotEquals("fresh nonces must produce unlinkable wire values", wire, secondWire); + Assert.assertEquals( + new ResolvedRoundTripReference("OrderItem", 8172, 3), + alice.resolveReference(wire.value(), "OrderItem")); + + UserContext personalTask = context(provider, "personal-task:alice:42"); + RoundTripReferenceException transfer = Assert.assertThrows( + RoundTripReferenceException.class, + () -> personalTask.resolveReference(wire.value(), "OrderItem")); + Assert.assertEquals(RoundTripReferenceErrorCode.CONTEXT_MISMATCH, transfer.getCode()); + + int payloadStart = wire.value().lastIndexOf('.') + 1; + char original = wire.value().charAt(payloadStart); + String tampered = wire.value().substring(0, payloadStart) + + (original == 'A' ? 'B' : 'A') + + wire.value().substring(payloadStart + 1); + Assert.assertThrows( + RoundTripReferenceException.class, + () -> alice.resolveReference(tampered, "OrderItem")); + RoundTripReferenceException wrongType = Assert.assertThrows( + RoundTripReferenceException.class, + () -> alice.resolveReference(wire.value(), "Order")); + Assert.assertEquals(RoundTripReferenceErrorCode.TYPE_MISMATCH, wrongType.getCode()); + } + + @Test + public void missingProviderBindingAndPersistedVersionFailClosed() { + TeaQLRuntime noProvider = TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()) + .build(); + UserContext plain = new DefaultUserContext(noProvider); + RoundTripReferenceException missingProvider = Assert.assertThrows( + RoundTripReferenceException.class, + () -> plain.referenceFor(new OrderItem(1, 1))); + Assert.assertEquals( + RoundTripReferenceErrorCode.PROVIDER_NOT_CONFIGURED, + missingProvider.getCode()); + + ContextReferenceBindingProvider absentBinding = ignored -> null; + UserContext unbound = context( + provider( + new StaticRoundTripReferenceMasterKeyRing( + new RoundTripReferenceKey("k2", CURRENT)), + absentBinding), + "unused"); + RoundTripReferenceException missingBinding = Assert.assertThrows( + RoundTripReferenceException.class, + () -> unbound.referenceFor(new OrderItem(1, 1))); + Assert.assertEquals( + RoundTripReferenceErrorCode.CONTEXT_BINDING_REQUIRED, + missingBinding.getCode()); + + Assert.assertThrows( + IllegalArgumentException.class, + () -> new InternalEntityIdentity("OrderItem", 1, 0)); + } + + @Test + public void previousKeyDecodesAfterRotationAndRawModeIsExplicit() { + ContextReferenceBindingProvider binding = context -> + ((String) context.getAttribute("example.reference.binding")) + .getBytes(StandardCharsets.UTF_8); + AeadRoundTripReferenceProvider oldProvider = provider( + new StaticRoundTripReferenceMasterKeyRing( + new RoundTripReferenceKey("k1", PREVIOUS)), binding); + UserContext beforeRotation = context(oldProvider, "task:7"); + String oldReference = beforeRotation.referenceFor(new OrderItem(9, 1)).value(); + + AeadRoundTripReferenceProvider rotated = provider( + new StaticRoundTripReferenceMasterKeyRing( + new RoundTripReferenceKey("k2", CURRENT), + new RoundTripReferenceKey("k1", PREVIOUS)), binding); + UserContext afterRotation = context(rotated, "task:7"); + Assert.assertEquals(9, afterRotation.resolveReference(oldReference, "OrderItem").id()); + Assert.assertTrue(afterRotation.referenceFor(new OrderItem(10, 2)).value().startsWith("tqr1.k2.")); + + Assert.assertEquals( + RoundTripReferenceConfiguration.Mode.GOVERNED, + RoundTripReferenceConfiguration.mode("development", "true")); + Assert.assertEquals( + RoundTripReferenceConfiguration.Mode.RAW_DIAGNOSTIC, + RoundTripReferenceConfiguration.mode( + "development", RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT)); + Assert.assertThrows( + IllegalStateException.class, + () -> RoundTripReferenceConfiguration.mode( + "production", RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT)); + + UserContext raw = context(new RawRoundTripReferenceProvider(), "ignored"); + String readable = raw.referenceFor(new OrderItem(8172, 3)).value(); + Assert.assertEquals("raw1.OrderItem.8172.3", readable); + Assert.assertEquals(8172, raw.resolveReference(readable, "OrderItem").id()); + } + + private static AeadRoundTripReferenceProvider provider( + RoundTripReferenceMasterKeyRing ring, ContextReferenceBindingProvider binding) { + return new AeadRoundTripReferenceProvider( + new DerivedRoundTripReferenceKeyProvider(ring, binding, "reference-example")); + } + + private static UserContext context(RoundTripReferenceProvider provider, String binding) { + TeaQLRuntime runtime = TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()) + .roundTripReferenceProvider(provider) + .build(); + UserContext context = new DefaultUserContext(runtime); + context.putAttribute("example.reference.binding", binding); + return context; + } + + private static byte[] bytes(int start) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) value[index] = (byte) (start + index); + return Arrays.copyOf(value, value.length); + } +} diff --git a/pom.xml b/pom.xml index 6b8066b6..cc74ff52 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,7 @@ teaql-business-id-jdbc examples/business-id-runtime examples/security-foundations + examples/round-trip-reference-runtime teaql-tfp-endpoint teaql-opentelemetry examples/conformance/lib diff --git a/teaql-core/src/main/java/io/teaql/core/UserContext.java b/teaql-core/src/main/java/io/teaql/core/UserContext.java index 67072fe7..70d11d84 100644 --- a/teaql-core/src/main/java/io/teaql/core/UserContext.java +++ b/teaql-core/src/main/java/io/teaql/core/UserContext.java @@ -11,6 +11,11 @@ import io.teaql.core.businessid.BusinessClock; import io.teaql.core.businessid.BusinessIdSchemaContributor; import io.teaql.core.businessid.BusinessIdService; +import io.teaql.core.reference.ResolvedRoundTripReference; +import io.teaql.core.reference.RoundTripReference; +import io.teaql.core.reference.RoundTripReferenceCodec; +import io.teaql.core.reference.RoundTripReferenceErrorCode; +import io.teaql.core.reference.RoundTripReferenceException; import java.time.Duration; import java.time.Instant; @@ -279,6 +284,25 @@ default java.time.LocalDate businessDate() { return clock.businessDate(this); } + default RoundTripReferenceCodec roundTripReferences() { + RoundTripReferenceCodec codec = capability(RoundTripReferenceCodec.class); + if (codec == null) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.PROVIDER_NOT_CONFIGURED, + "RoundTripReferenceCodec capability is not registered"); + } + return codec; + } + + default RoundTripReference referenceFor(Entity entity) { + return roundTripReferences().serialize(this, entity); + } + + default ResolvedRoundTripReference resolveReference( + String reference, String expectedEntityType) { + return roundTripReferences().deserialize(this, reference, expectedEntityType); + } + /** * Returns the Dynamic Fields facade for reading/writing dynamic field values. * The facade is resolved via {@link #capability(Class)} and must be registered diff --git a/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java b/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java new file mode 100644 index 00000000..da97e010 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java @@ -0,0 +1,16 @@ +package io.teaql.core.reference; + +/** Runtime identity carried inside a protected round-trip reference. */ +public record InternalEntityIdentity(String entityType, long id, long version) { + public InternalEntityIdentity { + if (entityType == null || entityType.isBlank()) { + throw new IllegalArgumentException("entityType must not be blank"); + } + if (id <= 0) { + throw new IllegalArgumentException("id must be positive"); + } + if (version <= 0) { + throw new IllegalArgumentException("version must be positive"); + } + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java b/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java new file mode 100644 index 00000000..7c56e436 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java @@ -0,0 +1,8 @@ +package io.teaql.core.reference; + +/** Decoded identity. This value is not an authorization decision. */ +public record ResolvedRoundTripReference(String entityType, long id, long version) { + public InternalEntityIdentity identity() { + return new InternalEntityIdentity(entityType, id, version); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReference.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReference.java new file mode 100644 index 00000000..19dfab62 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReference.java @@ -0,0 +1,15 @@ +package io.teaql.core.reference; + +/** Opaque wire value issued by a TeaQL runtime and returned to that runtime. */ +public record RoundTripReference(String value) { + public RoundTripReference { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("reference must not be blank"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java new file mode 100644 index 00000000..b40c6d22 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java @@ -0,0 +1,36 @@ +package io.teaql.core.reference; + +import io.teaql.core.Entity; +import io.teaql.core.UserContext; + +/** + * Framework-neutral serialization boundary. Spring, JAX-RS and other Web + * adapters call this codec; cryptographic behavior does not live in a Web layer. + */ +public final class RoundTripReferenceCodec { + private final RoundTripReferenceProvider provider; + + public RoundTripReferenceCodec(RoundTripReferenceProvider provider) { + if (provider == null) throw new IllegalArgumentException("provider must not be null"); + this.provider = provider; + } + + public RoundTripReference serialize(UserContext context, Entity entity) { + if (entity == null || entity.getId() == null || entity.getVersion() == null) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.INVALID_ENTITY_IDENTITY, + "A round-trip reference requires a persisted entity with id and version"); + } + return provider.issue( + context, + new InternalEntityIdentity(entity.typeName(), entity.getId(), entity.getVersion())); + } + + public ResolvedRoundTripReference deserialize( + UserContext context, String reference, String expectedEntityType) { + return provider.resolve( + context, + new RoundTripReference(reference), + expectedEntityType); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java new file mode 100644 index 00000000..14689991 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java @@ -0,0 +1,10 @@ +package io.teaql.core.reference; + +public enum RoundTripReferenceErrorCode { + PROVIDER_NOT_CONFIGURED, + CONTEXT_BINDING_REQUIRED, + INVALID_REFERENCE, + CONTEXT_MISMATCH, + TYPE_MISMATCH, + INVALID_ENTITY_IDENTITY +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceException.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceException.java new file mode 100644 index 00000000..64e9735a --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceException.java @@ -0,0 +1,20 @@ +package io.teaql.core.reference; + +public class RoundTripReferenceException extends RuntimeException { + private final RoundTripReferenceErrorCode code; + + public RoundTripReferenceException(RoundTripReferenceErrorCode code, String message) { + super(message); + this.code = code; + } + + public RoundTripReferenceException( + RoundTripReferenceErrorCode code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + public RoundTripReferenceErrorCode getCode() { + return code; + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java new file mode 100644 index 00000000..9bb98645 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java @@ -0,0 +1,14 @@ +package io.teaql.core.reference; + +import io.teaql.core.UserContext; + +/** Provider SPI; implementations must authenticate, not merely obscure, identities. */ +public interface RoundTripReferenceProvider { + RoundTripReference issue( + UserContext context, InternalEntityIdentity identity); + + ResolvedRoundTripReference resolve( + UserContext context, + RoundTripReference reference, + String expectedEntityType); +} diff --git a/teaql-core/src/main/java/module-info.java b/teaql-core/src/main/java/module-info.java index 8f95aca6..e4f8640d 100644 --- a/teaql-core/src/main/java/module-info.java +++ b/teaql-core/src/main/java/module-info.java @@ -13,6 +13,7 @@ exports io.teaql.core.value; exports io.teaql.core.spi; exports io.teaql.core.businessid; + exports io.teaql.core.reference; uses io.teaql.core.spi.ContextAssembler; uses io.teaql.core.spi.RemoteCacheProvider; diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java index c05e308e..d0cae95d 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java @@ -19,6 +19,7 @@ import io.teaql.core.businessid.BusinessIdService; import io.teaql.runtime.businessid.DefaultBusinessIdProfileFactory; import io.teaql.runtime.businessid.SystemBusinessClock; +import io.teaql.core.reference.RoundTripReferenceCodec; public class DefaultUserContext implements UserContext, OptNullBasicTypeFromObjectGetter { @@ -179,6 +180,9 @@ public T capability(Class capabilityType) { if (value == null && capabilityType == BusinessIdSchemaContributor.class) { value = runtime.getBusinessIdSchemaContributor(); } + if (value == null && capabilityType == RoundTripReferenceCodec.class) { + value = runtime.getRoundTripReferenceCodec(); + } if (value == null) { return null; } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index c2bf5748..528302ef 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -11,6 +11,8 @@ import io.teaql.core.businessid.BusinessIdSchemaContributor; import io.teaql.core.businessid.BusinessIdService; import io.teaql.runtime.businessid.DefaultBusinessIdService; +import io.teaql.core.reference.RoundTripReferenceCodec; +import io.teaql.core.reference.RoundTripReferenceProvider; import java.util.*; public class TeaQLRuntime { @@ -25,6 +27,7 @@ public class TeaQLRuntime { private final SchemaExecutor schemaExecutor; private final BusinessIdService businessIdService; private final BusinessIdSchemaContributor businessIdSchemaContributor; + private final RoundTripReferenceCodec roundTripReferenceCodec; private final Map> checkers = new java.util.concurrent.ConcurrentHashMap<>(); private final List generatedBootstraps = new java.util.concurrent.CopyOnWriteArrayList<>(); @@ -41,6 +44,9 @@ private TeaQLRuntime(Builder builder) { this.schemaExecutor = builder.schemaExecutor; this.businessIdService = builder.businessIdService; this.businessIdSchemaContributor = builder.businessIdSchemaContributor; + this.roundTripReferenceCodec = builder.roundTripReferenceProvider == null + ? null + : new RoundTripReferenceCodec(builder.roundTripReferenceProvider); } public static Builder builder() { @@ -95,6 +101,10 @@ BusinessIdSchemaContributor getBusinessIdSchemaContributor() { return businessIdSchemaContributor; } + RoundTripReferenceCodec getRoundTripReferenceCodec() { + return roundTripReferenceCodec; + } + /** Installs a passive generated manifest. Database schemas remain unchanged. */ public TeaQLRuntime install(RuntimeModule module) { module.install(metadata); @@ -921,6 +931,7 @@ public static class Builder { private SchemaExecutor schemaExecutor; private BusinessIdService businessIdService; private BusinessIdSchemaContributor businessIdSchemaContributor; + private RoundTripReferenceProvider roundTripReferenceProvider; public Builder metadata(EntityMetaFactory metadata) { this.metadata = metadata; @@ -971,6 +982,12 @@ Builder businessIdInfrastructure(A allocator) { return this; } + /** Installs context-bound identity serialization without coupling it to a Web framework. */ + public Builder roundTripReferenceProvider(RoundTripReferenceProvider provider) { + this.roundTripReferenceProvider = java.util.Objects.requireNonNull(provider, "provider"); + return this; + } + public Builder logSink(RuntimeLogSink logSink) { this.logSink = logSink; return this; diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java new file mode 100644 index 00000000..45e56b29 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java @@ -0,0 +1,140 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.UserContext; +import io.teaql.core.reference.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.AEADBadTagException; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** AES-256-GCM round-trip references with context-specific key selection. */ +public final class AeadRoundTripReferenceProvider implements RoundTripReferenceProvider { + private static final String PREFIX = "tqr1"; + private static final byte PAYLOAD_VERSION = 1; + private static final int NONCE_LENGTH = 12; + private static final int TAG_BITS = 128; + + private final RoundTripReferenceKeyProvider keys; + private final SecureRandom random; + + public AeadRoundTripReferenceProvider(RoundTripReferenceKeyProvider keys) { + this(keys, new SecureRandom()); + } + + AeadRoundTripReferenceProvider(RoundTripReferenceKeyProvider keys, SecureRandom random) { + if (keys == null || random == null) throw new IllegalArgumentException("keys and random are required"); + this.keys = keys; + this.random = random; + } + + @Override + public RoundTripReference issue(UserContext context, InternalEntityIdentity identity) { + RoundTripReferenceKey key = requireKey(keys.currentKey(context)); + byte[] nonce = new byte[NONCE_LENGTH]; + random.nextBytes(nonce); + try { + byte[] encrypted = cipher(Cipher.ENCRYPT_MODE, key, nonce) + .doFinal(encode(identity)); + byte[] envelope = new byte[nonce.length + encrypted.length]; + System.arraycopy(nonce, 0, envelope, 0, nonce.length); + System.arraycopy(encrypted, 0, envelope, nonce.length, encrypted.length); + return new RoundTripReference(PREFIX + "." + key.keyId() + "." + + Base64.getUrlEncoder().withoutPadding().encodeToString(envelope)); + } catch (GeneralSecurityException | IOException error) { + throw invalid("Unable to issue round-trip reference", error); + } + } + + @Override + public ResolvedRoundTripReference resolve( + UserContext context, RoundTripReference reference, String expectedEntityType) { + String[] parts = reference.value().split("\\.", -1); + if (parts.length != 3 || !PREFIX.equals(parts[0])) { + throw invalid("Invalid round-trip reference", null); + } + RoundTripReferenceKey key = keys.keyById(context, parts[1]); + if (key == null) { + throw invalid("Round-trip reference is unavailable", null); + } + try { + byte[] envelope = Base64.getUrlDecoder().decode(parts[2]); + if (envelope.length <= NONCE_LENGTH + 16) throw invalid("Invalid round-trip reference", null); + byte[] nonce = java.util.Arrays.copyOfRange(envelope, 0, NONCE_LENGTH); + byte[] encrypted = java.util.Arrays.copyOfRange(envelope, NONCE_LENGTH, envelope.length); + InternalEntityIdentity identity = decode( + cipher(Cipher.DECRYPT_MODE, key, nonce).doFinal(encrypted)); + if (expectedEntityType == null || !identity.entityType().equals(expectedEntityType)) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.TYPE_MISMATCH, + "Round-trip reference does not match the expected entity type"); + } + return new ResolvedRoundTripReference( + identity.entityType(), identity.id(), identity.version()); + } catch (RoundTripReferenceException error) { + throw error; + } catch (AEADBadTagException error) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.CONTEXT_MISMATCH, + "Round-trip reference is invalid for the current runtime context"); + } catch (GeneralSecurityException | IOException | IllegalArgumentException error) { + throw invalid("Invalid round-trip reference", error); + } + } + + private static Cipher cipher(int mode, RoundTripReferenceKey key, byte[] nonce) + throws GeneralSecurityException { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(mode, new SecretKeySpec(key.keyBytes(), "AES"), new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD((PREFIX + "." + key.keyId()).getBytes(StandardCharsets.US_ASCII)); + return cipher; + } + + private static byte[] encode(InternalEntityIdentity identity) throws IOException { + byte[] entityType = identity.entityType().getBytes(StandardCharsets.UTF_8); + if (entityType.length == 0 || entityType.length > 65535) { + throw new IOException("entity type length is invalid"); + } + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeByte(PAYLOAD_VERSION); + output.writeShort(entityType.length); + output.write(entityType); + output.writeLong(identity.id()); + output.writeLong(identity.version()); + } + return bytes.toByteArray(); + } + + private static InternalEntityIdentity decode(byte[] payload) throws IOException { + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) { + if (input.readUnsignedByte() != PAYLOAD_VERSION) throw new IOException("unsupported payload version"); + int length = input.readUnsignedShort(); + byte[] entityType = input.readNBytes(length); + if (entityType.length != length) throw new EOFException("truncated entity type"); + long id = input.readLong(); + long version = input.readLong(); + if (input.read() != -1) throw new IOException("trailing payload data"); + return new InternalEntityIdentity(new String(entityType, StandardCharsets.UTF_8), id, version); + } + } + + private static RoundTripReferenceKey requireKey(RoundTripReferenceKey key) { + if (key == null) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.CONTEXT_BINDING_REQUIRED, + "Runtime customization did not provide reference binding material"); + } + return key; + } + + private static RoundTripReferenceException invalid(String message, Throwable cause) { + return cause == null + ? new RoundTripReferenceException(RoundTripReferenceErrorCode.INVALID_REFERENCE, message) + : new RoundTripReferenceException(RoundTripReferenceErrorCode.INVALID_REFERENCE, message, cause); + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/ContextReferenceBindingProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/ContextReferenceBindingProvider.java new file mode 100644 index 00000000..e652bfe0 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/ContextReferenceBindingProvider.java @@ -0,0 +1,9 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.UserContext; + +/** Produces opaque, trusted binding bytes from the complete runtime context. */ +@FunctionalInterface +public interface ContextReferenceBindingProvider { + byte[] bindingFor(UserContext context); +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/DerivedRoundTripReferenceKeyProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/DerivedRoundTripReferenceKeyProvider.java new file mode 100644 index 00000000..e5fffbb2 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/DerivedRoundTripReferenceKeyProvider.java @@ -0,0 +1,100 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.UserContext; +import io.teaql.core.reference.RoundTripReferenceErrorCode; +import io.teaql.core.reference.RoundTripReferenceException; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Derives context-specific AES keys without assigning meaning to the context binding. */ +public final class DerivedRoundTripReferenceKeyProvider + implements RoundTripReferenceKeyProvider { + private static final byte[] SALT = "teaql-round-trip-reference-v1" + .getBytes(StandardCharsets.UTF_8); + + private final RoundTripReferenceMasterKeyRing masterKeys; + private final ContextReferenceBindingProvider bindings; + private final byte[] namespace; + + public DerivedRoundTripReferenceKeyProvider( + RoundTripReferenceMasterKeyRing masterKeys, + ContextReferenceBindingProvider bindings, + String namespace) { + if (masterKeys == null || bindings == null) { + throw new IllegalArgumentException("masterKeys and bindings are required"); + } + if (namespace == null || namespace.isBlank()) { + throw new IllegalArgumentException("namespace must not be blank"); + } + this.masterKeys = masterKeys; + this.bindings = bindings; + this.namespace = namespace.getBytes(StandardCharsets.UTF_8); + } + + @Override + public RoundTripReferenceKey currentKey(UserContext context) { + return derive(context, masterKeys.currentKey()); + } + + @Override + public RoundTripReferenceKey keyById(UserContext context, String keyId) { + RoundTripReferenceKey master = masterKeys.keyById(keyId); + return master == null ? null : derive(context, master); + } + + private RoundTripReferenceKey derive(UserContext context, RoundTripReferenceKey master) { + byte[] binding = bindings.bindingFor(context); + if (binding == null || binding.length == 0) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.CONTEXT_BINDING_REQUIRED, + "Runtime customization did not provide a round-trip reference binding"); + } + try { + byte[] pseudoRandomKey = hmac(SALT, master.keyBytes()); + ByteArrayOutputStream info = new ByteArrayOutputStream(); + info.writeBytes("teaql:tqr1".getBytes(StandardCharsets.UTF_8)); + writeLengthPrefixed(info, namespace); + writeLengthPrefixed(info, binding); + byte[] output = hkdfExpand(pseudoRandomKey, info.toByteArray(), 32); + return new RoundTripReferenceKey(master.keyId(), output); + } catch (RoundTripReferenceException error) { + throw error; + } catch (Exception error) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.INVALID_REFERENCE, + "Unable to derive a round-trip reference key", error); + } + } + + private static byte[] hmac(byte[] key, byte[] input) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(input); + } + + private static byte[] hkdfExpand(byte[] key, byte[] info, int length) throws Exception { + ByteArrayOutputStream result = new ByteArrayOutputStream(length); + byte[] previous = new byte[0]; + int counter = 1; + while (result.size() < length) { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + mac.update(previous); + mac.update(info); + mac.update((byte) counter++); + previous = mac.doFinal(); + result.write(previous, 0, Math.min(previous.length, length - result.size())); + } + return result.toByteArray(); + } + + private static void writeLengthPrefixed(ByteArrayOutputStream output, byte[] value) { + output.write((value.length >>> 24) & 0xff); + output.write((value.length >>> 16) & 0xff); + output.write((value.length >>> 8) & 0xff); + output.write(value.length & 0xff); + output.writeBytes(value); + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RawRoundTripReferenceProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RawRoundTripReferenceProvider.java new file mode 100644 index 00000000..f9caa91f --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RawRoundTripReferenceProvider.java @@ -0,0 +1,37 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.UserContext; +import io.teaql.core.reference.*; + +/** Development/test-only readable representation. Never install in production. */ +public final class RawRoundTripReferenceProvider implements RoundTripReferenceProvider { + @Override + public RoundTripReference issue(UserContext context, InternalEntityIdentity identity) { + if (identity.entityType().contains(".")) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.INVALID_ENTITY_IDENTITY, + "Raw diagnostic entity type must not contain '.'"); + } + return new RoundTripReference("raw1." + identity.entityType() + "." + + identity.id() + "." + identity.version()); + } + + @Override + public ResolvedRoundTripReference resolve( + UserContext context, RoundTripReference reference, String expectedEntityType) { + String[] parts = reference.value().split("\\.", -1); + try { + if (parts.length != 4 || !"raw1".equals(parts[0]) || !parts[1].equals(expectedEntityType)) { + throw new IllegalArgumentException("shape/type mismatch"); + } + InternalEntityIdentity identity = new InternalEntityIdentity( + parts[1], Long.parseLong(parts[2]), Long.parseLong(parts[3])); + return new ResolvedRoundTripReference( + identity.entityType(), identity.id(), identity.version()); + } catch (RuntimeException error) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.INVALID_REFERENCE, + "Invalid raw diagnostic round-trip reference", error); + } + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java new file mode 100644 index 00000000..2543e689 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java @@ -0,0 +1,41 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.reference.RoundTripReferenceProvider; + +public final class RoundTripReferenceConfiguration { + public static final String RAW_ID_ENV = "TEAQL_UNSAFE_EXPOSE_RAW_ENTITY_IDS"; + public static final String RAW_ID_ACKNOWLEDGEMENT = + "I_UNDERSTAND_THIS_EXPOSES_INTERNAL_ENTITY_IDS_FOR_LOCAL_DEBUGGING_ONLY"; + + private RoundTripReferenceConfiguration() {} + + public enum Mode { GOVERNED, RAW_DIAGNOSTIC } + + public static Mode mode(String deploymentProfile, String acknowledgement) { + if (!RAW_ID_ACKNOWLEDGEMENT.equals(acknowledgement)) return Mode.GOVERNED; + if (!"development".equals(deploymentProfile) && !"test".equals(deploymentProfile)) { + throw new IllegalStateException( + RAW_ID_ENV + " cannot enable raw entity IDs outside development/test"); + } + return Mode.RAW_DIAGNOSTIC; + } + + /** Selects once at runtime startup; Web adapters only consume the resulting provider. */ + public static RoundTripReferenceProvider configuredProvider( + String deploymentProfile, RoundTripReferenceProvider governedProvider) { + return configuredProvider( + deploymentProfile, System.getenv(RAW_ID_ENV), governedProvider); + } + + static RoundTripReferenceProvider configuredProvider( + String deploymentProfile, + String acknowledgement, + RoundTripReferenceProvider governedProvider) { + if (governedProvider == null) { + throw new IllegalArgumentException("governedProvider must not be null"); + } + return mode(deploymentProfile, acknowledgement) == Mode.RAW_DIAGNOSTIC + ? new RawRoundTripReferenceProvider() + : governedProvider; + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java new file mode 100644 index 00000000..09899267 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java @@ -0,0 +1,28 @@ +package io.teaql.runtime.reference; + +import java.util.Arrays; + +/** AES key material used only by the runtime's default reference provider. */ +public final class RoundTripReferenceKey { + private final String keyId; + private final byte[] keyBytes; + + public RoundTripReferenceKey(String keyId, byte[] keyBytes) { + if (keyId == null || !keyId.matches("[A-Za-z0-9_-]{1,32}")) { + throw new IllegalArgumentException("keyId must be 1-32 URL-safe characters"); + } + if (keyBytes == null || keyBytes.length != 32) { + throw new IllegalArgumentException("AES-256 key must contain exactly 32 bytes"); + } + this.keyId = keyId; + this.keyBytes = Arrays.copyOf(keyBytes, keyBytes.length); + } + + public String keyId() { + return keyId; + } + + public byte[] keyBytes() { + return Arrays.copyOf(keyBytes, keyBytes.length); + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java new file mode 100644 index 00000000..49c5fdc9 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java @@ -0,0 +1,10 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.UserContext; + +/** Selects the runtime's current or decode-only key using the complete context. */ +public interface RoundTripReferenceKeyProvider { + RoundTripReferenceKey currentKey(UserContext context); + + RoundTripReferenceKey keyById(UserContext context, String keyId); +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceMasterKeyRing.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceMasterKeyRing.java new file mode 100644 index 00000000..db7017fd --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceMasterKeyRing.java @@ -0,0 +1,7 @@ +package io.teaql.runtime.reference; + +public interface RoundTripReferenceMasterKeyRing { + RoundTripReferenceKey currentKey(); + + RoundTripReferenceKey keyById(String keyId); +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceMasterKeyRing.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceMasterKeyRing.java new file mode 100644 index 00000000..b2a59686 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceMasterKeyRing.java @@ -0,0 +1,35 @@ +package io.teaql.runtime.reference; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Small in-memory key ring for configuration adapters, examples and tests. */ +public final class StaticRoundTripReferenceMasterKeyRing + implements RoundTripReferenceMasterKeyRing { + private final RoundTripReferenceKey current; + private final Map keys; + + public StaticRoundTripReferenceMasterKeyRing( + RoundTripReferenceKey current, RoundTripReferenceKey... decodeOnlyKeys) { + if (current == null) throw new IllegalArgumentException("current key must not be null"); + this.current = current; + Map configured = new LinkedHashMap<>(); + configured.put(current.keyId(), current); + if (decodeOnlyKeys != null) { + for (RoundTripReferenceKey key : decodeOnlyKeys) { + if (key != null) configured.put(key.keyId(), key); + } + } + this.keys = Map.copyOf(configured); + } + + @Override + public RoundTripReferenceKey currentKey() { + return current; + } + + @Override + public RoundTripReferenceKey keyById(String keyId) { + return keys.get(keyId); + } +} diff --git a/teaql-runtime/src/main/java/module-info.java b/teaql-runtime/src/main/java/module-info.java index 0838c869..0acd8b13 100644 --- a/teaql-runtime/src/main/java/module-info.java +++ b/teaql-runtime/src/main/java/module-info.java @@ -6,6 +6,7 @@ exports io.teaql.runtime.memory; exports io.teaql.runtime.boot; exports io.teaql.runtime.businessid; + exports io.teaql.runtime.reference; provides io.teaql.core.spi.ContextAssembler with io.teaql.runtime.boot.CoreAssembler; } diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/reference/RoundTripReferenceConfigurationTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/reference/RoundTripReferenceConfigurationTest.java new file mode 100644 index 00000000..8df7e5fa --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/reference/RoundTripReferenceConfigurationTest.java @@ -0,0 +1,52 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.UserContext; +import io.teaql.core.reference.InternalEntityIdentity; +import io.teaql.core.reference.ResolvedRoundTripReference; +import io.teaql.core.reference.RoundTripReference; +import io.teaql.core.reference.RoundTripReferenceProvider; +import org.junit.Assert; +import org.junit.Test; + +public class RoundTripReferenceConfigurationTest { + private final RoundTripReferenceProvider governed = new RoundTripReferenceProvider() { + @Override + public RoundTripReference issue(UserContext context, InternalEntityIdentity identity) { + throw new UnsupportedOperationException(); + } + + @Override + public ResolvedRoundTripReference resolve( + UserContext context, + RoundTripReference reference, + String expectedEntityType) { + throw new UnsupportedOperationException(); + } + }; + + @Test + public void exactAcknowledgementSelectsRawOnlyForDevelopmentOrTest() { + Assert.assertSame( + governed, + RoundTripReferenceConfiguration.configuredProvider( + "development", "true", governed)); + Assert.assertTrue( + RoundTripReferenceConfiguration.configuredProvider( + "development", + RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT, + governed) + instanceof RawRoundTripReferenceProvider); + Assert.assertTrue( + RoundTripReferenceConfiguration.configuredProvider( + "test", + RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT, + governed) + instanceof RawRoundTripReferenceProvider); + Assert.assertThrows( + IllegalStateException.class, + () -> RoundTripReferenceConfiguration.configuredProvider( + "production", + RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT, + governed)); + } +} From 29ddd77a46f7f10d105636509ebb9881fb0cc3d3 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 27 Sep 2026 01:52:49 +0800 Subject: [PATCH 2/3] feat: align Java context-bound references with Rust --- README.md | 48 +- ...9-27-context-bound-round-trip-reference.md | 68 +++ .../round-trip-reference-runtime/README.md | 15 +- .../RoundTripReferenceRuntimeExampleTest.java | 177 ++------ .../SecurityFoundationsExampleTest.java | 61 ++- .../io/teaql/core/EntityReferenceClaims.java | 13 - .../io/teaql/core/EntityReferenceCodec.java | 10 - .../core/EntityReferenceTokenException.java | 15 - .../io/teaql/core/EntityReferenceWire.java | 96 ---- .../main/java/io/teaql/core/UserContext.java | 77 ++-- .../reference/ExternalEntityReference.java | 16 + .../reference/InternalEntityIdentity.java | 11 +- .../reference/ReferenceDocumentScope.java | 28 ++ .../teaql/core/reference/ReferenceMode.java | 6 + .../core/reference/ReferenceWireCodec.java | 49 ++ .../reference/ResolvedRoundTripReference.java | 17 +- .../core/reference/RoundTripReference.java | 15 - .../reference/RoundTripReferenceCodec.java | 27 +- .../RoundTripReferenceErrorCode.java | 4 + .../reference/RoundTripReferenceProvider.java | 15 +- .../reference/TrustedReferencePrincipal.java | 24 + .../AeadRoundTripReferenceProvider.java | 421 ++++++++++++++---- .../ContextReferenceBindingProvider.java | 9 - .../runtime/reference/DeploymentProfile.java | 11 + .../DerivedRoundTripReferenceKeyProvider.java | 100 ----- .../RawRoundTripReferenceProvider.java | 37 -- .../reference/ReferenceStartupNotice.java | 11 + ...RoundTripReferenceAuthorizationPolicy.java | 16 + .../RoundTripReferenceConfiguration.java | 31 -- .../reference/RoundTripReferenceKey.java | 4 +- .../RoundTripReferenceKeyProvider.java | 8 +- .../RoundTripReferenceMasterKeyRing.java | 7 - ... StaticRoundTripReferenceKeyProvider.java} | 19 +- .../security/AeadEntityReferenceCodec.java | 120 ----- .../ContextBoundRoundTripReferenceTest.java | 257 +++++++++++ .../RoundTripReferenceConfigurationTest.java | 52 --- .../AeadEntityReferenceCodecTest.java | 63 --- 37 files changed, 1044 insertions(+), 914 deletions(-) create mode 100644 examples/conformance/evidence/2026-09-27-context-bound-round-trip-reference.md delete mode 100644 teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java delete mode 100644 teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java delete mode 100644 teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java delete mode 100644 teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/ExternalEntityReference.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/ReferenceDocumentScope.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/ReferenceMode.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/ReferenceWireCodec.java delete mode 100644 teaql-core/src/main/java/io/teaql/core/reference/RoundTripReference.java create mode 100644 teaql-core/src/main/java/io/teaql/core/reference/TrustedReferencePrincipal.java delete mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/ContextReferenceBindingProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/DeploymentProfile.java delete mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/DerivedRoundTripReferenceKeyProvider.java delete mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/RawRoundTripReferenceProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/ReferenceStartupNotice.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceAuthorizationPolicy.java delete mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceMasterKeyRing.java rename teaql-runtime/src/main/java/io/teaql/runtime/reference/{StaticRoundTripReferenceMasterKeyRing.java => StaticRoundTripReferenceKeyProvider.java} (57%) delete mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/reference/ContextBoundRoundTripReferenceTest.java delete mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/reference/RoundTripReferenceConfigurationTest.java delete mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java diff --git a/README.md b/README.md index 57fed125..62200bfb 100644 --- a/README.md +++ b/README.md @@ -132,25 +132,39 @@ Install an application-owned key provider at the runtime boundary: ```java byte[] activeKey = loadThirtyTwoByteKeyFromSecretManager(); -EntityReferenceCodec codec = new AeadEntityReferenceCodec( - 2, Map.of(2, activeKey)); -userContext.withEntityReferenceCodec(codec); - -String token = userContext.encodeEntityReference( - "OrderItem", 42L, 7L, "edit-order", Duration.ofMinutes(15)); -EntityReferenceClaims claims = userContext.decodeEntityReference( - token, "OrderItem", "edit-order"); +var provider = AeadRoundTripReferenceProvider.fromProcessEnvironment( + DeploymentProfile.PRODUCTION, + "order-service", + "production", + new StaticRoundTripReferenceKeyProvider( + new RoundTripReferenceKey("k2", activeKey)), + currentAuthorizationPolicy); +var runtime = TeaQLRuntime.builder() + .roundTripReferenceProvider(provider) + .build(); +userContext.withTrustedReferencePrincipal( + new TrustedReferencePrincipal("oidc", "alice", "Platform", 7)); +var scope = new ReferenceDocumentScope( + "order-editor-100", "edit-order", "Order", 100, 9); + +Object wire = userContext.referenceFor(orderItem, scope, Duration.ofMinutes(15)); +ResolvedRoundTripReference resolved = userContext.resolveReference( + wire, scope, "OrderItem"); ``` -The token uses AES-256-GCM, carries a key version for rotation, expires, and is -bound to both entity type and purpose. Invalid, expired, substituted, or -tampered tokens all fail with `ENTITY_REFERENCE_INVALID`; a missing codec fails -with `ENTITY_REFERENCE_CODEC_REQUIRED`. Raw `tqr0.` references are available -only after setting the deliberately long local-development acknowledgement -documented in the canonical -[opaque entity reference contract](https://github.com/teaql/teaql-conformance/blob/main/design/opaque-entity-references.md). -Tokens complement authorization; they do not replace tenant, ownership, role, -or optimistic-lock checks. +The `tqr1` token uses AES-256-GCM with HKDF-SHA-256, carries a key ID for +rotation, expires, and binds Actor, Domain Root, service, environment, purpose, +document, Aggregate identity/revision, entity type/ID/version. Current +authorization runs both when issuing and consuming the reference. Java and +Rust retain the same deterministic golden vector. + +For local diagnosis only, the exact +`TEAQL_UNSAFE_EXPOSE_RAW_ENTITY_IDS=I_UNDERSTAND_THIS_EXPOSES_INTERNAL_ENTITY_IDS_FOR_LOCAL_DEBUGGING_ONLY` +acknowledgement changes the startup-selected wire shape to `{id, version}` in +development/test. Production fails closed; raw mode does not bypass current +authorization, type, version, projection, Checker/Fix, audit, or Mutation +Ledger enforcement. See the canonical +[context-bound reference contract](https://github.com/teaql/teaql-conformance/blob/main/design/context-bound-round-trip-references.md). ## Choose Modules diff --git a/examples/conformance/evidence/2026-09-27-context-bound-round-trip-reference.md b/examples/conformance/evidence/2026-09-27-context-bound-round-trip-reference.md new file mode 100644 index 00000000..30b690d1 --- /dev/null +++ b/examples/conformance/evidence/2026-09-27-context-bound-round-trip-reference.md @@ -0,0 +1,68 @@ +# Java context-bound Round-Trip Reference evidence + +Date: 2026-09-27 + +Tracking: [teaql-java#175](https://github.com/teaql/teaql-java/issues/175) + +Baseline: `origin/main` at `9ca9e261a7b8c5f39292374ac7eab2c7cab303d6`. + +Scope: the bounded `tqr1` independent-reference core. This evidence does not +claim a complete encrypted document manifest or generated `openDocument` / +`acceptDocument` adapters. + +## Implemented boundary + +- high-level `UserContext::referenceFor` / `resolveReference` boundary; +- trusted authentication realm, subject and Domain Root identity supplied by + runtime assembly, never accepted from the wire; +- service/environment, purpose, document, Aggregate type/ID/revision and + entity type/ID/version binding; +- AES-256-GCM, 96-bit CSPRNG nonce, HKDF-SHA-256 and unpadded Base64URL; +- current encryption key plus decode-only previous keys; +- expiry, service/environment isolation, tamper rejection, fresh nonces and + restart/rotation continuity; +- current authorization at issue and consume time; +- one runtime-selected diagnostic raw mode with an exact long acknowledgement, + production failure, explicit ERROR notice, telemetry and response metadata; +- removal of the earlier weaker codec/raw-fallback path so there is one public + Round-Trip Reference contract. + +## Cross-language vector + +The fixed inputs used by Rust and Java are identical: service `order-service`, +environment `test-a`, current key `k2` filled with `0x22`, timestamp +`2026-09-27T00:00:00Z`, nonce filled with `0x33`, principal +`oidc/alice/Platform#7`, scope `doc-100/edit-order/Order#100@9`, identity +`OrderItem#42@3`, and lifetime 600 seconds. + +Both runtimes assert this exact token: + +```text +tqr1.k2.MzMzMzMzMzMzMzMzmhCET15vBdSc1BbFJcrvMMnRP1wAyjunShYlFPnDZL7Gk5V1LuptWyrbiQ8LQSDHFUmN3o6vb-MEgXZuh3N-YkeO55CYLeVgiyBBYslzuSu-fvWC2KMZcnZ5D8BRmWpGYA2Ok5sWe1Fg1aIyadTjVoKB-H2vPW_0okj- +``` + +## RAW-01 through RAW-09 + +`ContextBoundRoundTripReferenceTest` contains individually named deterministic +tests for all nine cases: default governed mode, exact development/test opt-in, +near-match rejection, production failure, current authorization after actor +change, mode/wire-shape mismatch, identity/version preservation, and safe +downgrade metadata. + +## Commands + +```text +mvn -pl teaql-runtime -am \ + -Dtest=ContextBoundRoundTripReferenceTest \ + -Dsurefire.failIfNoSpecifiedTests=false test + +mvn -pl examples/round-trip-reference-runtime -am test +mvn clean verify +``` + +The focused runtime suite reports 12 passed tests. The full clean reactor +reports 39/39 modules successful, including 197 core tests, 48 runtime tests, +the migrated security-foundations example, the standalone Round-Trip Reference +example, all database dialect modules, TFP, telemetry, and generated +conformance workspaces. One external OTLP smoke test is skipped because its +external endpoint is not configured. No release is produced by this work. diff --git a/examples/round-trip-reference-runtime/README.md b/examples/round-trip-reference-runtime/README.md index 7ed15f46..2fd40a23 100644 --- a/examples/round-trip-reference-runtime/README.md +++ b/examples/round-trip-reference-runtime/README.md @@ -4,14 +4,15 @@ This example proves that the reusable runtime serialization boundary can be called by any Web adapter without depending on Spring, JAX-RS, Servlet, or a specific JSON library. -It covers governed issue/resolve, opaque context binding, tamper rejection, -cross-context rejection, expected-type validation, key rotation, and the -explicit development-only raw diagnostic representation. +It covers governed issue/resolve through `UserContext`, trusted Actor and +Domain Root binding, purpose/document/Aggregate scope, tamper rejection, +expected-type validation, expiry, key rotation, current authorization at issue +and consume time, and the explicit development/test-only raw diagnostic shape. -The core contract receives the complete `UserContext`; it does not define what -the context binding means. The runtime customization supplies opaque binding -bytes. Successfully resolving a reference only restores `(type, id, version)`: -the application must still re-run its current authorization and policy checks. +The core deliberately does not define tenant or role semantics. Runtime +assembly installs the trusted principal and application-owned authorization +policy. The focused runtime tests retain the same deterministic `tqr1` golden +vector as TeaQL Rust and enumerate RAW-01 through RAW-09. Run locally against repository sources: diff --git a/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java b/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java index 8f76d4ad..4b393546 100644 --- a/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java +++ b/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java @@ -3,19 +3,22 @@ import io.teaql.core.BaseEntity; import io.teaql.core.UserContext; import io.teaql.core.meta.SimpleEntityMetaFactory; -import io.teaql.core.reference.*; +import io.teaql.core.reference.ReferenceDocumentScope; +import io.teaql.core.reference.RoundTripReferenceErrorCode; +import io.teaql.core.reference.RoundTripReferenceException; +import io.teaql.core.reference.TrustedReferencePrincipal; import io.teaql.runtime.DefaultUserContext; import io.teaql.runtime.TeaQLRuntime; -import io.teaql.runtime.reference.*; -import java.nio.charset.StandardCharsets; +import io.teaql.runtime.reference.AeadRoundTripReferenceProvider; +import io.teaql.runtime.reference.DeploymentProfile; +import io.teaql.runtime.reference.RoundTripReferenceKey; +import io.teaql.runtime.reference.StaticRoundTripReferenceKeyProvider; +import java.time.Duration; import java.util.Arrays; import org.junit.Assert; import org.junit.Test; public class RoundTripReferenceRuntimeExampleTest { - private static final byte[] CURRENT = bytes(1); - private static final byte[] PREVIOUS = bytes(33); - private static final class OrderItem extends BaseEntity { OrderItem(long id, long version) { __internalSet("id", id); @@ -26,135 +29,45 @@ private static final class OrderItem extends BaseEntity { } @Test - public void frameworkNeutralCodecRoundTripsAndRejectsTransferOrTampering() { - RoundTripReferenceMasterKeyRing ring = new StaticRoundTripReferenceMasterKeyRing( - new RoundTripReferenceKey("k2", CURRENT), - new RoundTripReferenceKey("k1", PREVIOUS)); - ContextReferenceBindingProvider binding = context -> { - String value = (String) context.getAttribute("example.reference.binding"); - return value == null ? null : value.getBytes(StandardCharsets.UTF_8); - }; - AeadRoundTripReferenceProvider provider = new AeadRoundTripReferenceProvider( - new DerivedRoundTripReferenceKeyProvider(ring, binding, "reference-example")); - - UserContext alice = context(provider, "job-task:alice:42"); - RoundTripReference wire = alice.referenceFor(new OrderItem(8172, 3)); - RoundTripReference secondWire = alice.referenceFor(new OrderItem(8172, 3)); - Assert.assertTrue(wire.value().startsWith("tqr1.k2.")); - Assert.assertFalse(wire.value().contains("8172")); - Assert.assertNotEquals("fresh nonces must produce unlinkable wire values", wire, secondWire); - Assert.assertEquals( - new ResolvedRoundTripReference("OrderItem", 8172, 3), - alice.resolveReference(wire.value(), "OrderItem")); - - UserContext personalTask = context(provider, "personal-task:alice:42"); - RoundTripReferenceException transfer = Assert.assertThrows( - RoundTripReferenceException.class, - () -> personalTask.resolveReference(wire.value(), "OrderItem")); - Assert.assertEquals(RoundTripReferenceErrorCode.CONTEXT_MISMATCH, transfer.getCode()); - - int payloadStart = wire.value().lastIndexOf('.') + 1; - char original = wire.value().charAt(payloadStart); - String tampered = wire.value().substring(0, payloadStart) - + (original == 'A' ? 'B' : 'A') - + wire.value().substring(payloadStart + 1); - Assert.assertThrows( - RoundTripReferenceException.class, - () -> alice.resolveReference(tampered, "OrderItem")); - RoundTripReferenceException wrongType = Assert.assertThrows( - RoundTripReferenceException.class, - () -> alice.resolveReference(wire.value(), "Order")); - Assert.assertEquals(RoundTripReferenceErrorCode.TYPE_MISMATCH, wrongType.getCode()); - } - - @Test - public void missingProviderBindingAndPersistedVersionFailClosed() { - TeaQLRuntime noProvider = TeaQLRuntime.builder() - .metadata(new SimpleEntityMetaFactory()) - .build(); - UserContext plain = new DefaultUserContext(noProvider); - RoundTripReferenceException missingProvider = Assert.assertThrows( - RoundTripReferenceException.class, - () -> plain.referenceFor(new OrderItem(1, 1))); - Assert.assertEquals( - RoundTripReferenceErrorCode.PROVIDER_NOT_CONFIGURED, - missingProvider.getCode()); - - ContextReferenceBindingProvider absentBinding = ignored -> null; - UserContext unbound = context( - provider( - new StaticRoundTripReferenceMasterKeyRing( - new RoundTripReferenceKey("k2", CURRENT)), - absentBinding), - "unused"); - RoundTripReferenceException missingBinding = Assert.assertThrows( - RoundTripReferenceException.class, - () -> unbound.referenceFor(new OrderItem(1, 1))); - Assert.assertEquals( - RoundTripReferenceErrorCode.CONTEXT_BINDING_REQUIRED, - missingBinding.getCode()); - - Assert.assertThrows( - IllegalArgumentException.class, - () -> new InternalEntityIdentity("OrderItem", 1, 0)); - } - - @Test - public void previousKeyDecodesAfterRotationAndRawModeIsExplicit() { - ContextReferenceBindingProvider binding = context -> - ((String) context.getAttribute("example.reference.binding")) - .getBytes(StandardCharsets.UTF_8); - AeadRoundTripReferenceProvider oldProvider = provider( - new StaticRoundTripReferenceMasterKeyRing( - new RoundTripReferenceKey("k1", PREVIOUS)), binding); - UserContext beforeRotation = context(oldProvider, "task:7"); - String oldReference = beforeRotation.referenceFor(new OrderItem(9, 1)).value(); - - AeadRoundTripReferenceProvider rotated = provider( - new StaticRoundTripReferenceMasterKeyRing( - new RoundTripReferenceKey("k2", CURRENT), - new RoundTripReferenceKey("k1", PREVIOUS)), binding); - UserContext afterRotation = context(rotated, "task:7"); - Assert.assertEquals(9, afterRotation.resolveReference(oldReference, "OrderItem").id()); - Assert.assertTrue(afterRotation.referenceFor(new OrderItem(10, 2)).value().startsWith("tqr1.k2.")); - - Assert.assertEquals( - RoundTripReferenceConfiguration.Mode.GOVERNED, - RoundTripReferenceConfiguration.mode("development", "true")); - Assert.assertEquals( - RoundTripReferenceConfiguration.Mode.RAW_DIAGNOSTIC, - RoundTripReferenceConfiguration.mode( - "development", RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT)); - Assert.assertThrows( - IllegalStateException.class, - () -> RoundTripReferenceConfiguration.mode( - "production", RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT)); - - UserContext raw = context(new RawRoundTripReferenceProvider(), "ignored"); - String readable = raw.referenceFor(new OrderItem(8172, 3)).value(); - Assert.assertEquals("raw1.OrderItem.8172.3", readable); - Assert.assertEquals(8172, raw.resolveReference(readable, "OrderItem").id()); - } - - private static AeadRoundTripReferenceProvider provider( - RoundTripReferenceMasterKeyRing ring, ContextReferenceBindingProvider binding) { - return new AeadRoundTripReferenceProvider( - new DerivedRoundTripReferenceKeyProvider(ring, binding, "reference-example")); - } - - private static UserContext context(RoundTripReferenceProvider provider, String binding) { + public void contextApiIssuesAndConsumesAnOpaqueReference() { + byte[] current = new byte[32]; + byte[] previous = new byte[32]; + Arrays.fill(current, (byte) 0x22); + Arrays.fill(previous, (byte) 0x11); + AeadRoundTripReferenceProvider provider = AeadRoundTripReferenceProvider.governed( + DeploymentProfile.DEVELOPMENT, + "order-service", + "local", + new StaticRoundTripReferenceKeyProvider( + new RoundTripReferenceKey("k2", current), + new RoundTripReferenceKey("k1", previous)), + (context, principal, scope, identity) -> { + if (!"alice".equals(principal.subject()) + || !"OrderItem".equals(identity.entityType())) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.AUTHORIZATION_REQUIRED, + "current authorization rejected the entity reference"); + } + }); TeaQLRuntime runtime = TeaQLRuntime.builder() .metadata(new SimpleEntityMetaFactory()) .roundTripReferenceProvider(provider) .build(); - UserContext context = new DefaultUserContext(runtime); - context.putAttribute("example.reference.binding", binding); - return context; - } - - private static byte[] bytes(int start) { - byte[] value = new byte[32]; - for (int index = 0; index < value.length; index++) value[index] = (byte) (start + index); - return Arrays.copyOf(value, value.length); + UserContext context = new DefaultUserContext(runtime).withTrustedReferencePrincipal( + new TrustedReferencePrincipal("oidc", "alice", "Platform", 7)); + ReferenceDocumentScope scope = new ReferenceDocumentScope( + "order-editor-100", "edit-order", "Order", 100, 9); + + Object wire = context.referenceFor(new OrderItem(42, 3), scope, Duration.ofMinutes(15)); + Assert.assertTrue(wire instanceof String); + Assert.assertTrue(wire.toString().startsWith("tqr1.k2.")); + Assert.assertFalse(wire.toString().contains("OrderItem")); + Assert.assertEquals(42, context.resolveReference(wire, scope, "OrderItem").id()); + + ReferenceDocumentScope anotherDocument = new ReferenceDocumentScope( + "order-editor-101", "edit-order", "Order", 100, 9); + Assert.assertThrows( + RoundTripReferenceException.class, + () -> context.resolveReference(wire, anotherDocument, "OrderItem")); } } diff --git a/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java b/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java index a3ab2722..49e5628a 100644 --- a/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java +++ b/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java @@ -3,33 +3,58 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; -import io.teaql.core.EntityReferenceTokenException; +import io.teaql.core.BaseEntity; +import io.teaql.core.UserContext; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.reference.ReferenceDocumentScope; +import io.teaql.core.reference.RoundTripReferenceException; +import io.teaql.core.reference.TrustedReferencePrincipal; import io.teaql.runtime.DefaultUserContext; -import io.teaql.runtime.security.AeadEntityReferenceCodec; -import java.time.Clock; +import io.teaql.runtime.TeaQLRuntime; +import io.teaql.runtime.reference.AeadRoundTripReferenceProvider; +import io.teaql.runtime.reference.DeploymentProfile; +import io.teaql.runtime.reference.RoundTripReferenceKey; +import io.teaql.runtime.reference.StaticRoundTripReferenceKeyProvider; 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 { + private static final class OrderItem extends BaseEntity { + OrderItem(long id, long version) { + __internalSet("id", id); + __internalSet("version", version); + } + + @Override public String typeName() { return "OrderItem"; } + } + @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"); + AeadRoundTripReferenceProvider provider = AeadRoundTripReferenceProvider.governed( + DeploymentProfile.TEST, + "order-service", + "test-a", + new StaticRoundTripReferenceKeyProvider(new RoundTripReferenceKey("k2", key)), + (context, principal, scope, identity) -> {}); + TeaQLRuntime runtime = TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()) + .roundTripReferenceProvider(provider) + .build(); + UserContext context = new DefaultUserContext(runtime).withTrustedReferencePrincipal( + new TrustedReferencePrincipal("oidc", "alice", "Platform", 7)); + ReferenceDocumentScope scope = new ReferenceDocumentScope( + "doc-100", "edit-order", "Order", 100, 9); + + Object token = context.referenceFor(new OrderItem(42, 7), scope, Duration.ofHours(1)); + assertEquals(42, context.resolveReference(token, scope, "OrderItem").id()); + assertThrows(RoundTripReferenceException.class, + () -> context.resolveReference( + token, + new ReferenceDocumentScope("doc-100", "view-order", "Order", 100, 9), + "OrderItem")); + System.out.println("PASS Java security foundations: context-bound reference is opaque and purpose-bound"); } } diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java deleted file mode 100644 index 90828277..00000000 --- a/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java +++ /dev/null @@ -1,13 +0,0 @@ -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) {} diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java deleted file mode 100644 index 9ea944e2..00000000 --- a/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java +++ /dev/null @@ -1,10 +0,0 @@ -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); -} diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java deleted file mode 100644 index d5999197..00000000 --- a/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java +++ /dev/null @@ -1,15 +0,0 @@ -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; - } -} diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java deleted file mode 100644 index 77b05690..00000000 --- a/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java +++ /dev/null @@ -1,96 +0,0 @@ -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); - } -} diff --git a/teaql-core/src/main/java/io/teaql/core/UserContext.java b/teaql-core/src/main/java/io/teaql/core/UserContext.java index 70d11d84..b5751454 100644 --- a/teaql-core/src/main/java/io/teaql/core/UserContext.java +++ b/teaql-core/src/main/java/io/teaql/core/UserContext.java @@ -12,12 +12,12 @@ import io.teaql.core.businessid.BusinessIdSchemaContributor; import io.teaql.core.businessid.BusinessIdService; import io.teaql.core.reference.ResolvedRoundTripReference; -import io.teaql.core.reference.RoundTripReference; +import io.teaql.core.reference.ReferenceDocumentScope; import io.teaql.core.reference.RoundTripReferenceCodec; import io.teaql.core.reference.RoundTripReferenceErrorCode; import io.teaql.core.reference.RoundTripReferenceException; +import io.teaql.core.reference.TrustedReferencePrincipal; import java.time.Duration; -import java.time.Instant; public interface UserContext extends OptNullBasicTypeFromObjectGetter { @@ -26,50 +26,7 @@ public interface UserContext extends OptNullBasicTypeFromObjectGetter { 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; - } + String TEAQL_TRUSTED_REFERENCE_PRINCIPAL = TrustedReferencePrincipal.class.getName(); default void beginFixEvidence() { putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, new java.util.ArrayList()); @@ -294,13 +251,33 @@ default RoundTripReferenceCodec roundTripReferences() { return codec; } - default RoundTripReference referenceFor(Entity entity) { - return roundTripReferences().serialize(this, entity); + default UserContext withTrustedReferencePrincipal(TrustedReferencePrincipal principal) { + if (principal == null) throw new IllegalArgumentException("principal must not be null"); + putAttribute(TEAQL_TRUSTED_REFERENCE_PRINCIPAL, principal); + return this; + } + + default TrustedReferencePrincipal trustedReferencePrincipal() { + TrustedReferencePrincipal principal = getAttribute( + TEAQL_TRUSTED_REFERENCE_PRINCIPAL, TrustedReferencePrincipal.class); + if (principal == null) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.CONTEXT_BINDING_REQUIRED, + "Trusted reference principal is not installed in this UserContext"); + } + return principal; + } + + default Object referenceFor( + Entity entity, ReferenceDocumentScope scope, Duration lifetime) { + return roundTripReferences().serialize( + this, trustedReferencePrincipal(), scope, entity, lifetime); } default ResolvedRoundTripReference resolveReference( - String reference, String expectedEntityType) { - return roundTripReferences().deserialize(this, reference, expectedEntityType); + Object reference, ReferenceDocumentScope scope, String expectedEntityType) { + return roundTripReferences().deserialize( + this, trustedReferencePrincipal(), scope, reference, expectedEntityType); } /** diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ExternalEntityReference.java b/teaql-core/src/main/java/io/teaql/core/reference/ExternalEntityReference.java new file mode 100644 index 00000000..805f0215 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/ExternalEntityReference.java @@ -0,0 +1,16 @@ +package io.teaql.core.reference; + +/** Strongly typed internal representation; Web adapters use {@link ReferenceWireCodec}. */ +public sealed interface ExternalEntityReference { + record Governed(String token) implements ExternalEntityReference { + public Governed { + if (token == null || token.isBlank()) throw new IllegalArgumentException("token required"); + } + } + + record Raw(long id, long version) implements ExternalEntityReference { + public Raw { + if (id <= 0 || version < 0) throw new IllegalArgumentException("invalid raw identity"); + } + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java b/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java index da97e010..0224695f 100644 --- a/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java +++ b/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java @@ -1,16 +1,19 @@ package io.teaql.core.reference; +import java.nio.charset.StandardCharsets; + /** Runtime identity carried inside a protected round-trip reference. */ public record InternalEntityIdentity(String entityType, long id, long version) { public InternalEntityIdentity { - if (entityType == null || entityType.isBlank()) { - throw new IllegalArgumentException("entityType must not be blank"); + if (entityType == null || entityType.isBlank() || !entityType.equals(entityType.trim()) + || entityType.getBytes(StandardCharsets.UTF_8).length > 4096) { + throw new IllegalArgumentException("entityType must be non-blank, trimmed, and bounded"); } if (id <= 0) { throw new IllegalArgumentException("id must be positive"); } - if (version <= 0) { - throw new IllegalArgumentException("version must be positive"); + if (version < 0) { + throw new IllegalArgumentException("version must not be negative"); } } } diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ReferenceDocumentScope.java b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceDocumentScope.java new file mode 100644 index 00000000..45987ace --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceDocumentScope.java @@ -0,0 +1,28 @@ +package io.teaql.core.reference; + +import java.nio.charset.StandardCharsets; + +/** Purpose and Aggregate boundary for one UI/API document round trip. */ +public record ReferenceDocumentScope( + String documentId, + String purpose, + String aggregateType, + long aggregateId, + long aggregateRevision) { + public ReferenceDocumentScope { + requireText(documentId, "documentId"); + requireText(purpose, "purpose"); + requireText(aggregateType, "aggregateType"); + if (aggregateId <= 0) throw new IllegalArgumentException("aggregateId must be positive"); + if (aggregateRevision < 0) { + throw new IllegalArgumentException("aggregateRevision must not be negative"); + } + } + + private static void requireText(String value, String name) { + if (value == null || value.isBlank() || !value.equals(value.trim()) + || value.getBytes(StandardCharsets.UTF_8).length > 4096) { + throw new IllegalArgumentException(name + " must be non-blank and trimmed"); + } + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ReferenceMode.java b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceMode.java new file mode 100644 index 00000000..c9429669 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceMode.java @@ -0,0 +1,6 @@ +package io.teaql.core.reference; + +public enum ReferenceMode { + GOVERNED, + RAW +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ReferenceWireCodec.java b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceWireCodec.java new file mode 100644 index 00000000..886a887c --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceWireCodec.java @@ -0,0 +1,49 @@ +package io.teaql.core.reference; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Map; + +/** Exact boundary wire shapes: a governed token string or raw diagnostic {id, version}. */ +public final class ReferenceWireCodec { + private ReferenceWireCodec() {} + + public static Object serialize(ExternalEntityReference reference) { + if (reference instanceof ExternalEntityReference.Governed governed) { + return governed.token(); + } + ExternalEntityReference.Raw raw = (ExternalEntityReference.Raw) reference; + return Map.of("id", raw.id(), "version", raw.version()); + } + + public static ExternalEntityReference deserialize(Object wire, ReferenceMode mode) { + if (mode == ReferenceMode.GOVERNED && wire instanceof String token && token.startsWith("tqr1.")) { + return new ExternalEntityReference.Governed(token); + } + if (mode == ReferenceMode.RAW && wire instanceof Map map && map.size() == 2) { + Object id = map.get("id"); + Object version = map.get("version"); + Long exactId = exactLong(id); + Long exactVersion = exactLong(version); + if (exactId != null && exactVersion != null) { + return new ExternalEntityReference.Raw(exactId, exactVersion); + } + } + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.INVALID_REFERENCE, "ROUND_TRIP_REFERENCE_INVALID"); + } + + private static Long exactLong(Object value) { + try { + if (value instanceof Byte || value instanceof Short + || value instanceof Integer || value instanceof Long) { + return ((Number) value).longValue(); + } + if (value instanceof BigInteger integer) return integer.longValueExact(); + if (value instanceof BigDecimal decimal) return decimal.longValueExact(); + return null; + } catch (ArithmeticException ignored) { + return null; + } + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java b/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java index 7c56e436..a9c202ff 100644 --- a/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java +++ b/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java @@ -1,8 +1,15 @@ package io.teaql.core.reference; -/** Decoded identity. This value is not an authorization decision. */ -public record ResolvedRoundTripReference(String entityType, long id, long version) { - public InternalEntityIdentity identity() { - return new InternalEntityIdentity(entityType, id, version); - } +import java.time.Instant; + +/** Decoded identity after scope binding and current authorization have both succeeded. */ +public record ResolvedRoundTripReference( + InternalEntityIdentity identity, + ReferenceMode mode, + String keyId, + Instant issuedAt, + Instant expiresAt) { + public String entityType() { return identity.entityType(); } + public long id() { return identity.id(); } + public long version() { return identity.version(); } } diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReference.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReference.java deleted file mode 100644 index 19dfab62..00000000 --- a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReference.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.teaql.core.reference; - -/** Opaque wire value issued by a TeaQL runtime and returned to that runtime. */ -public record RoundTripReference(String value) { - public RoundTripReference { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("reference must not be blank"); - } - } - - @Override - public String toString() { - return value; - } -} diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java index b40c6d22..55a5695b 100644 --- a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java @@ -2,6 +2,7 @@ import io.teaql.core.Entity; import io.teaql.core.UserContext; +import java.time.Duration; /** * Framework-neutral serialization boundary. Spring, JAX-RS and other Web @@ -15,22 +16,38 @@ public RoundTripReferenceCodec(RoundTripReferenceProvider provider) { this.provider = provider; } - public RoundTripReference serialize(UserContext context, Entity entity) { + public Object serialize( + UserContext context, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + Entity entity, + Duration lifetime) { if (entity == null || entity.getId() == null || entity.getVersion() == null) { throw new RoundTripReferenceException( RoundTripReferenceErrorCode.INVALID_ENTITY_IDENTITY, "A round-trip reference requires a persisted entity with id and version"); } - return provider.issue( + return ReferenceWireCodec.serialize(provider.issue( context, - new InternalEntityIdentity(entity.typeName(), entity.getId(), entity.getVersion())); + principal, + scope, + new InternalEntityIdentity(entity.typeName(), entity.getId(), entity.getVersion()), + lifetime)); } public ResolvedRoundTripReference deserialize( - UserContext context, String reference, String expectedEntityType) { + UserContext context, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + Object reference, + String expectedEntityType) { return provider.resolve( context, - new RoundTripReference(reference), + principal, + scope, + ReferenceWireCodec.deserialize(reference, provider.mode()), expectedEntityType); } + + public ReferenceMode mode() { return provider.mode(); } } diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java index 14689991..8d5f4d62 100644 --- a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java @@ -4,6 +4,10 @@ public enum RoundTripReferenceErrorCode { PROVIDER_NOT_CONFIGURED, CONTEXT_BINDING_REQUIRED, INVALID_REFERENCE, + EXPIRED, + SCOPE_MISMATCH, + AUTHORIZATION_REQUIRED, + CONFIGURATION_INVALID, CONTEXT_MISMATCH, TYPE_MISMATCH, INVALID_ENTITY_IDENTITY diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java index 9bb98645..df243a3c 100644 --- a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java @@ -1,14 +1,23 @@ package io.teaql.core.reference; import io.teaql.core.UserContext; +import java.time.Duration; /** Provider SPI; implementations must authenticate, not merely obscure, identities. */ public interface RoundTripReferenceProvider { - RoundTripReference issue( - UserContext context, InternalEntityIdentity identity); + ExternalEntityReference issue( + UserContext context, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + InternalEntityIdentity identity, + Duration lifetime); ResolvedRoundTripReference resolve( UserContext context, - RoundTripReference reference, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + ExternalEntityReference reference, String expectedEntityType); + + ReferenceMode mode(); } diff --git a/teaql-core/src/main/java/io/teaql/core/reference/TrustedReferencePrincipal.java b/teaql-core/src/main/java/io/teaql/core/reference/TrustedReferencePrincipal.java new file mode 100644 index 00000000..a1295504 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/TrustedReferencePrincipal.java @@ -0,0 +1,24 @@ +package io.teaql.core.reference; + +import java.nio.charset.StandardCharsets; + +/** Trusted server-side identity used to bind an external reference to its current actor. */ +public record TrustedReferencePrincipal( + String authenticationRealm, + String subject, + String domainRootType, + long domainRootId) { + public TrustedReferencePrincipal { + requireText(authenticationRealm, "authenticationRealm"); + requireText(subject, "subject"); + requireText(domainRootType, "domainRootType"); + if (domainRootId <= 0) throw new IllegalArgumentException("domainRootId must be positive"); + } + + private static void requireText(String value, String name) { + if (value == null || value.isBlank() || !value.equals(value.trim()) + || value.getBytes(StandardCharsets.UTF_8).length > 4096) { + throw new IllegalArgumentException(name + " must be non-blank and trimmed"); + } + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java index 45e56b29..1abff135 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java @@ -1,140 +1,387 @@ package io.teaql.runtime.reference; import io.teaql.core.UserContext; -import io.teaql.core.reference.*; -import java.io.*; +import io.teaql.core.reference.ExternalEntityReference; +import io.teaql.core.reference.InternalEntityIdentity; +import io.teaql.core.reference.ReferenceDocumentScope; +import io.teaql.core.reference.ReferenceMode; +import io.teaql.core.reference.ResolvedRoundTripReference; +import io.teaql.core.reference.RoundTripReferenceErrorCode; +import io.teaql.core.reference.RoundTripReferenceException; +import io.teaql.core.reference.RoundTripReferenceProvider; +import io.teaql.core.reference.TrustedReferencePrincipal; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; +import java.security.MessageDigest; import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; import java.util.Base64; -import javax.crypto.AEADBadTagException; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; import javax.crypto.Cipher; +import javax.crypto.Mac; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; -/** AES-256-GCM round-trip references with context-specific key selection. */ +/** Context-bound AES-256-GCM implementation shared with TeaQL Rust's tqr1 format. */ public final class AeadRoundTripReferenceProvider implements RoundTripReferenceProvider { private static final String PREFIX = "tqr1"; - private static final byte PAYLOAD_VERSION = 1; - private static final int NONCE_LENGTH = 12; - private static final int TAG_BITS = 128; + private static final byte[] HKDF_SALT = "teaql.round-trip-reference.v1".getBytes(StandardCharsets.US_ASCII); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int MAX_TEXT_BYTES = 4096; + private static final int MAX_TOKEN_LENGTH = 24000; + private final DeploymentProfile profile; + private final ReferenceMode mode; + private final String service; + private final String environment; private final RoundTripReferenceKeyProvider keys; - private final SecureRandom random; + private final RoundTripReferenceAuthorizationPolicy authorization; + private Clock clock; + private Supplier nonceSource; - public AeadRoundTripReferenceProvider(RoundTripReferenceKeyProvider keys) { - this(keys, new SecureRandom()); + private AeadRoundTripReferenceProvider( + DeploymentProfile profile, + ReferenceMode mode, + String service, + String environment, + RoundTripReferenceKeyProvider keys, + RoundTripReferenceAuthorizationPolicy authorization) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.mode = Objects.requireNonNull(mode, "mode"); + this.service = requireText(service); + this.environment = requireText(environment); + this.keys = Objects.requireNonNull(keys, "keys"); + this.authorization = Objects.requireNonNull(authorization, "authorization"); + requireKey(keys.currentKey()); + this.clock = Clock.systemUTC(); + this.nonceSource = () -> { + byte[] nonce = new byte[12]; + RANDOM.nextBytes(nonce); + return nonce; + }; } - AeadRoundTripReferenceProvider(RoundTripReferenceKeyProvider keys, SecureRandom random) { - if (keys == null || random == null) throw new IllegalArgumentException("keys and random are required"); - this.keys = keys; - this.random = random; + public static AeadRoundTripReferenceProvider governed( + DeploymentProfile profile, + String service, + String environment, + RoundTripReferenceKeyProvider keys, + RoundTripReferenceAuthorizationPolicy authorization) { + return create(profile, service, environment, keys, authorization, null); } + public static AeadRoundTripReferenceProvider fromProcessEnvironment( + DeploymentProfile profile, + String service, + String environment, + RoundTripReferenceKeyProvider keys, + RoundTripReferenceAuthorizationPolicy authorization) { + AeadRoundTripReferenceProvider provider = create( + profile, service, environment, keys, authorization, + System.getenv(RoundTripReferenceConfiguration.RAW_ID_ENV)); + provider.startupNotice().ifPresent(notice -> System.err.printf( + "%s: %s; deployment_profile=%s; %s=%s%n", + notice.level(), notice.message(), notice.deploymentProfile(), + notice.telemetryKey(), notice.telemetryValue())); + return provider; + } + + static AeadRoundTripReferenceProvider create( + DeploymentProfile profile, + String service, + String environment, + RoundTripReferenceKeyProvider keys, + RoundTripReferenceAuthorizationPolicy authorization, + String acknowledgement) { + boolean raw = RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT.equals(acknowledgement); + if (raw && profile == DeploymentProfile.PRODUCTION) { + throw configuration("raw entity references cannot be enabled in production", null); + } + return new AeadRoundTripReferenceProvider( + profile, raw ? ReferenceMode.RAW : ReferenceMode.GOVERNED, + service, environment, keys, authorization); + } + + AeadRoundTripReferenceProvider withClock(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + return this; + } + + AeadRoundTripReferenceProvider withNonceSource(Supplier nonceSource) { + this.nonceSource = Objects.requireNonNull(nonceSource, "nonceSource"); + return this; + } + + public Optional startupNotice() { + if (mode != ReferenceMode.RAW) return Optional.empty(); + return Optional.of(new ReferenceStartupNotice( + "ERROR", + "TeaQL raw internal entity reference mode is active for local diagnostics", + profile.label(), + "teaql.reference.mode", + "raw", + Map.of("TeaQL-Reference-Mode", "raw-internal-id", "Cache-Control", "no-store"))); + } + + @Override + public ReferenceMode mode() { return mode; } + @Override - public RoundTripReference issue(UserContext context, InternalEntityIdentity identity) { - RoundTripReferenceKey key = requireKey(keys.currentKey(context)); - byte[] nonce = new byte[NONCE_LENGTH]; - random.nextBytes(nonce); + public ExternalEntityReference issue( + UserContext context, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + InternalEntityIdentity identity, + Duration lifetime) { + validate(principal, scope, identity); + if (lifetime == null || lifetime.isZero() || lifetime.isNegative()) throw invalid(null); + authorization.authorize(context, principal, scope, identity); + if (mode == ReferenceMode.RAW) { + return new ExternalEntityReference.Raw(identity.id(), identity.version()); + } try { - byte[] encrypted = cipher(Cipher.ENCRYPT_MODE, key, nonce) - .doFinal(encode(identity)); - byte[] envelope = new byte[nonce.length + encrypted.length]; - System.arraycopy(nonce, 0, envelope, 0, nonce.length); - System.arraycopy(encrypted, 0, envelope, nonce.length, encrypted.length); - return new RoundTripReference(PREFIX + "." + key.keyId() + "." - + Base64.getUrlEncoder().withoutPadding().encodeToString(envelope)); - } catch (GeneralSecurityException | IOException error) { - throw invalid("Unable to issue round-trip reference", error); + RoundTripReferenceKey key = requireKey(keys.currentKey()); + Instant issuedAt = clock.instant(); + Instant expiresAt = issuedAt.plus(lifetime); + byte[] fingerprint = actorFingerprint(key, principal); + byte[] plaintext = encodePayload(identity, scope, fingerprint, issuedAt, expiresAt); + byte[] nonce = nonceSource.get(); + if (nonce == null || nonce.length != 12) throw configuration("nonce must contain 12 bytes", null); + Cipher cipher = cipher(Cipher.ENCRYPT_MODE, key, nonce, scope.purpose()); + byte[] encrypted = cipher.doFinal(plaintext); + byte[] envelope = ByteBuffer.allocate(nonce.length + encrypted.length) + .put(nonce).put(encrypted).array(); + return new ExternalEntityReference.Governed( + PREFIX + "." + key.keyId() + "." + + Base64.getUrlEncoder().withoutPadding().encodeToString(envelope)); + } catch (RoundTripReferenceException error) { + throw error; + } catch (Exception error) { + throw invalid(error); } } @Override public ResolvedRoundTripReference resolve( - UserContext context, RoundTripReference reference, String expectedEntityType) { - String[] parts = reference.value().split("\\.", -1); - if (parts.length != 3 || !PREFIX.equals(parts[0])) { - throw invalid("Invalid round-trip reference", null); - } - RoundTripReferenceKey key = keys.keyById(context, parts[1]); - if (key == null) { - throw invalid("Round-trip reference is unavailable", null); + UserContext context, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + ExternalEntityReference reference, + String expectedEntityType) { + requireText(expectedEntityType); + ResolvedRoundTripReference resolved; + if (mode == ReferenceMode.RAW && reference instanceof ExternalEntityReference.Raw raw) { + resolved = new ResolvedRoundTripReference( + new InternalEntityIdentity(expectedEntityType, raw.id(), raw.version()), + ReferenceMode.RAW, null, null, null); + } else if (mode == ReferenceMode.GOVERNED + && reference instanceof ExternalEntityReference.Governed governed) { + resolved = resolveGoverned(principal, scope, governed.token(), expectedEntityType); + } else { + throw invalid(null); } + authorization.authorize(context, principal, scope, resolved.identity()); + return resolved; + } + + private ResolvedRoundTripReference resolveGoverned( + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + String token, + String expectedEntityType) { try { + String[] parts = token.split("\\.", -1); + if (token.length() > MAX_TOKEN_LENGTH || parts.length != 3 || !PREFIX.equals(parts[0])) { + throw invalid(null); + } + RoundTripReferenceKey key = keys.keyById(parts[1]); + if (key == null) throw invalid(null); byte[] envelope = Base64.getUrlDecoder().decode(parts[2]); - if (envelope.length <= NONCE_LENGTH + 16) throw invalid("Invalid round-trip reference", null); - byte[] nonce = java.util.Arrays.copyOfRange(envelope, 0, NONCE_LENGTH); - byte[] encrypted = java.util.Arrays.copyOfRange(envelope, NONCE_LENGTH, envelope.length); - InternalEntityIdentity identity = decode( - cipher(Cipher.DECRYPT_MODE, key, nonce).doFinal(encrypted)); - if (expectedEntityType == null || !identity.entityType().equals(expectedEntityType)) { - throw new RoundTripReferenceException( - RoundTripReferenceErrorCode.TYPE_MISMATCH, - "Round-trip reference does not match the expected entity type"); + if (envelope.length < 28) throw invalid(null); + byte[] nonce = Arrays.copyOfRange(envelope, 0, 12); + byte[] encrypted = Arrays.copyOfRange(envelope, 12, envelope.length); + DecodedPayload payload = decodePayload( + cipher(Cipher.DECRYPT_MODE, key, nonce, scope.purpose()).doFinal(encrypted)); + Instant now = clock.instant(); + if (!payload.expiresAt().isAfter(now)) { + throw new RoundTripReferenceException(RoundTripReferenceErrorCode.EXPIRED, + "ROUND_TRIP_REFERENCE_EXPIRED"); + } + if (payload.issuedAt().isAfter(now.plusSeconds(60))) throw invalid(null); + byte[] expectedActor = actorFingerprint(key, principal); + if (!MessageDigest.isEqual(payload.actorFingerprint(), expectedActor) + || !payload.documentId().equals(scope.documentId()) + || !payload.aggregateType().equals(scope.aggregateType()) + || payload.aggregateId() != scope.aggregateId() + || payload.aggregateRevision() != scope.aggregateRevision() + || !payload.identity().entityType().equals(expectedEntityType)) { + throw new RoundTripReferenceException(RoundTripReferenceErrorCode.SCOPE_MISMATCH, + "ROUND_TRIP_REFERENCE_SCOPE_MISMATCH"); } return new ResolvedRoundTripReference( - identity.entityType(), identity.id(), identity.version()); + payload.identity(), ReferenceMode.GOVERNED, key.keyId(), + payload.issuedAt(), payload.expiresAt()); } catch (RoundTripReferenceException error) { throw error; - } catch (AEADBadTagException error) { - throw new RoundTripReferenceException( - RoundTripReferenceErrorCode.CONTEXT_MISMATCH, - "Round-trip reference is invalid for the current runtime context"); - } catch (GeneralSecurityException | IOException | IllegalArgumentException error) { - throw invalid("Invalid round-trip reference", error); + } catch (Exception error) { + throw invalid(error); } } - private static Cipher cipher(int mode, RoundTripReferenceKey key, byte[] nonce) + private Cipher cipher(int mode, RoundTripReferenceKey key, byte[] nonce, String purpose) throws GeneralSecurityException { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(mode, new SecretKeySpec(key.keyBytes(), "AES"), new GCMParameterSpec(TAG_BITS, nonce)); - cipher.updateAAD((PREFIX + "." + key.keyId()).getBytes(StandardCharsets.US_ASCII)); + cipher.init(mode, new SecretKeySpec(deriveKey(key, "entity-reference-encryption"), "AES"), + new GCMParameterSpec(128, nonce)); + cipher.updateAAD(encodeTexts(PREFIX, key.keyId(), service, environment, purpose)); return cipher; } - private static byte[] encode(InternalEntityIdentity identity) throws IOException { - byte[] entityType = identity.entityType().getBytes(StandardCharsets.UTF_8); - if (entityType.length == 0 || entityType.length > 65535) { - throw new IOException("entity type length is invalid"); - } - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - try (DataOutputStream output = new DataOutputStream(bytes)) { - output.writeByte(PAYLOAD_VERSION); - output.writeShort(entityType.length); - output.write(entityType); - output.writeLong(identity.id()); - output.writeLong(identity.version()); + private byte[] actorFingerprint( + RoundTripReferenceKey key, TrustedReferencePrincipal principal) + throws GeneralSecurityException { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(deriveKey(key, "actor-fingerprint"), "HmacSHA256")); + ByteArrayOutputStream canonical = new ByteArrayOutputStream(); + writeText(canonical, principal.authenticationRealm()); + writeText(canonical, principal.subject()); + writeText(canonical, principal.domainRootType()); + canonical.writeBytes(ByteBuffer.allocate(8).putLong(principal.domainRootId()).array()); + return mac.doFinal(canonical.toByteArray()); + } + + private byte[] deriveKey(RoundTripReferenceKey key, String label) + throws GeneralSecurityException { + byte[] info = encodeTexts(label, service, environment, key.keyId()); + Mac extract = Mac.getInstance("HmacSHA256"); + extract.init(new SecretKeySpec(HKDF_SALT, "HmacSHA256")); + byte[] pseudoRandomKey = extract.doFinal(key.keyBytes()); + Mac expand = Mac.getInstance("HmacSHA256"); + expand.init(new SecretKeySpec(pseudoRandomKey, "HmacSHA256")); + expand.update(info); + expand.update((byte) 1); + return expand.doFinal(); + } + + private static byte[] encodePayload( + InternalEntityIdentity identity, + ReferenceDocumentScope scope, + byte[] fingerprint, + Instant issuedAt, + Instant expiresAt) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + output.writeBytes(fingerprint); + writeText(output, identity.entityType()); + output.writeBytes(ByteBuffer.allocate(16).putLong(identity.id()).putLong(identity.version()).array()); + writeText(output, scope.documentId()); + writeText(output, scope.aggregateType()); + output.writeBytes(ByteBuffer.allocate(32) + .putLong(scope.aggregateId()).putLong(scope.aggregateRevision()) + .putLong(issuedAt.getEpochSecond()).putLong(expiresAt.getEpochSecond()).array()); + return output.toByteArray(); + } + + private static DecodedPayload decodePayload(byte[] bytes) { + try { + ByteBuffer input = ByteBuffer.wrap(bytes); + byte[] fingerprint = new byte[32]; + input.get(fingerprint); + String entityType = readText(input); + long id = input.getLong(); + long version = input.getLong(); + String documentId = readText(input); + String aggregateType = readText(input); + long aggregateId = input.getLong(); + long revision = input.getLong(); + Instant issuedAt = Instant.ofEpochSecond(input.getLong()); + Instant expiresAt = Instant.ofEpochSecond(input.getLong()); + if (input.hasRemaining()) throw invalid(null); + return new DecodedPayload( + fingerprint, new InternalEntityIdentity(entityType, id, version), documentId, + aggregateType, aggregateId, revision, issuedAt, expiresAt); + } catch (RoundTripReferenceException error) { + throw error; + } catch (Exception error) { + throw invalid(error); } - return bytes.toByteArray(); - } - - private static InternalEntityIdentity decode(byte[] payload) throws IOException { - try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) { - if (input.readUnsignedByte() != PAYLOAD_VERSION) throw new IOException("unsupported payload version"); - int length = input.readUnsignedShort(); - byte[] entityType = input.readNBytes(length); - if (entityType.length != length) throw new EOFException("truncated entity type"); - long id = input.readLong(); - long version = input.readLong(); - if (input.read() != -1) throw new IOException("trailing payload data"); - return new InternalEntityIdentity(new String(entityType, StandardCharsets.UTF_8), id, version); + } + + private static byte[] encodeTexts(String... values) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + for (String value : values) writeText(output, value); + return output.toByteArray(); + } + + private static void writeText(ByteArrayOutputStream output, String value) { + byte[] bytes = requireText(value).getBytes(StandardCharsets.UTF_8); + if (bytes.length > 0xffff) throw invalid(null); + 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(null); + int length = Short.toUnsignedInt(input.getShort()); + if (input.remaining() < length) throw invalid(null); + byte[] bytes = new byte[length]; + input.get(bytes); + return requireText(new String(bytes, StandardCharsets.UTF_8)); + } + + private static String requireText(String value) { + if (value == null || value.isEmpty() || !value.equals(value.trim()) + || value.getBytes(StandardCharsets.UTF_8).length > MAX_TEXT_BYTES) { + throw invalid(null); } + return value; } private static RoundTripReferenceKey requireKey(RoundTripReferenceKey key) { - if (key == null) { - throw new RoundTripReferenceException( - RoundTripReferenceErrorCode.CONTEXT_BINDING_REQUIRED, - "Runtime customization did not provide reference binding material"); - } + if (key == null) throw configuration("current reference key is missing", null); return key; } - private static RoundTripReferenceException invalid(String message, Throwable cause) { + private static void validate( + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + InternalEntityIdentity identity) { + Objects.requireNonNull(principal, "principal"); + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(identity, "identity"); + } + + private static RoundTripReferenceException invalid(Throwable cause) { + return cause == null + ? new RoundTripReferenceException( + RoundTripReferenceErrorCode.INVALID_REFERENCE, "ROUND_TRIP_REFERENCE_INVALID") + : new RoundTripReferenceException( + RoundTripReferenceErrorCode.INVALID_REFERENCE, + "ROUND_TRIP_REFERENCE_INVALID", cause); + } + + private static RoundTripReferenceException configuration(String message, Throwable cause) { return cause == null - ? new RoundTripReferenceException(RoundTripReferenceErrorCode.INVALID_REFERENCE, message) - : new RoundTripReferenceException(RoundTripReferenceErrorCode.INVALID_REFERENCE, message, cause); + ? new RoundTripReferenceException(RoundTripReferenceErrorCode.CONFIGURATION_INVALID, message) + : new RoundTripReferenceException( + RoundTripReferenceErrorCode.CONFIGURATION_INVALID, message, cause); } + + private record DecodedPayload( + byte[] actorFingerprint, + InternalEntityIdentity identity, + String documentId, + String aggregateType, + long aggregateId, + long aggregateRevision, + Instant issuedAt, + Instant expiresAt) {} } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/ContextReferenceBindingProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/ContextReferenceBindingProvider.java deleted file mode 100644 index e652bfe0..00000000 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/ContextReferenceBindingProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.teaql.runtime.reference; - -import io.teaql.core.UserContext; - -/** Produces opaque, trusted binding bytes from the complete runtime context. */ -@FunctionalInterface -public interface ContextReferenceBindingProvider { - byte[] bindingFor(UserContext context); -} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/DeploymentProfile.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/DeploymentProfile.java new file mode 100644 index 00000000..3a426ae2 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/DeploymentProfile.java @@ -0,0 +1,11 @@ +package io.teaql.runtime.reference; + +public enum DeploymentProfile { + DEVELOPMENT("development"), TEST("test"), PRODUCTION("production"); + + private final String label; + + DeploymentProfile(String label) { this.label = label; } + + public String label() { return label; } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/DerivedRoundTripReferenceKeyProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/DerivedRoundTripReferenceKeyProvider.java deleted file mode 100644 index e5fffbb2..00000000 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/DerivedRoundTripReferenceKeyProvider.java +++ /dev/null @@ -1,100 +0,0 @@ -package io.teaql.runtime.reference; - -import io.teaql.core.UserContext; -import io.teaql.core.reference.RoundTripReferenceErrorCode; -import io.teaql.core.reference.RoundTripReferenceException; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -/** Derives context-specific AES keys without assigning meaning to the context binding. */ -public final class DerivedRoundTripReferenceKeyProvider - implements RoundTripReferenceKeyProvider { - private static final byte[] SALT = "teaql-round-trip-reference-v1" - .getBytes(StandardCharsets.UTF_8); - - private final RoundTripReferenceMasterKeyRing masterKeys; - private final ContextReferenceBindingProvider bindings; - private final byte[] namespace; - - public DerivedRoundTripReferenceKeyProvider( - RoundTripReferenceMasterKeyRing masterKeys, - ContextReferenceBindingProvider bindings, - String namespace) { - if (masterKeys == null || bindings == null) { - throw new IllegalArgumentException("masterKeys and bindings are required"); - } - if (namespace == null || namespace.isBlank()) { - throw new IllegalArgumentException("namespace must not be blank"); - } - this.masterKeys = masterKeys; - this.bindings = bindings; - this.namespace = namespace.getBytes(StandardCharsets.UTF_8); - } - - @Override - public RoundTripReferenceKey currentKey(UserContext context) { - return derive(context, masterKeys.currentKey()); - } - - @Override - public RoundTripReferenceKey keyById(UserContext context, String keyId) { - RoundTripReferenceKey master = masterKeys.keyById(keyId); - return master == null ? null : derive(context, master); - } - - private RoundTripReferenceKey derive(UserContext context, RoundTripReferenceKey master) { - byte[] binding = bindings.bindingFor(context); - if (binding == null || binding.length == 0) { - throw new RoundTripReferenceException( - RoundTripReferenceErrorCode.CONTEXT_BINDING_REQUIRED, - "Runtime customization did not provide a round-trip reference binding"); - } - try { - byte[] pseudoRandomKey = hmac(SALT, master.keyBytes()); - ByteArrayOutputStream info = new ByteArrayOutputStream(); - info.writeBytes("teaql:tqr1".getBytes(StandardCharsets.UTF_8)); - writeLengthPrefixed(info, namespace); - writeLengthPrefixed(info, binding); - byte[] output = hkdfExpand(pseudoRandomKey, info.toByteArray(), 32); - return new RoundTripReferenceKey(master.keyId(), output); - } catch (RoundTripReferenceException error) { - throw error; - } catch (Exception error) { - throw new RoundTripReferenceException( - RoundTripReferenceErrorCode.INVALID_REFERENCE, - "Unable to derive a round-trip reference key", error); - } - } - - private static byte[] hmac(byte[] key, byte[] input) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(key, "HmacSHA256")); - return mac.doFinal(input); - } - - private static byte[] hkdfExpand(byte[] key, byte[] info, int length) throws Exception { - ByteArrayOutputStream result = new ByteArrayOutputStream(length); - byte[] previous = new byte[0]; - int counter = 1; - while (result.size() < length) { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(key, "HmacSHA256")); - mac.update(previous); - mac.update(info); - mac.update((byte) counter++); - previous = mac.doFinal(); - result.write(previous, 0, Math.min(previous.length, length - result.size())); - } - return result.toByteArray(); - } - - private static void writeLengthPrefixed(ByteArrayOutputStream output, byte[] value) { - output.write((value.length >>> 24) & 0xff); - output.write((value.length >>> 16) & 0xff); - output.write((value.length >>> 8) & 0xff); - output.write(value.length & 0xff); - output.writeBytes(value); - } -} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RawRoundTripReferenceProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RawRoundTripReferenceProvider.java deleted file mode 100644 index f9caa91f..00000000 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RawRoundTripReferenceProvider.java +++ /dev/null @@ -1,37 +0,0 @@ -package io.teaql.runtime.reference; - -import io.teaql.core.UserContext; -import io.teaql.core.reference.*; - -/** Development/test-only readable representation. Never install in production. */ -public final class RawRoundTripReferenceProvider implements RoundTripReferenceProvider { - @Override - public RoundTripReference issue(UserContext context, InternalEntityIdentity identity) { - if (identity.entityType().contains(".")) { - throw new RoundTripReferenceException( - RoundTripReferenceErrorCode.INVALID_ENTITY_IDENTITY, - "Raw diagnostic entity type must not contain '.'"); - } - return new RoundTripReference("raw1." + identity.entityType() + "." - + identity.id() + "." + identity.version()); - } - - @Override - public ResolvedRoundTripReference resolve( - UserContext context, RoundTripReference reference, String expectedEntityType) { - String[] parts = reference.value().split("\\.", -1); - try { - if (parts.length != 4 || !"raw1".equals(parts[0]) || !parts[1].equals(expectedEntityType)) { - throw new IllegalArgumentException("shape/type mismatch"); - } - InternalEntityIdentity identity = new InternalEntityIdentity( - parts[1], Long.parseLong(parts[2]), Long.parseLong(parts[3])); - return new ResolvedRoundTripReference( - identity.entityType(), identity.id(), identity.version()); - } catch (RuntimeException error) { - throw new RoundTripReferenceException( - RoundTripReferenceErrorCode.INVALID_REFERENCE, - "Invalid raw diagnostic round-trip reference", error); - } - } -} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/ReferenceStartupNotice.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/ReferenceStartupNotice.java new file mode 100644 index 00000000..39b922d8 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/ReferenceStartupNotice.java @@ -0,0 +1,11 @@ +package io.teaql.runtime.reference; + +import java.util.Map; + +public record ReferenceStartupNotice( + String level, + String message, + String deploymentProfile, + String telemetryKey, + String telemetryValue, + Map responseHeaders) {} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceAuthorizationPolicy.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceAuthorizationPolicy.java new file mode 100644 index 00000000..0727fa9b --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceAuthorizationPolicy.java @@ -0,0 +1,16 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.UserContext; +import io.teaql.core.reference.InternalEntityIdentity; +import io.teaql.core.reference.ReferenceDocumentScope; +import io.teaql.core.reference.TrustedReferencePrincipal; + +/** Re-evaluated at issue and consume time; a valid token is never an authorization grant. */ +@FunctionalInterface +public interface RoundTripReferenceAuthorizationPolicy { + void authorize( + UserContext context, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + InternalEntityIdentity identity); +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java index 2543e689..3d594107 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java @@ -1,7 +1,5 @@ package io.teaql.runtime.reference; -import io.teaql.core.reference.RoundTripReferenceProvider; - public final class RoundTripReferenceConfiguration { public static final String RAW_ID_ENV = "TEAQL_UNSAFE_EXPOSE_RAW_ENTITY_IDS"; public static final String RAW_ID_ACKNOWLEDGEMENT = @@ -9,33 +7,4 @@ public final class RoundTripReferenceConfiguration { private RoundTripReferenceConfiguration() {} - public enum Mode { GOVERNED, RAW_DIAGNOSTIC } - - public static Mode mode(String deploymentProfile, String acknowledgement) { - if (!RAW_ID_ACKNOWLEDGEMENT.equals(acknowledgement)) return Mode.GOVERNED; - if (!"development".equals(deploymentProfile) && !"test".equals(deploymentProfile)) { - throw new IllegalStateException( - RAW_ID_ENV + " cannot enable raw entity IDs outside development/test"); - } - return Mode.RAW_DIAGNOSTIC; - } - - /** Selects once at runtime startup; Web adapters only consume the resulting provider. */ - public static RoundTripReferenceProvider configuredProvider( - String deploymentProfile, RoundTripReferenceProvider governedProvider) { - return configuredProvider( - deploymentProfile, System.getenv(RAW_ID_ENV), governedProvider); - } - - static RoundTripReferenceProvider configuredProvider( - String deploymentProfile, - String acknowledgement, - RoundTripReferenceProvider governedProvider) { - if (governedProvider == null) { - throw new IllegalArgumentException("governedProvider must not be null"); - } - return mode(deploymentProfile, acknowledgement) == Mode.RAW_DIAGNOSTIC - ? new RawRoundTripReferenceProvider() - : governedProvider; - } } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java index 09899267..b516e6e3 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java @@ -8,8 +8,8 @@ public final class RoundTripReferenceKey { private final byte[] keyBytes; public RoundTripReferenceKey(String keyId, byte[] keyBytes) { - if (keyId == null || !keyId.matches("[A-Za-z0-9_-]{1,32}")) { - throw new IllegalArgumentException("keyId must be 1-32 URL-safe characters"); + if (keyId == null || !keyId.matches("[A-Za-z0-9_-]{1,64}")) { + throw new IllegalArgumentException("keyId must be 1-64 URL-safe characters"); } if (keyBytes == null || keyBytes.length != 32) { throw new IllegalArgumentException("AES-256 key must contain exactly 32 bytes"); diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java index 49c5fdc9..0227265e 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java @@ -1,10 +1,8 @@ package io.teaql.runtime.reference; -import io.teaql.core.UserContext; - -/** Selects the runtime's current or decode-only key using the complete context. */ +/** Supplies one current encryption key and retained decode-only rotation keys. */ public interface RoundTripReferenceKeyProvider { - RoundTripReferenceKey currentKey(UserContext context); + RoundTripReferenceKey currentKey(); - RoundTripReferenceKey keyById(UserContext context, String keyId); + RoundTripReferenceKey keyById(String keyId); } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceMasterKeyRing.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceMasterKeyRing.java deleted file mode 100644 index db7017fd..00000000 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceMasterKeyRing.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.teaql.runtime.reference; - -public interface RoundTripReferenceMasterKeyRing { - RoundTripReferenceKey currentKey(); - - RoundTripReferenceKey keyById(String keyId); -} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceMasterKeyRing.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java similarity index 57% rename from teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceMasterKeyRing.java rename to teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java index b2a59686..2f7ee7a7 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceMasterKeyRing.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java @@ -3,13 +3,12 @@ import java.util.LinkedHashMap; import java.util.Map; -/** Small in-memory key ring for configuration adapters, examples and tests. */ -public final class StaticRoundTripReferenceMasterKeyRing - implements RoundTripReferenceMasterKeyRing { +/** In-memory key ring for configuration adapters, tests, and examples. */ +public final class StaticRoundTripReferenceKeyProvider implements RoundTripReferenceKeyProvider { private final RoundTripReferenceKey current; private final Map keys; - public StaticRoundTripReferenceMasterKeyRing( + public StaticRoundTripReferenceKeyProvider( RoundTripReferenceKey current, RoundTripReferenceKey... decodeOnlyKeys) { if (current == null) throw new IllegalArgumentException("current key must not be null"); this.current = current; @@ -17,19 +16,17 @@ public StaticRoundTripReferenceMasterKeyRing( configured.put(current.keyId(), current); if (decodeOnlyKeys != null) { for (RoundTripReferenceKey key : decodeOnlyKeys) { - if (key != null) configured.put(key.keyId(), key); + if (key == null || configured.put(key.keyId(), key) != null) { + throw new IllegalArgumentException("duplicate or null reference key"); + } } } this.keys = Map.copyOf(configured); } @Override - public RoundTripReferenceKey currentKey() { - return current; - } + public RoundTripReferenceKey currentKey() { return current; } @Override - public RoundTripReferenceKey keyById(String keyId) { - return keys.get(keyId); - } + public RoundTripReferenceKey keyById(String keyId) { return keys.get(keyId); } } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java b/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java deleted file mode 100644 index c6854a1a..00000000 --- a/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java +++ /dev/null @@ -1,120 +0,0 @@ -package io.teaql.runtime.security; - -import io.teaql.core.EntityReferenceClaims; -import io.teaql.core.EntityReferenceCodec; -import io.teaql.core.EntityReferenceTokenException; -import io.teaql.core.EntityReferenceWire; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.function.Supplier; -import javax.crypto.Cipher; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; - -/** Portable AES-256-GCM implementation of TeaQL opaque entity references. */ -public final class AeadEntityReferenceCodec implements EntityReferenceCodec { - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - private final int activeKeyVersion; - private final Map keys; - private Clock clock = Clock.systemUTC(); - private Supplier nonceSource = () -> { - byte[] nonce = new byte[12]; - SECURE_RANDOM.nextBytes(nonce); - return nonce; - }; - - public AeadEntityReferenceCodec(int activeKeyVersion, Map keys) { - if (keys == null || !keys.containsKey(activeKeyVersion)) { - throw new IllegalArgumentException("Active entity reference key is missing"); - } - this.keys = new HashMap<>(); - keys.forEach((version, key) -> { - if (key == null || key.length != 32) { - throw new IllegalArgumentException("Every entity reference key must contain 32 bytes"); - } - this.keys.put(version, Arrays.copyOf(key, key.length)); - }); - this.activeKeyVersion = activeKeyVersion; - } - - public AeadEntityReferenceCodec withClock(Clock clock) { - this.clock = java.util.Objects.requireNonNull(clock); - return this; - } - - public AeadEntityReferenceCodec withNonceSource(Supplier nonceSource) { - this.nonceSource = java.util.Objects.requireNonNull(nonceSource); - return this; - } - - @Override - public String encode(String entityType, long id, long version, String purpose, Duration lifetime) { - if (entityType == null || entityType.isBlank() || id <= 0 || lifetime == null - || lifetime.isZero() || lifetime.isNegative()) throw EntityReferenceWire.invalid(); - try { - Instant now = clock.instant(); - byte[] plaintext = EntityReferenceWire.encodeClaims(new EntityReferenceClaims( - entityType, id, version, now, now.plus(lifetime), purpose == null ? "" : purpose, 0)); - byte[] nonce = nonceSource.get(); - if (nonce == null || nonce.length != 12) throw new IllegalArgumentException("AES-GCM nonce must contain 12 bytes"); - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keys.get(activeKeyVersion), "AES"), - new GCMParameterSpec(128, nonce)); - cipher.updateAAD(EntityReferenceWire.ASSOCIATED_DATA.getBytes(StandardCharsets.UTF_8)); - byte[] ciphertextAndTag = cipher.doFinal(plaintext); - byte[] envelope = ByteBuffer.allocate(4 + nonce.length + ciphertextAndTag.length) - .putInt(activeKeyVersion).put(nonce).put(ciphertextAndTag).array(); - return EntityReferenceWire.ENCRYPTED_PREFIX + EntityReferenceWire.base64Url(envelope); - } catch (EntityReferenceTokenException exception) { - throw exception; - } catch (Exception exception) { - throw new TeaqlEntityReferenceConfigurationException("Unable to encode entity reference", exception); - } - } - - @Override - public EntityReferenceClaims decode(String token, String expectedEntityType, String purpose) { - try { - if (token == null || !token.startsWith(EntityReferenceWire.ENCRYPTED_PREFIX)) throw EntityReferenceWire.invalid(); - byte[] envelope = EntityReferenceWire.base64UrlDecode(token.substring(EntityReferenceWire.ENCRYPTED_PREFIX.length())); - if (envelope.length < 4 + 12 + 16) throw EntityReferenceWire.invalid(); - ByteBuffer input = ByteBuffer.wrap(envelope); - int keyVersion = input.getInt(); - byte[] key = keys.get(keyVersion); - if (key == null) throw EntityReferenceWire.invalid(); - byte[] nonce = new byte[12]; - input.get(nonce); - byte[] ciphertextAndTag = new byte[input.remaining()]; - input.get(ciphertextAndTag); - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); - cipher.updateAAD(EntityReferenceWire.ASSOCIATED_DATA.getBytes(StandardCharsets.UTF_8)); - EntityReferenceClaims decoded = EntityReferenceWire.decodeClaims(cipher.doFinal(ciphertextAndTag)); - EntityReferenceClaims claims = new EntityReferenceClaims( - decoded.entityType(), decoded.id(), decoded.version(), decoded.issuedAt(), - decoded.expiresAt(), decoded.purpose(), keyVersion); - Instant now = clock.instant(); - if (!claims.expiresAt().isAfter(now) || claims.issuedAt().isAfter(now.plusSeconds(60)) - || !claims.entityType().equals(expectedEntityType) - || !claims.purpose().equals(purpose)) throw EntityReferenceWire.invalid(); - return claims; - } catch (EntityReferenceTokenException exception) { - throw exception; - } catch (Exception exception) { - throw EntityReferenceWire.invalid(); - } - } - - public static final class TeaqlEntityReferenceConfigurationException extends RuntimeException { - public TeaqlEntityReferenceConfigurationException(String message, Throwable cause) { - super(message, cause); - } - } -} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/reference/ContextBoundRoundTripReferenceTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/reference/ContextBoundRoundTripReferenceTest.java new file mode 100644 index 00000000..ae29d80b --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/reference/ContextBoundRoundTripReferenceTest.java @@ -0,0 +1,257 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.BaseEntity; +import io.teaql.core.UserContext; +import io.teaql.core.meta.SimpleEntityMetaFactory; +import io.teaql.core.reference.ReferenceDocumentScope; +import io.teaql.core.reference.ReferenceMode; +import io.teaql.core.reference.RoundTripReferenceErrorCode; +import io.teaql.core.reference.RoundTripReferenceException; +import io.teaql.core.reference.TrustedReferencePrincipal; +import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.TeaQLRuntime; +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 java.util.concurrent.atomic.AtomicInteger; +import org.junit.Assert; +import org.junit.Test; + +public class ContextBoundRoundTripReferenceTest { + private static final Instant NOW = Instant.parse("2026-09-27T00:00:00Z"); + private static final String GOLDEN = "tqr1.k2.MzMzMzMzMzMzMzMzmhCET15vBdSc1BbFJcrvMMnRP1wAyjunShYlFPnDZL7Gk5V1LuptWyrbiQ8LQSDHFUmN3o6vb-MEgXZuh3N-YkeO55CYLeVgiyBBYslzuSu-fvWC2KMZcnZ5D8BRmWpGYA2Ok5sWe1Fg1aIyadTjVoKB-H2vPW_0okj-"; + + private static final class OrderItem extends BaseEntity { + OrderItem(long id, long version) { + __internalSet("id", id); + __internalSet("version", version); + } + + @Override public String typeName() { return "OrderItem"; } + } + + @Test + public void governedVectorMatchesRustAndBindsActorDocumentAggregateAndType() { + AeadRoundTripReferenceProvider provider = provider(DeploymentProfile.TEST, "test-a", null, null); + UserContext alice = context(provider, "alice"); + Object reference = alice.referenceFor(new OrderItem(42, 3), scope(), Duration.ofSeconds(600)); + + Assert.assertEquals(GOLDEN, reference); + Assert.assertFalse(reference.toString().contains("OrderItem")); + Assert.assertEquals(42, alice.resolveReference(reference, scope(), "OrderItem").id()); + Assert.assertEquals(ReferenceMode.GOVERNED, + alice.resolveReference(reference, scope(), "OrderItem").mode()); + + assertCode(RoundTripReferenceErrorCode.SCOPE_MISMATCH, + () -> context(provider, "bob").resolveReference(reference, scope(), "OrderItem")); + assertCode(RoundTripReferenceErrorCode.SCOPE_MISMATCH, + () -> alice.resolveReference(reference, + new ReferenceDocumentScope("doc-101", "edit-order", "Order", 100, 9), + "OrderItem")); + assertCode(RoundTripReferenceErrorCode.SCOPE_MISMATCH, + () -> alice.resolveReference(reference, scope(), "InvoiceItem")); + } + + @Test + public void expiryEnvironmentRotationTamperingAndCurrentAuthorizationFailClosed() { + AtomicInteger authorizations = new AtomicInteger(); + AeadRoundTripReferenceProvider provider = provider( + DeploymentProfile.TEST, "test-a", null, + (context, principal, scope, identity) -> { + authorizations.incrementAndGet(); + if (!"alice".equals(principal.subject())) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.AUTHORIZATION_REQUIRED, + "ROUND_TRIP_REFERENCE_AUTHORIZATION_REQUIRED"); + } + }); + UserContext alice = context(provider, "alice"); + Object reference = alice.referenceFor(new OrderItem(42, 3), scope(), Duration.ofSeconds(600)); + alice.resolveReference(reference, scope(), "OrderItem"); + Assert.assertEquals("authorization must run at issue and consume", 2, authorizations.get()); + + String token = reference.toString(); + String tampered = token.substring(0, token.length() - 1) + + (token.endsWith("A") ? "B" : "A"); + assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE, + () -> alice.resolveReference(tampered, scope(), "OrderItem")); + assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE, + () -> context(provider(DeploymentProfile.TEST, "test-b", null, null), "alice") + .resolveReference(reference, scope(), "OrderItem")); + + provider.withClock(Clock.fixed(NOW.plusSeconds(601), ZoneOffset.UTC)); + assertCode(RoundTripReferenceErrorCode.EXPIRED, + () -> alice.resolveReference(reference, scope(), "OrderItem")); + + AeadRoundTripReferenceProvider old = provider(DeploymentProfile.TEST, "test-a", "k1", null); + UserContext beforeRotation = context(old, "alice"); + Object oldReference = beforeRotation.referenceFor( + new OrderItem(42, 3), scope(), Duration.ofSeconds(600)); + Assert.assertEquals(42, + context(provider(DeploymentProfile.TEST, "test-a", null, null), "alice") + .resolveReference(oldReference, scope(), "OrderItem").id()); + } + + @Test + public void freshNoncesMakeRepeatedReferencesUnlinkable() { + AtomicInteger nonce = new AtomicInteger(1); + AeadRoundTripReferenceProvider provider = provider( + DeploymentProfile.TEST, "test-a", null, null) + .withNonceSource(() -> filledNonce(nonce.getAndIncrement())); + UserContext context = context(provider, "alice"); + Object first = context.referenceFor(new OrderItem(42, 3), scope(), Duration.ofMinutes(10)); + Object second = context.referenceFor(new OrderItem(42, 3), scope(), Duration.ofMinutes(10)); + Assert.assertNotEquals(first, second); + Assert.assertEquals(42, context.resolveReference(first, scope(), "OrderItem").id()); + Assert.assertEquals(42, context.resolveReference(second, scope(), "OrderItem").id()); + } + + @Test + public void raw01UnsetUsesGovernedReferences() { + Assert.assertEquals(ReferenceMode.GOVERNED, + provider(DeploymentProfile.TEST, "test-a", null, null).mode()); + } + + @Test + public void raw02ExactAcknowledgementEnablesDevelopmentRawMode() { + Assert.assertEquals(ReferenceMode.RAW, raw(DeploymentProfile.DEVELOPMENT, null).mode()); + } + + @Test + public void raw03ExactAcknowledgementEnablesTestRawMode() { + Assert.assertEquals(ReferenceMode.RAW, raw(DeploymentProfile.TEST, null).mode()); + } + + @Test + public void raw04NearMatchDoesNotEnableRawMode() { + Assert.assertEquals(ReferenceMode.GOVERNED, + provider(DeploymentProfile.TEST, "test-a", "near-match", null).mode()); + } + + @Test + public void raw05ProductionFailsClosed() { + assertCode(RoundTripReferenceErrorCode.CONFIGURATION_INVALID, + () -> raw(DeploymentProfile.PRODUCTION, null)); + } + + @Test + public void raw06AnotherActorIsRejectedByCurrentAuthorization() { + AeadRoundTripReferenceProvider raw = raw(DeploymentProfile.TEST, + (context, principal, scope, identity) -> { + if (!"alice".equals(principal.subject())) { + throw new RoundTripReferenceException( + RoundTripReferenceErrorCode.AUTHORIZATION_REQUIRED, + "ROUND_TRIP_REFERENCE_AUTHORIZATION_REQUIRED"); + } + }); + Object wire = context(raw, "alice").referenceFor( + new OrderItem(42, 3), scope(), Duration.ofMinutes(10)); + assertCode(RoundTripReferenceErrorCode.AUTHORIZATION_REQUIRED, + () -> context(raw, "bob").resolveReference(wire, scope(), "OrderItem")); + } + + @Test + public void raw07ModeChangeRejectsOldWireShape() { + AeadRoundTripReferenceProvider raw = raw(DeploymentProfile.TEST, null); + assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE, + () -> context(raw, "alice").resolveReference(GOLDEN, scope(), "OrderItem")); + Object rawWire = context(raw, "alice").referenceFor( + new OrderItem(42, 3), scope(), Duration.ofMinutes(10)); + assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE, + () -> context(provider(DeploymentProfile.TEST, "test-a", null, null), "alice") + .resolveReference(rawWire, scope(), "OrderItem")); + } + + @Test + public void raw08RetainsVersionTypeAndAuthorizationGuards() { + + AtomicInteger authorizations = new AtomicInteger(); + AeadRoundTripReferenceProvider raw = raw( + DeploymentProfile.DEVELOPMENT, + (context, principal, scope, identity) -> authorizations.incrementAndGet()); + UserContext context = context(raw, "alice"); + Object wire = context.referenceFor(new OrderItem(42, 3), scope(), Duration.ofMinutes(10)); + Assert.assertEquals(Map.of("id", 42L, "version", 3L), wire); + Assert.assertEquals(3, context.resolveReference(wire, scope(), "OrderItem").version()); + Assert.assertEquals("OrderItem", + context.resolveReference(wire, scope(), "OrderItem").entityType()); + Assert.assertEquals(3, authorizations.get()); + assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE, + () -> context.resolveReference( + Map.of("id", 42.5, "version", 3), scope(), "OrderItem")); + } + + @Test + public void raw09ExposesSafeDowngradeMetadataWithoutSecretMaterial() { + AeadRoundTripReferenceProvider raw = raw(DeploymentProfile.DEVELOPMENT, null); + ReferenceStartupNotice notice = raw.startupNotice().orElseThrow(); + Assert.assertEquals("ERROR", notice.level()); + Assert.assertEquals("raw", notice.telemetryValue()); + Assert.assertEquals("raw-internal-id", + notice.responseHeaders().get("TeaQL-Reference-Mode")); + Assert.assertEquals("no-store", notice.responseHeaders().get("Cache-Control")); + Assert.assertFalse(notice.toString().contains("22".repeat(32))); + } + + private static AeadRoundTripReferenceProvider raw( + DeploymentProfile profile, RoundTripReferenceAuthorizationPolicy policy) { + AeadRoundTripReferenceProvider raw = provider( + profile, "test-a", RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT, policy); + Assert.assertEquals(ReferenceMode.RAW, raw.mode()); + return raw; + } + + private static AeadRoundTripReferenceProvider provider( + DeploymentProfile profile, + String environment, + String acknowledgement, + RoundTripReferenceAuthorizationPolicy policy) { + RoundTripReferenceKey current = new RoundTripReferenceKey( + "k1".equals(acknowledgement) ? "k1" : "k2", + filled("k1".equals(acknowledgement) ? 0x11 : 0x22)); + StaticRoundTripReferenceKeyProvider keys = "k1".equals(acknowledgement) + ? new StaticRoundTripReferenceKeyProvider(current) + : new StaticRoundTripReferenceKeyProvider( + current, new RoundTripReferenceKey("k1", filled(0x11))); + return AeadRoundTripReferenceProvider.create( + profile, "order-service", environment, keys, + policy == null ? (context, principal, scope, identity) -> {} : policy, + "k1".equals(acknowledgement) ? null : acknowledgement) + .withClock(Clock.fixed(NOW, ZoneOffset.UTC)) + .withNonceSource(() -> filledNonce(0x33)); + } + + private static UserContext context(AeadRoundTripReferenceProvider provider, String subject) { + TeaQLRuntime runtime = TeaQLRuntime.builder() + .metadata(new SimpleEntityMetaFactory()) + .roundTripReferenceProvider(provider) + .build(); + return new DefaultUserContext(runtime).withTrustedReferencePrincipal( + new TrustedReferencePrincipal("oidc", subject, "Platform", 7)); + } + + private static ReferenceDocumentScope scope() { + return new ReferenceDocumentScope("doc-100", "edit-order", "Order", 100, 9); + } + + private static byte[] filled(int value) { + byte[] bytes = new byte[32]; + Arrays.fill(bytes, (byte) value); + return bytes; + } + + private static byte[] filledNonce(int value) { + byte[] bytes = new byte[12]; + Arrays.fill(bytes, (byte) value); + return bytes; + } + + private static void assertCode(RoundTripReferenceErrorCode code, Runnable action) { + RoundTripReferenceException error = Assert.assertThrows( + RoundTripReferenceException.class, action::run); + Assert.assertEquals(code, error.getCode()); + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/reference/RoundTripReferenceConfigurationTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/reference/RoundTripReferenceConfigurationTest.java deleted file mode 100644 index 8df7e5fa..00000000 --- a/teaql-runtime/src/test/java/io/teaql/runtime/reference/RoundTripReferenceConfigurationTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.teaql.runtime.reference; - -import io.teaql.core.UserContext; -import io.teaql.core.reference.InternalEntityIdentity; -import io.teaql.core.reference.ResolvedRoundTripReference; -import io.teaql.core.reference.RoundTripReference; -import io.teaql.core.reference.RoundTripReferenceProvider; -import org.junit.Assert; -import org.junit.Test; - -public class RoundTripReferenceConfigurationTest { - private final RoundTripReferenceProvider governed = new RoundTripReferenceProvider() { - @Override - public RoundTripReference issue(UserContext context, InternalEntityIdentity identity) { - throw new UnsupportedOperationException(); - } - - @Override - public ResolvedRoundTripReference resolve( - UserContext context, - RoundTripReference reference, - String expectedEntityType) { - throw new UnsupportedOperationException(); - } - }; - - @Test - public void exactAcknowledgementSelectsRawOnlyForDevelopmentOrTest() { - Assert.assertSame( - governed, - RoundTripReferenceConfiguration.configuredProvider( - "development", "true", governed)); - Assert.assertTrue( - RoundTripReferenceConfiguration.configuredProvider( - "development", - RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT, - governed) - instanceof RawRoundTripReferenceProvider); - Assert.assertTrue( - RoundTripReferenceConfiguration.configuredProvider( - "test", - RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT, - governed) - instanceof RawRoundTripReferenceProvider); - Assert.assertThrows( - IllegalStateException.class, - () -> RoundTripReferenceConfiguration.configuredProvider( - "production", - RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT, - governed)); - } -} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java deleted file mode 100644 index 94ec2909..00000000 --- a/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package io.teaql.runtime.security; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; - -import io.teaql.core.EntityReferenceClaims; -import io.teaql.core.EntityReferenceTokenException; -import io.teaql.runtime.DefaultUserContext; -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 AeadEntityReferenceCodecTest { - private static final String GOLDEN = "tqr1.AAAAAjMzMzMzMzMzMzMzM3bKiZgRSQQhfIj2cBXRDZIloUGHWLBp8QrXL_aejwIXPFtvV_E71O7wbOXy3cvYo_SwxvuS-89x572T9CO_pDAY4tbjWCNv"; - private static final Instant NOW = Instant.parse("2026-09-26T12:00:00Z"); - - @Test - public void referenceIsPortableOpaqueBoundRotatableAndExpiring() { - 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(NOW, 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(GOLDEN, token); - assertFalse(token.contains("OrderItem")); - assertFalse(token.contains("42")); - EntityReferenceClaims claims = context.decodeEntityReference(token, "OrderItem", "edit-order"); - assertEquals(42, claims.id()); - assertEquals(7, claims.version()); - assertEquals(2, claims.keyVersion()); - - assertThrows(EntityReferenceTokenException.class, - () -> context.decodeEntityReference(token, "InvoiceItem", "edit-order")); - assertThrows(EntityReferenceTokenException.class, - () -> context.decodeEntityReference(token, "OrderItem", "other-purpose")); - assertThrows(EntityReferenceTokenException.class, - () -> context.decodeEntityReference(token.substring(0, token.length() - 1) + "A", "OrderItem", "edit-order")); - - codec.withClock(Clock.fixed(NOW.plus(Duration.ofHours(2)), ZoneOffset.UTC)); - assertThrows(EntityReferenceTokenException.class, - () -> context.decodeEntityReference(token, "OrderItem", "edit-order")); - } - - @Test - public void missingCodecFailsClosedWithoutExactDevelopmentAcknowledgement() { - DefaultUserContext context = new DefaultUserContext(null); - EntityReferenceTokenException error = assertThrows(EntityReferenceTokenException.class, - () -> context.encodeEntityReference("Order", 1, 1, "edit", Duration.ofMinutes(1))); - assertEquals("ENTITY_REFERENCE_CODEC_REQUIRED", error.code()); - } -} From a17377ca0c970cee61c8559b515cc3d689f29ac7 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 27 Sep 2026 01:56:34 +0800 Subject: [PATCH 3/3] test: include round-trip reference in example gate --- scripts/verify-examples.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 1e2bf541..2c8d4e63 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -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 security-foundations) +expected=(business-id-runtime conformance order-management round-trip-reference-runtime 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 @@ -17,6 +17,8 @@ mvn -q -pl examples/business-id-runtime \ -Dtest=BusinessIdRuntimeExampleTest test mvn -q -pl examples/security-foundations \ -Dtest=SecurityFoundationsExampleTest test +mvn -q -pl examples/round-trip-reference-runtime \ + -Dtest=RoundTripReferenceRuntimeExampleTest 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"