Security
Reference documentation for Zypp's encrypted storage, key management, tamper detection, and trust tier enforcement.
Core Principle
The SDK verifies integrity. It does not establish validity.
- Integrity: "This data has not been tampered with since it was signed." Verified by signatures, content hashes, and HMAC.
- Validity: "This data represents a legitimate, authorized action." Established by the issuer backend, settlement layer, and application policy.
Encrypted at Rest
Queue data can be encrypted locally using AES-256-GCM. This ensures intent payloads, signatures, and metadata are protected if the device storage is compromised.
Algorithm
- Cipher: AES-256-GCM (authenticated encryption)
- Nonce: 12 random bytes per encryption operation
- Auth tag: 16 bytes (integrity verification)
- Scope: All
QueueEntryfields — payloads, signatures, status, timestamps - Serialization: The entire queue is serialized as a single bincode blob, encrypted under one key, stored under the constant key
"zypp_queue"
What's Encrypted
| Field | Encrypted | Notes |
|---|---|---|
| Intent payloads | Yes | Amounts, assets, recipients, ticket data |
| Signatures | Yes | Both CryptographicEnvelope and DualSignatureEnvelope |
| Queue status | Yes | Pending, SyncedProvisional, etc. |
| Timestamps | Yes | created_at, updated_at |
What's NOT Encrypted
- The encryption key itself (held in memory by
AesGcmStorage) - The nonce registry (in-memory only, not persisted)
- The
TransferPacketwire format (it's a transport format, not storage)
Key Management
The SDK owns the encryption primitive. The caller owns the key.
The SDK accepts a raw 32-byte key across the FFI/JSI boundary. It does not derive, store, rotate, or validate the key. The caller is responsible for:
- Generating a cryptographically random 32-byte key
- Storing the key in platform secure storage (not hardcoded)
- Keeping the key available across process restarts for queue decryption
- Key rotation strategy (requires re-encrypting the queue blob)
Initialization
Encryption must be initialized before any intents are created. If the queue already contains unencrypted entries, the call is rejected (no mixed plaintext/encrypted state).
// Expo — two identical methods, different signaling
import { initWithSecureStorage, initWithEncryption } from '@zypp-labs/expo-sdk';
// Recommended: signals key comes from platform secure storage
initWithSecureStorage(thirtyTwoByteKey);
// Low-level: same algorithm, different naming
initWithEncryption(thirtyTwoByteKey);
// Unity
ZyppSdk.InitWithSecureStorage(thirtyTwoByteKey);
ZyppSdk.InitWithEncryption(thirtyTwoByteKey);
Both methods enforce exactly 32 bytes — shorter or longer keys are rejected with a descriptive error.
Platform Secure Storage
initWithSecureStorage delegates to the same AES-256-GCM encryption but uses platform-idiomatic key sourcing:
| Platform | Key Source |
|---|---|
| iOS | Keychain Services — 256-bit key generated on first launch, stored in Secure Enclave |
| Android | Android Keystore — 256-bit key generated on first launch, stored in TEE |
| Unity | Platform-specific secure storage (iOS Keychain, Android Keystore via native plugins) |
The platform layer generates the key on first launch, persists it in the secure enclave, and passes it to the SDK at initialization time. This is the recommended path for production applications.
// Expo — key comes from iOS Keychain / Android Keystore
import { initWithSecureStorage } from '@zypp-labs/expo-sdk';
initWithSecureStorage(platformKey);
Tamper Detection
Content Hash (FNV-1a)
Every intent gets a deterministic 64-bit FNV-1a content fingerprint computed from the serialized payload only:
let serialized = bincode::serde::encode_to_vec(&payload, bincode::config::standard()).unwrap();
let content_hash = fnv1a(&serialized);
The hash covers only IntentPayload — not intent_type, trust_tier, envelope, or id. This is by design: dedup keys on the logical action, not on signature material.
The content_hash is included in the TransferPacket and verified on decode. Any modification to the payload changes the hash, causing the decode to fail.
HMAC-SHA256
The tamper module provides HMAC computation and verification for payload integrity in transport:
// Compute a tag over the payload
let tag = compute_hmac_sha256(payload, session_key);
// Verify on receipt
verify_hmac_sha256(payload, tag, session_key)?;
The HMAC key is derived from the session context, not hardcoded.
CryptographicEnvelope
For TrustTier::Signed intents, the CryptographicEnvelope bundles:
signature: Ed25519 signature from the devicepublic_key: The signer's public keynonce: Per-key monotonically increasing nonce (replay protection)
This envelope is verified by the sync engine before allowing the intent into the sync pipeline.
Replay Protection
Nonces are structurally bundled with signatures inside the CryptographicEnvelope. A signed intent cannot ship without replay protection — the two are inseparable at the type level.
pub struct CryptographicEnvelope {
pub signature: Vec<u8>, // Ed25519 signature
public_key: Vec<u8>, // Signer's public key
nonce: u64, // Per-key monotonic counter
}
The nonce is scoped to the public key — two devices sharing a counter value would be mutually replayable. Per-key monotonicity prevents cross-device replay.
Trust Tier Lint
A development-time audit system checks registered kinds and queued intents for trust tier misconfigurations:
import { registerKind, lint } from '@zypp-labs/expo-sdk';
// Register kinds with expected trust tiers
const warnings = registerKind({
kind: 'spend_tokens',
intent_type: 'Action',
trust_tier: 'Signed',
fields: []
});
// Audit all registered kinds and queued intents
const lintWarnings = lint();
Lint returns warnings with three severity levels:
| Severity | Meaning |
|---|---|
Info | Informational — no action required |
Warning | Potential misconfiguration — review recommended |
Error | Actual mismatch — intent tier below registered tier |
High-stakes action kinds matching patterns like "spend", "trade", "claim", "stake", "transfer" registered as Unsigned emit warnings at registration time.
Lint is a development-time safety net, not runtime enforcement. Actual tier enforcement happens at template processing time.
Trust Tier Enforcement (Template-level)
Trust tier requirements are enforced by templates, not by the core or settlement:
- Arcade template:
required_trust_tier(action_kind)returnsSignedfor high-stakes actions andUnsignedfor low-stakes actions.process_action()rejects withTrustTierTooLowif the intent's tier is below the requirement. - Ticket template:
redeem_ticket()requiresTrustTier::Signedand rejects if theCryptographicEnvelopeis missing.
There is no trust tier check in the settlement engine. Settlement routes intents based on IntentType, not TrustTier.
Security Boundaries
| Area | SDK Responsibility | Caller Responsibility |
|---|---|---|
| Encryption | AES-256-GCM encryption/decryption of queue data | Key generation, storage, rotation |
| Signatures | Ed25519 verification at sync boundary | Key management, signing |
| Integrity | Content hash, HMAC on transport payloads | Providing correct session keys |
| Trust tiers | Lint warnings, template enforcement | Registering correct tiers per kind |
| Nonce | Per-key monotonicity enforcement | Providing sequential nonces |