Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Chacha20 Laravel Package

dsbaars/chacha20

Pure PHP implementation of the ChaCha20 encryption algorithm, built for high performance relative to PHP. Supports streaming via persistent context objects and keystream seeking with setCounter. A fork of leigh/chacha20.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm what you actually get.

composer require dsbaars/chacha20

The README badge references dsbaars/PHP-ChaCha20, but the Composer name is dsbaars/chacha20. Confirm after install:

composer show dsbaars/chacha20

You should see ChaCha20\Cipher in the autoload map. If the package name resolves differently, check the repo's composer.json before wiring anything into production code.

Where to look first. Two files matter:

  • src/Cipher.php — init(), encrypt(), decrypt(), setCounter()
  • src/Context.php — the mutable keystream state (counter + partial block position)

Read Cipher::init() before writing any code: the accepted key and nonce lengths are the single most important thing to verify. This is a fork of leigh/chacha20, which follows the original 64-bit-nonce / 64-bit-counter ChaCha20 layout, so expect a 32-byte key and an 8-byte nonce — but confirm it in the source rather than trusting this assessment or the README, which does not state lengths at all.

First use case: a thin wrapper, not a direct call. The API is a raw stream cipher with no authentication, no key derivation, and no envelope format. Treat the library as a keystream primitive and put one small class in front of it so you can swap implementations later (libsodium, OpenSSL, or a managed KMS) without touching call sites.

// app/Services/ChaCha20Sealer.php
namespace App\Services;

use ChaCha20\Cipher;

final class ChaCha20Sealer
{
    public function __construct(private Cipher $cipher) {}

    public function seal(string $plaintext, string $key): string
    {
        $nonce = random_bytes(8);                    // verify length in Cipher::init()
        $ctx   = $this->cipher->init($key, $nonce);
        $ct    = $this->cipher->encrypt($ctx, $plaintext);
        $mac   = hash_hmac('sha256', $nonce . $ct, $key, true);

        return $nonce . $mac . $ct;                  // 8 + 32 + N
    }

    public function open(string $blob, string $key): string
    {
        $nonce = substr($blob, 0, 8);
        $mac   = substr($blob, 8, 32);
        $ct    = substr($blob, 40);

        $expected = hash_hmac('sha256', $nonce . $ct, $key, true);
        if (! hash_equals($expected, $mac)) {
            throw new \RuntimeException('Ciphertext failed authentication');
        }

        return $this->cipher->decrypt($this->cipher->init($key, $nonce), $ct);
    }
}

Bind it once in a service provider — the Cipher itself is stateless (state lives in the Context), so a singleton is safe:

$this->app->singleton(\ChaCha20\Cipher::class, fn () => new \ChaCha20\Cipher());
$this->app->singleton(\App\Services\ChaCha20Sealer::class);

Implementation Patterns

Encrypt-then-MAC envelope (the default pattern)

Never store encrypt() output on its own. ChaCha20 as implemented here is malleable: flipping a ciphertext bit flips the corresponding plaintext bit, and an attacker can do so blind. The seal/open wrapper above is the minimum viable pattern. Better still, derive two independent keys so the encryption key and MAC key are not the same:

$master = base64_decode(str_replace('base64:', '', config('app.key')));

$encKey = hash_hkdf('sha256', $master, 32, 'chacha20/enc');
$macKey = hash_hkdf('sha256', $master, 32, 'chacha20/mac');

Do not reuse APP_KEY raw for both roles, and do not feed the base64:-prefixed string straight in — decode it first.

Eloquent cast for column-level encryption

namespace App\Casts;

use App\Services\ChaCha20Sealer;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class ChaCha20Encrypted implements CastsAttributes
{
    public function __construct(private ChaCha20Sealer $sealer) {}

    public function get($model, $key, $value, $attributes): ?string
    {
        return $value === null ? null : $this->sealer->open($value, config('crypto.key'));
    }

    public function set($model, $key, $value, $attributes): ?string
    {
        return $value === null ? null : $this->sealer->seal($value, config('crypto.key'));
    }
}
protected $casts = ['api_secret' => \App\Casts\ChaCha20Encrypted::class];

Two rules that follow from this:

  • The column becomes opaque to SQL. No where('api_secret', ...), no LIKE, no ORDER BY. If you need lookup, add a separate blind-index column (hash_hmac('sha256', $value, $indexKey)).
  • Rotating the key means a migration job that reads, decrypts with the old key, re-seals with the new key. Plan for a key_id byte prefix in the envelope now rather than later.

Streaming with a persistent context

init() returns a Context that tracks the counter and any partial-block offset. That makes chunked processing possible without buffering the whole payload:

