Skip to content

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:

  • load returns the previously saved blob. On GLN_OK, write a fresh allocation to *out_payload and its length to *out_len. A zero-length blob may use NULL with *out_len == 0. Return GLN_ERR_NOT_FOUND when no state has been saved yet.
  • save persists in_payload[0..in_len). The buffer is library-owned and valid only during the callback. Copy or durably write it before returning.
  • free_payload releases a non-null buffer previously returned by load. It must use the same allocator family that load used.
  • user_data is 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_STATE state stores -> .galanthus_fints_state.key
  • REST_TOKEN_STORE token stores -> .galanthus_rest_token_store.key
  • CAPI_TOKEN_STORE token 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.key for the constructor's default blob path galanthus.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_OK from save.
  • Never retain in_payload after save returns.
  • Never free a load buffer directly from library code or host code; implement free_payload and let the library call it.
  • Keep user_data alive 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