Build Security That Can Evolve
Templates, checklists and educational examples for teams preparing a cryptographic migration. Everything here is copyable; every code example is educational unless marked otherwise.
01
Quickstart Guide
- Step 1
Read two explainers
Start with “Why Digital Ownership Depends on Cryptography” and “Why Quantum Readiness Is More Than an Algorithm Swap”. Together they take about fifteen minutes and set the vocabulary.
- Step 2
Run the readiness assessment
Answer the six sections honestly. “Not sure” is a valid answer and becomes an open question. Export the JSON and keep it with your architecture documents.
- Step 3
Build your cryptographic inventory
Use the template below. List every algorithm, library, version, the data it protects and an owner. This is the artefact every later decision depends on.
- Step 4
Introduce a provider interface
Route signing and verification through one interface that carries an algorithm identifier. Prove it works by enabling a second algorithm behind a flag in staging.
- Step 5
Experiment in the playground, then on Devnet
Compare sizes and timings with ephemeral keys. If you prototype on-chain behaviour, use Solana Devnet with disposable keys and label the work as a prototype.
- Step 6
Write a one-page plan
Even “monitor these three roadmaps and review quarterly” is a plan. Assign an owner and a review date.
02
Threat Model Template
Copy, fill in, and keep next to your architecture documentation.
# Threat model: <system name>
Last reviewed: <date> Owner: <name>
## 1. Assets
- On-chain assets controlled by user keys
- Service signing keys (list each: purpose, algorithm, storage)
- Long-lived encrypted data (what, where, confidentiality lifetime in years)
- Session and API credentials
## 2. Trust boundaries
- User device ↔ application frontend
- Frontend ↔ backend services
- Backend ↔ key management (KMS / HSM / signing service)
- Application ↔ third-party wallet or signing providers
- Application ↔ blockchain RPC
## 3. Cryptographic dependencies (link to inventory)
| Use | Algorithm | Library + version | Quantum-vulnerable? | Lifetime of protected data |
|-----|-----------|-------------------|---------------------|----------------------------|
## 4. Threats considered
- Key theft from application servers
- Key misuse by insiders
- Harvest-now-decrypt-later of recorded traffic or backups
- Forgery of signatures if the scheme is broken (classical or quantum)
- Supply-chain compromise of a cryptographic dependency
- Loss of keys without recovery
## 5. Mitigations and gaps
| Threat | Current mitigation | Gap | Owner | Target date |
|--------|--------------------|-----|-------|-------------|
## 6. Migration triggers
- Which standards or ecosystem events would change priorities?
- Who monitors them and how often?
## 7. Out of scope
State explicitly what this model does not cover.03
Cryptographic Inventory Template
The artefact every migration decision depends on.
# Cryptographic inventory: <system name>
Last reviewed: <date> Owner: <name>
| # | Component | Use (sign / verify / KEM / encrypt / hash / TLS) | Algorithm + parameters | Library + version | Where keys live | Data protected | Required lifetime | Rotatable? | Algorithm id stored with data? | Owner |
|---|-----------|--------------------------------------------------|------------------------|-------------------|-----------------|----------------|-------------------|------------|-------------------------------|-------|
| 1 | Wallet adapter | sign | Ed25519 | @solana/web3.js 1.x | user wallet | transaction authorization | n/a | no (identity) | implicit | |
| 2 | Session auth | verify | Ed25519 | @noble/curves 2.4.0 | user wallet | sign-in challenge | minutes | n/a | yes | |
| 3 | Backup encryption | encrypt | AES-256-GCM, key wrapped by KMS | ... | KMS | user data | 10 years | yes | partially | |
Notes
- Include third-party providers and what they sign or hold on your behalf.
- "Required lifetime" drives harvest-now-decrypt-later priority.
- Review after every dependency upgrade and at least quarterly.04
Key Management Checklist
- Every class of key has a documented purpose, algorithm and owner.
- Sensitive keys are generated in hardware, a KMS/HSM, MPC or on the user's device, never on general-purpose servers.
- Keys are stored in hardware or platform secure storage; nothing in configuration files or environment variables.
- Signing is isolated behind a narrow service boundary with its own authentication and audit log.
- Key rotation is automated, rehearsed in staging on a schedule, and supports an overlap window.
- Recovery is documented and has been tested end to end.
- Key access and signing operations are logged and alerted on anomalies.
- Every stored key carries an algorithm identifier and a key id.
- Backups and sync channels that encrypt key material use hybrid post-quantum key establishment where available.
- No tooling, form or support process ever asks users for seed phrases or private keys.
05
Migration Planning Checklist
Inventory complete
Every algorithm, library and version listed with owners and protected data.
Exposure ranked
Long-lived confidentiality first; hard-to-change identities planned earliest.
Provider interface in place
No direct library calls outside the cryptography module.
Algorithm identifiers stored
Keys, signatures and ciphertexts are tagged; untagged data is treated as legacy.
Second algorithm proven in staging
Enabled behind a flag; tests cover both paths.
Hybrid key exchange enabled
TLS and internal channels use hybrid ML-KEM where the platform supports it.
Dual-support period defined
Verification accepts old and new; adoption is measured.
Ecosystem dependencies tracked
Wallet, RPC, HSM and chain roadmaps monitored with named owners.
Retirement criteria written
What adoption level and date allow removing the legacy path.
Plan owned and dated
One page, one owner, one review cadence.
Background: Planning a Cryptographic Migration.
06
Cryptographic Agility Guide
Six habits that make an algorithm change a configuration change.
One interface, many algorithms
All signing, verification and key establishment flows through a provider interface. The interface returns metadata (algorithm, mode, library) with every result.
Tag everything
Keys, signatures, ciphertexts and sessions carry an algorithm identifier and a version. Code never infers the algorithm from byte length.
No hard-coded sizes
Serialisation reads sizes from algorithm metadata. Tests that assert 64-byte signatures are tests of the past.
Rotation is routine
If rotating a key requires a meeting, migrating an algorithm will require a crisis. Automate rotation and run it.
Fail closed on unknowns
An unrecognised algorithm identifier is rejected, not defaulted. Simulated providers are never substituted for real ones.
Measure before choosing
Know your byte budgets and latency budgets. On Solana, the 1,232-byte transaction limit decides more than raw speed does.
07
Solana Integration Notes
Devnet only. Never production funds.
- Devnet only
- All experimentation must target Solana Devnet with disposable keys funded by the Devnet faucet. Never reuse mainnet keys and never test with real funds.
- Base-layer authorization is Ed25519
- Every transaction needs Ed25519 signatures from its signers, including the fee payer. Nothing an application does changes this.
- Application-level verification is possible but constrained
- A program can verify a signature passed in instruction data and act on a PDA-controlled account. It must fit within the transaction size limit and compute budget, which rules out standard post-quantum signatures in a single transaction today.
- No native post-quantum verifier
- Solana ships native Ed25519 and secp256k1 verification programs. There is no post-quantum equivalent at the time of review.
- Label prototypes clearly
- Any on-chain experiment must state that it is a prototype, does not make accounts quantum-resistant, and is unreviewed.
08
Example Architecture Diagrams
09
Example API Contracts
Lattice's own typed routes. Every input is validated with Zod and every response uses one envelope.
GET
/api/assessmentsList this browser's assessments.
response: { "ok": true, "data": [ { "id": "<uuid>", "projectName": "…", "status": "draft", "answeredCount": 12, … } ] }POST
/api/assessmentsCreate a draft assessment.
request: { "projectName": "Example dApp" }response: { "ok": true, "data": { "id": "<uuid>", "status": "draft", … } }PUT
/api/assessments/{id}/answersUpsert a batch of answers. Each is scored deterministically on save.
request: { "answers": [ { "questionId": "key_storage", "values": ["hsm"] } ], "step": 3 }response: { "ok": true, "data": [ { "questionId": "key_storage", "score": 4, "priority": "informational", "unknown": false } ] }POST
/api/assessments/{id}/completeCheck required questions, generate the report and mark the assessment completed.
response: { "ok": true, "data": { "assessment": {…}, "answers": […], "report": { "categoryScores": […], "findings": […] } } }GET
/api/assessments/{id}/export?format=json|mdDownload the report as JSON or Markdown (Content-Disposition: attachment).
response: { "schema": "lattice.assessment.v1", "platform": { "disclaimer": "…" }, "assessment": {…}, "answers": […] }POST
/api/assessments/{id}/shareCreate a read-only link to a completed report. DELETE turns the link off.
response: { "ok": true, "data": { "shared": true, "token": "…", "url": "https://getlattice.online/report/…" } }GET
/api/research?q=&category=&difficulty=Search and filter research articles.
response: { "ok": true, "data": [ { "slug": "…", "title": "…", "readingTime": 8, … } ] }POST
/api/playground/signatureRun a stateless signature demonstration on the server with an ephemeral key.
request: { "algorithm": "ml-dsa-65", "message": "hello" }response: { "ok": true, "data": { "mode": "prototype", "publicKeyBytes": 1952, "signatureBytes": 3309, "steps": […] } }POST
/api/playground/benchmarkBounded timing run in the server environment; slow algorithms run fewer iterations.
request: { "algorithm": "ml-kem-768", "iterations": 5 }response: { "ok": true, "data": { "mode": "prototype", "environment": {…}, "samples": […], "mean": {…} } }ANY
Error shapeAll errors share one envelope and never include stack traces.
response: { "ok": false, "error": { "code": "validation_error", "message": "…", "details": [ { "path": "answers.0.values", "message": "…" } ] } }
10
Code Examples
Copyable TypeScript. Each example is educational and says so in its first line.
Creating a cryptographic provider interface
Put every signing and verification call behind one interface that carries algorithm metadata. This is the single most valuable agility step.
// EDUCATIONAL EXAMPLE — illustrates structure, not a production implementation.
// Review any real implementation independently before use.
export type ProviderMode = "prototype" | "simulated" | "unavailable";
export interface ProviderMetadata {
id: string; // e.g. "noble-ml-dsa-65"
algorithm: string; // e.g. "ml-dsa-65"
mode: ProviderMode; // never let a simulation masquerade as real
library?: { name: string; version: string };
}
export interface SignatureProvider {
readonly metadata: ProviderMetadata;
generateKeyPair(): Promise<{ publicKey: Uint8Array; secretKey: Uint8Array }>;
sign(message: Uint8Array, secretKey: Uint8Array): Promise<Uint8Array>;
verify(signature: Uint8Array, message: Uint8Array, publicKey: Uint8Array): Promise<boolean>;
}
// A registry resolves algorithm ids to providers. There is deliberately no
// fallback from a real provider to a simulated one.
const providers = new Map<string, SignatureProvider>();
export function registerProvider(provider: SignatureProvider) {
providers.set(provider.metadata.algorithm, provider);
}
export function getProvider(algorithm: string): SignatureProvider {
const provider = providers.get(algorithm);
if (!provider) throw new Error(`No provider registered for ${algorithm}`);
return provider;
}Signing a challenge
A server issues a random, short-lived challenge; the client signs it with an ephemeral key through the provider. Never sign arbitrary server-supplied bytes with a wallet key without a clear domain prefix.
// EDUCATIONAL EXAMPLE — uses ephemeral demo keys, never wallet keys.
import { getProvider } from "./providers";
const DOMAIN_PREFIX = new TextEncoder().encode("lattice-demo-challenge:v1:");
export async function signChallenge(algorithm: string, challenge: Uint8Array) {
const provider = getProvider(algorithm);
const { publicKey, secretKey } = await provider.generateKeyPair(); // demo key only
// Domain separation: prefix the challenge so the signature cannot be replayed
// as something else (for example a transaction) in another context.
const message = new Uint8Array(DOMAIN_PREFIX.length + challenge.length);
message.set(DOMAIN_PREFIX, 0);
message.set(challenge, DOMAIN_PREFIX.length);
const signature = await provider.sign(message, secretKey);
return { algorithm, publicKey, signature, mode: provider.metadata.mode };
}Verifying a signature
Verification must use the algorithm recorded with the signature, reconstruct the exact signed bytes, and treat any exception as a failed verification.
// EDUCATIONAL EXAMPLE — verification path with explicit algorithm metadata.
import { getProvider } from "./providers";
interface SignedChallenge {
algorithm: string; // taken from the envelope, never guessed
publicKey: Uint8Array;
signature: Uint8Array;
}
const DOMAIN_PREFIX = new TextEncoder().encode("lattice-demo-challenge:v1:");
export async function verifyChallenge(input: SignedChallenge, challenge: Uint8Array) {
const provider = getProvider(input.algorithm);
const message = new Uint8Array(DOMAIN_PREFIX.length + challenge.length);
message.set(DOMAIN_PREFIX, 0);
message.set(challenge, DOMAIN_PREFIX.length);
try {
return await provider.verify(input.signature, message, input.publicKey);
} catch {
// Malformed keys or signatures are a verification failure, not a crash.
return false;
}
}Recording algorithm metadata
Store an algorithm identifier and version alongside every key, signature and ciphertext. Untagged artefacts are the hardest part of a migration.
// EDUCATIONAL EXAMPLE — a tagged envelope for stored signatures.
export interface SignatureEnvelope {
v: 1; // envelope format version
alg: string; // "ed25519" | "ml-dsa-65" | ...
lib: string; // "@noble/post-quantum@0.7.1" — provenance for audits
kid: string; // key identifier, so rotation is traceable
createdAt: string; // ISO-8601
sig: string; // base64url signature bytes
}
export function wrapSignature(alg: string, lib: string, kid: string, sig: Uint8Array): SignatureEnvelope {
return { v: 1, alg, lib, kid, createdAt: new Date().toISOString(), sig: toBase64Url(sig) };
}
// Reject envelopes with unknown algorithms instead of guessing a default.
export function assertKnownAlgorithm(env: SignatureEnvelope, allowed: ReadonlySet<string>) {
if (!allowed.has(env.alg)) throw new Error(`Unsupported algorithm: ${env.alg}`);
}
function toBase64Url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}Designing key rotation
Rotation with an overlap window: new signatures use the current key, verification accepts current and previous keys until the window closes. The same mechanism later carries an algorithm change.
// EDUCATIONAL EXAMPLE — rotation policy with an overlap window.
interface KeyRecord {
kid: string;
alg: string;
publicKey: Uint8Array;
notBefore: string; // ISO-8601
notAfter: string; // ISO-8601, end of the verification overlap
status: "current" | "previous" | "retired";
}
export function selectSigningKey(keys: KeyRecord[]): KeyRecord {
const current = keys.find((k) => k.status === "current");
if (!current) throw new Error("No current signing key");
return current;
}
export function keysAcceptedForVerification(keys: KeyRecord[], at = new Date()): KeyRecord[] {
return keys.filter(
(k) => k.status !== "retired" && new Date(k.notBefore) <= at && at <= new Date(k.notAfter),
);
}
// Rotation steps (also rehearsed in staging on a schedule):
// 1. Generate the next key (possibly with a new algorithm) → status "current".
// 2. Demote the old key → status "previous" with notAfter = now + overlap.
// 3. After the overlap, mark the old key "retired" and remove its code path.Separating wallet authorization from application sessions
A wallet signature should prove control once, at sign-in. The application then issues its own short-lived session, which can change algorithm independently of the chain.
// EDUCATIONAL EXAMPLE — conceptual sign-in flow; not a complete auth system.
// 1. Server issues a one-time challenge bound to the origin and a nonce.
export function issueChallenge(origin: string) {
const nonce = randomId();
const issuedAt = new Date().toISOString();
// Human-readable so the wallet can display what is being signed.
const text = `${origin} wants you to sign in.\nNonce: ${nonce}\nIssued at: ${issuedAt}`;
store(nonce, { origin, issuedAt, used: false });
return { nonce, text };
}
// 2. The wallet signs the text. The server verifies it ONCE and marks the nonce used.
export async function exchangeForSession(nonce: string, publicKey: Uint8Array, signature: Uint8Array) {
const challenge = take(nonce); // single use
if (!challenge || challenge.used) throw new Error("Invalid or reused challenge");
const ok = await verifyWithRegisteredProvider("ed25519", signature, encode(challengeText(challenge)), publicKey);
if (!ok) throw new Error("Signature invalid");
// 3. Issue an application session that does not depend on the wallet key again.
// Rotating the session scheme later does not touch the chain or the wallet.
return createSession({ subject: toAddress(publicKey), expiresInMinutes: 30 });
}
// Helper stubs omitted for brevity: randomId, store, take, encode, challengeText,
// verifyWithRegisteredProvider, createSession, toAddress.11