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 new file mode 100644 index 00000000..2fd40a23 --- /dev/null +++ b/examples/round-trip-reference-runtime/README.md @@ -0,0 +1,21 @@ +# 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 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 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: + +```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..4b393546 --- /dev/null +++ b/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java @@ -0,0 +1,73 @@ +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.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.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 class OrderItem extends BaseEntity { + OrderItem(long id, long version) { + __internalSet("id", id); + __internalSet("version", version); + } + + @Override public String typeName() { return "OrderItem"; } + } + + @Test + 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).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/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/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" 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 67072fe7..b5751454 100644 --- a/teaql-core/src/main/java/io/teaql/core/UserContext.java +++ b/teaql-core/src/main/java/io/teaql/core/UserContext.java @@ -11,8 +11,13 @@ 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.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 { @@ -21,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()); @@ -279,6 +241,45 @@ 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 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( + Object reference, ReferenceDocumentScope scope, String expectedEntityType) { + return roundTripReferences().deserialize( + this, trustedReferencePrincipal(), scope, 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/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 new file mode 100644 index 00000000..0224695f --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java @@ -0,0 +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() || !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 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 new file mode 100644 index 00000000..a9c202ff --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java @@ -0,0 +1,15 @@ +package io.teaql.core.reference; + +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/RoundTripReferenceCodec.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java new file mode 100644 index 00000000..55a5695b --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java @@ -0,0 +1,53 @@ +package io.teaql.core.reference; + +import io.teaql.core.Entity; +import io.teaql.core.UserContext; +import java.time.Duration; + +/** + * 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 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 ReferenceWireCodec.serialize(provider.issue( + context, + principal, + scope, + new InternalEntityIdentity(entity.typeName(), entity.getId(), entity.getVersion()), + lifetime)); + } + + public ResolvedRoundTripReference deserialize( + UserContext context, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + Object reference, + String expectedEntityType) { + return provider.resolve( + context, + 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 new file mode 100644 index 00000000..8d5f4d62 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java @@ -0,0 +1,14 @@ +package io.teaql.core.reference; + +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/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..df243a3c --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java @@ -0,0 +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 { + ExternalEntityReference issue( + UserContext context, + TrustedReferencePrincipal principal, + ReferenceDocumentScope scope, + InternalEntityIdentity identity, + Duration lifetime); + + ResolvedRoundTripReference resolve( + UserContext context, + 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-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..1abff135 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java @@ -0,0 +1,387 @@ +package io.teaql.runtime.reference; + +import io.teaql.core.UserContext; +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 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; + +/** 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[] 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 RoundTripReferenceAuthorizationPolicy authorization; + private Clock clock; + private Supplier nonceSource; + + 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; + }; + } + + 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 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 { + 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, + 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 < 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( + payload.identity(), ReferenceMode.GOVERNED, key.keyId(), + payload.issuedAt(), payload.expiresAt()); + } catch (RoundTripReferenceException error) { + throw error; + } catch (Exception error) { + throw invalid(error); + } + } + + 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(deriveKey(key, "entity-reference-encryption"), "AES"), + new GCMParameterSpec(128, nonce)); + cipher.updateAAD(encodeTexts(PREFIX, key.keyId(), service, environment, purpose)); + return cipher; + } + + 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); + } + } + + 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 configuration("current reference key is missing", null); + return key; + } + + 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.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/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/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 new file mode 100644 index 00000000..3d594107 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java @@ -0,0 +1,10 @@ +package io.teaql.runtime.reference; + +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() {} + +} 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..b516e6e3 --- /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,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"); + } + 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..0227265e --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java @@ -0,0 +1,8 @@ +package io.teaql.runtime.reference; + +/** Supplies one current encryption key and retained decode-only rotation keys. */ +public interface RoundTripReferenceKeyProvider { + RoundTripReferenceKey currentKey(); + + RoundTripReferenceKey keyById(String keyId); +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java new file mode 100644 index 00000000..2f7ee7a7 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java @@ -0,0 +1,32 @@ +package io.teaql.runtime.reference; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 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 StaticRoundTripReferenceKeyProvider( + 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) != null) { + throw new IllegalArgumentException("duplicate or null reference 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/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/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/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/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()); - } -}