Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,23 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

## [Unreleased]

## [2.3.0] — 2026-10-16

### Added
- `#[Sensitive]` with the `HasSensitiveFields` trait: the value is kept out of
hydration error messages, `serialize()` throws while it is set, and
`var_dump()` / `print_r()` show `[redacted]`. `toArray()` is unchanged.

### Security
- Card numbers (13–19 digits) are masked to the last four digits in every
library exception message and in `HydrationResult::errors()`.
- `UuidCast`, `MoneyCast`, `DateTimeCast` and `DateTimeImmutableCast` escape
control characters in their messages.

### Changed
- Messages that contain a 13–19 digit number now show it masked.
- Documentation: `EncryptedCast` page corrected (format, tamper error, key
requirements, a working key-rotation procedure).
- Releases ship with a source archive, a CycloneDX SBOM and a build
provenance attestation (`gh attestation verify`). Security reports go
through GitHub private vulnerability reporting; 2.x is the supported line.
Expand Down Expand Up @@ -552,7 +568,8 @@ Initial release.
`toResponse()`).
- CI pipeline: tests across Laravel 10–13 and a scheduled `composer audit`.

[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.2.1...HEAD
[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.3.0...HEAD
[2.3.0]: https://github.com/std-out/simple-data-objects/compare/v2.2.1...v2.3.0
[2.2.1]: https://github.com/std-out/simple-data-objects/compare/v2.2.0...v2.2.1
[1.8.0]: https://github.com/std-out/simple-data-objects/compare/v1.7.2...v1.8.0
[1.7.2]: https://github.com/std-out/simple-data-objects/compare/v1.4.3...v1.7.2
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export interface OrderData {
| `#[Pipe(TrimStringsPipe::class)]` | class | array-level preprocessing pipeline |
| `#[Flatten]` | property | inline nested DTO fields into parent |
| `#[Hidden(except: ['admin'])]` | property | exclude from `toArray()` / JSON, optionally per `toArray(context:)` |
| `#[Sensitive]` | property | keep the value out of exception messages, `errors()`, `serialize()` and `var_dump()` (with `use HasSensitiveFields`) |
| `#[IgnoreIfNull]` | property | omit from output when `null` |
| `#[Computed]` | method | add a derived, method-backed field to `toArray()` |
| `#[MapPropertyName('input_key', ...)]` | property | map input key(s) (aliases) → property, same name on output |
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const attributes = [
text: 'Output',
items: [
{ text: '#[Hidden]', link: '/attributes/hidden' },
{ text: '#[Sensitive]', link: '/attributes/sensitive' },
{ text: '#[IgnoreIfNull]', link: '/attributes/ignore-if-null' },
{ text: '#[Computed]', link: '/attributes/computed' },
{ text: '#[WrapIn]', link: '/attributes/wrap-in' },
Expand Down
1 change: 1 addition & 0 deletions docs/attributes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All behaviour in Simple Data Objects is declared via PHP attributes on construct
| [`#[InferRules]`](./infer-rules.md) | Class | Auto-generate validation rules from property types |
| [`#[Flatten]`](./flatten.md) | Parameter or property | Inline nested DTO fields into the parent array |
| [`#[Hidden]`](./hidden.md) | Parameter or property | Exclude from `toArray()` / JSON output |
| [`#[Sensitive]`](./sensitive.md) | Parameter or property | Keep a value out of exception messages, `serialize()` and `var_dump()` |
| [`#[IgnoreIfNull]`](./ignore-if-null.md) | Parameter or property | Omit from output when value is `null` |
| [`#[Computed]`](./computed.md) | Method | Add a derived, method-backed field to serialization output |
| [`#[MapPropertyName]`](./map-property-name.md) | Parameter or property | Map a different input key (or several aliases) to this property |
Expand Down
66 changes: 66 additions & 0 deletions docs/attributes/sensitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# #[Sensitive]

Marks a field whose value must never leak through an implicit channel: exception messages, `HydrationResult::errors()`, `serialize()`, `var_dump()` and `print_r()`. Use it for card numbers (PAN), CVV, tokens and personal data.

The value is still hydrated, readable on the object and included in `toArray()` / `toJson()` — combine it with [`#[Hidden]`](./hidden.md) to keep it out of output as well.

## Syntax

The class must use the `HasSensitiveFields` trait. A class with `#[Sensitive]` fields but without the trait throws `InvalidArgumentException` on its first hydration, so a forgotten trait can never leak silently.

```php
use StdOut\SimpleDataObjects\Attributes\Hidden;
use StdOut\SimpleDataObjects\Attributes\Sensitive;
use StdOut\SimpleDataObjects\Concerns\HasSensitiveFields;

class CardData extends BaseData
{
use HasSensitiveFields;

public function __construct(
public readonly string $holder,
#[Sensitive, Hidden]
public readonly string $pan,
#[Sensitive]
public readonly CardBrand $brand,
) {}
}
```

## What it changes

| Channel | Behavior |
|---|---|
| Hydration errors | `from()`, `fromResult()` and `with()` report `Invalid value for sensitive field 'brand' of CardData.` — the rejected value and the cast's original exception are dropped |
| `serialize()` | Throws `LogicException` while a sensitive field is not `null`, so the value cannot reach a queue, cache or session in plaintext |
| `var_dump()` / `print_r()` | Shows `[redacted]` instead of the value |
| `toArray()` / `toJson()` | Unchanged — add `#[Hidden]` to exclude the field |

```php
$card = CardData::from(['holder' => 'Alice', 'pan' => '4111111111111111', 'brand' => 'visa']);

print_r($card);
// CardData Object ( [holder] => Alice [pan] => [redacted] [brand] => [redacted] )

serialize($card);
// LogicException: Cannot serialize CardData: field 'pan' is #[Sensitive].

CardData::fromResult(['holder' => 'Alice', 'pan' => '4111111111111111', 'brand' => '4111111111111111'])->errors();
// ['brand' => "Invalid value for sensitive field 'brand' of CardData."]
```

To queue a job that needs the card, pass an identifier (or a token from your payment provider) and load the data inside the job.

## Limits

- `var_export()` and Symfony VarDumper (`dump()`, `dd()`) read properties directly and ignore `__debugInfo()`, so they still show the value.
- A field without a cast, enum or nested type goes to the constructor as-is; a wrong type raises PHP's `TypeError`, which names the type, never the value.
- Set `zend.exception_ignore_args=On` in production so values passed to `from()` do not appear in stack traces.

## PAN masking

Independently of `#[Sensitive]`, every message built by the library — unknown keys, rejected enum and discriminator values, `UuidCast`, `MoneyCast`, `DateTimeCast` and `DateTimeImmutableCast` — and every entry of `HydrationResult::errors()` masks digit sequences that look like a card number (13–19 digits, optionally separated by spaces or dashes), keeping only the last four:

```
Unknown discriminator value '************1111' in field 'type' for PaymentMethodData.
```
40 changes: 27 additions & 13 deletions docs/casts/encrypted.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# EncryptedCast

Encrypts values when serializing to storage and decrypts them on hydration. Uses **XSalsa20-Poly1305 authenticated encryption** (via libsodium) — providing both confidentiality and integrity guarantees.
Encrypts values when serializing to storage and decrypts them on hydration. Uses **XSalsa20-Poly1305 authenticated encryption** (libsodium `crypto_secretbox`), which provides confidentiality and detects tampering with a single ciphertext.

## Requirements

Expand Down Expand Up @@ -35,7 +35,7 @@ A literal key is also accepted (`new EncryptedCast(key: '...')`) — useful in t
```php
$user = UserData::from([
'name' => 'Alice',
'taxId' => 'v1:AAAA...base64...', // ciphertext from database
'taxId' => 'zW1o...base64url...', // ciphertext from database
]);

$user->taxId; // '1234567890' — plaintext
Expand All @@ -44,7 +44,7 @@ $user->taxId; // '1234567890' — plaintext
### Serialization (encrypt)

```php
$user->toArray()['taxId']; // 'v1:AAAA...base64...' — ciphertext, unique each call
$user->toArray()['taxId']; // 'Qk3x...base64url...' — ciphertext, unique each call
```

Every `set()` call produces a **different ciphertext** because a random 24-byte nonce is generated for each encryption. This is normal and expected.
Expand All @@ -54,35 +54,49 @@ Every `set()` call produces a **different ciphertext** because a random 24-byte
| Property | Detail |
|---|---|
| Algorithm | XSalsa20-Poly1305 (`sodium_crypto_secretbox`) |
| Authentication | AEAD — tamper detection is built in |
| Key derivation | BLAKE2b (`sodium_crypto_generichash`) from the provided key string |
| Authentication | Poly1305 tag — a modified ciphertext is rejected. There is no associated data: a valid ciphertext copied to another field or record still decrypts |
| Key derivation | BLAKE2b (`sodium_crypto_generichash`) of the provided key string — a fast hash, not a password KDF |
| Nonce | 24 random bytes prepended to each ciphertext |
| Encoding | Base64 URL-safe, no padding (SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING) |
| Format | `base64url(nonce ‖ ciphertext)`, no padding, no version prefix or key ID |

## Key Handling

The cast is designed so key material never leaves process memory:
Use a key of **32 random bytes** (for example `base64_encode(random_bytes(32))`). Because the key is hashed with a fast function, a short or guessable string such as a password can be brute-forced from any stored ciphertext.

The cast keeps key material out of the places the library controls:

- **Never written to the metadata file cache.** `EncryptedCast` deliberately does not implement `__set_state()`, so classes using it are excluded from [file-based metadata persistence](../features/cache.md) — only the in-memory cache applies. Exporting the cast would write the key to disk in plaintext.
- **Not serializable.** `serialize()` on the cast throws a `LogicException`.
- **Redacted in debug output.** `var_dump()` / `print_r()` show `[redacted]` instead of the derived key, and the constructor parameter is marked `#[\SensitiveParameter]`, so the raw key does not appear in stack traces.

It cannot protect the key everywhere: a key read with `env:` stays in the process environment, and `var_export()` of the cast shows the derived key.

## Tamper Detection

If the ciphertext has been modified, `get()` throws `RuntimeException`:

```php
$user = UserData::from(['taxId' => 'v1:tampered_ciphertext']);
// throws RuntimeException: Failed to decrypt value: authentication tag mismatch
$user = UserData::from(['taxId' => $tamperedCiphertext]);
// RuntimeException: Decryption failed: authentication tag mismatch.
```

Input that is not base64url or is shorter than a nonce throws `InvalidArgumentException` instead.

## Key Rotation

`EncryptedCast` uses the provided key directly. To rotate keys:
A ciphertext carries no key ID, so one cast instance can only read values written with its own key. Rotate with a one-off migration that holds both keys:

```php
$old = new EncryptedCast(env: 'DATA_ENCRYPTION_KEY_OLD');
$new = new EncryptedCast(env: 'DATA_ENCRYPTION_KEY');

foreach (Customer::query()->cursor() as $customer) {
$customer->tax_id = $new->set($old->get($customer->tax_id));
$customer->saveQuietly();
}
```

1. Deploy new cast instance with the new key
2. Re-encrypt existing records: read with old key, write with new key
3. Remove the old cast
Until every row is migrated, the application can read only the rows written with the key it is configured with, so run the migration during a maintenance window or before switching the application to the new key.

## Breaking Change Notice

Expand Down
1 change: 1 addition & 0 deletions docs/guide/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ All behaviour is declared on the constructor parameters:
- **`#[Cast(...)]`** — type conversion for dates, booleans, JSON, encryption, …
- **`#[Rules([...])]`** — Laravel validation rules
- **`#[Hidden]`** — exclude from `toArray()` output
- **`#[Sensitive]`** — keep a value (PAN, CVV, tokens) out of exception messages, `serialize()` and `var_dump()`
- **`#[IgnoreIfNull]`** — omit null fields from output
- **`#[Flatten]`** — inline nested DTO fields into the parent array
- **`#[MapPropertyName]`** / **`#[MapInputName]`** / **`#[MapOutputName]`** / **`#[TransformKeys]`** — key remapping
Expand Down
10 changes: 10 additions & 0 deletions src/Attributes/Sensitive.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace StdOut\SimpleDataObjects\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
final class Sensitive {}
6 changes: 5 additions & 1 deletion src/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,11 @@ public function with(mixed ...$overrides): static
foreach ($meta->parameters as $param) {
if (array_key_exists($param->phpName, $overrides)) {
$value = $overrides[$param->phpName];
$value = $param->isPlain ? $value : ValueCaster::cast($param, $value);
$value = match (true) {
$param->isPlain => $value,
$param->isSensitive => ValueCaster::castSensitive($param, $value, static::class),
default => ValueCaster::cast($param, $value),
};
unset($overrides[$param->phpName]);
} else {
$value = $current[$param->phpName];
Expand Down
3 changes: 2 additions & 1 deletion src/Casts/DateTimeCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use InvalidArgumentException;
use StdOut\SimpleDataObjects\Contracts\CastsValue;
use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema;
use StdOut\SimpleDataObjects\Support\Redactor;

final class DateTimeCast implements CastsValue, ProvidesJsonSchema
{
Expand Down Expand Up @@ -50,7 +51,7 @@ public function get(mixed $value): ?DateTime

if ($dt === false) {
throw new InvalidArgumentException(
"Cannot parse \"{$value}\" using format \"{$this->inputFormat}\".",
'Cannot parse "'.Redactor::forMessage((string) $value)."\" using format \"{$this->inputFormat}\".",
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Casts/DateTimeImmutableCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use InvalidArgumentException;
use StdOut\SimpleDataObjects\Contracts\CastsValue;
use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema;
use StdOut\SimpleDataObjects\Support\Redactor;

final class DateTimeImmutableCast implements CastsValue, ProvidesJsonSchema
{
Expand Down Expand Up @@ -50,7 +51,7 @@ public function get(mixed $value): ?DateTimeImmutable

if ($dt === false) {
throw new InvalidArgumentException(
"Cannot parse \"{$value}\" using format \"{$this->inputFormat}\".",
'Cannot parse "'.Redactor::forMessage((string) $value)."\" using format \"{$this->inputFormat}\".",
);
}

Expand Down
1 change: 0 additions & 1 deletion src/Casts/EncryptedCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ public function __construct(
}
}

// BLAKE2b: a secure, fast KDF — no brute-force shortcut unlike raw sha256
$this->secretKey = sodium_crypto_generichash($key, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Casts/MoneyCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use InvalidArgumentException;
use StdOut\SimpleDataObjects\Contracts\CastsValue;
use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema;
use StdOut\SimpleDataObjects\Support\Redactor;
use StdOut\SimpleDataObjects\ValueObjects\Money;

/**
Expand Down Expand Up @@ -91,7 +92,7 @@ private function parseDecimalString(string $amount): int
$amount = trim($amount);

if (! preg_match('/^[+-]?+(?:\d++\.?+\d*+|\.\d++)$/', $amount)) {
throw new InvalidArgumentException("\"{$amount}\" is not a valid decimal amount.");
throw new InvalidArgumentException('"'.Redactor::forMessage($amount).'" is not a valid decimal amount.');
}

$negative = $amount[0] === '-';
Expand Down
3 changes: 2 additions & 1 deletion src/Casts/UuidCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use InvalidArgumentException;
use StdOut\SimpleDataObjects\Contracts\CastsValue;
use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema;
use StdOut\SimpleDataObjects\Support\Redactor;

final class UuidCast implements CastsValue, ProvidesJsonSchema
{
Expand All @@ -26,7 +27,7 @@ public function get(mixed $value): ?string
$uuid = (string) $value;

if (! preg_match(self::PATTERN, $uuid)) {
throw new InvalidArgumentException("\"{$uuid}\" is not a valid RFC 4122 UUID.");
throw new InvalidArgumentException('"'.Redactor::forMessage($uuid).'" is not a valid RFC 4122 UUID.');
}

return strtolower($uuid);
Expand Down
60 changes: 60 additions & 0 deletions src/Concerns/HasSensitiveFields.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace StdOut\SimpleDataObjects\Concerns;

use StdOut\SimpleDataObjects\Support\MetadataRegistry;
use StdOut\SimpleDataObjects\Support\Redactor;

trait HasSensitiveFields
{
public function __serialize(): array
{
(new \ReflectionClass($this))->initializeLazyObject($this);

$vars = get_mangled_object_vars($this);
$exposed = self::sensitiveKeys($vars);

if ($exposed !== []) {
throw new \LogicException(sprintf('Cannot serialize %s: field \'%s\' is #[Sensitive].', static::class, reset($exposed)));
}

return $vars;
}

public function __debugInfo(): array
{
if ((new \ReflectionClass($this))->isUninitializedLazyObject($this)) {
return [];
}

$vars = get_mangled_object_vars($this);

foreach (array_keys(self::sensitiveKeys($vars)) as $key) {
$vars[$key] = Redactor::REDACTED;
}

return $vars;
}

/**
* @param array<string, mixed> $vars
* @return array<string, string>
*/
private static function sensitiveKeys(array $vars): array
{
$sensitive = MetadataRegistry::get(static::class)->sensitiveFields;
$keys = [];

foreach ($vars as $key => $value) {
$name = substr($key, (int) strrpos("\0".$key, "\0"));

if ($value !== null && isset($sensitive[$name])) {
$keys[$key] = $name;
}
}

return $keys;
}
}
Loading
Loading