Skip to content

Field Encryption ​

Tayra's field encryption engine scans your entity types for [PersonalData] and [DeepPersonalData] annotations, then encrypts and decrypts those fields using AES-256-GCM with per-subject keys - strings in place, non-string members into a companion byte[].

ITayra - The Public API ​

All encryption operations go through ITayra:

cs
var tayra = provider.GetRequiredService<ITayra>();

await tayra.EncryptAsync(customer);   // Encrypts all PII fields in-place
await tayra.DecryptAsync(customer);   // Decrypts back to plaintext
anchor

Without dependency injection, create a standalone host:

cs
using var tayra = TayraHost.Create(opts => opts.LicenseKey = key);
anchor
MethodPurpose
EncryptAsync<T>Encrypts all [PersonalData] fields on the object in-place. Keys are auto-created if needed. Blind indexes are computed automatically.
DecryptAsync<T>Decrypts all [PersonalData] fields in-place. If the key is missing (shredded), replacement values are used.
HasPersonalData<T>Returns true if the type has any PII annotations. Useful for conditional encryption.

Metadata-Driven Reflection ​

Internal implementation - provided for clarity

The field encryption engine does not hard-code field names or types. Instead, it relies on a metadata cache to discover annotated properties:

  1. Type scanning - On first use, the metadata cache scans the type's public instance properties using reflection.
  2. Field classification - Each annotated property is classified as Text, Deep, Serialized, TextCollection, or DeepCollection.
  3. Subject resolution - [DataSubjectId] properties are recorded with their group and prefix.
  4. Caching - The metadata is cached in a ConcurrentDictionary and reused for all subsequent calls.

After the first call, there is zero reflection overhead.

EncryptAsync Flow ​

Internal implementation - provided for clarity

When EncryptAsync<T> is called:

EncryptAsync(customer)
    │
    ├─ Get metadata for typeof(Customer)
    │   └─ Fields: Name (Text), Email (Text)
    │   └─ Subjects: Id (default group)
    │
    ├─ Resolve keys for encryption
    │   └─ Get or create AES key for the data subject
    │       └─ Returns 32-byte AES key (created or existing)
    │
    ├─ For each field:
    │   ├─ Text: AesGcm.Encrypt → Base64 → set property
    │   ├─ Deep: Recurse into nested object
    │   ├─ Serialized: To JSON → AesGcm.Encrypt → set companion byte[], zero the original
    │   ├─ TextCollection: Encrypt each element
    │   └─ DeepCollection: Recurse into each element
    │
    ├─ Emit telemetry (activity, metrics, audit event)
    └─ Return

Key points:

  • Keys are created automatically on first use.
  • The operation is in-place - properties are modified directly on the object. Integrations that only need the encrypted bytes (the Marten, Wolverine, EF Core, MongoDB and JSON integrations) encrypt through FieldEncrypterReflectionHelper.EncryptForSerializationAsync instead, which returns a handle that puts the object's plaintext back exactly once the write has ended, whether it succeeded or failed. A custom serializer wrapper should do the same, so repeated writes of one instance never encrypt ciphertext.
  • Encryption fails closed: if a [DataSubjectId] value is null, or no key can be resolved for a field's group, EncryptAsync throws an InvalidOperationException instead of leaving those fields as plaintext. The one exception is a subject marked AllowMissing: its group is then stored as it is, and the write is audited (StoredWithoutSubject) and counted.
  • If a masking strategy is set (via the Masking property), the masked value is embedded in the ciphertext string.

DecryptAsync Flow ​

Internal implementation - provided for clarity

When DecryptAsync<T> is called:

DecryptAsync(customer)
    │
    ├─ Get metadata for typeof(Customer)
    │
    ├─ Resolve keys for decryption
    │   └─ Retrieve key for the data subject
    │       ├─ Key exists → byte[] returned
    │       └─ Key shredded → null returned
    │
    ├─ For each field:
    │   ├─ Group has no subject (the [DataSubjectId] is null):
    │   │   └─ Leave the value as stored
    │   │
    │   ├─ Key exists:
    │   │   └─ Base64 decode → AesGcm.Decrypt → set property
    │   │
    │   └─ Key is null (shredded):
    │       ├─ Check for embedded redacted value (TAYRA_M: prefix)
    │       │   └─ Found → use redacted value
    │       └─ Not found → use ReplacementValue from attribute (or default)
    │
    ├─ Emit telemetry
    └─ Return

Key points:

  • Never creates keys during decryption.
  • A null subject is not a missing key: encryption never writes ciphertext without one, so that group's fields are returned exactly as stored and no CryptoShreddingDetected event is raised.
  • A missing key triggers the replacement value logic. It uses the attribute ReplacementValue when set (for string fields a masking strategy, if configured, supersedes it), otherwise the IReplacementValueProvider type default.
  • A FormatException (the stored value is not Base64 - typically legacy plaintext from before Tayra was adopted) is tolerated quietly at Debug level, leaving the property unchanged.
  • A CryptographicException (tampered ciphertext or AAD context mismatch) is surfaced: it is logged at Warning level and emits an IntegrityCheckFailed audit event. The property is left unchanged.

Masking Embedding ​

When a [PersonalData] field has a masking strategy configured (via the Masking property):

During encryption:

  1. The plaintext value is redacted according to the mode (e.g., "Jane Doe" becomes "Ja******" with MaskAfter(2)).
  2. The redacted value is prepended to the ciphertext: TAYRA_M:Ja******\n{base64_ciphertext}.
  3. The combined string is stored in the property.

During decryption with key:

  1. The TAYRA_M: prefix is detected and stripped.
  2. The remaining Base64 ciphertext is decrypted normally.
  3. The original plaintext is restored.

During decryption without key (shredded):

  1. The TAYRA_M: prefix is detected.
  2. The redacted value (Ja******) is extracted and returned.
  3. The ReplacementValue from the attribute is ignored.

Thread Safety ​

ITayra is registered as a singleton and is thread-safe. Concurrent calls on different objects are safe. Concurrent calls on the same object instance are not safe - use external synchronization if needed.

See Also ​