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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Breaking any of these is a bug even when all tests pass. When a change makes a t
- **No key material in exception messages, traces, or object dumps.** Raw key bytes are held only in `HiddenString`, never in plain properties. Values repeated in messages that can come from stored data or from a mispasted config slot go through `LogSafeValue`. Parameters carrying keys or plaintext get `#[SensitiveParameter]`, including on private helper methods, because traces mask arguments per frame.
- **Exception constructors are not public API**, change their parameters freely. The exception class names, the inheritance (related failures are empty subclasses inheriting the message), and the no-key-material guarantee are API.
- **Validation happens in constructors**, not on first use: a misconfiguration fails at deploy time.
- **Byte encoding in `src/` goes through the constant-time sodium functions** (`sodium_hex2bin()`, `sodium_bin2hex()`, `sodium_bin2base64()`), never `hex2bin()`/`bin2hex()`/`base64_*()`. The tests still use `bin2hex()` until planned linter rules land; don't add new uses anywhere.
- **Byte encoding goes through the constant-time sodium functions** (`sodium_hex2bin()`, `sodium_bin2hex()`, `sodium_bin2base64()`), never `hex2bin()`/`bin2hex()`/`base64_*()`; PHPStan enforces this everywhere via the bundled `disallowed-non-timing-safe-calls.neon`.
- **Verify claims about Halite and libsodium against `vendor/paragonie/halite` sources or a `php -r` experiment**, never from memory or secondhand docs.

