Primitive based interface #313
Unanswered
PhilippGackstatter
asked this question in
RFC
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
primitive-based-interface
Summary
Implement an interface that is based on stronghold primitives on a type level, allows concurrent usage and requires less password management.
Motivation
The identity.rs and wallet.rs crates wrap stronghold for similar reasons. API-level ideas from these wrappers can flow back into the stronghold interface itself to reduce the amount of required wrapping. These wrappers primarily solve three issues with the current approach:
One important reason is to implement password management. Password management referes to the ability of users to provide the password once in a session and then not having to provide it again for further snapshot reads. However, in the wrappers this is currently done relatively insecurely as passwords are stored in unguarded memory. The interface cannot fix this problem unless architectural changes are made, which is the among the topics of other RFCs (such as Feature/implementation #2). This RFC makes the assumption that password management will be solved within stronghold and users do not have to provide the password repeatedly.
Another reason is to allow for race-condition free usage of the interface. In the current interface, only one actor can be set as the current target for operations. Users who want to access stronghold with multiple clients concurrently need to wrap the Stronghold into a mutex, to ensure that switching the target and a subsequent operation is atomic. If mutually exclusive access is not guaranteed, another concurrent caller may switch the target to some other actor. Whether the actor system is removed or not, the new architecture should allow for concurrent usage, and the interface should expose that capability.
From a user perspective, there are multiple primitives to learn about and work with on a conceptual level: snapshots, clients, vaults, records and stores. Currently, the interface is made up of a single
Stronghold
type, which exposes all of these primitives. Using Rust's type system, these primitives could be exposed more directly, making it easier to map stronghold concepts onto the interface. It should also become easier to do multiple operations on a nested primitive, like a vault or store. Overall, this should make the interface easier to reason about because the mental model of stronghold maps more directly to the interface, and it should be easier to learn the interface in an exploratory manner.Guide-level explanation
Introduce a type in the interface for each primitive mentioned above, so there's (roughly) one-to-one relationship between the mental model of stronghold primitives and types in the interface.
Local Example
First we instantiate a new
Stronghold
. This is a collection of clients and snapshots and acts as a centralized supervisor type. A snapshot is identified by aSnapshotLocation
which is a file path in this instance, but could be extended with other location types in the future. AKeyProvider
stores the encryption key for a snapshot. Both together can be passed to a stronghold to load the snapshot from its storage location into memory. To get an already loaded snapshot,Stronghold::get_snapshot(location)
could be used, without providing theKeyProvider
again.Client
s are then created using thestronghold
, and their state is loaded from a snapshot. To interact with theClient
, we use the store (Store
) and vault interface (Client::execute_procedure
). We write, read and delete from a store, which has a hashmap-like interface. We interact with a vault on client-level. All operations on a vault are procedures, like generating a seed or writing data. To store the state from the client to storage, first the state is written back into the snapshot, and the snapshot is then persisted to storage.P2P Example
Exposing a stronghold over
libp2p
is done on a snapshot-level, which means multiple snapshots can be exposed simultaneously. Exposing those interfaces onStronghold
instead would make it unclear which snapshot is network reachable.The sender side has equivalent
Remote
-prefixed types for each of the local types. TheRemoteSnapshot
is used to connect to a network-reachable snapshot, identified byPeerId
andMultiaddress
. Then on the snapshot, aRemoteClient
can be created, by passing in theclient_path
. This client will be created on the remote stronghold, if the caller has permission to do so. Various policies can be used to customize the permissions, which are out of scope for this RFC. Once aRemoteClient
has been obtained, aRemoteStore
can be used to modify the store with a very similar interface as the regularStore
. TheRemoteClient
also has the facilities to execute procedures over the network.Reference-level explanation
SnapshotLocation
Stronghold currently has two concepts for providing the location of a stronghold file. These can be found in
Stronghold::write_snapshot
andStronghold::read_snapshot
. For, example, the latter:If
path
isSome(p)
, thenp
is used, if it'sNone
,filename
is evaluated. If that isSome(name)
, then a path is created in a default directory withname
as the filename. If it'sNone
, the default namemain
is used in the default directory. So, if bothfilename
andpath
areSome
, one of them is ignored. To make this last case unrepresentable and make the behavior more obvious to users, aSnapshotLocation
type is introduced.Here,
named
uses the parameter as the name of the file in the default directory, andpath
is simply turned into aPathBuf
. Theread_snapshot
function takes a single parameter of typeSnapshotLocation
, making the mentioned case unrepresentable. It implementsDefault
which is simplyStronghold::named("main")
.It should be evaluated whether the default feature is used and makes sense. If multiple applications would use the default snapshot, they would also need to use the same password. If it is user provided, this might be fine. However, users might not be aware that applications write to the same snapshot and one of the applications could accidentally overwrite the state of the other one. Because of that, applications might simply avoid using the default snapshot, and if no application uses it, the feature can also be removed.
Snapshot
Ideally a
Snapshot
represents an open, read- and writable snapshot file, which means the password (i.e. theKeyProvider
) is only required when creating it throughStronghold::load_snapshot
.Stronghold::load_snapshot
reads the snapshot contents if the file exists, and loads a default state if it does not exist. An alternative would be not to load the state at construction-time and have a separateread
method, that restores the state of a snapshot from a file. That seems less desireable. The assumption is that in most cases one wants to restore the state from the file rather than starting with an empty state, so the default behavior should match that andStronghold::load_snapshot
should therefore restore the state. Aclear_state
method could be added, if deemed useful.This approach through a
Stronghold
means no two snapshots can be created that point to the same non-existing file, but creating twoStronghold
s would allow for it. If bothSnapshot
s write, the second write would overwrite the first. This is a race condition we choose not to prevent, because it is hard to do so consistently across OSs, and adds additional complexity. Note that the current interface also does not prevent this case.The
Snapshot::write
method writes the current state to storage. This method does not require a password, because it is assumed that the password is stored securely through theKeyProvider
within theStronghold
the snapshot was created from.Ideally, the same
Snapshot
can be used from different threads. This can be facilitated through theStronghold
type, which would have to be clonable. Then from different threads/tasks,Snapshot::load_snapshot
can be called with the sameSnapshotLocation
to get access to the same snapshot. In identity.rs, for example, the typical use case is to open a single snapshot, in which multiple identities can be stored. However, each identity is an independent object, so those can be modified on different threads which requires writing into stronghold from multiple threads.Client
A client is identified through a
client_path
, independent from aSnapshot
and is created throughStronghold
. Without writing the client to a snapshot (e.g. throughClient::store_state
), everything happens in-memory.A client should be usable from different threads, i.e. it should be thread-safe. Its exact structure is omitted here, since it depends on the implementation of the underlying architecture which is the topic of RFC 1.
Ideally, Clients can easily be embedded in other structs, so they should be free of lifetime-bound references. That should make it easy to create bindings for other languages, too.
In the current interface, users don't have to explicitly save a client's state into the snapshot state before writing. The proposed interface makes this more explicit, at the cost of convenience.
Store
Each client has a single store, which can be accessed with
Client::store
. TheStore
has a simple hashmap-like interface:The
Store
's lifetime could be constrained to the client:The
Store
is just a conceptual representation of the interface, and does not hold any data itself. It uses theclient
to modify the actual store. Creating bindings for such a struct is basically impossible, so that struct would effectively have to be re-created in bindings, which is a downside.Vault
While the vault is a stronghold primitive, it does not have its own type (being the only exception). Most interesting operations on a vault are procedures. Since a client can have multiple vaults, a procedure generally works on the client-level rather than the vault-level, which means a procedure can read and write across vaults. Changing procedures to only work in a single vault makes them less composable and more restricted, so this should be avoided. Hence, executing a procedure is done through
Client::execute_procedure
.This leaves the question where the simpler vault operations are put:
execute_procedure
. This leaves the question whether the return type of garbage collection is useful. It is abool
to indicate whether the vault that was supposed to be cleaned was found. If that is important to check, users can use the vault existence check instead, meaning garbage collection can also be turned into a procedure with no output. However, since the return type ofexecute_procedure
is fairly complex, many of the error paths it can represent cannot occur with these simple procedures. Becaus of that, aexecute_single_procedure
method could be added, that simplifies the signature, but which can only run a single, non-chained procedure.Client::vault_exists
, or alternativelyClient::is_vault_empty
, which would be more consistent with naming in Rust's std library.RecordId
s, but inserting a record requires a record path of typeVec<u8>
. 5 is primarily useful for checking the number of records in a vault, or comparing vaults. No usage of that functionality was found in wallet.rs or identity.rs. Similarly, hints aren't used in the mentioned packages either. It seems to be only used for testing stronghold itself, so it should be feature-gated behind#[cfg(test)]
. Moreover, this prevents leaking the internalRecordId
type to the public interface. Whether obtaining the number of records in a vault or comparing vaults is useful, is debatable. Overall, without exposing the*Id
types to users, hints should be removed as users do not and can not use them.Remote Primitives
Generally, the remote types mirror the functionality of the local types. This section focuses on the differences. This is an overview of the remote API:
The advantage of having a separate
RemoteSnapshot
, instead of adding these methods to the existingSnapshot
, is that sender and receiver can be clearly separated on a conceptual level, following the principle of single responsibility. As a side effect, it also makes theSnapshot
API more manageable. Moreover, compiling theRemoteSnapshot
does not require compiling the stronghold core. This is especially relevant for users who only want to use remote strongholds and thus do not want to compile the entirety of stronghold, to reduce the size of the resulting binary. This would most likely require quite a bit of feature-gating throughout the library.This API also assumes that one
PeerId
can be mapped to multipleClientId
s on the receiver side. Adding this would mean that local and remote strongholds can be designed with similar storage layouts -- how data is stored in stronghold -- in mind. For example, in identity.rs each identity will be mapped to one client in a local stronghold. When operating on an identity, only a single client needs to be loaded from the snapshot, improving efficiency. If the same storage layout can be used in remote strongholds, the same efficiency improvement applies, and it makes it easier for users to share code between local and remote stronghold implementations. They may even become interoperable, e.g. a remote stronghold can later be used locally, or vice versa, without changes. Generally, it means local and remote strongholds can be used in very similar ways, reducing the differences users need to be aware of and accommodate.A potential drawback of this approach is that the connections are one-directional. The receiver cannot send commands to the sender and instead would have to open a separate connection with a
RemoteSnapshot
, while the sender would have to start listening with aSnapshot
. If that use case should be easier, this approach would have to be modified.Drawbacks
This proposal creates flexibility at the cost of convenience. In particular, loading and storing of
Snapshot
s andClient
s becomes explicit. The user has precise control over when state is moved from one to the other. Thanks to other RFCs, the interface becomes easier to use in some respects, like not having to provide the password for every snapshot interaction. Another important aspect - building on the software-transaction memory RFC - is that it will be easier to use aClient
from multiple threads and tasks.To a degree, the
Store
is duplicated in the local and remote types. Since one of implementations is sync, while the other is async, a unification of both would essentially mean the local interface needs to become async, too. That seems undesirable. Moreover, the store interface is limited in scope, so the duplication is unlikely to become a large maintenance burden.Rationale and alternatives
This design maps the concept of stronghold primitives to the interface, thereby making the interface easier to read and reason about. The alternative is to keep the current interface approach, which has the mentioned downsides of being complex to understand and use, since all primitives are controlled through a single type. Not adopting the proposed approach effectively means that more easy-to-use wrappers will be created for identity.rs and wallet.rs, as it was already the case in the past.
A different flavor of the presented interface was also proposed.
Stronghold
type with a snapshot. SinceStronghold
is in-part a collection type, storing clients and snapshots, this does not seem to follow the convention of other collection types likeVec
orHashMap
.Snapshot
itself as the identifier of a snapshot. It seems unintuitive to create aSnapshot
to only use it as an identifier, and then return a&Snapshot
.Unresolved questions
core
andp2p
parts, so they can be compiled separately?Future possibilities
Separation into local and remote types, as well as splitting the interface into its primitives means the extension can happen very naturally. For example, modifications to the remote aspects only require changes in the remote types. New primitives or extensions are added to both local and remote types.
Beta Was this translation helpful? Give feedback.
All reactions