Skip to content

4.7 Encrypted Keystores

Import :std/ensemble/keystore for the public interface and constructors. The same API is available from :std/ensemble/keystore/api. Implementation modules export only their public constructors, not secret-bearing objects.

Internally, memory and persistent stores subclass a shared keystore struct and implement KeystoreBackend methods for snapshot I/O, locking, and resource cleanup. The base struct caches its backend interface instance in this. Neither the backend interface nor the concrete implementation types are re-exported by the public API.

The current implementation stores Ed25519 private keys, identified by their public-key DIDs. It is intended for small stores of at most 4096 keys.

4.7.1 Constructors

Procedure Arguments Result
make-memory-keystore passphrase bytevector New empty Keystore
create-persistent-keystore directory path, passphrase bytevector New empty Keystore
open-persistent-keystore directory path, passphrase bytevector Existing authenticated Keystore

Passphrases are u8vectors. Encoding text or prompting without terminal echo is the application’s responsibility. Constructors borrow the supplied bytes: they neither modify nor retain the passphrase. The caller should clear its own passphrase buffer when it is no longer needed. Empty passphrases are accepted, but provide no meaningful protection against offline guessing.

Creation requires an existing parent directory and fails if the keystore path already exists. Opening never creates a missing store. Paths may be absolute or relative, but must end in a directory name, not a trailing slash, . or ... A failed creation can leave a partial directory; it is never silently overwritten or reused.

4.7.2 Keystore Interface

Keystore extends Closer. Each method also has its generated procedure form, such as Keystore-get-private-key, with the store as the first argument.

Method Arguments Result
get-private-key DID string Fresh PrivKey
put-private-key! PrivKey Canonical DID of the stored key
generate-key! none Canonical DID of a newly generated and stored key
list-keys none Sorted list of canonical DID strings
close none Void

The canonical DID encoding is the Base64url form produced by :std/ensemble/ucan/did. Lookup normalizes all DID encodings accepted by that module, so aliases for the same public key resolve to the same entry. Missing or invalid DIDs, unsupported key types, corrupt authenticated content, and operations on closed stores raise ContractViolation.

Insertion does not retain the caller’s PrivKey and inserting an existing key is idempotent. Retrieval returns a new native key owned by the caller; it remains usable after the store is closed. generate-key! uses the same insertion logic as put-private-key!. A new insertion into a persistent store returns only after the snapshot has been written and synced. Duplicate insertion authenticates the current snapshot but does not rewrite it.

Close is idempotent. It clears the retained derivation material on a best-effort basis and closes persistent file handles. Callers must explicitly close their stores, normally from an unwind-protect cleanup.

4.7.3 Persistence And Locking

The persistent implementation is pure Gerbil using :std/io/file, :std/os/file, and :std/os/flock; it has no private filesystem FFI.

The keystore directory must be owned by the current user with mode 0700. It contains keys (the encrypted snapshot) and lock (a stable advisory lock file), each owned by that user with mode 0600. Regular files must have one link. Unsafe file types, symlinks, ownership, or modes are rejected rather than repaired. Parent directories must be trusted: path-replacement and symlink races are outside the threat model.

Readers acquire a shared lock; insertion and generation acquire an exclusive lock. Every operation reloads and authenticates the snapshot while locked, so independent cooperating handles and processes do not overwrite stale state. A per-instance mutex serializes operations using the same handle. The stable lock file must not be replaced or removed while a store is open.

Writes use a newly created, mode-0600 temporary in the same directory, followed by file fsync, atomic rename, and directory fsync. Creation also syncs the parent directory. No plaintext is written to temporary files. If a sync fails after publication, the operation reports that the snapshot was committed but durability failed; it does not claim rollback. Failed saves may leave ciphertext temporary files.

These guarantees require local POSIX filesystem semantics, advisory locking, and directory fsync. Network filesystems and fork-inherited store handles are not supported. Filesystem errors propagate separately from authentication and key-lookup errors.

4.7.4 Cryptographic Format

Both backends retain encrypted snapshots. The memory backend has no disk storage; the persistent backend uses the same snapshot encoding.

Each store has a random 32-byte salt. Profile 1 derives 64 bytes with scrypt (N=131072, r=8, p=1, explicit 256 MiB maximum memory), using approximately 128 MiB of working memory per derivation. The first 32 bytes form an AES-256-CTR key and the remaining 32 bytes form an HMAC-SHA256 key. Every new snapshot has a fresh random 16-byte IV. The MAC covers the entire header and ciphertext and is checked with OpenSSL’s constant-time comparison before decryption.

All integers are unsigned big-endian. Version 1 has the following layout:

Offset Field
0 Four-byte magic GKS1
4 Version byte: 1
5 Crypto profile byte: 1
6 32-byte salt
38 16-byte IV
54 Four-byte ciphertext length
58 Ciphertext
After ciphertext 32-byte HMAC-SHA256 tag

Plaintext contains a four-byte entry count followed by 32-byte Ed25519 seeds. DIDs are derived rather than stored separately. Readers validate lengths, version, profile, and duplicates; the maximum file size is 131166 bytes. Wrong passphrases and authentication failures have the same generic error.

4.7.5 Security Limits

The derived keys remain in memory while a store is open. Temporary plaintext and retained keys are explicitly cleansed, but moving garbage collection, swap, native allocations, and crash dumps prevent a guaranteed-erasure claim. This is protection for stored keys, not against a compromised running process, root, or hostile code with the same user identity.

Snapshot length reveals the entry count. Authentication does not prevent deletion or replay of an older valid snapshot. Every operation is linear in the number of stored keys; updates rewrite the entire snapshot. Passphrase rotation, deletion, rollback protection, and additional key types are not part of this API.