Free Ebook cover Practical Cryptography for Developers: Build, Break, and Secure Real Systems

Practical Cryptography for Developers: Build, Break, and Secure Real Systems

New course

11 pages

Implementation Labs: Library-First Patterns and Secure Defaults

Capítulo 10

Estimated reading time: 0 minutes

+ Exercise

What “Library-First” Means in Practice

Goal: implement cryptography by composing well-reviewed library primitives and high-level APIs, not by re-creating protocols or “gluing” low-level operations yourself. A library-first approach treats cryptography like a dependency you configure and constrain, rather than a set of algorithms you manually orchestrate.

Key idea: your code should mostly express intent (encrypt this payload for storage, sign this message for integrity, derive a key from this secret) while the library handles nonce management rules, padding details, constant-time operations, and format correctness. When you must handle those details, you do so behind a narrow interface with tests and strict invariants.

Why it matters for developers: most production crypto failures come from integration mistakes: wrong parameters, wrong encodings, nonce reuse, mismatched formats, partial verification, or “optional” checks that become skipped. Library-first patterns reduce the surface area where those mistakes can happen.

Secure Defaults as a Design Constraint

Secure defaults means the safest behavior is the easiest behavior. If a caller does nothing special, the system should still do the safe thing: strong algorithms, correct randomness, authenticated encryption, strict verification, and safe key handling.

Design rule: make insecure options hard to access. If you must support legacy modes, put them behind explicit “unsafe” names, separate modules, feature flags, or configuration gates that require justification and code review.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Practical outcome: your crypto code becomes boring: few knobs, predictable formats, and consistent error handling. Boring is good in cryptography.

Pattern: Wrap Crypto Behind a Small, Opinionated Interface

Problem: letting application code call crypto APIs directly leads to inconsistent parameters, ad-hoc encodings, and duplicated logic.

Pattern: create a small module (or service) that exposes only the operations your application needs. It should accept and return well-defined types (bytes in/bytes out, or structured objects), and it should own algorithm choice, versioning, and serialization format.

Interface example: a storage encryption wrapper that always uses AEAD, always generates nonces internally, and always returns a self-describing envelope.

// Pseudocode: an opinionated crypto facade for storage encryption
interface CryptoBox {
  encryptForStorage(plaintextBytes, aadBytes) -> envelopeBytes
  decryptFromStorage(envelopeBytes, aadBytes) -> plaintextBytes
}

// The envelope is versioned and includes algorithm identifiers.
// Callers never pass nonces, never pick algorithms, never handle tags.

Secure default: the only available encryption is authenticated encryption; there is no “encrypt without integrity” method.

Pattern: Versioned, Self-Describing Envelopes

Problem: crypto formats evolve. If you store raw ciphertext without metadata, you later cannot rotate algorithms or parameters without out-of-band knowledge.

Pattern: define an envelope format that includes: a version byte, algorithm ID, nonce/IV, ciphertext, authentication tag (if separate), and optional key ID. Serialize it in a canonical way (binary or JSON with strict rules).

Secure default: the decrypt function rejects unknown versions, unknown algorithms, and malformed fields. “Be conservative in what you accept” is dangerous in crypto; prefer strict parsing.

// Example envelope layout (binary):
// [1 byte version][1 byte algId][1 byte kidLen][kid][1 byte nonceLen][nonce][4 bytes ctLen][ciphertext+tag]
// Strict parsing: lengths must match, no trailing bytes, max sizes enforced.

Implementation note: if you use JSON, define canonical encoding rules (field names, base64 variant, required fields) and reject duplicates or unknown fields to avoid ambiguity.

Lab 1: Build a Storage Encryption Module with Secure Defaults

Objective: implement a reusable module that encrypts application data for storage using a high-level library API, with safe defaults and a versioned envelope.

Step 1 — Choose a high-level AEAD API

Pick a library that provides an AEAD construction through a high-level interface (for example, a “secretbox”/“aead” API). Favor APIs that: generate nonces safely (or make it easy), return combined ciphertext+tag, and validate tags during decryption automatically.

  • Do not expose algorithm selection to callers.
  • Do not allow caller-supplied nonces unless you have a strong reason and a strict strategy.
  • Prefer combined mode outputs (ciphertext includes tag) to reduce “forgot to verify tag” risks.

Step 2 — Define your envelope and AAD policy