$ctx = $cipher->init($key, $nonce);

// Read from a stream, encrypt chunk by chunk. All calls must use the SAME $ctx.
foreach ($reader->chunks(8192) as $chunk) {
    $writer->write($cipher->encrypt($ctx, $chunk));
}

This is the correct way to encrypt a large file or a queued job payload without holding it in memory. Store the nonce alongside the output — you cannot resume decryption without it.

Counter-based seek for parallel or random-access work

setCounter($ctx, $n) positions the keystream at block n (64 bytes per block). This is what lets you split a large file across queue workers:

$blockOffset = $chunkIndex * $blocksPerChunk;   // blocks, not bytes
$ctx = $cipher->init($key, $nonce);
$cipher->setCounter($ctx, $blockOffset);
$ct  = $cipher->encrypt($ctx, $chunkBytes);

Every worker uses the same key and nonce but a distinct counter offset. That is safe only because the keystream ranges are disjoint — a bug in your offset math silently XORs two chunks against the same keystream and destroys both. Assert strlen($chunk) % 64 === 0 for every chunk except the last, or do a byte-level skip (encrypt str_repeat("\0", $bytesIntoBlock) and discard) when you need non-aligned access.

Integration into Laravel's encryption pipeline

If you need ChaCha20 specifically because a downstream system demands it, encapsulate it as a custom encrypter rather than sprinkling new Cipher through controllers. Laravel ships AES-256-GCM via Crypt (authenticated, audited, native-speed) — treat this package as an interoperability shim, not a replacement. A Crypt::extend()-style binding, or a ChaCha20Sealer injected where Illuminate\Contracts\Encryption\Encrypter was used, keeps the surface area to one class.


Gotchas and Tips

No authentication is built in. This is the headline risk. The library gives you confidentiality only. Anything you persist without an HMAC (or better, a proper AEAD construction) is trivially malleable. If you cannot articulate why you need raw ChaCha20 instead of AES-256-GCM, stop and use Crypt or sodium_crypto_secretbox.

Nonce reuse is catastrophic and silent. The README warns once; take it literally. With a random 8-byte nonce and a birthday bound around 2³² messages, you should be rotating keys well before you approach that, or using a counter-based nonce with a persisted high-water mark. Never derive the nonce from a model ID, a timestamp, or uniqid(). No error is raised on reuse — you just silently leak P1 ⊕ P2 to anyone with both ciphertexts.

Performance will surprise you. "High performance relative to PHP" still means single-digit-to-low-double-digit MB/s on a modern CPU, and much worse on PHP 7.x without JIT. Benchmark on your actual runtime before putting this on a hot path. Never use it per-request on a busy API endpoint, and never for real-time media.

Context objects are single-use and direction-specific. A Context is mutable state — the same object advances the keystream on every call. If you encrypt and then try to decrypt with the same context instance, you get garbage. decrypt() is a literal alias of encrypt(); it exists for readability only, not as a separate mode. Use one context per operation, and never share one across requests or across queued job attempts.

Verify the nonce/key length contract in the source. The README omits it. The README also omits any compatibility claim with RFC 8439 (96-bit nonce, 32-bit counter) — this fork almost certainly uses the original 64-bit-nonce layout, which means you cannot drop it in next to openssl_encrypt('...', 'chacha20-ietf', ...) or libsodium and expect matching output. If you need to interop with another system, validate against that system's test vectors before writing data you can't re-encrypt.

Testing: use the repo's own vectors, then add your own round-trip test. Write a test that seals a known payload and asserts stability, plus one that flips a byte in the MAC and asserts an exception. Also test the null path and the empty-string path through your cast — '' and null behave differently and both are common in real columns.

Maturity risk is real. One star, zero dependents, a fork of an abandoned original, no README documentation of the primitive's parameters. Treat it as vendored code: pin the version exactly in composer.json, and keep your wrapper thin enough that replacing the implementation with ten lines of openssl_encrypt or sodium_crypto_stream_xchacha20_xor is a one-file change.

Extension points are effectively zero. There is no pluggable backend, no interface, no DI hooks — just a concrete class. Your extension point is the wrapper class. That is another argument for writing it on day one rather than refactoring later.

Debugging tip. When decryption produces plausible-looking-but-wrong output, the cause is almost always one of exactly three things: (1) nonce not stored or stored truncated, (2) counter reset by reusing a context across chunks, (3) a byte-length change between the MAC input at seal time and at open time — for example, a column defined as VARCHAR(255) silently truncating a longer envelope. Use BLOB/TEXT, and validate the decoded length before calling open().

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator