State and Continuation Stores
Stores are embedder-supplied persistence hooks. State stores hold durable provider state. Continuation stores hold in-flight resume artifacts produced by action-required backend result envelopes.
State Store Functions
GLN_API gln_status_t GLN_CALL gln_create_state_store(
const gln_state_store_vtable_t* in_vtable,
gln_state_store_t** out_store,
gln_error_t* out_error);
GLN_API gln_status_t GLN_CALL gln_create_file_state_store(
const char* in_state_path,
const char* in_key_path_or_null,
gln_state_store_t** out_store,
gln_error_t* out_error);
GLN_API void GLN_CALL gln_destroy_state_store(gln_state_store_t* in_store);
gln_open_fints_backend, gln_open_revolut_backend, and
gln_open_wise_backend consume state stores. FinTS retains the underlying
state-store implementation during backend open, so the C store handle itself can
be destroyed after a successful FinTS open. Revolut and Wise keep non-owning
references to the C store handle; keep that handle alive until after closing
every Revolut or Wise backend that uses it.
The file-backed constructor stores an encrypted blob at in_state_path.
in_key_path_or_null selects the encryption-key sidecar path; pass NULL to
use the implementation default. Destroy the returned handle with
gln_destroy_state_store.
State Store Vtable
typedef struct {
gln_status_t (GLN_CALL *load)(
void* in_user_data,
uint8_t** out_payload,
size_t* out_len);
gln_status_t (GLN_CALL *save)(
void* in_user_data,
const uint8_t* in_payload,
size_t in_len);
void (GLN_CALL *free_payload)(
void* in_user_data,
uint8_t* in_payload);
void* user_data;
} gln_state_store_vtable_t;
Field contract:
loadreturns the previously saved blob. OnGLN_OK, write a fresh allocation to*out_payloadand its length to*out_len. A zero-length blob may useNULLwith*out_len == 0. ReturnGLN_ERR_NOT_FOUNDwhen no state has been saved yet.savepersistsin_payload[0..in_len). The buffer is library-owned and valid only during the callback. Copy or durably write it before returning.free_payloadreleases a non-null buffer previously returned byload. It must use the same allocator family thatloadused.user_datais passed back unchanged to every callback. The library does not inspect or copy it.
The direction of ownership is the practical rule: load produces a buffer that
the library later returns to free_payload; save receives a borrowed
library-owned buffer that the store must not retain.
Continuation Store Functions
GLN_API gln_status_t GLN_CALL gln_create_continuation_store(
const gln_continuation_store_vtable_t* in_vtable,
gln_secret_t* in_encryption_key,
gln_continuation_store_t** out_store,
gln_error_t* out_error);
GLN_API gln_status_t GLN_CALL gln_create_file_continuation_store(
const char* in_directory_path,
const char* in_key_path_or_null,
gln_continuation_store_t** out_store,
gln_error_t* out_error);
GLN_API void GLN_CALL gln_destroy_continuation_store(gln_continuation_store_t* in_store);
gln_open_fints_backend accepts an optional continuation store. Pass NULL if
resume artifacts do not need to survive process boundaries. When a backend
result envelope contains an action-required continuation, take it with
gln_take_backend_result_continuation, save it with gln_save_continuation,
and destroy the in-memory handle when finished.
in_encryption_key is required for the custom vtable constructor and must wrap
exactly 32 bytes of AES-256-GCM key material. The library encrypts every
continuation at the C-API boundary; the user-supplied vtable callbacks
therefore see ciphertext (header || nonce || ciphertext || tag) rather than
plaintext. The store retains its own owning copy of the key bytes, so the
caller may destroy in_encryption_key after the constructor returns.
The file-backed continuation store writes encrypted resume blobs under
in_directory_path. in_key_path_or_null optionally selects the protected-key
sidecar path; pass NULL to use the Galanthus default in the continuation
directory. The file-backed constructor owns key creation and does not expose
caller-controlled crypto purpose or magic.
Local Cache Functions
GLN_API gln_status_t GLN_CALL gln_save_local_cache(
const char* in_cache_path,
const char* in_key_path_or_null,
const uint8_t* in_payload,
size_t in_payload_len,
gln_error_t* out_error);
GLN_API gln_status_t GLN_CALL gln_load_local_cache(
const char* in_cache_path,
const char* in_key_path_or_null,
uint8_t** out_payload,
size_t* out_payload_len,
gln_error_t* out_error);
GLN_API gln_status_t GLN_CALL gln_remove_local_cache(
const char* in_cache_path,
const char* in_key_path_or_null,
int* out_removed_or_null,
gln_error_t* out_error);
The local-cache helpers encrypt one opaque cache payload using the fixed
Galanthus local-cache storage purpose. Missing cache is a normal
GLN_ERR_NOT_FOUND load result, so wrappers can expose first-run/no-cache as an
optional value. gln_remove_local_cache removes both payload and sidecar key,
including a custom sidecar path when one was supplied to save/load.
Sidecar Key Protection
Every file-backed store above keeps the AES-256-GCM payload key in a separate
sidecar file rather than inside the encrypted blob. Passing NULL for the
key-path argument selects the store's default sidecar next to the payload file.
The storage-layer per-purpose resolver (resolve_key_path ->
purpose_key_filename) is reached only by the three stores that hand a NULL
override down to it, and each of those resolves to its own default sidecar
filename next to the payload file:
FINTS_STATEstate stores ->.galanthus_fints_state.keyREST_TOKEN_STOREtoken stores ->.galanthus_rest_token_store.keyCAPI_TOKEN_STOREtoken stores ->.galanthus_capi_token_store.key
The remaining five file-backed stores never reach that resolver default. Each
constructs its own override path and passes it down, so a NULL argument yields
the store's own default, not the matching purpose_key_filename entry:
- Local cache ->
<cache_path>.key - File-backed continuation store ->
<directory>/.galanthus_continuations.key - Runtime resume-envelope continuation store ->
<key_source_path>.resume.key - File-backed EBICS key store ->
<key_blob_path>.sidecar.key(for example,galanthus.ebics.keys.sidecar.keyfor the constructor's default blob pathgalanthus.ebics.keys) - Plugin secret blob ->
<blob_path>.key
Because of this, the LOCAL_CACHE, RESUME_ENVELOPE, EBICS_KEY_STORE, and
PLUGIN_SECRET_BLOB entries in purpose_key_filename
(.galanthus_local_cache.key, .galanthus_resume_envelope.key,
.galanthus_ebics_key_store.key, .galanthus_plugin_secret_blob.key) keep the
resolver a total function over the purpose enum but are never the in-use sidecar
path for those stores.
How the sidecar is protected depends on the platform:
- On Windows the sidecar is a DPAPI-protected key file under the current user account, and the storage purpose is bound into both the protected-file header and the DPAPI entropy.
- On POSIX the sidecar is a raw, unencrypted 32-byte key file. It must be owner-readable and must have no group or world permission bits; the loader refuses to read a key file whose mode exposes any group/world bit. On POSIX the file permissions are the only thing protecting the key.
What this protection is and is not: the sidecar separation defends a payload against other local principals, not against an actor who already has both halves. A local actor who can read both the encrypted payload file and its sidecar key file can decrypt the payload. On Windows, DPAPI ties unprotection to the current user account, so a different local user who copies both files cannot unprotect the key; on POSIX, owner-only permissions are the entire boundary. Galanthus does not protect a payload against an actor with full local read of both files.
This drives a backup rule: a backup that captures both the encrypted payload files and the sidecar key files exposes the whole stack to anyone who can read the backup. Encrypt such backups at rest, or keep the sidecar key files out of backups that travel with the payload.
Continuation Store Vtable
typedef struct {
gln_status_t (GLN_CALL *load)(
void* in_user_data,
const char* in_id,
uint8_t** out_payload,
size_t* out_len);
gln_status_t (GLN_CALL *save)(
void* in_user_data,
const char* in_id,
const uint8_t* in_payload,
size_t in_len);
gln_status_t (GLN_CALL *remove)(
void* in_user_data,
const char* in_id,
int* out_removed);
void (GLN_CALL *free_payload)(
void* in_user_data,
uint8_t* in_payload);
void* user_data;
} gln_continuation_store_vtable_t;
The ownership contract is the same as the state store. The extra in_id
parameter is a library-owned string valid only during the callback. Copy it if
the backing store needs to keep it after the callback returns.
The bytes the library hands save are ciphertext (header || nonce ||
ciphertext || tag) under the AES-256-GCM key supplied at store creation. The
bytes returned from load must be the same ciphertext envelope; the library
decrypts internally before any higher layer sees the plaintext. A custom store
therefore never needs its own encryption layer.
Return GLN_ERR_NOT_FOUND from load when no artifact exists for in_id.
remove deletes the artifact for in_id and stores nonzero in *out_removed
when an artifact was present. A missing artifact may be reported as GLN_OK
with *out_removed == 0 or as GLN_ERR_NOT_FOUND; both forms become
gln_remove_continuation success with zero removed. Return other non-OK
statuses to surface failures from the backing storage. The library returns
GLN_ERR_RESUME_ARTIFACT_INVALID itself if the loaded ciphertext fails to
decrypt or to parse, so the store implementation does not need to classify
malformed bytes.
Custom Store Skeleton
struct mem_state {
uint8_t* bytes;
size_t len;
};
static gln_status_t mem_load(
void* in_user_data,
uint8_t** out_payload,
size_t* out_len)
{
struct mem_state* state = (struct mem_state*)in_user_data;
if (state->bytes == NULL) {
return GLN_ERR_NOT_FOUND;
}
uint8_t* copy = (uint8_t*)malloc(state->len);
if (copy == NULL && state->len != 0) {
return GLN_ERR_OUT_OF_MEMORY;
}
if (state->len != 0) {
memcpy(copy, state->bytes, state->len);
}
*out_payload = copy;
*out_len = state->len;
return GLN_OK;
}
static gln_status_t mem_save(
void* in_user_data,
const uint8_t* in_payload,
size_t in_len)
{
struct mem_state* state = (struct mem_state*)in_user_data;
uint8_t* copy = NULL;
if (in_len != 0) {
copy = (uint8_t*)malloc(in_len);
if (copy == NULL) {
return GLN_ERR_OUT_OF_MEMORY;
}
memcpy(copy, in_payload, in_len);
}
free(state->bytes);
state->bytes = copy;
state->len = in_len;
return GLN_OK;
}
static void mem_free_payload(void* in_user_data, uint8_t* in_payload)
{
(void)in_user_data;
free(in_payload);
}
static struct mem_state state = { NULL, 0 };
static const gln_state_store_vtable_t vtable = {
mem_load,
mem_save,
mem_free_payload,
&state,
};
For continuation stores, add the in_id parameter to load, save, and
remove, then use it as the key in the backing store.
Practical Rules
- Keep state stores profile-scoped. Sharing one state file across unrelated profiles will mix provider state.
- Keep continuation IDs narrow and explicit. Include enough application context to avoid one user's resume artifact overwriting another's.
- Complete durable writes before returning
GLN_OKfromsave. - Never retain
in_payloadaftersavereturns. - Never free a
loadbuffer directly from library code or host code; implementfree_payloadand let the library call it. - Keep
user_dataalive until no live custom store handle or retained backend store implementation can invoke its callbacks. - For Revolut and Wise state stores, destroy the C store handle only after all backends using it have been closed.
See Also
- Conventions - error, ownership, and threading rules.
- Continuations - saving, loading, and resuming artifacts.
- Key store - EBICS key-store vtable ownership.
- Troubleshooting - allocator and persistence failures.