diff --git a/.agents/skills/cryptography/SKILL.md b/.agents/skills/cryptography/SKILL.md new file mode 100644 index 0000000..3d91b81 --- /dev/null +++ b/.agents/skills/cryptography/SKILL.md @@ -0,0 +1,52 @@ +--- +name: cryptography +description: Use whenever generating or reviewing F# code that opens Feather.Cryptography.Encode/Hash/Cryptography or calls Base64.encodeString, Base64.toBase64Url, SHA256.sha256Hex, Crc32.crc32OfString, RSA256.createKeyPair, AES256GCM.encrypt/decrypt, EncryptedEnvelope.encrypt/decrypt, Symmetric.encrypt/decrypt, or Bcrypt.hashPassword/verifyPassword. Trigger also on mentions of envelope encryption, DEK/KEK, AAD, base64url for JWTs, AES-256-GCM, RSA-OAEP, HKDF-derived symmetric secrets, or password hashing in F#. +--- + +# Cryptography + +Library: [FeatherTools/cryptography](https://github.com/FeatherTools/cryptography) +NuGet: `Feather.Cryptography` + +## Purpose + +F# library of ready-made use-cases for encryption, decryption, hashing, and encoding. It wraps the .NET cryptographic primitives behind small qualified-access modules and single-case discriminated-union types so calling code passes well-typed keys, nonces, tags, and ciphertext instead of bare byte arrays. + +## When to Use + +- Encoding bytes/strings to Base64 or URL-safe base64url (e.g. for JWTs or query parameters). +- Hashing strings with SHA-256 or computing a CRC-32 checksum. +- Encrypting/decrypting with RSA (OAEP-SHA256), AES-256-GCM, envelope encryption (DEK wrapped by an external KEK service), or a single-secret symmetric helper. +- Hashing and verifying passwords with bcrypt. + +## When NOT to Use + +- As a general-purpose crypto framework: only the use-cases below are exposed. +- For integrity-only checks where a non-cryptographic checksum is wanted security-wise — `Crc32` is a checksum, not a secure hash. +- For key/secret storage or rotation: the library encrypts but does not persist or manage key material. + +## Main Concepts + +- `Encode` — UTF-8 string/byte conversion and Base64 (`Base64` submodule, including base64url helpers). +- `Hash` — `SHA256` (cryptographic hash, lowercase hex) and `Crc32` (checksum over ASCII bytes). +- `Cryptography` — the encryption module hosting the types and algorithm submodules below. +- `RSA256` — asymmetric RSA-2048 key-pair generation and OAEP-SHA256 encrypt/decrypt. +- `AES256GCM` — authenticated symmetric encryption producing `IV`, ciphertext, and `Tag`. +- `EncryptedEnvelope` — record bundling a payload encrypted by a per-message `DEK`, where the `DEK` itself is wrapped by a caller-supplied function (external KEK/vault). +- `Symmetric` — single-secret encrypt/decrypt where any string secret is run through HKDF-SHA256. +- `Bcrypt` — password hashing and verification. +- `Secret` — random base64url/byte secret generation. +- Wrapper types: `PrivateKey`, `PublicKey`, `EncryptedData`, `KEK`, `DEK`, `IV`, `Tag`, `AAD`, `Version`, `Algorithm`. + +## Related Libraries + +- `Feather.ErrorHandling` — supplies `AsyncResult`, the `asyncResult` computation expression, and `Result.orFail`; envelope APIs return `AsyncResult<_, string>`. +- `BCrypt.Net-Core` — backs the `Bcrypt` module. + +## Keywords for Search + +cryptography, encryption, decryption, hashing, F#, fsharp, Feather.Cryptography, Base64, base64url, JWT, SHA256, sha256Hex, CRC32, crc32OfString, RSA, RSA256, OAEP, AES, AES256GCM, AES-256-GCM, GCM, nonce, IV, Tag, AAD, DEK, KEK, envelope encryption, EncryptedEnvelope, Symmetric, HKDF, secret, generateSecret, Bcrypt, password hashing, AsyncResult + +## Reference Files + +For composition principles and recommended API usage, read `references/preferred-patterns.md`. For known pitfalls and incorrect assumptions, read `references/anti-patterns.md`. For worked code examples, read `references/examples.md`. diff --git a/.agents/skills/cryptography/references/anti-patterns.md b/.agents/skills/cryptography/references/anti-patterns.md new file mode 100644 index 0000000..2f6fb98 --- /dev/null +++ b/.agents/skills/cryptography/references/anti-patterns.md @@ -0,0 +1,37 @@ +# Anti-Patterns + +Each item is **mistake → why → fix**. Code lives only in `references/examples.md`. + +## Common Mistakes + +- **Using `Crc32` to protect or fingerprint data securely** → CRC-32 is a non-cryptographic checksum for detecting accidental corruption; it is trivially forgeable → use `SHA256.sha256Hex`/`sha256` when integrity must resist tampering. + +- **Expecting `SHA256.sha256Hex` to return uppercase hex** → it lowercases the output → compare against lowercase expected values (e.g. `b94d27b9...`), or normalise both sides before comparing. + +- **Assuming `Crc32.crc32OfString` encodes the input as UTF-8** → it encodes via ASCII before hashing → for non-ASCII input convert bytes yourself and call `Crc32.crc32`, and never assume the checksum matches a UTF-8 CRC from another tool. + +- **Treating standard Base64 and base64url as interchangeable** → JWTs and URLs require base64url (`+/=` replaced/stripped); plain `Base64.encode` output breaks them → pipe through `Base64.toBase64Url` for transport and `Base64.fromBase64Url` before `Base64.decode`. + +- **Discarding the `IV` and `Tag` returned by `AES256GCM.encrypt`** → decryption needs the exact nonce and authentication tag; without them the ciphertext is unrecoverable → store/transmit all three (`IV`, ciphertext, `Tag`) together. + +- **Reusing or hand-rolling a nonce for AES-GCM** → the library already generates a fresh random nonce per `encrypt` call, and nonce reuse with the same key destroys GCM security → call `AES256GCM.encrypt` again for each message and keep the returned `IV`; never inject your own. + +- **Mismatching `AAD` between encrypt and decrypt** → GCM authenticates the `AAD`, so any difference (including `Some` vs `None`) throws/fails authentication → pass the identical `AAD` value to both sides. + +- **Ignoring the `Result` from `Symmetric.decrypt`** → it returns `Result` and reports wrong-secret/corrupted/format errors as `Error`, not exceptions → match on `Ok`/`Error` rather than assuming success or wrapping in `try/with`. + +- **Catching exceptions around `EncryptedEnvelope.decrypt`** → it already converts `CryptographicException` into `Error "Envelope authentication failed"` on the `AsyncResult` error channel → handle the error channel; a `try/with` will never fire for auth failure. + +- **Hashing passwords with `SHA256`** → fast hashes are unsuitable for passwords (no work factor, brute-forceable) → use `Bcrypt.hashPassword`/`Bcrypt.verifyPassword`. + +## Do Not Use / Avoid + +- **`open`-ing the qualified submodules to shorten calls** → the modules are `[]` for disambiguation (`SHA256`, `Base64`, etc. collide with .NET names) → keep the module prefix. + +- **Passing raw `byte[]` where a wrapper type is expected** → functions take `DEK`, `IV`, `Tag`, `AAD`, `PublicKey`, etc., and the compiler will reject bare arrays → wrap explicitly (`DEK bytes`, `AAD bytes`). + +- **Embedding a master/KEK key inside the encrypt/decrypt callbacks naively** → the envelope design deliberately delegates DEK wrapping to your `encryptDEK`/`decryptDEK` so the master key can stay in an external service → route DEK wrapping through that external key service rather than holding the KEK in process. + +## Wrong Abstractions + +- **Building your own envelope by manually combining `RSA256` + `AES256GCM`** → `EncryptedEnvelope` already generates the per-message `DEK`, encrypts the payload, wraps the `DEK`, and clears key bytes from memory → use `EncryptedEnvelope.encrypt`/`decrypt` (and the batch variants for bulk). diff --git a/.agents/skills/cryptography/references/examples.md b/.agents/skills/cryptography/references/examples.md new file mode 100644 index 0000000..e58b3b3 --- /dev/null +++ b/.agents/skills/cryptography/references/examples.md @@ -0,0 +1,215 @@ +# Examples + +This is the single source of all example code for the skill. Examples are ordered from basic to full workflow and are self-contained. + +## Encoding + +```fsharp +open Feather.Cryptography.Encode + +// String <-> bytes (UTF-8) +let bytes = stringToBytes "Hello, World!" +let text = bytesToString bytes + +// Standard Base64 round-trip +let encoded = Base64.encodeString "Hello, World!" +let decoded = Base64.decodeString encoded + +// URL-safe base64url (for JWTs, query parameters) +let urlSafe = + "Hello, World!" + |> Base64.encodeString + |> Base64.toBase64Url + +let original = + urlSafe + |> Base64.fromBase64Url + |> Base64.decodeString +``` + +## Hashing + +```fsharp +open Feather.Cryptography.Hash + +// SHA-256 as lowercase hex +let digest = SHA256.sha256Hex "hello world" +// "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + +// SHA-256 of raw bytes +let rawDigest = SHA256.sha256 "hello world"B + +// CRC-32 checksum (hex) +let checksum = Crc32.crc32OfString "hello world" +// "d4a1185" +``` + +## RSA + +```fsharp +open Feather.Cryptography.Cryptography + +let privateKey, publicKey = RSA256.createKeyPair () + +let encrypted = RSA256.encrypt publicKey "Secret message"B +let decrypted = RSA256.decrypt privateKey encrypted +``` + +## AES-GCM + +```fsharp +open Feather.Cryptography.Cryptography + +let dek = AES256GCM.generateKey () + +// Without additional authenticated data +let iv, ciphertext, tag = AES256GCM.encrypt dek None "Sensitive data"B +let plaintext = AES256GCM.decrypt dek iv ciphertext tag None + +// With AAD (authenticated, not encrypted) — must match on decrypt +let aad = AAD "record-metadata"B +let iv2, ciphertext2, tag2 = AES256GCM.encrypt dek (Some aad) "Sensitive data"B +let plaintext2 = AES256GCM.decrypt dek iv2 ciphertext2 tag2 (Some aad) +``` + +## Symmetric + +```fsharp +open Feather.Cryptography.Cryptography + +// Generate a strong secret, or supply any string from an external secret store +let secret = Symmetric.generateSecret () + +let ciphertext = Symmetric.encrypt secret "Hello, symmetric world!" + +match Symmetric.decrypt secret ciphertext with +| Ok plaintext -> printfn "Decrypted: %s" plaintext +| Error msg -> printfn "Failed: %s" msg +``` + +## Bcrypt + +```fsharp +open Feather.Cryptography.Cryptography + +let hashed = Bcrypt.hashPassword "StrongPassword123!" + +let isValid = Bcrypt.verifyPassword "StrongPassword123!" hashed // true +let isWrong = Bcrypt.verifyPassword "WrongPassword" hashed // false +``` + +## Envelope Encryption (Integration) + +The payload is encrypted with a per-message `DEK`; the `DEK` is wrapped by a caller-supplied function so the master key can live in an external key service. Here RSA stands in for that service. + +```fsharp +open Feather.Cryptography.Cryptography +open Feather.ErrorHandling + +let privateKey, publicKey = RSA256.createKeyPair () + +let encryptDEK (DEK dek) = asyncResult { + return RSA256.encrypt publicKey dek +} + +let decryptDEK encryptedDek = asyncResult { + let dekBytes = RSA256.decrypt privateKey encryptedDek + return DEK dekBytes +} + +let aad = AAD "resource-123"B |> Some + +let envelope = + "Top secret data"B + |> EncryptedEnvelope.encrypt encryptDEK aad + |> AsyncResult.runSynchronously + |> Result.orFail + +let decrypted = + envelope + |> EncryptedEnvelope.decrypt decryptDEK aad + |> AsyncResult.runSynchronously + |> Result.orFail +``` + +## Envelope Encryption with an External Key Service (Integration) + +Plug any vault/HSM client into the same `encryptDEK`/`decryptDEK` shape. `KeyService` is a neutral placeholder for such a client. + +```fsharp +open Feather.Cryptography.Cryptography +open Feather.ErrorHandling + +let encryptDEKWithService (keyService: KeyService) (DEK dek) = asyncResult { + try + let! ciphertext = keyService.WrapAsync dek |> Async.AwaitTask + return EncryptedData ciphertext + with ex -> + return! Error $"Key service wrap failed: {ex.Message}" +} + +let decryptDEKWithService (keyService: KeyService) (EncryptedData wrappedDek) = asyncResult { + try + let! dek = keyService.UnwrapAsync wrappedDek |> Async.AwaitTask + return DEK dek + with ex -> + return! Error $"Key service unwrap failed: {ex.Message}" +} +``` + +## Batch Envelope Encryption (Integration) + +Wrap many `DEK`s in a single round-trip to the key service; list order is preserved. + +```fsharp +open Feather.Cryptography.Cryptography +open Feather.ErrorHandling + +let encryptDEKs (deks: DEK list) = asyncResult { + return deks |> List.map (fun (DEK dek) -> RSA256.encrypt publicKey dek) +} + +let decryptDEKs (wrapped: EncryptedData list) = asyncResult { + return wrapped |> List.map (fun ed -> DEK (RSA256.decrypt privateKey ed)) +} + +let payloads = [ "Item 1"B; "Item 2"B; "Item 3"B ] + +let envelopes = + payloads + |> EncryptedEnvelope.encryptBatch encryptDEKs None + |> AsyncResult.runSynchronously + |> Result.orFail + +let restored = + envelopes + |> EncryptedEnvelope.decryptBatch decryptDEKs None + |> AsyncResult.runSynchronously + |> Result.orFail +``` + +## Test + +A round-trip and a negative case with Expecto. + +```fsharp +open Expecto +open Feather.Cryptography.Cryptography + +[] +let symmetricTests = + testList "Symmetric" [ + testCase "round-trips a payload" <| fun _ -> + let secret = Symmetric.generateSecret () + let encrypted = Symmetric.encrypt secret "payload" + match Symmetric.decrypt secret encrypted with + | Ok decrypted -> Expect.equal decrypted "payload" "should match original" + | Error err -> failtest err + + testCase "rejects a wrong secret" <| fun _ -> + let encrypted = Symmetric.encrypt (Symmetric.generateSecret ()) "payload" + match Symmetric.decrypt (Symmetric.generateSecret ()) encrypted with + | Ok _ -> failtest "expected decryption to fail" + | Error _ -> () + ] +``` diff --git a/.agents/skills/cryptography/references/preferred-patterns.md b/.agents/skills/cryptography/references/preferred-patterns.md new file mode 100644 index 0000000..b97e828 --- /dev/null +++ b/.agents/skills/cryptography/references/preferred-patterns.md @@ -0,0 +1,46 @@ +# Preferred Patterns + +For all runnable code referenced below, see `references/examples.md`. + +## Core Principles + +- Every module is `[]`; always call through the module name (`Base64.encode`, `RSA256.encrypt`, `AES256GCM.decrypt`), never `open` the submodule to drop the prefix. +- Keys, nonces, tags, ciphertext, and authenticated data are single-case DUs (`PrivateKey`, `PublicKey`, `DEK`, `IV`, `Tag`, `AAD`, `EncryptedData`). Construct and pattern-match them explicitly; do not pass raw `byte[]` where a wrapper is expected. +- Plaintext and binary payloads are `byte[]`; use the `Encode` helpers to cross the string boundary rather than calling `System.Text.Encoding` directly. +- Transmit binary output as text with base64url (`Base64.toBase64Url`) when it must survive URLs or JWTs; reverse with `Base64.fromBase64Url` before decoding. + +## Recommended API Usage + +- Encoding round-trips: pair `Base64.encodeString`/`decodeString`, and `Base64.toBase64Url`/`fromBase64Url`. See `examples.md` → Encoding. +- Hashing: `SHA256.sha256Hex` returns lowercase hex; `SHA256.sha256` returns raw bytes; `Crc32.crc32OfString` returns a hex checksum. See `examples.md` → Hashing. +- Asymmetric: generate once with `RSA256.createKeyPair`, then `RSA256.encrypt`/`decrypt`. See `examples.md` → RSA. +- Authenticated symmetric: `AES256GCM.generateKey` yields a `DEK`; `encrypt` returns the `IV`, ciphertext, and `Tag` you must retain for `decrypt`. See `examples.md` → AES-GCM. +- Single-secret helper: `Symmetric.encrypt`/`decrypt` take any string secret and a string payload; `Symmetric.generateSecret` produces a strong one. See `examples.md` → Symmetric. +- Passwords: `Bcrypt.hashPassword` then `Bcrypt.verifyPassword`. See `examples.md` → Bcrypt. + +## Error Handling + +- `RSA256` and `AES256GCM` raise on failure (a wrong key, tag, or `AAD` throws `CryptographicException`); wrap calls in `try/with` if the failure is expected. +- `Symmetric.decrypt` returns `Result` — match on `Ok`/`Error` instead of catching exceptions; it already maps tampering and bad encoding to descriptive `Error` values. +- `EncryptedEnvelope.decrypt`/`decryptBatch` catch `CryptographicException` internally and surface it as `Error "Envelope authentication failed"`, so handle the `AsyncResult` error channel rather than exceptions. + +## Composition + +- `EncryptedEnvelope.encrypt` and `decrypt` are parameterised by a caller-supplied `encryptDEK: DEK -> AsyncResult` and `decryptDEK: EncryptedData -> AsyncResult`. This is where an external KEK/vault/HSM is plugged in; the library never sees the master key. +- Use `encryptBatch`/`decryptBatch` when wrapping many `DEK`s in one round-trip to the key service; they preserve list order between inputs and outputs. +- The `AAD` argument, when supplied to `encrypt`, must be supplied identically to `decrypt`; it is authenticated but not encrypted. + +## Integration with Feather.ErrorHandling + +- Envelope APIs return `AsyncResult<_, string>`; build the `encryptDEK`/`decryptDEK` callbacks with the `asyncResult` computation expression. +- Run an `AsyncResult` to a value with `AsyncResult.runSynchronously` and unwrap with `Result.orFail` at the edge (tests, scripts), or keep composing within `asyncResult` in production code. + +## Naming Conventions + +- Bind destructured wrappers at the call site (`let (DEK dekBytes) = ...`, `let (IV nonce), ciphertext, (Tag tag) = ...`) to name the inner bytes clearly. +- Mirror the library's vocabulary in your own code: `dek`, `kek`, `iv`, `tag`, `aad`, `ciphertext`, `plaintext`. + +## Testing Recommendations + +- The suite uses Expecto; assert round-trips (`encrypt` then `decrypt` equals the original) and negative cases (wrong `AAD`/secret fails). See `examples.md` → Test. +- For known-answer tests, `SHA256.sha256Hex "hello world"` and `Crc32.crc32OfString "hello world"` have stable expected values shown in `examples.md` → Hashing. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6157e95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,132 @@ +# AGENTS.md — Feather.Cryptography + +This repo ships Agent Skill for the `Feather.Cryptography` library. Compatible agents discover it automatically; see `.agents/skills/cryptography/SKILL.md`. + +## Project Purpose + +`Feather.Cryptography` is an F# library of predefined use-cases for encryption, decryption, hashing, and encoding. It wraps .NET cryptographic primitives behind small `[]` modules and single-case discriminated-union types, so calling code passes well-typed keys, nonces, tags, and ciphertext instead of bare byte arrays. It exposes Base64/base64url encoding, SHA-256 and CRC32 hashing, RSA-OAEP key wrapping, AES-256-GCM, envelope encryption (DEK/KEK) with batch variants, HKDF-derived symmetric string encryption, and bcrypt password hashing. + +## Tech Stack + +- **Language:** F# (`net10.0`) +- **Package manager:** Paket +- **Build system:** FAKE (run via `build.sh`) +- **Test framework:** Expecto (with `YoloDev.Expecto.TestSdk`) +- **NuGet package id:** `Feather.Cryptography` +- **Repository URL:** https://github.com/FeatherTools/cryptography + +## Key Dependencies + +- `FSharp.Core ~> 10.0` — F# core library. +- `BCrypt.Net-Core ~> 1.6` — bcrypt password hashing/verification (`Cryptography.Bcrypt`). +- `Feather.ErrorHandling ~> 2.0` — provides the `AsyncResult` type and `asyncResult` computation expression used by the envelope encryption APIs. + +## Commands + +```bash +# Restore local dotnet tools (fake-cli, paket, dotnet-fsharplint) and Paket dependencies +dotnet tool restore +dotnet tool run paket restore + +# Full pipeline: Clean -> AssemblyInfo -> Build -> Lint -> Tests -> Release -> Publish +./build.sh + +# Individual targets +./build.sh -t build +./build.sh -t lint +./build.sh -t tests + +# Useful flags (passed as extra args) +./build.sh -t tests no-lint # run lint but do not fail on lint results +./build.sh no-clean # skip the Clean step (used on CI) +``` + +## Project Structure + +``` +Cryptography.fsproj # Library project: package id, version, compile order +build.sh # Entry point: tool restore + paket restore + FAKE run +paket.dependencies # Dependency manifest (main + Build group) +paket.references # Library package references +paket.lock # Locked dependency versions +CHANGELOG.md # Keep-a-changelog style history (Unreleased on top) +README.md # Packed into the NuGet package +src/ + Encode.fs # Encode module: Base64 / base64url + UTF8 helpers + Hash.fs # Hash module: SHA256, Crc32 + Cryptography.fs # Core crypto: Secret, RSA256, AES256GCM, EncryptedEnvelope, Symmetric, Bcrypt +tests/ + Tests.fs # Test entry point + EncodingTests.fs # Tests for Encode + HashTests.fs # Tests for Hash + CryptographyTests.fs # Tests for Cryptography + tests.fsproj # Expecto test project +build/ + Build.fs # FAKE build entry point + project definition + Targets.fs # FAKE target definitions and hierarchy + Utils.fs # Build helpers + SafeBuildHelpers.fs # SAFE-stack helpers (unused by this library) + build.fsproj # Build project +.config/ + dotnet-tools.json # Pinned local tools: fake-cli, paket, dotnet-fsharplint +.github/ + workflows/ # CI/CD: pr-check, tests, publish +.agents/skills/cryptography/ # Agent Skill for consumers of the library +``` + +## Architecture + +The library is organized as one namespace, `Feather.Cryptography`, split across three source files compiled in order (`Encode.fs` → `Hash.fs` → `Cryptography.fs`). Each public surface is a `[]` module, and cryptographic values are wrapped in single-case discriminated unions for type safety. + +Core types and components: + +1. **`Encode`** (`src/Encode.fs`) — `bytesToString`/`stringToBytes` UTF-8 helpers and the `Base64` module (`encode`, `decode`, `encodeString`, `decodeString`, `toBase64Url`, `fromBase64Url`). +2. **`Hash.SHA256`** (`src/Hash.fs`) — `sha256` (bytes) and `sha256Hex` (lowercase hex string). +3. **`Hash.Crc32`** (`src/Hash.fs`) — CRC32 lookup-table implementation; `crc32`, `crc32OfAscii`, `crc32OfString`. +4. **`Cryptography.Secret`** — random secret generation: `generateBase64Url`, `generateBytes`. +5. **`Cryptography.RSA256`** — RSA-2048 with OAEP-SHA256: `createKeyPair`, `encrypt`, `decrypt`. +6. **`Cryptography.AES256GCM`** — AES-256-GCM: `generateKey`, `encrypt`, `decrypt` (96-bit nonce, 128-bit tag, optional AAD). +7. **`Cryptography.EncryptedEnvelope`** — envelope encryption (`EncryptedEnvelope` record) using a per-item DEK wrapped by a caller-supplied function: `encrypt`, `decrypt`, `encryptBatch`, `decryptBatch`. +8. **`Cryptography.Symmetric`** — string-in/string-out AES-256-GCM with HKDF-SHA256 key derivation: `generateSecret`, `encrypt`, `decrypt`. +9. **`Cryptography.Bcrypt`** — `hashPassword`, `verifyPassword`. + +### Pattern: Single-case DU wrappers + +Cryptographic byte arrays are wrapped in single-case discriminated unions (`PrivateKey`, `PublicKey`, `EncryptedData`, `KEK`, `DEK`, `IV`, `Tag`, `AAD`, `Version`, `Algorithm`) instead of being passed as raw `byte[]`. This makes function signatures self-documenting and prevents accidentally swapping, for example, a nonce for a tag. The `EncryptedEnvelope` record composes these types together, and the envelope APIs accept caller-supplied DEK-wrapping functions (`DEK -> AsyncResult`) so key management (vault, HSM, etc.) stays outside the library. + +## Conventions + +- All source lives under a single `namespace Feather.Cryptography`. +- Public modules use `[]`; callers invoke them fully qualified (e.g. `AES256GCM.encrypt`). +- Cryptographic values are modeled as single-case DUs rather than bare `byte[]`. +- Key material is zeroed after use with `Array.Clear` to limit exposure in memory. +- Shared internal helpers live in an `[] module internal Utils` (e.g. `String.toLower`). +- The build injects `InternalsVisibleTo "tests"` so tests can reach internal members. +- Compile order is explicit in `Cryptography.fsproj` via `` entries — files must appear in dependency order. +- Async, fallible operations return `AsyncResult<_, string>` via the `asyncResult` computation expression from `Feather.ErrorHandling`. + +## CI/CD + +| Workflow | Trigger | What it does | +| --- | --- | --- | +| `pr-check.yaml` | `pull_request` | Blocks merging of `fixup!` commits and runs ShellCheck on shell scripts (with `SHELLCHECK_OPTS: -e SC1090`). | +| `tests.yaml` | `pull_request` and nightly `cron` (`0 3 * * *`) | Sets up .NET 10, then runs `./build.sh -t tests no-lint` on `ubuntu-latest`. | +| `publish.yaml` | `push` of a tag matching `[0-9]+\.[0-9]+\.[0-9]+` | Sets up .NET 10, then runs `./build.sh -t publish no-lint` to pack and push the package to NuGet.org using the `NUGET_API_KEY` secret. | + +## Release Process + +1. Bump `` in `Cryptography.fsproj`. +2. Move the relevant entries from the `## Unreleased` section of `CHANGELOG.md` into a new dated version section (e.g. `## 2.3.0 - 2026-06-11`). +3. Commit the changes. +4. Create and push a Git tag of the form `X.Y.Z` (matching `[0-9]+\.[0-9]+\.[0-9]+`). +5. The `publish.yaml` workflow runs on the tag push and publishes the package to NuGet.org. + +## Pitfalls + +- **Do not remove the `Array.Clear` calls** that wipe DEKs and derived keys — they intentionally scrub key material from memory after use. +- **Preserve the `` order** in `Cryptography.fsproj` (`Encode.fs` → `Hash.fs` → `Cryptography.fs`); F# requires dependencies to be compiled before their consumers. +- **`Crc32` hashes ASCII, not UTF-8** (`crc32OfAscii` uses `Encoding.ASCII`), unlike the SHA-256 helpers which use UTF-8 — non-ASCII input will not round-trip the way you might expect. +- **AES-256-GCM sizes are fixed** (12-byte nonce, 16-byte tag, 32-byte key); changing them will break compatibility with previously produced ciphertext and with the `Symmetric` `nonce ++ ciphertext ++ tag` layout. +- **`Symmetric` derives its key with HKDF-SHA256** from the secret string; the same secret must be used for `encrypt` and `decrypt`, and the encoded output is base64url, not standard base64. +- **`AssemblyInfo.fs` is generated** by the FAKE `AssemblyInfo` target from `CHANGELOG.md` and Git metadata — do not edit it by hand; update the changelog/version instead. +- **CI currently runs with `no-lint`** and there is no `fsharplint.json` or `.editorconfig` in the repo, so `fsharplint` uses its default rules and lint failures do not block CI; do not assume lint is enforced on PRs.