Decide what must be bound to the ciphertext via AAD (additional authenticated data). AAD is not secret but is integrity-protected. Common AAD choices for storage encryption are: record ID, tenant ID, object type, schema version, or a stable context string.

Secure default: require AAD for decryption and make it deterministic. If the caller passes empty AAD, treat it as a deliberate choice and still bind at least a fixed context string like "app:storage:v1" so ciphertexts cannot be replayed across unrelated contexts.

Step 3 — Implement encrypt()

Encryption should: validate input sizes, generate a fresh nonce, call the AEAD encrypt function, and serialize a strict envelope. It should also include a key identifier (KID) if you support key rotation.

// Pseudocode
function encryptForStorage(plaintext, aad):
  assert plaintext.length <= MAX_PLAINTEXT
  nonce = secureRandom(NONCE_LEN)
  ct = aeadEncrypt(key=currentKey, nonce=nonce, plaintext=plaintext, aad=aad)
  envelope = encodeEnvelope(version=1, algId=ALG_AEAD, kid=currentKid, nonce=nonce, ct=ct)
  return envelope

Secure default: enforce maximum sizes to prevent memory abuse and to avoid parsing edge cases. Make limits explicit constants.

Step 4 — Implement decrypt() with strict validation

Decryption should: parse the envelope strictly, enforce limits, select the key by KID, and call AEAD decrypt. If authentication fails, return a single generic error (do not leak whether KID existed, whether parsing succeeded, etc.).

// Pseudocode
function decryptFromStorage(envelope, aad):
  parsed = decodeEnvelopeStrict(envelope)
  assert parsed.version == 1
  assert parsed.algId == ALG_AEAD
  assert parsed.nonce.length == NONCE_LEN
  assert parsed.ct.length >= MIN_CT
  key = keyring.get(parsed.kid) // may be null
  if key is null: return error("decrypt failed")
  pt = aeadDecrypt(key=key, nonce=parsed.nonce, ciphertext=parsed.ct, aad=aad)
  if pt is error: return error("decrypt failed")
  return pt

Secure default: one failure mode. Callers should not branch on “reason” for crypto failures.

Step 5 — Add tests that lock in invariants

Write tests that ensure your wrapper is hard to misuse:

  • Decrypt fails if AAD changes by one byte.
  • Decrypt fails if any envelope field is modified.
  • Unknown version is rejected.
  • Trailing bytes are rejected (strict parsing).
  • Key rotation works: old KID decrypts old data, new KID encrypts new data.

Secure default: treat the envelope format as an API contract. Tests are your guardrails against “helpful” future changes that weaken strictness.

Pattern: Key IDs and Rotation Without Spreading Complexity

Problem: key rotation often leaks into application code: “try decrypt with key1, then key2,” or “store which key was used somewhere else.” That creates inconsistent behavior and makes it easy to forget migration paths.

Pattern: keep rotation inside the crypto module. The envelope carries KID; the module owns a keyring that can fetch by KID and knows the “current” key for encryption.

Secure default: encryption always uses the current key; decryption uses the KID in the envelope and never guesses. If you must support “no KID” legacy data, handle it in a dedicated legacy decoder path and plan a migration.

// Pseudocode keyring behavior
class Keyring:
  currentKid
  keysByKid

  function currentKey(): return keysByKid[currentKid]
  function get(kid): return keysByKid.get(kid)

Lab 2: Add Algorithm Agility with Version Gates (Without “Crypto Roulette”)

Objective: support future upgrades (new algorithms or parameters) while preventing callers from selecting weak options.

Step 1 — Introduce envelope versioning rules

Define a small set of versions, each mapping to exactly one algorithm suite and parameter set. For example:

  • Version 1: AEAD suite A with nonce length N
  • Version 2: AEAD suite B with nonce length M

Secure default: do not allow “version chosen by caller.” The encrypt function always emits the latest version. Only decrypt supports older versions.

Step 2 — Implement decrypt dispatch by version

Dispatch to a version-specific decoder and decryptor. Keep each version’s parsing and constraints separate to avoid “optional fields” that become ambiguous.

// Pseudocode
function decryptFromStorage(envelope, aad):
  v = peekVersion(envelope)
  if v == 1: return decryptV1(envelope, aad)
  if v == 2: return decryptV2(envelope, aad)
  return error("decrypt failed")

Secure default: unknown versions fail closed. Never “try to interpret” unknown versions.

Step 3 — Add migration tooling

