Encrypting operational secrets
Marking a value [Encrypted] tells Chronicle to encrypt it at rest — the same way [PII] does — but as a security measure with no data subject and no lawful basis for erasure, rather than a GDPR compliance mechanism. See Security for when to reach for this instead of [PII].
The marker
Section titled “The marker”[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Parameter)]public sealed class EncryptedAttribute(EncryptionScope scope = EncryptionScope.Subject, string details = "") : Attribute{ public EncryptionScope Scope { get; } = scope; public string Details { get; } = details;}import io.cratis.chronicle.confidentiality.Encryptedimport io.cratis.chronicle.confidentiality.EncryptionScopeimport io.cratis.chronicle.events.EventType
// annotation class Encrypted(// val scope: EncryptionScope = EncryptionScope.Subject,// val description: String = ""// )// Apply it to a class (a concept type), a property, a field, or a constructor parameter.// Both arguments are optional; description records why the value needs encryption.@EventTypedata class EncryptedMarkerPartnerIntegrationConfigured( @Encrypted(EncryptionScope.Subject, "Partner API credential - an operational secret with no data subject") val apiKey: String)import io.cratis.chronicle.confidentiality.Encrypted;import io.cratis.chronicle.confidentiality.EncryptionScope;import io.cratis.chronicle.events.EventType;
// @interface Encrypted {// EncryptionScope scope() default EncryptionScope.Subject;// String description() default "";// }// Apply it to a class (a concept type), a record component, a field, or a constructor parameter.// Both elements are optional; description records why the value needs encryption.@EventTyperecord EncryptedMarkerPartnerIntegrationConfigured( @Encrypted( scope = EncryptionScope.Subject, description = "Partner API credential - an operational secret with no data subject") String apiKey) {}# Chronicle.Confidentiality.encrypted/1,2,3 - imported automatically inside# modules that `use Chronicle.Events.EventType` or# `use Chronicle.ReadModels.ReadModel`. Marks one struct field.## defmacro encrypted(field, scope \\ :subject, details \\ "")## Chronicle.Concept.encrypted/0,1,2 - imported automatically inside modules# that `use Chronicle.Concept`. Marks the concept's value itself.## defmacro encrypted(scope \\ :subject, details \\ "")import { encrypted, EncryptionScope, eventType } from '@cratis/chronicle';
// encrypted(scope: EncryptionScope = EncryptionScope.Subject, details?: string): PropertyDecorator & ClassDecorator// Apply it to a class (a concept type) or to a property. Both arguments are optional.@eventType()class EncryptedMarkerPartnerIntegrationConfigured { @encrypted(EncryptionScope.Subject, 'Partner API credential - an operational secret with no data subject') apiKey = '';}EncryptedAttribute lives in Cratis.Chronicle.ProtectedValues — a namespace of its own, deliberately not nested under Compliance. The optional details parameter records why the value needs encryption, exactly like [PII]’s details parameter.
using Cratis.Chronicle.ProtectedValues;import io.cratis.chronicle.confidentiality.Encryptedimport io.cratis.chronicle.confidentiality.Encrypted;# encrypted/1,2,3 is imported automatically inside modules that# `use Chronicle.Events.EventType` or `use Chronicle.ReadModels.ReadModel` -# there is nothing to import explicitly.import { encrypted } from '@cratis/chronicle';Applying to a concept
Section titled “Applying to a concept”The preferred approach, exactly as with [PII]: mark the concept type itself. Every event using this type is then automatically encrypted.
[Encrypted]public record PartnerApiKey(string Value) : ConceptAs<string>(Value);
public record PartnerIntegrationConfiguredWithKey(PartnerApiKey ApiKey);import io.cratis.chronicle.concepts.ConceptAsimport io.cratis.chronicle.confidentiality.Encrypted
@Encrypteddata class EncryptedAttrPartnerApiKey(override val value: String) : ConceptAs<String>
data class EncryptedAttrPartnerIntegrationConfiguredWithKey(val apiKey: EncryptedAttrPartnerApiKey)import io.cratis.chronicle.concepts.ConceptAs;import io.cratis.chronicle.confidentiality.Encrypted;
@Encryptedrecord EncryptedAttrPartnerApiKey(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}
record EncryptedAttrPartnerIntegrationConfiguredWithKey(EncryptedAttrPartnerApiKey apiKey) {}defmodule MyApp.Confidentiality.Encrypted.PartnerApiKey do use Chronicle.Concept, type: :string encrypted()end
defmodule MyApp.Events.EncryptedAttrPartnerIntegrationConfiguredWithKey do use Chronicle.Events.EventType, id: "encrypted-attr-partner-integration-configured-with-key"
defstruct api_key: %MyApp.Confidentiality.Encrypted.PartnerApiKey{}endimport { encrypted } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
@encrypted()class EncryptedAttrPartnerApiKey extends ConceptAs<string> { static readonly valueType = String;
constructor(value: string) { super(value); }}
class EncryptedAttrPartnerIntegrationConfiguredWithKey { apiKey: EncryptedAttrPartnerApiKey = new EncryptedAttrPartnerApiKey('');}Applying to an event property
Section titled “Applying to an event property”You can mark a single property directly when the value has no dedicated concept type:
public record PartnerIntegrationConfigured( [Encrypted] string ApiKey, string PartnerName);
// When this event is written, ApiKey is encrypted. PartnerName is stored as plaintext.import io.cratis.chronicle.confidentiality.Encryptedimport io.cratis.chronicle.events.EventType
@EventTypedata class EncryptedAttrPartnerIntegrationConfigured( @Encrypted val apiKey: String, val partnerName: String)
// When this event is written, apiKey is encrypted. partnerName is stored as plaintext.import io.cratis.chronicle.confidentiality.Encrypted;import io.cratis.chronicle.events.EventType;
@EventTyperecord EncryptedAttrPartnerIntegrationConfigured( @Encrypted String apiKey, String partnerName) {}
// When this event is written, apiKey is encrypted. partnerName is stored as plaintext.defmodule MyApp.Events.EncryptedAttrPartnerIntegrationConfigured do use Chronicle.Events.EventType, id: "encrypted-attr-partner-integration-configured"
defstruct [:partner_name, :api_key]
encrypted(:api_key, :subject, "Partner API key")end
# When this event is written, api_key is encrypted. partner_name is stored as plaintext.import { encrypted, eventType } from '@cratis/chronicle';
@eventType()class EncryptedAttrPartnerIntegrationConfigured { @encrypted() apiKey = ''; partnerName = '';}
// When this event is written, apiKey is encrypted. partnerName is stored as plaintext.[PII] and [Encrypted] never mix
Section titled “[PII] and [Encrypted] never mix”A property cannot resolve both [PII] and [Encrypted] metadata. Chronicle applies every matching compliance handler for a property in sequence, so a value marked both ways would be encrypted first under the PII key and then again under the [Encrypted] key — releasing it would decrypt with the wrong key against ciphertext, which fails loudly rather than returning a wrong value.
// Throws PIIAndEncryptedCombinedNotSupported at schema-generation time,// and is flagged at compile time by CHR0053.public record EncryptedCustomerRegistered( [PII] [Encrypted] string SomeValue);import io.cratis.chronicle.compliance.Piiimport io.cratis.chronicle.confidentiality.Encryptedimport io.cratis.chronicle.events.EventType
// Throws PiiAndEncryptedCombinedNotSupported at schema-generation time.@EventTypedata class EncryptedAttrCustomerRegistered(@Pii @Encrypted val someValue: String)import io.cratis.chronicle.compliance.Pii;import io.cratis.chronicle.confidentiality.Encrypted;import io.cratis.chronicle.events.EventType;
// Throws PiiAndEncryptedCombinedNotSupported at schema-generation time.@EventTyperecord EncryptedAttrCustomerRegistered(@Pii @Encrypted String someValue) {}# Raises Chronicle.Confidentiality.PiiAndEncryptedCombinedNotSupported at# schema-generation time.## defmodule MyApp.Events.EncryptedAttrCustomerRegistered do# use Chronicle.Events.EventType, id: "encrypted-attr-customer-registered"# defstruct [:some_value]## pii(:some_value)# encrypted(:some_value)# endimport { pii } from '@cratis/chronicle';import { encrypted, eventType } from '@cratis/chronicle';
// Throws PIIAndEncryptedCombinedNotSupported at schema-generation time.@eventType()class EncryptedAttrCustomerRegistered { @pii() @encrypted() someValue = '';}Choose exactly one: [PII] for personal data with a lawful basis for erasure, [Encrypted] for an operational secret with none. In .NET, CHR0053 catches the two directly-visible combinations at compile time; the check shown above is the backstop every client applies at schema-generation time.
Why the two keys are never the same
Section titled “Why the two keys are never the same”A subject can carry both a [PII] value and an [Encrypted] value — a customer record with a [PII] email address and an [Encrypted] API key issued to that customer, for example. The two are provisioned under deliberately disjoint key identities, even though they resolve to the same compliance identity (the subject, or the event source id when none is set). Erasing the subject’s PII — a lawful, routine GDPR right-to-erasure request — destroys only the PII key. The [Encrypted] key, and the value it protects, is untouched and remains fully readable.
This is enforced two ways:
- The key identities are disjoint by construction — nothing on the PII path ever constructs an identifier that could collide with an
[Encrypted]value’s key identity. DeleteEncryptionKeyForandAllowNewEncryptionKeyFor— the two operations behind GDPR erasure — refuse an identifier that belongs to an[Encrypted]value, as defense in depth for the one entry point (the compliance gRPC surface) that accepts a caller-supplied identifier directly.
There is no operation anywhere that deletes an [Encrypted] value’s key. A key protecting a secret with no data subject has no lawful basis to be destroyed on request.
EncryptionScope controls the identity a value’s key is provisioned under:
public enum EncryptionScope{ Subject, // per compliance identity - the default Namespace, // one key shared by every value marked this way in the event store namespace Global // one key shared by every value marked this way across the whole installation}import io.cratis.chronicle.confidentiality.EncryptionScope
// EncryptionScope has exactly these three members - the when below is exhaustive over them.fun encryptionScopeKeyBoundary(scope: EncryptionScope): String = when (scope) { EncryptionScope.Subject -> "per compliance identity - the default" EncryptionScope.Namespace -> "one key shared by every value marked this way in the event store namespace" EncryptionScope.Global -> "one key shared by every value marked this way across the whole installation"}import io.cratis.chronicle.confidentiality.EncryptionScope;
// EncryptionScope has exactly these three members - the switch below is exhaustive over them.class EncryptionScopeKeyBoundaries { static String describe(EncryptionScope scope) { return switch (scope) { case Subject -> "per compliance identity - the default"; case Namespace -> "one key shared by every value marked this way in the event store namespace"; case Global -> "one key shared by every value marked this way across the whole installation"; }; }}# The scope is an atom passed as the scope argument of encrypted/...:## :subject - per compliance identity - the default# :namespace - one key shared by every value marked this way in the event store namespace# :global - one key shared by every value marked this way across the whole installationimport { EncryptionScope } from '@cratis/chronicle';
// EncryptionScope is exported by @cratis/chronicle and has exactly these three members.const encryptionScopeKeyBoundaries: Record<EncryptionScope, string> = { [EncryptionScope.Subject]: 'per compliance identity - the default', [EncryptionScope.Namespace]: 'one key shared by every value marked this way in the event store namespace', [EncryptionScope.Global]: 'one key shared by every value marked this way across the whole installation'};EncryptionScope.Subject (the default) provisions a key per compliance identity, the same way [PII] resolves its identity (the subject, or the event source id when none is set) — but always under a disjoint key from any [PII] value on the same subject.
EncryptionScope.Namespace provisions one key shared by every value marked that way in a given event store namespace, regardless of which document it came from — useful for a secret that belongs to the deployment rather than to any one entity, such as a webhook signing secret shared across every partner integration.
EncryptionScope.Global provisions one key shared by every value marked that way across the whole installation — every event store, every namespace.
Widening the scope is safe for [Encrypted] in a way it is never safe for [PII]: an operational secret has no data subject, so there is no erasure request a shared key could ever need to honor separately for one subject and not another. [PII] always resolves to a compliance identity and never gains a wider scope — see Compliance.
[Encrypted]public record PartnerApiKeyScoped(string Value) : ConceptAs<string>(Value); // one key per partner (EncryptionScope.Subject, the default)
[Encrypted(EncryptionScope.Namespace)]public record PartnerWebhookSecret(string Value) : ConceptAs<string>(Value); // one key for every partner in the namespace
[Encrypted(EncryptionScope.Global)]public record LicenseToken(string Value) : ConceptAs<string>(Value); // one key for the whole installationimport io.cratis.chronicle.concepts.ConceptAsimport io.cratis.chronicle.confidentiality.Encryptedimport io.cratis.chronicle.confidentiality.EncryptionScope
// One key per partner (EncryptionScope.Subject, the default).@Encrypteddata class EncryptedAttrPartnerApiKeyScoped(override val value: String) : ConceptAs<String>
// One key for every partner in the namespace.@Encrypted(scope = EncryptionScope.Namespace)data class EncryptedAttrPartnerWebhookSecret(override val value: String) : ConceptAs<String>
// One key for the whole installation.@Encrypted(scope = EncryptionScope.Global)data class EncryptedAttrLicenseToken(override val value: String) : ConceptAs<String>import io.cratis.chronicle.concepts.ConceptAs;import io.cratis.chronicle.confidentiality.Encrypted;import io.cratis.chronicle.confidentiality.EncryptionScope;
// One key per partner (EncryptionScope.Subject, the default).@Encryptedrecord EncryptedAttrPartnerApiKeyScoped(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}
// One key for every partner in the namespace.@Encrypted(scope = EncryptionScope.Namespace)record EncryptedAttrPartnerWebhookSecret(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}
// One key for the whole installation.@Encrypted(scope = EncryptionScope.Global)record EncryptedAttrLicenseToken(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}# One key per partner (:subject, the default).defmodule MyApp.Confidentiality.Encrypted.PartnerApiKeyScoped do use Chronicle.Concept, type: :string encrypted()end
# One key for every partner in the namespace.defmodule MyApp.Confidentiality.Encrypted.PartnerWebhookSecret do use Chronicle.Concept, type: :string encrypted(:namespace)end
# One key for the whole installation.defmodule MyApp.Confidentiality.Encrypted.LicenseToken do use Chronicle.Concept, type: :string encrypted(:global)endimport { encrypted, EncryptionScope } from '@cratis/chronicle';import { ConceptAs } from '@cratis/fundamentals';
// One key per partner (EncryptionScope.Subject, the default).@encrypted()class EncryptedAttrPartnerApiKeyScoped extends ConceptAs<string> { static readonly valueType = String;
constructor(value: string) { super(value); }}
// One key for every partner in the namespace.@encrypted(EncryptionScope.Namespace)class EncryptedAttrPartnerWebhookSecret extends ConceptAs<string> { static readonly valueType = String;
constructor(value: string) { super(value); }}
// One key for the whole installation.@encrypted(EncryptionScope.Global)class EncryptedAttrLicenseToken extends ConceptAs<string> { static readonly valueType = String;
constructor(value: string) { super(value); }}Constraints
Section titled “Constraints”EventSourceId is not supported
Section titled “EventSourceId is not supported”Applying [Encrypted] to an event source identifier throws EncryptedNotSupportedOnEventSourceId at runtime, and in .NET is flagged at compile time by CHR0052:
public record PartnerId(Guid Value) : EventSourceId<Guid>(Value);
// Throws EncryptedNotSupportedOnEventSourceIdpublic record PartnerIntegrationConfiguredWithId( [Encrypted] PartnerId PartnerId, string ApiKey);import io.cratis.chronicle.concepts.EventSourceIdimport io.cratis.chronicle.confidentiality.Encrypted
// This will throw EncryptedNotSupportedOnEventSourceId@Encrypteddata class EncryptedAttrPartnerId(override val value: String) : EventSourceIdimport io.cratis.chronicle.concepts.EventSourceId;import io.cratis.chronicle.confidentiality.Encrypted;
// This will throw EncryptedNotSupportedOnEventSourceId@Encryptedrecord EncryptedAttrPartnerId(String value) implements EventSourceId { @Override public String getValue() { return value; }}# Raises ArgumentError at compile time — encryption is not supported on a# Chronicle.Concept declared with event_source_id: true.## defmodule MyApp.Confidentiality.Encrypted.PartnerId do# use Chronicle.Concept, type: :uuid, event_source_id: true# encrypted()# endimport { encrypted } from '@cratis/chronicle';
// TypeScript has no dedicated EventSourceId<T> type - the event source identifier is always// the conventional 'eventSourceId' property. Marking it @encrypted() throws// EncryptedNotSupportedOnEventSourceId at decoration time, for the same reason C# forbids// [Encrypted] on EventSourceId<T>: encrypting it would make its own decryption key unfindable.class EncryptedAttrPartnerIntegrationConfiguredWithId { @encrypted() eventSourceId = '';}Event source identifiers are used to correlate events. If the identifier itself is a secret, use a non-sensitive surrogate as the event source id and store the secret in a separate [Encrypted] property.
Client coverage
Section titled “Client coverage”[Encrypted] is available in the .NET, TypeScript, Kotlin/Java, and Elixir clients.