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 QueueEntry fields — 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

FieldEncryptedNotes
Intent payloadsYesAmounts, assets, recipients, ticket data
SignaturesYesBoth CryptographicEnvelope and DualSignatureEnvelope
Queue statusYesPending, SyncedProvisional, etc.
TimestampsYescreated_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 TransferPacket wire 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:

PlatformKey Source
iOSKeychain Services — 256-bit key generated on first launch, stored in Secure Enclave
AndroidAndroid Keystore — 256-bit key generated on first launch, stored in TEE
UnityPlatform-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 device
  • public_key: The signer's public key
  • nonce: 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:

SeverityMeaning
InfoInformational — no action required
WarningPotential misconfiguration — review recommended
ErrorActual 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) returns Signed for high-stakes actions and Unsigned for low-stakes actions. process_action() rejects with TrustTierTooLow if the intent's tier is below the requirement.
  • Ticket template: redeem_ticket() requires TrustTier::Signed and rejects if the CryptographicEnvelope is missing.

There is no trust tier check in the settlement engine. Settlement routes intents based on IntentType, not TrustTier.

Security Boundaries

AreaSDK ResponsibilityCaller Responsibility
EncryptionAES-256-GCM encryption/decryption of queue dataKey generation, storage, rotation
SignaturesEd25519 verification at sync boundaryKey management, signing
IntegrityContent hash, HMAC on transport payloadsProviding correct session keys
Trust tiersLint warnings, template enforcementRegistering correct tiers per kind
NoncePer-key monotonicity enforcementProviding sequential nonces

Next Steps

  • Review the Queue for lifecycle state management
  • Explore the Intent type system
  • See SECURITY_MODEL.md in the SDK repository for the full threat analysis