Create a helper that reads an envelope, decrypts it, and re-encrypts it with the latest version and current key. This is used by background jobs or on-read migration.

// Pseudocode
function rewrapToLatest(envelope, aad):
  pt = decryptFromStorage(envelope, aad)
  if pt is error: return error
  return encryptForStorage(pt, aad)

Secure default: migration is explicit and uses the same wrapper, not ad-hoc scripts calling low-level crypto APIs.

Pattern: Deterministic Serialization and Canonical Encoding

Problem: crypto operates on bytes, but applications operate on objects. If you serialize objects inconsistently (field order, whitespace, float formatting, Unicode normalization), signatures and MACs may fail or—worse—verify different meanings in different components.

Pattern: define a canonical serialization for any data you authenticate. Options include: a strict binary format, canonical JSON rules, or a stable protobuf schema with deterministic serialization enabled. Then treat serialization as part of the cryptographic boundary.

Secure default: your crypto wrapper should accept bytes, not arbitrary objects, unless it also owns canonicalization. If you must accept objects, canonicalize inside the wrapper and test it.

// Pseudocode: canonical JSON approach
function canonicalize(obj):
  // sort keys, disallow NaN/Infinity, normalize Unicode, no extra whitespace
  return canonicalJsonBytes

Lab 3: Build a Signed Message Envelope for Internal Events

Objective: implement a signing wrapper for internal event messages (e.g., job payloads, webhooks between services) that prevents partial verification and enforces strict parsing.

Step 1 — Define what is signed

Sign a canonical byte representation of: version, timestamp (if used), event type, payload bytes, and a key ID. Keep the signed bytes unambiguous and identical across languages.

Secure default: sign the exact bytes you will interpret. Do not sign a subset and then parse additional fields outside the signature.

Step 2 — Create a strict signed envelope

The envelope should include: version, kid, payload, and signature. If you include headers, include them in the signed bytes. Avoid “unsigned metadata.”

// Pseudocode envelope
// { v: 1, kid: "k1", payload: base64(...), sig: base64(...) }

function signEvent(payloadBytes, contextBytes):
  toSign = encodeToSign(v=1, kid=currentKid, context=contextBytes, payload=payloadBytes)
  sig = sign(privateKey=currentSigningKey, message=toSign)
  return encodeEnvelope(v=1, kid=currentKid, payload=payloadBytes, sig=sig)

Step 3 — Verify with a single entry point

Verification should parse strictly, rebuild the exact signed bytes, select the public key by KID, and verify. If verification fails, return a generic error.

// Pseudocode
function verifyEvent(envelopeBytes, contextBytes):
  env = decodeEnvelopeStrict(envelopeBytes)
  assert env.v == 1
  pub = pubkeyStore.get(env.kid)
  if pub is null: return error("verify failed")
  toVerify = encodeToSign(v=env.v, kid=env.kid, context=contextBytes, payload=env.payload)
  ok = verify(pub, toVerify, env.sig)
  if not ok: return error("verify failed")
  return env.payload

Secure default: the only way to access the payload is through successful verification. Do not expose a “parse without verify” helper in the same module unless it is clearly marked unsafe and used only in tests.

Step 4 — Add replay and context binding hooks

Even for internal events, you often need context binding: include a stable context string (service name, queue name, environment) in contextBytes. If you include timestamps or nonces for replay control, make them required and validated in the wrapper, not in application code.

  • Bind to environment: "prod" vs "staging".
  • Bind to channel: queue/topic name.
  • Bind to purpose: "billing:charge" vs "billing:refund".

Pattern: Misuse-Resistant APIs (Make the Wrong Thing Impossible)

Problem: developers under time pressure will pick the easiest API call, even if it is unsafe. If your wrapper exposes foot-guns, they will be used.

Pattern: design APIs that encode invariants in types and function signatures:

  • Use distinct types for plaintext vs ciphertext vs envelope bytes.
  • Require AAD/context parameters where needed; do not make them optional.
  • Return structured errors that are safe to log internally but do not leak details to callers.
  • Hide low-level primitives behind package-private modules.
// Pseudocode type separation
class Plaintext(bytes)
class CipherEnvelope(bytes)

function encryptForStorage(pt: Plaintext, aad: bytes) -> CipherEnvelope
function decryptFromStorage(env: CipherEnvelope, aad: bytes) -> Plaintext

Secure default: if a caller tries to pass raw bytes where an envelope is expected, it should not compile (or should fail fast at runtime with clear developer-facing errors).