## Conventions
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Spaze\Encryption\SymmetricKeyEncryption::__construct(array $keys, string $active
```
#### `array $keys`
An array of encryption keys, a _key id_ (will be part of the encrypted string) as the array key, the prefixed _key_ (`prefix` + `_` + `[0-9A-F]{64}`) as the value.
Comment thread
spaze marked this conversation as resolved.
Generate your own encryption keys with for example `bin2hex(random_bytes(32))`.
Generate your own encryption keys with for example `sodium_bin2hex(random_bytes(32))`.
The constructor validates each key: the prefix must match, the key material must be valid hex, and it must decode to exactly 32 bytes (64 hexadecimal characters). The key id must be non-empty and must not contain `$`, because the id becomes part of the encrypted output format. A misconfigured key throws an exception at construction time, not on first use.

#### `string $activeKeyId`
Expand Down Expand Up @@ -147,8 +147,8 @@ $encryption = new Spaze\Encryption\AuthenticatedPublicKeyEncryption($secretKeys,
Each party generates their own pair, keeps the secret key to themselves and gives the public key to the other party:
```php
$keyPair = sodium_crypto_box_keypair();
$secretKey = 'adek_secret_' . bin2hex(sodium_crypto_box_secretkey($keyPair));
$publicKey = 'adek_public_' . bin2hex(sodium_crypto_box_publickey($keyPair));
$secretKey = 'adek_secret_' . sodium_bin2hex(sodium_crypto_box_secretkey($keyPair));
$publicKey = 'adek_public_' . sodium_bin2hex(sodium_crypto_box_publickey($keyPair));
```

### Encrypt & decrypt
Expand Down Expand Up @@ -244,7 +244,7 @@ A file the service reads is a good fit if you want to keep the keys in a file: m

YOU HAVE TO GENERATE YOUR OWN KEYS. You can use for example
```php
bin2hex(random_bytes(32))
sodium_bin2hex(random_bytes(32))
```
to generate a key, then add the prefix. You can have multiple keys in each group (here we see two groups: `password` and `email`), meaning you will be able to decrypt data encrypted with these keys. Data will always be encrypted with what's defined in `activeKeyIds` section.

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"php-parallel-lint/php-console-highlighter": "^1.0",
"phpstan/phpstan": "^2.2.7",
"nette/tester": "^2.5.7",
"phpstan/phpstan-strict-rules": "^2.0"
"phpstan/phpstan-strict-rules": "^2.0",
"spaze/phpstan-disallowed-calls": "^4.14"
},
"config": {
"allow-plugins": {
Expand Down
6 changes: 6 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ parameters:
includes:
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/spaze/phpstan-disallowed-calls/extension.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-dangerous-calls.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-execution-calls.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-insecure-calls.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-loose-calls.neon
- vendor/spaze/phpstan-disallowed-calls/disallowed-non-timing-safe-calls.neon
10 changes: 5 additions & 5 deletions tests/AnonymousPublicKeyEncryptionTest.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ class AnonymousPublicKeyEncryptionTest extends TestCase
$this->publicKeys = [];
foreach ([self::INACTIVE_KEY, self::ACTIVE_KEY] as $id) {
$keyPair = sodium_crypto_box_keypair();
$this->secretKeys[$id] = self::KEY_PREFIX . '_secret_' . bin2hex(sodium_crypto_box_secretkey($keyPair));
$this->publicKeys[$id] = self::KEY_PREFIX . '_public_' . bin2hex(sodium_crypto_box_publickey($keyPair));
$this->secretKeys[$id] = self::KEY_PREFIX . '_secret_' . sodium_bin2hex(sodium_crypto_box_secretkey($keyPair));
$this->publicKeys[$id] = self::KEY_PREFIX . '_public_' . sodium_bin2hex(sodium_crypto_box_publickey($keyPair));
}
$this->encryption = new AnonymousPublicKeyEncryption($this->secretKeys, $this->publicKeys, self::ACTIVE_KEY, self::KEY_PREFIX);
}
Expand Down Expand Up @@ -491,7 +491,7 @@ class AnonymousPublicKeyEncryptionTest extends TestCase

public function testConstructorInvalidKeyLength(): void
{
$shortKey = bin2hex(random_bytes(16));
$shortKey = sodium_bin2hex(random_bytes(16));
$e = Assert::exception(
function () use ($shortKey): void {
new AnonymousPublicKeyEncryption(['short' => self::KEY_PREFIX . '_secret_' . $shortKey], [], 'short', self::KEY_PREFIX);
Expand All @@ -504,7 +504,7 @@ class AnonymousPublicKeyEncryptionTest extends TestCase
// The public keys array is validated the same way as the secret keys array
Assert::exception(
function (): void {
new AnonymousPublicKeyEncryption([], ['bytes31' => self::KEY_PREFIX . '_public_' . bin2hex(random_bytes(31))], 'bytes31', self::KEY_PREFIX);
new AnonymousPublicKeyEncryption([], ['bytes31' => self::KEY_PREFIX . '_public_' . sodium_bin2hex(random_bytes(31))], 'bytes31', self::KEY_PREFIX);
},
InvalidKeyLengthException::class,
"Key 'bytes31' must be 32 bytes (64 hexadecimal characters) but is 31 bytes",
Expand All @@ -514,7 +514,7 @@ class AnonymousPublicKeyEncryptionTest extends TestCase

public function testConstructorInvalidKeyEncoding(): void
{
$truncatedKey = substr(bin2hex(random_bytes(32)), 0, 63);
$truncatedKey = substr(sodium_bin2hex(random_bytes(32)), 0, 63);
$e = Assert::exception(
function () use ($truncatedKey): void {
new AnonymousPublicKeyEncryption(['truncated' => self::KEY_PREFIX . '_secret_' . $truncatedKey], [], 'truncated', self::KEY_PREFIX);
Expand Down
16 changes: 8 additions & 8 deletions tests/AuthenticatedPublicKeyEncryptionTest.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ class AuthenticatedPublicKeyEncryptionTest extends TestCase
foreach ([self::INACTIVE_KEY, self::ACTIVE_KEY] as $id) {
$ourKeyPair = sodium_crypto_box_keypair();
$theirKeyPair = sodium_crypto_box_keypair();
$this->ourSecretKeys[$id] = self::KEY_PREFIX . '_secret_' . bin2hex(sodium_crypto_box_secretkey($ourKeyPair));
$this->ourPublicKeys[$id] = self::KEY_PREFIX . '_public_' . bin2hex(sodium_crypto_box_publickey($ourKeyPair));
$this->theirSecretKeys[$id] = self::KEY_PREFIX . '_secret_' . bin2hex(sodium_crypto_box_secretkey($theirKeyPair));
$this->theirPublicKeys[$id] = self::KEY_PREFIX . '_public_' . bin2hex(sodium_crypto_box_publickey($theirKeyPair));
$this->ourSecretKeys[$id] = self::KEY_PREFIX . '_secret_' . sodium_bin2hex(sodium_crypto_box_secretkey($ourKeyPair));
$this->ourPublicKeys[$id] = self::KEY_PREFIX . '_public_' . sodium_bin2hex(sodium_crypto_box_publickey($ourKeyPair));
$this->theirSecretKeys[$id] = self::KEY_PREFIX . '_secret_' . sodium_bin2hex(sodium_crypto_box_secretkey($theirKeyPair));
$this->theirPublicKeys[$id] = self::KEY_PREFIX . '_public_' . sodium_bin2hex(sodium_crypto_box_publickey($theirKeyPair));
}
$this->encryption = new AuthenticatedPublicKeyEncryption($this->ourSecretKeys, $this->theirPublicKeys, self::ACTIVE_KEY, self::KEY_PREFIX);
}
Expand Down Expand Up @@ -605,7 +605,7 @@ class AuthenticatedPublicKeyEncryptionTest extends TestCase

public function testConstructorInvalidKeyLength(): void
{
$shortKey = bin2hex(random_bytes(16));
$shortKey = sodium_bin2hex(random_bytes(16));
$e = Assert::exception(
function () use ($shortKey): void {
new AuthenticatedPublicKeyEncryption(['short' => self::KEY_PREFIX . '_secret_' . $shortKey], [], 'short', self::KEY_PREFIX);
Expand All @@ -618,7 +618,7 @@ class AuthenticatedPublicKeyEncryptionTest extends TestCase
// The public keys array is validated the same way as the secret keys array
Assert::exception(
function (): void {
new AuthenticatedPublicKeyEncryption($this->ourSecretKeys, [self::ACTIVE_KEY => self::KEY_PREFIX . '_public_' . bin2hex(random_bytes(31))], self::ACTIVE_KEY, self::KEY_PREFIX);
new AuthenticatedPublicKeyEncryption($this->ourSecretKeys, [self::ACTIVE_KEY => self::KEY_PREFIX . '_public_' . sodium_bin2hex(random_bytes(31))], self::ACTIVE_KEY, self::KEY_PREFIX);
},
InvalidKeyLengthException::class,
"Key 'dev2' must be 32 bytes (64 hexadecimal characters) but is 31 bytes",
Expand All @@ -628,7 +628,7 @@ class AuthenticatedPublicKeyEncryptionTest extends TestCase

public function testConstructorInvalidKeyEncoding(): void
{
$truncatedKey = substr(bin2hex(random_bytes(32)), 0, 63);
$truncatedKey = substr(sodium_bin2hex(random_bytes(32)), 0, 63);
$e = Assert::exception(
function () use ($truncatedKey): void {
new AuthenticatedPublicKeyEncryption(['truncated' => self::KEY_PREFIX . '_secret_' . $truncatedKey], [], 'truncated', self::KEY_PREFIX);
Expand Down Expand Up @@ -727,7 +727,7 @@ class AuthenticatedPublicKeyEncryptionTest extends TestCase

private function derivePublicKeyHex(string $secretKeyHex): string
{
return bin2hex(sodium_crypto_box_publickey_from_secretkey(sodium_hex2bin($secretKeyHex)));
return sodium_bin2hex(sodium_crypto_box_publickey_from_secretkey(sodium_hex2bin($secretKeyHex)));
}

}
Expand Down
20 changes: 10 additions & 10 deletions tests/SymmetricKeyEncryptionTest.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ class SymmetricKeyEncryptionTest extends TestCase
protected function setUp(): void
{
$this->keys = [
self::INACTIVE_KEY => self::KEY_PREFIX . '_' . bin2hex(random_bytes(32)),
self::ACTIVE_KEY => self::KEY_PREFIX . '_' . bin2hex(random_bytes(32)),
self::INACTIVE_KEY => self::KEY_PREFIX . '_' . sodium_bin2hex(random_bytes(32)),
self::ACTIVE_KEY => self::KEY_PREFIX . '_' . sodium_bin2hex(random_bytes(32)),
];
$this->encryption = new SymmetricKeyEncryption($this->keys, self::ACTIVE_KEY, self::KEY_PREFIX);
}
Expand Down Expand Up @@ -455,15 +455,15 @@ class SymmetricKeyEncryptionTest extends TestCase
// An empty key id would produce '$$<ciphertext>' which the parser rejects
Assert::exception(
function (): void {
new SymmetricKeyEncryption(['' => self::KEY_PREFIX . '_' . bin2hex(random_bytes(32))], '', self::KEY_PREFIX);
new SymmetricKeyEncryption(['' => self::KEY_PREFIX . '_' . sodium_bin2hex(random_bytes(32))], '', self::KEY_PREFIX);
},
InvalidKeyIdException::class,
'Key id must not be empty',
);
// A key id with the separator would encrypt fine but produce output that can never be decrypted
Assert::exception(
function (): void {
new SymmetricKeyEncryption(['key$1' => self::KEY_PREFIX . '_' . bin2hex(random_bytes(32))], 'key$1', self::KEY_PREFIX);
new SymmetricKeyEncryption(['key$1' => self::KEY_PREFIX . '_' . sodium_bin2hex(random_bytes(32))], 'key$1', self::KEY_PREFIX);
},
InvalidKeyIdException::class,
"Key id 'key\$1' must not contain '\$'",
Expand Down Expand Up @@ -491,7 +491,7 @@ class SymmetricKeyEncryptionTest extends TestCase
public function testConstructorNumericKeyId(): void
{
// PHP casts a numeric key id to an integer, the constructor has to cope with that and not just with strings
$keys = ['1' => self::KEY_PREFIX . '_' . bin2hex(random_bytes(32))];
$keys = ['1' => self::KEY_PREFIX . '_' . sodium_bin2hex(random_bytes(32))];
Assert::same([0 => 1], array_keys($keys)); // the id is an int now, there's no way to keep it a string
$encryption = new SymmetricKeyEncryption($keys, '1', self::KEY_PREFIX);
$encrypted = $encryption->encrypt(self::PLAINTEXT);
Expand All @@ -503,7 +503,7 @@ class SymmetricKeyEncryptionTest extends TestCase

public function testConstructorInvalidKeyLength(): void
{
$shortKey = bin2hex(random_bytes(16));
$shortKey = sodium_bin2hex(random_bytes(16));
$e = Assert::exception(
function () use ($shortKey): void {
new SymmetricKeyEncryption(['short' => self::KEY_PREFIX . '_' . $shortKey], 'short', self::KEY_PREFIX);
Expand All @@ -515,7 +515,7 @@ class SymmetricKeyEncryptionTest extends TestCase
Assert::notContains($shortKey, $e->getMessage());
Assert::exception(
function (): void {
new SymmetricKeyEncryption(['bytes31' => self::KEY_PREFIX . '_' . bin2hex(random_bytes(31))], 'bytes31', self::KEY_PREFIX);
new SymmetricKeyEncryption(['bytes31' => self::KEY_PREFIX . '_' . sodium_bin2hex(random_bytes(31))], 'bytes31', self::KEY_PREFIX);
},
InvalidKeyLengthException::class,
"Key 'bytes31' must be 32 bytes (64 hexadecimal characters) but is 31 bytes",
Expand All @@ -525,7 +525,7 @@ class SymmetricKeyEncryptionTest extends TestCase

public function testConstructorInvalidKeyEncoding(): void
{
$truncatedKey = substr(bin2hex(random_bytes(32)), 0, 63);
$truncatedKey = substr(sodium_bin2hex(random_bytes(32)), 0, 63);
$e = Assert::exception(
function () use ($truncatedKey): void {
new SymmetricKeyEncryption(['truncated' => self::KEY_PREFIX . '_' . $truncatedKey], 'truncated', self::KEY_PREFIX);
Expand All @@ -544,15 +544,15 @@ class SymmetricKeyEncryptionTest extends TestCase
// str_replace() used to strip all prefix occurrences silently, substr() removes only the leading one
Assert::exception(
function (): void {
new SymmetricKeyEncryption(['double' => self::KEY_PREFIX . '_' . self::KEY_PREFIX . '_' . bin2hex(random_bytes(32))], 'double', self::KEY_PREFIX);
new SymmetricKeyEncryption(['double' => self::KEY_PREFIX . '_' . self::KEY_PREFIX . '_' . sodium_bin2hex(random_bytes(32))], 'double', self::KEY_PREFIX);
},
InvalidKeyEncodingException::class,
);
// The secret/public tags of the public-key classes mean nothing here, a tagged value is just invalid hex:
// the symmetric class must never start interpreting the tags
Assert::exception(
function (): void {
new SymmetricKeyEncryption(['tagged' => self::KEY_PREFIX . '_secret_' . bin2hex(random_bytes(32))], 'tagged', self::KEY_PREFIX);
new SymmetricKeyEncryption(['tagged' => self::KEY_PREFIX . '_secret_' . sodium_bin2hex(random_bytes(32))], 'tagged', self::KEY_PREFIX);
},
InvalidKeyEncodingException::class,
);
Expand Down