Pattern: Centralize Randomness, Time, and Encoding Dependencies

Problem: crypto wrappers often depend on randomness, time, and encoding. If each call site supplies these, you get inconsistent behavior and hard-to-test code.

Pattern: inject dependencies into the crypto module: a CSPRNG provider, a clock, and a base64/serialization implementation. Then you can test deterministically and enforce consistent encoding.

Secure default: production wiring uses the platform’s secure randomness and a single base64 variant. Tests use deterministic stubs to validate envelope layout and versioning.

// Pseudocode dependency injection
class CryptoDeps:
  rng
  clock
  encoder

class CryptoBox(deps: CryptoDeps, keyring: Keyring): ...

Lab 4: Hardening Checklist as Code (Preconditions and Guards)

Objective: implement defensive checks inside your wrapper so misuse fails fast and safely.

Step 1 — Add size and structure limits

Define maximum sizes for plaintext, ciphertext, envelope, and fields like KID. Enforce them during encoding and decoding.

  • Max envelope size (e.g., 64 KB) to prevent memory abuse.
  • Max KID length (e.g., 64 bytes) and allowed character set if textual.
  • Exact nonce length per version.

Step 2 — Enforce constant-time comparisons via library calls

Do not manually compare tags, signatures, or derived values using regular equality. Use the library’s verify functions, which should be constant-time and side-channel aware.

Secure default: never expose raw tags or signatures for manual checking. Verification is a single call that returns true/false.

Step 3 — Normalize error handling and logging

Return a generic error to callers (e.g., "decrypt failed" or "verify failed"). Internally, log structured diagnostics carefully: version mismatch, parse failure, unknown KID, verification failure. Ensure logs do not include secrets, plaintext, or full ciphertexts.

// Pseudocode
try:
  ...decrypt...
catch ParseError:
  log("crypto_parse_error", {reason:"bad_format"})
  return error("decrypt failed")
catch VerifyError:
  log("crypto_verify_error", {reason:"auth_failed"})
  return error("decrypt failed")

Step 4 — Add “unsafe” modules only when necessary

If you must support legacy integrations, isolate them:

  • Put legacy decryptors in legacy/ with loud naming.
  • Require explicit configuration to enable legacy support.
  • Add metrics to measure remaining legacy traffic and plan removal.

Secure default: legacy support is off by default in new deployments.

Pattern: Test Vectors, Cross-Language Compatibility, and Fuzzing

Problem: crypto wrappers often need to work across services written in different languages. Subtle differences in encoding or parsing can break verification or, worse, create ambiguous interpretations.

Pattern: publish test vectors for each envelope version: fixed keys (test-only), fixed nonces (test-only), known plaintext/AAD, and expected envelope bytes. Use them in every language implementation.

Secure default: treat the envelope decoder as an attack surface. Add fuzz tests that feed random bytes and ensure: no crashes, no hangs, strict rejection of malformed inputs, and consistent error behavior.

// Example test vector structure
// - version: 1
// - kid: "test-k1"
// - aad: hex(...)
// - plaintext: hex(...)
// - envelope: base64(...)
// Each implementation must produce the same envelope (when nonce fixed) and must decrypt it.

Pattern: Configuration That Cannot Drift

Problem: “just a config change” can silently weaken crypto: enabling legacy modes, changing encoding, or disabling verification in a debug environment that later becomes production-like.

Pattern: make crypto configuration immutable at runtime and validated at startup. Use allowlists for versions and algorithms. Fail startup if configuration is inconsistent (e.g., current KID missing, unknown version enabled).

Secure default: production builds have a minimal configuration surface. Debug toggles that weaken security should not exist; use test keys and test environments instead.

// Pseudocode startup validation
function validateCryptoConfig(cfg, keyring):
  assert cfg.latestVersion in [1,2]
  assert keyring.has(cfg.currentKid)
  assert cfg.enabledDecryptVersions subsetOf [1,2]
  assert not cfg.enableLegacy unless cfg.env != "prod"

Now answer the exercise about the content:

Which design choice best supports secure algorithm upgrades without letting callers choose weaker crypto options?

You are right! Congratulations, now go to the next page

You missed! Try again.

Versioned, self-describing envelopes allow upgrades safely: encryption outputs the latest version, decryption supports older versions via strict version dispatch, and unknown versions fail closed.

Next chapter

Capstone: Secure Authentication and Data Protection for a Small SaaS

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.