diff --git a/.drone.jsonnet b/.drone.jsonnet index 4876ce13..e33f6d4a 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -285,9 +285,9 @@ local static_build(name, 'echo "Building on ${DRONE_STAGE_MACHINE}"', apt_get_quiet + ' update', apt_get_quiet + ' install -y python3-requests rsync', - 'npm i docsify-cli -g', - 'npm i docsify -g', + 'npm i docsify-cli docsify-themeable docsify-katex@1.4.4 katex marked@4', 'cd docs/api/', + 'export NODE_PATH=node_modules', 'make', '../../utils/ci/drone-docs-upload.sh', ], diff --git a/docs/api/api-to-markdown.py b/docs/api/api-to-markdown.py index 4a6aa9c0..14ec7ef3 100755 --- a/docs/api/api-to-markdown.py +++ b/docs/api/api-to-markdown.py @@ -47,6 +47,7 @@ # # "Inputs: none." # "Outputs: none." +# "Member variable." # "Inputs:" followed by markdown (typically an unordered list) until the next match from this list. # "Outputs:" followed by markdown # "Example input:" followed by a code block (i.e. containing json) @@ -91,6 +92,7 @@ DEV_RPC_START = re.compile(r"^Dev-API:\s*([\w/:]+)(.*)$") IN_NONE = re.compile(r"^Inputs?: *[nN]one\.?$") IN_SOME = re.compile(r"^Inputs?:\s*$") +MEMBER_VAR = re.compile(r"^Member +[vV]ar(?:iable)?\.?$") DECL_SOME = re.compile(r"^Declaration?:\s*$") OUT_SOME = re.compile(r"^Outputs?:\s*$") EXAMPLE_IN = re.compile(r"^Example [iI]nputs?:\s*$") @@ -159,6 +161,7 @@ class Parsing(Enum): description, decl, inputs, outputs = "", "", "", "" done_desc = False no_inputs = False + member_var = False examples = [] cur_ex_in = None old_names = [] @@ -194,6 +197,9 @@ class Parsing(Enum): error("found multiple Inputs:") inputs, no_inputs, mode = MD_NO_INPUT, True, Parsing.NONE + elif re.search(MEMBER_VAR, line): + member_var, no_inputs, mode = True, True, Parsing.DESC + elif re.search(DECL_SOME, line): if inputs: error("found multiple Syntax:") @@ -285,7 +291,7 @@ class Parsing(Enum): # We hit the end of the commented section if not description or inputs.isspace(): problems.append("endpoint has no description") - if not inputs or inputs.isspace(): + if (not inputs or inputs.isspace()) and not member_var: problems.append( "endpoint has no inputs description; perhaps you need to add 'Inputs: none.'?" ) @@ -321,7 +327,9 @@ class Parsing(Enum): {MD_DECL_HEADER} {decl} - +""" + if not member_var: + md = md + f""" {MD_INPUT_HEADER} {inputs} diff --git a/docs/api/static/sidebar.md b/docs/api/static/sidebar.md index e622fe5b..569af33b 100644 --- a/docs/api/static/sidebar.md +++ b/docs/api/static/sidebar.md @@ -4,6 +4,7 @@ - [Convo Info Volatile](convo_info_volatile.md) - [Encrypt](encrypt.md) - [Error](error.md) +- [Groups](groups.md) - [User Groups](user_groups.md) - [User Profile](user_profile.md) - [Utils](util.md) diff --git a/external/oxen-encoding b/external/oxen-encoding index fc85dfd3..867d0797 160000 --- a/external/oxen-encoding +++ b/external/oxen-encoding @@ -1 +1 @@ -Subproject commit fc85dfd352e8474bc7195b0ba881838bd72ebea6 +Subproject commit 867d0797a08361eee613b91060a2ef447d2f9f4d diff --git a/include/session/config.hpp b/include/session/config.hpp index 45044615..170019c0 100644 --- a/include/session/config.hpp +++ b/include/session/config.hpp @@ -60,6 +60,11 @@ struct missing_signature : signature_error { struct config_parse_error : config_error { using config_error::config_error; }; +/// Type thrown for some bad value in a config (e.g. missing required key, or key with an +/// unexpected/unhandled value). +struct config_value_error : config_parse_error { + using config_parse_error::config_parse_error; +}; /// Class for a parsed, read-only config message; also serves as the base class of a /// MutableConfigMessage which allows setting values. @@ -87,7 +92,7 @@ class ConfigMessage { /// (so that they can return a reference to it). seqno_hash_t seqno_hash_{0, {0}}; - bool verified_signature_ = false; + std::optional> verified_signature_; // This will be set during construction from configs based on the merge result: // -1 means we had to merge one or more configs together into a new merged config @@ -123,7 +128,7 @@ class ConfigMessage { verify_callable verifier = nullptr, sign_callable signer = nullptr, int lag = DEFAULT_DIFF_LAGS, - bool signature_optional = false); + bool trust_signature = false); /// Constructs a new ConfigMessage by loading and potentially merging multiple serialized /// ConfigMessages together, according to the config conflict resolution rules. The result @@ -147,10 +152,6 @@ class ConfigMessage { /// diffs that exceeding this lag value will have those early lagged diffs dropping during /// loading. /// - /// signature_optional - if true then accept a message with no signature even when a verifier is - /// set, thus allowing unsigned messages (though messages with an invalid signature are still - /// not allowed). This option is ignored when verifier is not set. - /// /// error_handler - if set then any config message parsing error will be passed to this function /// for handling with the index of `configs` that failed and the error exception: the callback /// typically warns and, if the overall construction should abort, rethrows the error. If this @@ -163,7 +164,6 @@ class ConfigMessage { verify_callable verifier = nullptr, sign_callable signer = nullptr, int lag = DEFAULT_DIFF_LAGS, - bool signature_optional = false, std::function error_handler = nullptr); /// Returns a read-only reference to the contained data. (To get a mutable config object use @@ -211,10 +211,13 @@ class ConfigMessage { /// data), this will return -1. int unmerged_index() const { return unmerged_; } - /// Returns true if this message contained a valid, verified signature when it was parsed. - /// Returns false otherwise (e.g. not loaded from verification at all; loaded without a - /// verification function; or had no signature and a signature wasn't required). - bool verified_signature() const { return verified_signature_; } + /// Read-only access to the optional verified signature if this message contained a valid, + /// verified signature when it was parsed. Returns nullopt otherwise (e.g. not loaded from + /// verification at all; loaded without a verification function; or had no signature and a + /// signature wasn't required). + const std::optional>& verified_signature() { + return verified_signature_; + } /// Constructs a new MutableConfigMessage from this config message with an incremented seqno. /// The new config message's diff will reflect changes made after this construction. @@ -283,7 +286,6 @@ class MutableConfigMessage : public ConfigMessage { verify_callable verifier = nullptr, sign_callable signer = nullptr, int lag = DEFAULT_DIFF_LAGS, - bool signature_optional = false, std::function error_handler = nullptr); /// Wrapper around the above that takes a single string view to load a single message, doesn't @@ -293,8 +295,7 @@ class MutableConfigMessage : public ConfigMessage { ustring_view config, verify_callable verifier = nullptr, sign_callable signer = nullptr, - int lag = DEFAULT_DIFF_LAGS, - bool signature_optional = false); + int lag = DEFAULT_DIFF_LAGS); /// Does the same as the base incrementing, but also records any diff info from the current /// MutableConfigMessage. *this* object gets pruned and signed as part of this call. If the @@ -342,6 +343,48 @@ class MutableConfigMessage : public ConfigMessage { void increment_impl(); }; +/// API: base/verify_config_sig +/// +/// Verifies a config message signature, throwing a missing_signature or signature_error exception +/// if the signature is missing or invalid. +/// +/// A config message signature is always in the "~" key of a config message, which must be the +/// very last key of the message, and signs the config value up to (but not including) the ~ +/// key-value pair in the serialized config message. +/// +/// For instance, for a config message of: +/// +/// d[...configdata...]1:~64:[sigdata]e +/// +/// the signature signs the value `d[...configdata...]` (i.e. the `1:~64:[sigdata]` signature +/// pair, and the final closing `e` of the config message, are not included). No keys may +/// follow the signature key/value. +/// +/// Inputs: +/// - `dict` -- a `bt_dict_consumer` positioned at or before the "~" key where the signature is +/// expected. (If the bt_dict_consumer has already consumed the "~" key then this call will fail +/// as if the signature was missing). +/// - `config_msg` -- the full config message; this must be a view of the same data in memory that +/// `dict` is parsing (i.e. it cannot be a copy). +/// - `verifier` -- a callback to invoke to verify the signature of the message. If the callback is +/// empty then the signature will be ignored (it is neither required nor verified). +/// - `verified_signature` is a pointer to a std::optional array of signature data; if this is +/// specified and not nullptr then the optional with be emplaced with the signature bytes if the +/// signature successfully validates. +/// - `trust_signature` bypasses the verification and signature requirements, blinding trusting a +/// signature if present. This is intended for use when restoring from a dump (along with a +/// nullptr verifier). +/// +/// Outputs: +/// - returns with no value on success +/// - throws on failure +void verify_config_sig( + oxenc::bt_dict_consumer dict, + ustring_view config_msg, + const ConfigMessage::verify_callable& verifier, + std::optional>* verified_signature = nullptr, + bool trust_signature = false); + } // namespace session::config namespace oxenc::detail { diff --git a/include/session/config/base.h b/include/session/config/base.h index de9806e3..a14ba528 100644 --- a/include/session/config/base.h +++ b/include/session/config/base.h @@ -281,6 +281,27 @@ typedef struct config_string_list { /// - `config_string_list*` -- point to the list of hashes, pointer belongs to the caller LIBSESSION_EXPORT config_string_list* config_current_hashes(const config_object* conf); +/// API: base/config_get_keys +/// +/// Obtains the current group decryption keys. +/// +/// Returns a buffer where each consecutive 32 bytes is an encryption key for the object, in +/// priority order (i.e. the key at 0 is the encryption key, and the first decryption key). +/// +/// This function is mainly for debugging/diagnostics purposes; most config types have one single +/// key (based on the secret key), and multi-keyed configs such as groups have their own methods for +/// encryption/decryption that are already aware of the multiple keys. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config_object object +/// - `len` -- [out] Pointer where the number of keys will be written (that is: the returned pointer +/// will be to a buffer which has a size of of this value times 32). +/// +/// Outputs: +/// - `unsigned char*` -- pointer to newly malloced key data (a multiple of 32 bytes); the pointer +/// belongs to the caller and must be `free()`d when done with it. +LIBSESSION_EXPORT unsigned char* config_get_keys(const config_object* conf, size_t* len); + /// Config key management; see the corresponding method docs in base.hpp. All `key` arguments here /// are 32-byte binary buffers (and since fixed-length, there is no keylen argument). @@ -446,6 +467,59 @@ LIBSESSION_EXPORT const unsigned char* config_key(const config_object* conf, siz /// - `char*` -- encryption domain C-str used to encrypt values LIBSESSION_EXPORT const char* config_encryption_domain(const config_object* conf); +/// API: base/config_set_sig_keys +/// +/// Sets an Ed25519 keypair pair for signing and verifying config messages. When set, this adds an +/// additional signature for verification into the config message (*after* decryption) that +/// validates a config message. +/// +/// This is used in config contexts where the encryption/decryption keys are insufficient for +/// permission verification to produce new messages, such as in groups where non-admins need to be +/// able to decrypt group data, but are not permitted to push new group data. In such a case only +/// the admins have the secret key with which messages can be signed; regular users can only read, +/// but cannot write, config messages. +/// +/// When a signature public key (with or without a secret key) is set the config object enters a +/// "signing-required" mode, which has some implications worth noting: +/// - incoming messages must contain a signature that verifies with the public key; messages +/// without such a signature will be dropped as invalid. +/// - because of the above, a config object cannot push config updates without the secret key: +/// thus any attempt to modify the config message with a pubkey-only config object will raise +/// an exception. +/// +/// Inputs: +/// - `secret` -- pointer to a 64-byte sodium-style Ed25519 "secret key" buffer (technically the +/// seed+precomputed pubkey concatenated together) that sets both the secret key and public key. +LIBSESSION_EXPORT void config_set_sig_keys(config_object* conf, const unsigned char* secret); + +/// API: base/config_set_sig_pubkey +/// +/// Sets a Ed25519 signing pubkey which incoming messages must be signed by to be acceptable. This +/// is intended for use when the secret key is not known (see `config_set_sig_keys()` to set both +/// secret and pubkey keys together). +/// +/// Inputs: +/// - `pubkey` -- pointer to the 32-byte Ed25519 pubkey that must have signed incoming messages. +LIBSESSION_EXPORT void config_set_sig_pubkey(config_object* conf, const unsigned char* pubkey); + +/// API: base/config_get_sig_pubkey +/// +/// Returns a pointer to the 32-byte Ed25519 signing pubkey, if set. Returns nullptr if there is no +/// current signing pubkey. +/// +/// Inputs: none. +/// +/// Outputs: +/// - pointer to the 32-byte pubkey, or NULL if not set. +LIBSESSION_EXPORT const unsigned char* config_get_sig_pubkey(const config_object* conf); + +/// API: base/config_clear_sig_keys +/// +/// Drops the signature pubkey and/or secret key, if the object has them. +/// +/// Inputs: none. +LIBSESSION_EXPORT void config_clear_sig_keys(config_object* conf); + #ifdef __cplusplus } // extern "C" #endif diff --git a/include/session/config/base.hpp b/include/session/config/base.hpp index 987e587d..73516a4d 100644 --- a/include/session/config/base.hpp +++ b/include/session/config/base.hpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include #include @@ -43,9 +45,97 @@ enum class ConfigState : int { Waiting = 2, }; +using Ed25519PubKey = std::array; +using Ed25519Secret = sodium_array; + +// Helper base class for holding a config signing keypair +class ConfigSig { + protected: + // Contains an optional signing keypair; if the public key is set then incoming messages must + // contain a valid signature from that key to be loaded. If the private key is set then a + // signature will be added to the message signed by that key. (Note that if a public key is set + // but not a private key then this config object cannot push config changes!) + std::optional _sign_pk = std::nullopt; + Ed25519Secret _sign_sk; + + ConfigSig() = default; + + // Returns a blake2b 32-byte hash of the config signing seed using hash key `key`. `key` must + // be 64 bytes or less, and should generally be unique for each key use case. + // + // Throws if a secret key hasn't been set via `set_sig_keys`. + std::array seed_hash(std::string_view key) const; + + virtual void set_verifier(ConfigMessage::verify_callable v) = 0; + virtual void set_signer(ConfigMessage::sign_callable v) = 0; + + // Meant to be called from the subclass constructor after other necessary initialization; calls + // set_sig_keys, set_sig_pubkey, or clear_sig_keys() for you, based on which are non-nullopt. + // + // Throws if given invalid data (i.e. wrong key size, or mismatched pubkey/secretkey). + void init_sig_keys( + std::optional ed25519_pubkey, + std::optional ed25519_secretkey); + + public: + virtual ~ConfigSig() = default; + + /// API: base/ConfigSig::set_sig_keys + /// + /// Sets an Ed25519 keypair pair for signing and verifying config messages. When set, this adds + /// an additional signature for verification into the config message (*after* decryption) that + /// validates a config message. + /// + /// This is used in config contexts where the encryption/decryption keys are insufficient for + /// permission verification to produce new messages, such as in groups where non-admins need to + /// be able to decrypt group data, but are not permitted to push new group data. In such a case + /// only the admins have the secret key with which messages can be signed; regular users can + /// only read, but cannot write, config messages. + /// + /// When a signature public key (with or without a secret key) is set the config object enters + /// a "signing-required" mode, which has some implications worth noting: + /// - incoming messages must contain a signature that verifies with the public key; messages + /// without such a signature will be dropped as invalid. + /// - because of the above, a config object cannot push config updates without the secret key: + /// thus any attempt to modify the config message with a pubkey-only config object will raise + /// an exception. + /// + /// Inputs: + /// - `secret` -- the 64-byte sodium-style Ed25519 "secret key" (actually the seed+pubkey + /// concatenated together) that sets both the secret key and public key. + void set_sig_keys(ustring_view secret); + + /// API: base/ConfigSig::set_sig_pubkey + /// + /// Sets a Ed25519 signing pubkey which incoming messages must be signed by to be acceptable. + /// This is intended for use when the secret key is not known (see `set_sig_keys()` to set both + /// secret and pubkey keys together). + /// + /// Inputs: + /// - `pubkey` -- the 32 byte Ed25519 pubkey that must have signed incoming messages + void set_sig_pubkey(ustring_view pubkey); + + /// API: base/ConfigSig::get_sig_pubkey + /// + /// Returns a const reference to the 32-byte Ed25519 signing pubkey, if set. + /// + /// Inputs: none. + /// + /// Outputs: + /// - reference to the 32-byte pubkey, or `std::nullopt` if not set. + const std::optional>& get_sig_pubkey() const { return _sign_pk; } + + /// API: base/ConfigSig::clear_sig_keys + /// + /// Drops the signature pubkey and/or secret key, if the object has them. + /// + /// Inputs: none. + void clear_sig_keys(); +}; + /// Base config type for client-side configs containing common functionality needed by all config /// sub-types. -class ConfigBase { +class ConfigBase : public ConfigSig { private: // The object (either base config message or MutableConfigMessage) that stores the current // config message. Subclasses do not directly access this: instead they call `dirty()` if they @@ -55,15 +145,15 @@ class ConfigBase { // Tracks our current state ConfigState _state = ConfigState::Clean; + void init_from_dump(std::string_view dump); + static constexpr size_t KEY_SIZE = 32; // Contains the base key(s) we use to encrypt/decrypt messages. If non-empty, the .front() // element will be used when encrypting a new message to push. When decrypting, we attempt each // of them, starting with .front(), until decryption succeeds. using Key = std::array; - Key* _keys = nullptr; - size_t _keys_size = 0; - size_t _keys_capacity = 0; + sodium_vector _keys; // Contains the current active message hash, as fed into us in `confirm_pushed()`. Empty if we // don't know it yet. When we dirty the config this value gets moved into `old_hashes_` to be @@ -78,7 +168,15 @@ class ConfigBase { // Constructs a base config by loading the data from a dump as produced by `dump()`. If the // dump is nullopt then an empty base config is constructed with no config settings and seqno // set to 0. - explicit ConfigBase(std::optional dump = std::nullopt); + // + // Can optionally be passed a pubkey or secretkey (or both, but the pubkey can be obtained from + // the secretkey automatically): if either is given, the config object is set up to require + // verification of incoming messages using the associated pubkey, and will be signed using the + // secretkey (if a secret key is given). + explicit ConfigBase( + std::optional dump = std::nullopt, + std::optional ed25519_pubkey = std::nullopt, + std::optional ed25519_secretkey = std::nullopt); // Tracks whether we need to dump again; most mutating methods should set this to true (unless // calling set_state, which sets to to true implicitly). @@ -99,6 +197,9 @@ class ConfigBase { // already dirty (i.e. Clean or Waiting) then calling this increments the seqno counter. MutableConfigMessage& dirty(); + void set_verifier(ConfigMessage::verify_callable v) override; + void set_signer(ConfigMessage::sign_callable s) override; + public: // class for proxying subfield access; this class should never be stored but only used // ephemerally (most of its methods are rvalue-qualified). This lets constructs such as @@ -682,7 +783,14 @@ class ConfigBase { void load_key(ustring_view ed25519_secretkey); public: - virtual ~ConfigBase(); + virtual ~ConfigBase() = default; + + // Object is non-movable and non-copyable; you need to hold it in a smart pointer if it needs to + // be managed. + ConfigBase(ConfigBase&&) = delete; + ConfigBase(const ConfigBase&) = delete; + ConfigBase& operator=(ConfigBase&&) = delete; + ConfigBase& operator=(const ConfigBase&) = delete; // Proxy class providing read and write access to the contained config data. const DictFieldRoot data{*this}; @@ -794,6 +902,43 @@ class ConfigBase { /// - `bool` -- Returns true if changes have been serialized bool is_clean() const { return _state == ConfigState::Clean; } + /// API: base/ConfigBase::is_readonly + /// + /// Returns true if this config object is in read-only mode: specifically that means that this + /// config object can only absorb new config entries but is incapable of producing new entries, + /// and thus cannot modify or merge configs. + /// + /// This currently happens for config messages that require verification of a signature but do + /// not have the private keys required to *produce* a signature. For private config types, such + /// as single-user configs, this will never be the case (as those can only be decrypted in the + /// first place if you possess the private key). Note, however, that additional conditions for + /// read-only could be added in the future, so this being true should not *strictly* be + /// interpreted as a cannot-sign issue. + /// + /// There are some consequences of being readonly: + /// + /// - any attempt to modify config values will throw an exception. + /// - when multiple conflicting config objects are loaded only the "best" (i.e. higher seqno, + /// with ties determined by hashed value) config is loaded; if values need to be merged this + /// config will ignore the alternate values until someone who can produce a signature produces + /// a merged config that properly incorporates (and signs) the updated config. + /// - read-only configurations never have anything to push, that is, `needs_push()` will always + /// be false. + /// - it is still possible to `push()` a config anyway, but this only returns the current config + /// and signature of the message currently being used, and *never* returns any obsolete + /// hashes. Typically this is unlikely to be useful, as it is expected that only signers (who + /// can update and merge) are likely also the only ones who can actually push new configs to + /// the swarm. + /// - read-only configurations do not reliably track obsolete hashes as the obsolesence logic + /// depends on the results of merging, which read-only configs do not support. (If you do + /// call `push()`, you'll always just get back an empty list of obsolete hashes). + /// + /// Inputs: None + /// + /// Outputs: + /// - `bool` true if this config object is read-only + bool is_readonly() const { return _config->verifier && !_config->signer; } + /// API: base/ConfigBase::current_hashes /// /// The current config hash(es); this can be empty if the current hash is unknown or the current @@ -919,19 +1064,24 @@ class ConfigBase { /// Inputs: /// - `ustring_view key` -- 32 byte binary key /// - `high_priority` -- Whether to add to front or back of key list. If true then key is added - /// to beginning and replace highest-priority key for encryption - void add_key(ustring_view key, bool high_priority = true); + /// to beginning and replace highest-priority key for encryption + /// - `dirty_config` -- if true then mark the config as dirty (incrementing seqno and needing a + /// push) if the first key (i.e. the key used for encryption) is changed as a result of this + /// call. Ignored if the config is not modifiable. + void add_key(ustring_view key, bool high_priority = true, bool dirty_config = false); /// API: base/ConfigBase::clear_keys /// /// Clears all stored encryption/decryption keys. This is typically immediately followed with /// one or more `add_key` call to replace existing keys. Returns the number of keys removed. /// - /// Inputs: None + /// Inputs: + /// - `dirty_config` -- if this removes a key then mark the config as dirty (incrementing seqno + /// and requiring a push). Only has an effect if the config is modifiable. /// /// Outputs: /// - `int` -- Returns number of keys removed - int clear_keys(); + int clear_keys(bool dirty_config = false); /// API: base/ConfigBase::remove_key /// @@ -944,16 +1094,36 @@ class ConfigBase { /// Inputs: /// - `key` -- the key to remove from the key list /// - `from` -- optional agrument to specify which position to remove from, usually omitted + /// - `dirty_config` -- if true, and the *first* key (the encryption key) is removed from the + /// list then mark the config as dirty (incrementing seqno and requiring a re-push). Ignored + /// if the config is not modifiable. /// /// Outputs: /// - `bool` -- Returns true if found and removed - bool remove_key(ustring_view key, size_t from = 0); + bool remove_key(ustring_view key, size_t from = 0, bool dirty_config = false); + + /// API: base/ConfigBase::replace_keys + /// + /// Replaces the full set of keys with the given vector of keys. This is equivalent to calling + /// `clear_keys()` and then `add_key` with the keys, in order (and so the first key in the + /// vector becomes the highest priority, i.e. the key used for encryption). + /// + /// Inputs: + /// - `new_keys` -- the new decryption keys; the first key becomes the new encryption key + /// - `dirty_config` -- if true then set the config status to dirty (incrementing seqno and + /// requiring a repush) if the old and new first key are not the same. Ignored if the config + /// is not modifiable. + void replace_keys(const std::vector& new_keys, bool dirty_config = false); /// API: base/ConfigBase::get_keys /// /// Returns a vector of encryption keys, in priority order (i.e. element 0 is the encryption /// key, and the first decryption key). /// + /// This method is mainly for debugging/diagnostics purposes; most config types have one single + /// key (based on the secret key), and multi-keyed configs such as groups have their own methods + /// for encryption/decryption that are already aware of the multiple keys. + /// /// Inputs: None /// /// Outputs: @@ -993,7 +1163,7 @@ class ConfigBase { /// Outputs: /// - `ustring_view` -- binary data of the key ustring_view key(size_t i = 0) const { - assert(i < _keys_size); + assert(i < _keys.size()); return {_keys[i].data(), _keys[i].size()}; } }; diff --git a/include/session/config/convo_info_volatile.h b/include/session/config/convo_info_volatile.h index fc94ab1a..6ddba3eb 100644 --- a/include/session/config/convo_info_volatile.h +++ b/include/session/config/convo_info_volatile.h @@ -24,6 +24,12 @@ typedef struct convo_info_volatile_community { bool unread; // true if marked unread } convo_info_volatile_community; +typedef struct convo_info_volatile_group { + char group_id[67]; // in hex; 66 hex chars + null terminator. Begins with "03". + int64_t last_read; // ms since unix epoch + bool unread; // true if marked unread +} convo_info_volatile_group; + typedef struct convo_info_volatile_legacy_group { char group_id[67]; // in hex; 66 hex chars + null terminator. Looks just like a Session ID, // though isn't really one. @@ -189,7 +195,7 @@ LIBSESSION_EXPORT bool convo_info_volatile_get_community( /// /// Declaration: /// ```cpp -/// BOOL convo_info_volatile_get_or_constructcommunity( +/// BOOL convo_info_volatile_get_or_construct_community( /// [in] config_object* conf, /// [out] convo_info_volatile_community* comm, /// [in] const char* base_url, @@ -206,7 +212,7 @@ LIBSESSION_EXPORT bool convo_info_volatile_get_community( /// - `pubkey` -- [in] 32 byte binary data of the pubkey /// /// Outputs: -/// - `bool` - Returns true if the community exists +/// - `bool` - Returns true if the call succeeds LIBSESSION_EXPORT bool convo_info_volatile_get_or_construct_community( config_object* conf, convo_info_volatile_community* convo, @@ -214,6 +220,66 @@ LIBSESSION_EXPORT bool convo_info_volatile_get_or_construct_community( const char* room, unsigned const char* pubkey) __attribute__((warn_unused_result)); +/// API: convo_info_volatile/convo_info_volatile_get_group +/// +/// Fills `convo` with the conversation info given a group ID (specified as a null-terminated +/// hex string), if the conversation exists, and returns true. If the conversation does not exist +/// then `convo` is left unchanged and false is returned. On error, false is returned and the error +/// is set in conf->last_error (on non-error, last_error is cleared). +/// +/// Declaration: +/// ```cpp +/// BOOL convo_info_volatile_get_group( +/// [in] config_object* conf, +/// [out] convo_info_volatile_group* convo, +/// [in] const char* id +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `convo` -- [out] Pointer to group +/// - `id` -- [in] Null terminated hex string (66 chars, beginning with 03) specifying the ID of the +/// group +/// +/// Outputs: +/// - `bool` - Returns true if the group exists +LIBSESSION_EXPORT bool convo_info_volatile_get_group( + config_object* conf, convo_info_volatile_group* convo, const char* id) + __attribute__((warn_unused_result)); + +/// API: convo_info_volatile/convo_info_volatile_get_or_construct_group +/// +/// Same as the above except that when the conversation does not exist, this sets all the convo +/// fields to defaults and loads it with the given id. +/// +/// Returns true as long as it is given a valid group id (i.e. 66 hex chars beginning with "03"). A +/// false return is considered an error, and means the id was not a valid session id; an error +/// string will be set in `conf->last_error`. +/// +/// This is the method that should usually be used to create or update a conversation, followed by +/// setting fields in the convo, and then giving it to convo_info_volatile_set(). +/// +/// Declaration: +/// ```cpp +/// BOOL convo_info_volatile_get_or_construct_group( +/// [in] config_object* conf, +/// [out] convo_info_volatile_group* convo, +/// [in] const char* id +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `convo` -- [out] Pointer to group +/// - `id` -- [in] Null terminated hex string specifying the ID of the group +/// +/// Outputs: +/// - `bool` - Returns true if the call succeeds +LIBSESSION_EXPORT bool convo_info_volatile_get_or_construct_group( + config_object* conf, convo_info_volatile_group* convo, const char* id) + __attribute__((warn_unused_result)); + /// API: convo_info_volatile/convo_info_volatile_get_legacy_group /// /// Fills `convo` with the conversation info given a legacy group ID (specified as a null-terminated @@ -233,10 +299,10 @@ LIBSESSION_EXPORT bool convo_info_volatile_get_or_construct_community( /// Inputs: /// - `conf` -- [in] Pointer to the config object /// - `convo` -- [out] Pointer to legacy group -/// - `id` -- [in] Null terminated jex string specifying the ID of the legacy group +/// - `id` -- [in] Null terminated hex string specifying the ID of the legacy group /// /// Outputs: -/// - `bool` - Returns true if the community exists +/// - `bool` - Returns true if the legacy group exists LIBSESSION_EXPORT bool convo_info_volatile_get_legacy_group( config_object* conf, convo_info_volatile_legacy_group* convo, const char* id) __attribute__((warn_unused_result)); @@ -265,10 +331,10 @@ LIBSESSION_EXPORT bool convo_info_volatile_get_legacy_group( /// Inputs: /// - `conf` -- [in] Pointer to the config object /// - `convo` -- [out] Pointer to legacy group -/// - `id` -- [in] Null terminated jex string specifying the ID of the legacy group +/// - `id` -- [in] Null terminated hex string specifying the ID of the legacy group /// /// Outputs: -/// - `bool` - Returns true if the community exists +/// - `bool` - Returns true if the call succeeds LIBSESSION_EXPORT bool convo_info_volatile_get_or_construct_legacy_group( config_object* conf, convo_info_volatile_legacy_group* convo, const char* id) __attribute__((warn_unused_result)); @@ -309,6 +375,24 @@ LIBSESSION_EXPORT void convo_info_volatile_set_1to1( LIBSESSION_EXPORT void convo_info_volatile_set_community( config_object* conf, const convo_info_volatile_community* convo); +/// API: convo_info_volatile/convo_info_volatile_set_group +/// +/// Adds or updates a group from the given convo info +/// +/// Declaration: +/// ```cpp +/// VOID convo_info_volatile_set_group( +/// [in] config_object* conf, +/// [in] const convo_info_volatile_group* convo +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `convo` -- [in] Pointer to group info structure +LIBSESSION_EXPORT void convo_info_volatile_set_group( + config_object* conf, const convo_info_volatile_group* convo); + /// API: convo_info_volatile/convo_info_volatile_set_legacy_group /// /// Adds or updates a legacy group from the given convo info @@ -323,7 +407,7 @@ LIBSESSION_EXPORT void convo_info_volatile_set_community( /// /// Inputs: /// - `conf` -- [in] Pointer to the config object -/// - `convo` -- [in] Pointer to community info structure +/// - `convo` -- [in] Pointer to legacy group info structure LIBSESSION_EXPORT void convo_info_volatile_set_legacy_group( config_object* conf, const convo_info_volatile_legacy_group* convo); @@ -374,6 +458,27 @@ LIBSESSION_EXPORT bool convo_info_volatile_erase_1to1(config_object* conf, const LIBSESSION_EXPORT bool convo_info_volatile_erase_community( config_object* conf, const char* base_url, const char* room); +/// API: convo_info_volatile/convo_info_volatile_erase_group +/// +/// Erases a group. Returns true if the group was found and removed, false if the group was not +/// present. You must not call this during iteration. +/// +/// Declaration: +/// ```cpp +/// BOOL convo_info_volatile_erase_group( +/// [in] config_object* conf, +/// [in] const char* group_id +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `group_id` -- [in] Null terminated hex string +/// +/// Outputs: +/// - `bool` - Returns true if group was found and removed +LIBSESSION_EXPORT bool convo_info_volatile_erase_group(config_object* conf, const char* group_id); + /// API: convo_info_volatile/convo_info_volatile_erase_legacy_group /// /// Erases a legacy group. Returns true if the group was found @@ -451,6 +556,24 @@ LIBSESSION_EXPORT size_t convo_info_volatile_size_1to1(const config_object* conf /// - `size_t` -- number of communities LIBSESSION_EXPORT size_t convo_info_volatile_size_communities(const config_object* conf); +/// API: convo_info_volatile/convo_info_volatile_size_groups +/// +/// Returns the number of groups. +/// +/// Declaration: +/// ```cpp +/// SIZE_T convo_info_volatile_size_groups( +/// [in] const config_object* conf +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `size_t` -- number of groups +LIBSESSION_EXPORT size_t convo_info_volatile_size_groups(const config_object* conf); + /// API: convo_info_volatile/convo_info_volatile_size_legacy_groups /// /// Returns the number of legacy groups. @@ -479,15 +602,18 @@ typedef struct convo_info_volatile_iterator convo_info_volatile_iterator; /// ```cpp /// convo_info_volatile_1to1 c1; /// convo_info_volatile_community c2; -/// convo_info_volatile_legacy_group c3; +/// convo_info_volatile_group c3; +/// convo_info_volatile_legacy_group c4; /// convo_info_volatile_iterator *it = convo_info_volatile_iterator_new(my_convos); /// for (; !convo_info_volatile_iterator_done(it); convo_info_volatile_iterator_advance(it)) { /// if (convo_info_volatile_it_is_1to1(it, &c1)) { /// // use c1.whatever /// } else if (convo_info_volatile_it_is_community(it, &c2)) { /// // use c2.whatever -/// } else if (convo_info_volatile_it_is_legacy_group(it, &c3)) { +/// } else if (convo_info_volatile_it_is_group(it, &c3)) { /// // use c3.whatever +/// } else if (convo_info_volatile_it_is_legacy_group(it, &c4)) { +/// // use c4.whatever /// } /// } /// convo_info_volatile_iterator_free(it); @@ -557,6 +683,29 @@ LIBSESSION_EXPORT convo_info_volatile_iterator* convo_info_volatile_iterator_new LIBSESSION_EXPORT convo_info_volatile_iterator* convo_info_volatile_iterator_new_communities( const config_object* conf); +/// API: convo_info_volatile/convo_info_volatile_iterator_new_groups +/// +/// The same as `convo_info_volatile_iterator_new` except that this iterates *only* over one type of +/// conversation. You still need to use `convo_info_volatile_it_is_group` (or the alternatives) to +/// load the data in each pass of the loop. (You can, however, safely ignore the bool return value +/// of the `it_is_whatever` function: it will always be true for the particular type being iterated +/// over). +/// +/// Declaration: +/// ```cpp +/// CONVO_INFO_VOLATILE_ITERATOR* convo_info_volatile_iterator_new_groups( +/// [in] const config_object* conf +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `convo_info_volatile_iterator*` -- Iterator +LIBSESSION_EXPORT convo_info_volatile_iterator* convo_info_volatile_iterator_new_groups( + const config_object* conf); + /// API: convo_info_volatile/convo_info_volatile_iterator_new_legacy_groups /// /// The same as `convo_info_volatile_iterator_new` except that this iterates *only* over one type of @@ -656,7 +805,7 @@ LIBSESSION_EXPORT bool convo_info_volatile_it_is_1to1( /// ```cpp /// BOOL convo_info_volatile_it_is_community( /// [in] convo_info_volatile_iterator* it, -/// [out] convo_info_volatile_1to1* c +/// [out] convo_info_volatile_community* c /// ); /// ``` /// @@ -672,6 +821,28 @@ LIBSESSION_EXPORT bool convo_info_volatile_it_is_1to1( LIBSESSION_EXPORT bool convo_info_volatile_it_is_community( convo_info_volatile_iterator* it, convo_info_volatile_community* c); +/// API: convo_info_volatile/convo_info_volatile_it_is_group +/// +/// If the current iterator record is a group conversation this sets the details into `g` and +/// returns true. Otherwise it returns false. +/// +/// Declaration: +/// ```cpp +/// BOOL convo_info_volatile_it_is_group( +/// [in] convo_info_volatile_iterator* it, +/// [out] convo_info_volatile_group* g +/// ); +/// ``` +/// +/// Inputs: +/// - `it` -- [in] The convo_info_volatile_iterator +/// - `c` -- [out] Pointer to the convo_info_volatile, will be populated if true +/// +/// Outputs: +/// - `bool` -- True if the record is a group conversation +LIBSESSION_EXPORT bool convo_info_volatile_it_is_group( + convo_info_volatile_iterator* it, convo_info_volatile_group* c); + /// API: convo_info_volatile/convo_info_volatile_it_is_legacy_group /// /// If the current iterator record is a legacy group conversation this sets the details into `c` and @@ -680,8 +851,8 @@ LIBSESSION_EXPORT bool convo_info_volatile_it_is_community( /// Declaration: /// ```cpp /// BOOL convo_info_volatile_it_is_legacy_group( -/// [in] convo_info_volatile_iterator* it, -/// [out] convo_info_volatile_1to1* c +/// [in] convo_info_volatile_iterator* it, +/// [out] convo_info_volatile_legacy_group* c /// ); /// ``` /// diff --git a/include/session/config/convo_info_volatile.hpp b/include/session/config/convo_info_volatile.hpp index 482906af..28b3e822 100644 --- a/include/session/config/convo_info_volatile.hpp +++ b/include/session/config/convo_info_volatile.hpp @@ -14,6 +14,7 @@ using namespace std::literals; extern "C" { struct convo_info_volatile_1to1; struct convo_info_volatile_community; +struct convo_info_volatile_group; struct convo_info_volatile_legacy_group; } @@ -41,14 +42,18 @@ class ConvoInfoVolatile; /// included, but will be 0 if no messages are read. /// u - will be present and set to 1 if this conversation is specifically marked unread. /// +/// g - group conversations (aka new, non-legacy closed groups). The key is the group identifier +/// (beginning with 03). Values are dicts with keys: +/// r - the unix timestamp (in integer milliseconds) of the last-read message. Always +/// included, but will be 0 if no messages are read. +/// u - will be present and set to 1 if this conversation is specifically marked unread. +/// /// C - legacy group conversations (aka closed groups). The key is the group identifier (which /// looks indistinguishable from a Session ID, but isn't really a proper Session ID). Values /// are dicts with keys: /// r - the unix timestamp (integer milliseconds) of the last-read message. Always included, /// but will be 0 if no messages are read. /// u - will be present and set to 1 if this conversation is specifically marked unread. -/// -/// c - reserved for future tracking of new group conversations. namespace convo { @@ -103,6 +108,26 @@ namespace convo { friend struct session::config::comm_iterator_helper; }; + struct group : base { + std::string id; // 66 hex digits starting with "03" + + /// API: convo_info_volatile/group::group + /// + /// Constructs an empty group from an id + /// + /// Inputs: + /// - `group_id` -- hex string of group_id, 66 hex bytes starting with "03" + explicit group(std::string&& group_id); + explicit group(std::string_view group_id); + + // Internal ctor/method for C API implementations: + group(const struct convo_info_volatile_group& c); // From c struct + void into(convo_info_volatile_group& c) const; // Into c struct + + private: + friend class session::config::ConvoInfoVolatile; + }; + struct legacy_group : base { std::string id; // in hex, indistinguishable from a Session ID @@ -129,7 +154,7 @@ namespace convo { friend class session::config::ConvoInfoVolatile; }; - using any = std::variant; + using any = std::variant; } // namespace convo class ConvoInfoVolatile : public ConfigBase { @@ -249,6 +274,18 @@ class ConvoInfoVolatile : public ConfigBase { /// - `std::optional` - Returns a community std::optional get_community(std::string_view partial_url) const; + /// API: convo_info_volatile/ConvoInfoVolatile::get_group + /// + /// Looks up and returns a group conversation by ID. The ID is a 66-character hex string + /// beginning with "03". Returns nullopt if there is no record of the group conversation. + /// + /// Inputs: + /// - `pubkey_hex` -- Hex string of the group ID + /// + /// Outputs: + /// - `std::optional` - Returns a group + std::optional get_group(std::string_view pubkey_hex) const; + /// API: convo_info_volatile/ConvoInfoVolatile::get_legacy_group /// /// Looks up and returns a legacy group conversation by ID. The ID looks like a hex Session ID, @@ -275,6 +312,19 @@ class ConvoInfoVolatile : public ConfigBase { /// - `convo::one_to_one` - Returns a contact convo::one_to_one get_or_construct_1to1(std::string_view session_id) const; + /// API: convo_info_volatile/ConvoInfoVolatile::get_or_construct_group + /// + /// These are the same as the above `get` methods (without "_or_construct" in the name), except + /// that when the conversation doesn't exist a new one is created, prefilled with the + /// pubkey/url/etc. + /// + /// Inputs: + /// - `pubkey_hex` -- Hex string pubkey + /// + /// Outputs: + /// - `convo::group` - Returns a group + convo::group get_or_construct_group(std::string_view pubkey_hex) const; + /// API: convo_info_volatile/ConvoInfoVolatile::get_or_construct_legacy_group /// /// These are the same as the above `get` methods (without "_or_construct" in the name), except @@ -347,6 +397,7 @@ class ConvoInfoVolatile : public ConfigBase { /// Declaration: /// ```cpp /// void set(const convo::one_to_one& c); + /// void set(const convo::group& c); /// void set(const convo::legacy_group& c); /// void set(const convo::community& c); /// void set(const convo::any& c); // Variant which can be any of the above @@ -356,6 +407,7 @@ class ConvoInfoVolatile : public ConfigBase { /// - `c` -- struct containing any contact, community or group void set(const convo::one_to_one& c); void set(const convo::legacy_group& c); + void set(const convo::group& c); void set(const convo::community& c); void set(const convo::any& c); // Variant which can be any of the above @@ -392,6 +444,17 @@ class ConvoInfoVolatile : public ConfigBase { /// - `bool` - Returns true if found and removed, otherwise false bool erase_community(std::string_view base_url, std::string_view room); + /// API: convo_info_volatile/ConvoInfoVolatile::erase_group + /// + /// Removes a group conversation. Returns true if found and removed, false if not present. + /// + /// Inputs: + /// - `pubkey_hex` -- String of the group pubkey + /// + /// Outputs: + /// - `bool` - Returns true if found and removed, otherwise false + bool erase_group(std::string_view pubkey_hex); + /// API: convo_info_volatile/ConvoInfoVolatile::erase_legacy_group /// /// Removes a legacy group conversation. Returns true if found and removed, false if not @@ -423,6 +486,7 @@ class ConvoInfoVolatile : public ConfigBase { /// - `bool` - Returns true if found and removed, otherwise false bool erase(const convo::one_to_one& c); bool erase(const convo::community& c); + bool erase(const convo::group& c); bool erase(const convo::legacy_group& c); bool erase(const convo::any& c); // Variant of any of them @@ -438,6 +502,7 @@ class ConvoInfoVolatile : public ConfigBase { /// size_t size() const; /// size_t size_1to1() const; /// size_t size_communities() const; + /// size_t size_groups() const; /// size_t size_legacy_groups() const; /// ``` /// @@ -447,9 +512,11 @@ class ConvoInfoVolatile : public ConfigBase { /// - `size_t` - Returns the number of conversations size_t size() const; - /// Returns the number of 1-to-1, community, and legacy group conversations, respectively. + /// Returns the number of 1-to-1, community, group, and legacy group conversations, + /// respectively. size_t size_1to1() const; size_t size_communities() const; + size_t size_groups() const; size_t size_legacy_groups() const; /// API: convo_info_volatile/ConvoInfoVolatile::empty @@ -474,6 +541,8 @@ class ConvoInfoVolatile : public ConfigBase { /// // use dm->session_id, dm->last_read, etc. /// } else if (const auto* og = std::get_if(&convo)) { /// // use og->base_url, og->room, om->last_read, etc. + /// } else if (const auto* cg = std::get_if(&convo)) { + /// // use cg->id, cg->last_read /// } else if (const auto* lcg = std::get_if(&convo)) { /// // use lcg->id, lcg->last_read /// } @@ -483,6 +552,9 @@ class ConvoInfoVolatile : public ConfigBase { /// This iterates through all conversations in sorted order (sorted first by convo type, then by /// id within the type). /// + /// The `begin_TYPE()` versions of the iterator return an iterator that loops only through the + /// given `TYPE` of conversations. (The .end() iterator works for all the iterator variations). + /// /// It is NOT permitted to add/modify/remove records while iterating; performing modifications /// based on a condition requires two passes: one to collect the required changes, and another /// to apply them key by key. @@ -492,6 +564,7 @@ class ConvoInfoVolatile : public ConfigBase { /// iterator begin() const; /// subtype_iterator begin_1to1() const; /// subtype_iterator begin_communities() const; + /// subtype_iterator begin_groups() const; /// subtype_iterator begin_legacy_groups() const; /// ``` /// @@ -503,7 +576,8 @@ class ConvoInfoVolatile : public ConfigBase { /// API: convo_info_volatile/ConvoInfoVolatile::end /// - /// Iterator for passing the end of the conversations + /// Iterator for passing the end of the conversations. This works for both the all-convo + /// iterator (`begin()`) and the type-specific iterators (e.g. `begin_groups()`). /// /// Inputs: None /// @@ -517,10 +591,12 @@ class ConvoInfoVolatile : public ConfigBase { /// Returns an iterator that iterates only through one type of conversations subtype_iterator begin_1to1() const { return {data}; } subtype_iterator begin_communities() const { return {data}; } + subtype_iterator begin_groups() const { return {data}; } subtype_iterator begin_legacy_groups() const { return {data}; } using iterator_category = std::input_iterator_tag; - using value_type = std::variant; + using value_type = + std::variant; using reference = value_type&; using pointer = value_type*; using difference_type = std::ptrdiff_t; @@ -528,15 +604,18 @@ class ConvoInfoVolatile : public ConfigBase { struct iterator { protected: std::shared_ptr _val; - std::optional _it_11, _end_11, _it_lgroup, _end_lgroup; + std::optional _it_11, _end_11, _it_group, _end_group, _it_lgroup, + _end_lgroup; std::optional _it_comm; void _load_val(); iterator() = default; // Constructs an end tombstone - explicit iterator( + iterator( const DictFieldRoot& data, - bool oneto1 = true, - bool communities = true, - bool legacy_groups = true); + bool oneto1, + bool communities, + bool groups, + bool legacy_groups); + explicit iterator(const DictFieldRoot& data) : iterator(data, true, true, true, true) {} friend class ConvoInfoVolatile; public: @@ -561,6 +640,7 @@ class ConvoInfoVolatile : public ConfigBase { data, std::is_same_v, std::is_same_v, + std::is_same_v, std::is_same_v) {} friend class ConvoInfoVolatile; diff --git a/include/session/config/groups/info.h b/include/session/config/groups/info.h new file mode 100644 index 00000000..1efc4a75 --- /dev/null +++ b/include/session/config/groups/info.h @@ -0,0 +1,201 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "../base.h" +#include "../profile_pic.h" +#include "../util.h" + +/// API: groups/groups_info_init +/// +/// Constructs a group info config object and sets a pointer to it in `conf`. +/// +/// When done with the object the `config_object` must be destroyed by passing the pointer to +/// config_free() (in `session/config/base.h`). +/// +/// Inputs: +/// - `conf` -- [out] Pointer to the config object +/// - `ed25519_pubkey` -- [in] 32-byte pointer to the group's public key +/// - `ed25519_secretkey` -- [in] optional 64-byte pointer to the group's secret key +/// (libsodium-style 64 byte value). Pass as NULL for a non-admin member. +/// - `dump` -- [in] if non-NULL this restores the state from the dumped byte string produced by a +/// past instantiation's call to `dump()`. To construct a new, empty object this should be NULL. +/// - `dumplen` -- [in] the length of `dump` when restoring from a dump, or 0 when `dump` is NULL. +/// - `error` -- [out] the pointer to a buffer in which we will write an error string if an error +/// occurs; error messages are discarded if this is given as NULL. If non-NULL this must be a +/// buffer of at least 256 bytes. +/// +/// Outputs: +/// - `int` -- Returns 0 on success; returns a non-zero error code and write the exception message +/// as a C-string into `error` (if not NULL) on failure. +LIBSESSION_EXPORT int groups_info_init( + config_object** conf, + const unsigned char* ed25519_pubkey, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, + size_t dumplen, + char* error) __attribute__((warn_unused_result)); + +/// API: groups_info/groups_info_get_name +/// +/// Returns a pointer to the currently-set name (null-terminated), or NULL if there is no name at +/// all. Should be copied right away as the pointer may not remain valid beyond other API calls. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `char*` -- Pointer to the currently-set name as a null-terminated string, or NULL if there is +/// no name +LIBSESSION_EXPORT const char* groups_info_get_name(const config_object* conf); + +/// API: groups_info/groups_info_set_name +/// +/// Sets the group's name to the null-terminated C string. Returns 0 on success, non-zero on +/// error (and sets the config_object's error string). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `name` -- [in] Pointer to the name as a null-terminated C string +/// +/// Outputs: +/// - `int` -- Returns 0 on success, non-zero on error +LIBSESSION_EXPORT int groups_info_set_name(config_object* conf, const char* name); + +/// API: groups_info/groups_info_get_pic +/// +/// Obtains the current profile pic. The pointers in the returned struct will be NULL if a profile +/// pic is not currently set, and otherwise should be copied right away (they will not be valid +/// beyond other API calls on this config object). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `user_profile_pic` -- Pointer to the currently-set profile pic (despite the "user_profile" in +/// the struct name, this is the group's profile pic). +LIBSESSION_EXPORT user_profile_pic groups_info_get_pic(const config_object* conf); + +/// API: groups_info/groups_info_set_pic +/// +/// Sets a user profile +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `pic` -- [in] Pointer to the pic +/// +/// Outputs: +/// - `int` -- Returns 0 on success, non-zero on error +LIBSESSION_EXPORT int groups_info_set_pic(config_object* conf, user_profile_pic pic); + +/// API: groups_info/groups_info_get_expiry_timer +/// +/// Gets the group's message expiry timer (seconds). Returns 0 if not set. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int` -- Returns the expiry timer in seconds. Returns 0 if not set +LIBSESSION_EXPORT int groups_info_get_expiry_timer(const config_object* conf); + +/// API: groups_info/groups_info_set_expiry_timer +/// +/// Sets the group's message expiry timer (seconds). Setting 0 (or negative) will clear the current +/// timer. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `expiry` -- [in] Integer of the expiry timer in seconds +LIBSESSION_EXPORT void groups_info_set_expiry_timer(config_object* conf, int expiry); + +/// API: groups_info/groups_info_get_created +/// +/// Returns the timestamp (unix time, in seconds) when the group was created. Returns 0 if unset. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` -- Unix timestamp when the group was created (if set by an admin). +LIBSESSION_EXPORT int64_t groups_info_get_created(const config_object* conf); + +/// API: groups_info/groups_info_set_created +/// +/// Sets the creation time (unix timestamp, in seconds) when the group was created. Setting 0 +/// clears the value. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `ts` -- [in] the unix timestamp, or 0 to clear a current value. +LIBSESSION_EXPORT void groups_info_set_created(config_object* conf, int64_t ts); + +/// API: groups_info/groups_info_get_delete_before +/// +/// Returns the delete-before timestamp (unix time, in seconds); clients should delete all messages +/// from the group with timestamps earlier than this value, if set. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` -- Unix timestamp before which messages should be deleted. Returns 0 if not set. +LIBSESSION_EXPORT int64_t groups_info_get_delete_before(const config_object* conf); + +/// API: groups_info/groups_info_set_delete_before +/// +/// Sets the delete-before time (unix timestamp, in seconds) before which messages should be +/// deleted. Setting 0 clears the value. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `ts` -- [in] the unix timestamp, or 0 to clear a current value. +LIBSESSION_EXPORT void groups_info_set_delete_before(config_object* conf, int64_t ts); + +/// API: groups_info/groups_info_get_attach_delete_before +/// +/// Returns the delete-before timestamp (unix time, in seconds) for attachments; clients should drop +/// all attachments from messages from the group with timestamps earlier than this value, if set. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` -- Unix timestamp before which messages should be deleted. Returns 0 if not set. +LIBSESSION_EXPORT int64_t groups_info_get_attach_delete_before(const config_object* conf); + +/// API: groups_info/groups_info_set_attach_delete_before +/// +/// Sets the delete-before time (unix timestamp, in seconds) for attachments; attachments should be +/// dropped from messages older than this value. Setting 0 clears the value. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `ts` -- [in] the unix timestamp, or 0 to clear a current value. +LIBSESSION_EXPORT void groups_info_set_attach_delete_before(config_object* conf, int64_t ts); + +/// API: groups_info/groups_info_is_destroyed(const config_object* conf); +/// +/// Returns true if this group has been marked destroyed by an admin, which indicates to a receiving +/// client that they should destroy it locally. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `true` if the group has been nuked, `false` otherwise. +LIBSESSION_EXPORT bool groups_info_is_destroyed(const config_object* conf); + +/// API: groups_info/groups_info_destroy_group(const config_object* conf); +/// +/// Nukes a group from orbit. This is permanent (i.e. there is no removing this setting once set). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +LIBSESSION_EXPORT void groups_info_destroy_group(config_object* conf); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/include/session/config/groups/info.hpp b/include/session/config/groups/info.hpp new file mode 100644 index 00000000..9ebdfd7d --- /dev/null +++ b/include/session/config/groups/info.hpp @@ -0,0 +1,273 @@ +#pragma once + +#include +#include +#include + +#include "../base.hpp" +#include "../namespaces.hpp" +#include "../profile_pic.hpp" + +namespace session::config::groups { + +using namespace std::literals; + +/// keys used in this config, either currently or in the past (so that we don't reuse): +/// +/// ! - set to true if the group has been destroyed (and should be removed from receiving clients) +/// c - creation unix timestamp (seconds) +/// d - delete before timestamp: this instructs receiving clients that they should delete all +/// messages with a timestamp < the set value. +/// D - delete attachments before - same as above, but specific to attachments. +/// E - disappearing message timer (seconds) if the delete-after-send disappearing messages mode is +/// enabled for the group. Omitted if disappearing messages is disabled. +/// n - utf8 group name (human-readable) +/// p - group profile url +/// q - group profile decryption key (binary) + +class Info final : public ConfigBase { + + public: + // No default constructor + Info() = delete; + + /// API: groups/Info::Info + /// + /// Constructs a group info config object from existing data (stored from `dump()`). + /// + /// To construct a blank info object (i.e. with no pre-existing dumped data to load) pass + /// `std::nullopt` as the third argument. + /// + /// Encryption keys must be loaded before the Info object can be modified or parse other Info + /// messages, and are typically loaded by providing the `Info` object to the `Keys` class. + /// + /// Inputs: + /// - `ed25519_pubkey` is the public key of this group, used to validate config messages. + /// Config messages not signed with this key will be rejected. + /// - `ed25519_secretkey` is the secret key of the group, used to sign pushed config messages. + /// This is only possessed by the group admin(s), and must be provided in order to make and + /// push config changes. + /// - `dumped` -- either `std::nullopt` to construct a new, empty object; or binary state data + /// that was previously dumped from an instance of this class by calling `dump()`. + Info(ustring_view ed25519_pubkey, + std::optional ed25519_secretkey, + std::optional dumped); + + /// API: groups/Info::storage_namespace + /// + /// Returns the Info namespace. Is constant, will always return Namespace::GroupInfo + /// + /// Inputs: None + /// + /// Outputs: + /// - `Namespace` - Will return Namespace::GroupInfo + Namespace storage_namespace() const override { return Namespace::GroupInfo; } + + /// API: groups/Info::encryption_domain + /// + /// Returns the encryption domain used when encrypting messages of this type. + /// + /// Inputs: None + /// + /// Outputs: + /// - `const char*` - Will return "groups::Info" + const char* encryption_domain() const override { return "groups::Info"; } + + /// API: groups/Info::id + /// + /// Contains the (read-only) id of this group, that is, 03 followed by the pubkey in hex. (This + /// is equivalent to a 05-prefixed session_id, but is the group-specific identifier). + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::string` containing the hex group id/pubkey + const std::string id; + + /// API: groups/Info::get_name + /// + /// Returns the group name, or std::nullopt if there is no group name set. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::optional` - Returns the group name if it is set + std::optional get_name() const; + + /// API: groups/Info::set_name + /// + /// Sets the group name; if given an empty string then the name is removed. + /// + /// Declaration: + /// ```cpp + /// void set_name(std::string_view new_name); + /// ``` + /// + /// Inputs: + /// - `new_name` -- The name to be put into the group Info + void set_name(std::string_view new_name); + + /// API: groups/Info::get_profile_pic + /// + /// Gets the group's current profile pic URL and decryption key. The returned object will + /// evaluate as false if the URL and/or key are not set. + /// + /// Declaration: + /// ```cpp + /// profile_pic get_group_pic() const; + /// ``` + /// + /// Inputs: None + /// + /// Outputs: + /// - `profile_pic` - Returns the group's profile pic + profile_pic get_profile_pic() const; + + /// API: groups/Info::set_profile_pic + /// + /// Sets the group's current profile pic to a new URL and decryption key. Clears both if either + /// one is empty. + /// + /// Declaration: + /// ```cpp + /// void set_profile_pic(std::string_view url, ustring_view key); + /// void set_profile_pic(profile_pic pic); + /// ``` + /// + /// Inputs: + /// - First function: + /// - `url` -- URL pointing to the profile pic + /// - `key` -- Decryption key + /// - Second function: + /// - `pic` -- Profile pic object + void set_profile_pic(std::string_view url, ustring_view key); + void set_profile_pic(profile_pic pic); + + /// API: groups/Info::set_expiry_timer + /// + /// Sets (or clears) the group's message expiry timer. If > 0s the setting becomes the + /// delete-after-send value; if omitted or given a 0 or negative duration then the expiring + /// message timer is disabled for the group. + /// + /// Inputs: + /// - `expiration_timer` -- how long the expiration timer should be, defaults to zero (disabling + /// message expiration) if the argument is omitted. + void set_expiry_timer(std::chrono::seconds expiration_timer = 0min); + + /// API: groups/Info::get_expiry_timer + /// + /// Returns the group's current message expiry timer, or `std::nullopt` if no expiry timer is + /// set. If not nullopt then the expiry will always be >= 1s. + /// + /// Note that groups only support expire-after-send expiry timers and so there is no separate + /// expiry type setting. + /// + /// Inputs: none + /// + /// Outputs: + /// - `std::chrono::seconds` -- the expiry timer duration + std::optional get_expiry_timer() const; + + /// API: groups/Info::set_created + /// + /// Sets the created timestamp. It's recommended (but not required) that you only set this if + /// not already set. + /// + /// Inputs: + /// - `session_id` -- hex string of the session id + /// - `timestamp` -- standard unix timestamp when the group was created + void set_created(int64_t timestamp); + + /// API: groups/Info::get_created + /// + /// Returns the creation timestamp, if set/known. + /// + /// Inputs: none. + /// + /// Outputs: + /// - `std::optional` -- the unix timestamp when the group was created, or nullopt if + /// the creation timestamp is not set. + std::optional get_created() const; + + /// API: groups/Info::set_delete_before + /// + /// Sets a "delete before" unix timestamp: this instructs clients to delete all messages from + /// the closed group history with a timestamp earlier than this value. Returns nullopt if no + /// delete-before timestamp is set. + /// + /// The given value is not checked for sanity (e.g. if you pass milliseconds it will be + /// interpreted as deleting everything for the next 50000+ years). Be careful! + /// + /// Inputs: + /// - `timestamp` -- the new unix timestamp before which clients should delete messages. Pass 0 + /// (or negative) to disable the delete-before timestamp. + void set_delete_before(int64_t timestamp); + + /// API: groups/Info::get_delete_before + /// + /// Returns the delete-before unix timestamp (seconds) for the group; clients should delete all + /// messages from the closed group with timestamps earlier than this value, if set. + /// + /// Returns std::nullopt if no delete-before timestamp is set. + /// + /// Inputs: none. + /// + /// Outputs: + /// - `int64_t` -- the unix timestamp for which all older messages shall be delete + std::optional get_delete_before() const; + + /// API: groups/Info::set_delete_attach_before + /// + /// Sets a "delete attachments before" unix timestamp: this instructs clients to drop the + /// attachments (though not necessarily the messages themselves; see `get_delete_before` for + /// that) from any messages older than the given timestamp. Returns nullopt if no + /// delete-attachments-before timestamp is set. + /// + /// The given value is not checked for sanity (e.g. if you pass milliseconds it will be + /// interpreted as deleting all attachments for the next 50000+ years). Be careful! + /// + /// Inputs: + /// - `timestamp` -- the new unix timestamp before which clients should delete attachments. Pass + /// 0 + /// (or negative) to disable the delete-attachment-before timestamp. + void set_delete_attach_before(int64_t timestamp); + + /// API: groups/Info::get_delete_attach_before + /// + /// Returns the delete-attachments-before unix timestamp (seconds) for the group; clients should + /// delete all messages from the closed group with timestamps earlier than this value, if set. + /// + /// Returns std::nullopt if no delete-attachments-before timestamp is set. + /// + /// Inputs: none. + /// + /// Outputs: + /// - `int64_t` -- the unix timestamp for which all older message attachments shall be deleted + std::optional get_delete_attach_before() const; + + /// API: groups/Info::destroy_group + /// + /// Sets the group as permanently deleted, and set this status in the group's config. Receiving + /// clients are supposed to remove the conversation from their conversation list when this + /// happens. + /// + /// This change is permanent; the flag cannot be unset once set! + /// + /// Inputs: + /// + /// None: this call is destructive and permanent. Be careful! + void destroy_group(); + + /// API: groups/Info::is_destroyed + /// + /// Returns true if this group has been marked destroyed; the receiving client is expected to + /// delete it. + /// + /// Inputs: none. + /// + /// Outputs: + /// - `true` if the group has been destroyed, `false` otherwise. + bool is_destroyed() const; +}; + +} // namespace session::config::groups diff --git a/include/session/config/groups/keys.h b/include/session/config/groups/keys.h new file mode 100644 index 00000000..25592c26 --- /dev/null +++ b/include/session/config/groups/keys.h @@ -0,0 +1,553 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "../base.h" +#include "../util.h" + +// This is an opaque type analagous to `config_object` but specific to the groups keys object. +// +// It is constructed via groups_keys_init and destructed via groups_keys_free. +typedef struct config_group_keys { + // Internal opaque object pointer; calling code should leave this alone. + void* internals; + + // When an error occurs in the C API this string will be set to the specific error message. May + // be empty. + const char* last_error; + + // Sometimes used as the backing buffer for `last_error`. Should not be touched externally. + char _error_buf[256]; + +} config_group_keys; + +/// API: groups/groups_keys_init +/// +/// Constructs a group keys management config object and sets a pointer to it in `conf`. +/// +/// Note that this is *not* a regular `config_object` and thus does not use the usual +/// `config_free()` and similar methods from `session/config/base.h`; instead it must be managed by +/// the functions declared in the header. +/// +/// Inputs: +/// - `conf` -- [out] Pointer-pointer to a `config_group_keys` pointer (i.e. double pointer); the +/// pointer will be set to a new config_group_keys object on success. +/// +/// Intended use: +/// +/// ```C +/// config_group_keys* keys; +/// int rc = groups_keys_init(&keys, ...); +/// ``` +/// - `user_ed25519_secretkey` -- [in] 64-byte pointer to the **user**'s (not group's) secret +/// ed25519 key. (Used to be able to decrypt keys encrypted individually for us). +/// - `group_ed25519_pubkey` -- [in] 32-byte pointer to the group's public key +/// - `group_ed25519_secretkey` -- [in] optional 64-byte pointer to the group's secret key +/// (libsodium-style 64 byte value). Pass as NULL for a non-admin member. +/// - `group_info_conf` -- the group info config instance (keys will be added) +/// - `group_members_conf` -- the group members config instance (keys will be added) +/// - `dump` -- [in] if non-NULL this restores the state from the dumped byte string produced by a +/// past instantiation's call to `dump()`. To construct a new, empty object this should be NULL. +/// - `dumplen` -- [in] the length of `dump` when restoring from a dump, or 0 when `dump` is NULL. +/// - `error` -- [out] the pointer to a buffer in which we will write an error string if an error +/// occurs; error messages are discarded if this is given as NULL. If non-NULL this must be a +/// buffer of at least 256 bytes. +/// +/// Outputs: +/// - `int` -- Returns 0 on success; returns a non-zero error code and write the exception message +/// as a C-string into `error` (if not NULL) on failure. +LIBSESSION_EXPORT int groups_keys_init( + config_group_keys** conf, + const unsigned char* user_ed25519_secretkey, + const unsigned char* group_ed25519_pubkey, + const unsigned char* group_ed25519_secretkey, + config_object* group_info_conf, + config_object* group_members_conf, + const unsigned char* dump, + size_t dumplen, + char* error) __attribute__((warn_unused_result)); + +/// API: groups/groups_keys_size +/// +/// Returns the number of decryption keys stored in this Keys object. Mainly for +/// debugging/information purposes. +/// +/// Inputs: +/// - `conf` -- keys config object +/// +/// Outputs: +/// - `size_t` number of keys +LIBSESSION_EXPORT size_t groups_keys_size(const config_group_keys* conf); + +/// API: groups/groups_keys_get_key +/// +/// Accesses the Nth encryption key, ordered from most-to-least recent starting from index 0. +/// Calling this with 0 thus returns the most-current key (which is also the current _en_cryption +/// key). +/// +/// This function is not particularly efficient and is not typically needed except for diagnostics: +/// instead encryption/decryption should be performed used the dedicated functions which +/// automatically manage the decryption keys. +/// +/// This function can be used to obtain all decryption keys by calling it with an incrementing value +/// until it returns nullptr (or alternatively, looping over `0 <= i < groups_keys_size`). +/// +/// Returns nullptr if N is >= the current number of decryption keys. +/// +/// The returned pointer points at a 32-byte binary value containing the key; it should be copied or +/// used at once as it may not remain valid past other calls to the keys object. It should *not* be +/// freed. +/// +/// Inputs: +/// - `conf` -- keys config object +/// - `N` -- the index of the key to obtain +/// +/// Outputs: +/// - `const unsigned char*` -- pointer to the 32-byte key, or nullptr if there +LIBSESSION_EXPORT const unsigned char* groups_keys_get_key(const config_group_keys* conf, size_t N); + +/// API: groups/groups_keys_is_admin +/// +/// Returns true if this object has the group private keys, i.e. the user is an all-powerful +/// wiz^H^H^Hadmin of the group. +/// +/// Inputs: +/// - `conf` -- the groups config object +/// +/// Outputs: +/// - `true` if we have admin keys, `false` otherwise. +LIBSESSION_EXPORT bool groups_keys_is_admin(const config_group_keys* conf); + +/// API: groups/groups_keys_rekey +/// +/// Generates a new encryption key for the group and returns an encrypted key message to be pushed +/// to the swarm containing the key, encrypted for the members of the group. +/// +/// The returned binary key message to be pushed is written into a newly-allocated buffer. A +/// pointer to this buffer is set in the pointer-pointer `out` argument, and its length is set in +/// the `outlen` pointer. +/// +/// See Keys::rekey in the C++ API for more details about intended use. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `info` -- [in] Pointer to group Info object +/// - `members` -- [in] Pointer to group Members object +/// - `out` -- [out] Will be set to a pointer to the message to be pushed (only if the function +/// returns true). This value must be used immediately (it is not guaranteed to remain valid +/// beyond other calls to the config object), and must not be freed (i.e. ownership remains with +/// the keys config object). +/// - `outlen` -- [out] Length of the output value. Only set when the function returns true. +/// +/// Output: +/// - `bool` -- Returns true on success, false on failure. +LIBSESSION_EXPORT bool groups_keys_rekey( + config_group_keys* conf, + config_object* info, + config_object* members, + const unsigned char** out, + size_t* outlen) __attribute__((warn_unused_result)); + +/// API: groups/groups_keys_pending_config +/// +/// If a `rekey()` is currently in progress (and not yet confirmed, or possibly lost), this returns +/// the config message that should be pushed. As with the result of `rekey()` the pointer ownership +/// remains with the keys config object, and the value should be used/copied immediately. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `out` -- [out] Pointer-pointer that will be updated to point at the config data. Only set if +/// this function returns true! +/// - `outlen` -- [out] Pointer to the config data size (only set if the function returns true). +/// +/// Outputs: +/// - `bool` -- true if `out` and `outlen` have been updated to point to a pending config message; +/// false if there is no pending config message. +LIBSESSION_EXPORT bool groups_keys_pending_config( + const config_group_keys* conf, const unsigned char** out, size_t* outlen) + __attribute__((warn_unused_result)); + +/// API: groups/groups_keys_load_message +/// +/// Loads a key config message downloaded from the swarm, and loads the key into the info/member +/// configs. +/// +/// Such messages should be processed via this method *before* attempting to load config messages +/// downloaded from an info/members namespace. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `msg_hash` -- [in] Null-terminated C string containing the message hash +/// - `data` -- [in] Pointer to the incoming key config message +/// - `datalen` -- [in] length of `data` +/// - `timestamp_ms` -- [in] the timestamp (from the swarm) of the message +/// - `info` -- [in] the info config object to update with newly discovered keys +/// - `members` -- [in] the members config object to update with newly discovered keys +/// +/// Outputs: +/// Returns `true` if the message was parsed successfully (whether or not any new keys were +/// decrypted or loaded). Returns `false` on failure to parse (and sets `conf->last_error`). +LIBSESSION_EXPORT bool groups_keys_load_message( + config_group_keys* conf, + const char* msg_hash, + const unsigned char* data, + size_t datalen, + int64_t timestamp_ms, + config_object* info, + config_object* members) __attribute__((warn_unused_result)); + +/// API: groups/groups_keys_current_hashes +/// +/// Returns the hashes of currently active keys messages, that is, messages that have a decryption +/// key that new devices or clients might require; these are the messages that should have their +/// expiries renewed periodically. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the keys config object +/// +/// Outputs: +/// - `config_string_list*` -- pointer to an array of message hashes. The returned pointer belongs +/// to the caller and must be free()d when done. +LIBSESSION_EXPORT config_string_list* groups_keys_current_hashes(const config_group_keys* conf); + +/// API: groups/groups_keys_needs_rekey +/// +/// Checks whether a rekey is required (for instance, because of key generation conflict). Note +/// that this is *not* a check for when members changed (such rekeys are up to the caller to +/// manage), but mergely whether a rekey is needed after loading one or more config messages. +/// +/// See the C++ Keys::needs_rekey and Keys::rekey descriptions for more details. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `bool` -- `true` if `rekey()` needs to be called, `false` otherwise. +LIBSESSION_EXPORT bool groups_keys_needs_rekey(const config_group_keys* conf) + __attribute__((warn_unused_result)); + +/// API: groups/groups_keys_needs_dump +/// +/// Checks whether a groups_keys_dump needs to be called to save state. This is analagous to +/// config_dump, but specific for the group keys object. The value becomes false as soon as +/// `groups_keys_dump` is called, and remains false until the object's state is mutated (e.g. by +/// rekeying or loading new config messages). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `bool` -- `true` if a dump is needed, `false` otherwise. +LIBSESSION_EXPORT bool groups_keys_needs_dump(const config_group_keys* conf) + __attribute__((warn_unused_result)); + +/// API: groups/groups_keys_dump +/// +/// Produces a dump of the keys object state to be stored by the application to later restore the +/// object by passing the dump into the constructor. This is analagous to config_dump, but specific +/// for the group keys object. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `out` -- [out] Pointer-pointer to a data buffer; this will be set to a newly malloc'd pointer +/// containing the dump data. The caller is responsible for freeing the data when done! +/// - `outlen` -- [out] Pointer to a size_t where the length of `out` will be stored. +LIBSESSION_EXPORT void groups_keys_dump( + config_group_keys* conf, unsigned char** out, size_t* outlen); + +/// API: groups/groups_keys_key_supplement +/// +/// Generates a supplemental key message for one or more session IDs. This is used to distribute +/// existing active keys to a new member so that that member can access existing keys, configs, and +/// messages. Only admins can call this. +/// +/// The recommended order of operations for adding such a member is: +/// - add the member to Members +/// - generate the key supplement +/// - push new members & key supplement (ideally in a batch) +/// - send invite details, auth signature, etc. to the new user +/// +/// To add a member *without* giving them access to old messages you would use groups_keys_rekey() +/// instead of this method. +/// +/// Inputs: +/// - `conf` -- pointer to the keys config object +/// - `sids` -- array of session IDs of the members to generate a supplemental key for; each element +/// must be an ordinary (null-terminated) C string containing the 66-character session id. +/// - `sids_len` -- length of the `sids` array +/// - `message` -- pointer-pointer that will be set to a newly allocated buffer containing the +/// message that should be sent to the swarm. The caller must free() the pointer when finished to +/// not leak the message memory (but only if the function returns true). +/// - `message_len` -- pointer to a `size_t` that will be set to the length of the `message` buffer. +/// +/// Oututs: +/// - `true` and sets `*message` and `*message_len` on success; returns `false` and does not set +/// them on failure. +LIBSESSION_EXPORT bool groups_keys_key_supplement( + config_group_keys* conf, + const char** sids, + size_t sids_len, + unsigned char** message, + size_t* message_len); + +/// API: groups/groups_keys_swarm_make_subaccount +/// +/// Constructs a swarm subaccount signing value that a member can use to access messages in the +/// swarm. The member will have read and write access, but not delete access. Requires group +/// admins keys. +/// +/// Inputs: +/// - `conf` -- the config object +/// - `session_id` -- the session ID of the member (in hex) +/// - `sign_value` -- [out] pointer to a 100 byte (or larger) buffer where the 100 byte signing +/// value will be written. This is the value that should be sent to a member to allow +/// authentication. +/// +/// Outputs: +/// - `true` -- if making the subaccount succeeds, false if it fails (e.g. because of an invalid +/// session id, or not being an admin). If a failure occurs, sign_value will not be written to. +LIBSESSION_EXPORT bool groups_keys_swarm_make_subaccount( + config_group_keys* conf, const char* session_id, unsigned char* sign_value); + +/// API: groups/groups_keys_swarm_make_subaccount_flags +/// +/// Same as groups_keys_swarm_make_subaccount, but lets you specify whether the write/del flags are +/// present. +/// +/// +/// Inputs: +/// - `conf` -- the config object +/// - `session_id` -- the member session id (hex c string) +/// - `write` -- if true then the member shall be allowed to submit messages into the group account +/// of the swarm and extend (but not shorten) the expiry of messages in the group account. If +/// false then the user can only retrieve messages. Typically this is true. +/// - `del` -- if true (default is false) then the user shall be allowed to delete messages from the +/// swarm. This permission can be used to appoint a sort of "moderator" who can delete messages +/// without having the full admin group keys. Typically this is false. +/// - `sign_value` -- pointer to a buffer with at least 100 bytes where the 100 byte signing value +/// will be written. +/// +/// Outputs: +/// - `bool` - same as groups_keys_swarm_make_subaccount +LIBSESSION_EXPORT bool groups_keys_swarm_make_subaccount_flags( + config_group_keys* conf, + const char* session_id, + bool write, + bool del, + unsigned char* sign_value); + +/// API: groups/groups_keys_swarm_verify_subaccount +/// +/// Verifies that a received subaccount signing value (allegedly produced by +/// groups_keys_swarm_make_subaccount) is a valid subaccount signing value for the given group +/// pubkey, including a proper signature by an admin of the group. The signing value must have read +/// permission, but parameters can be given to also require write or delete permissions. A +/// subaccount signing value should always be checked for validity using this before creating a +/// group that would depend on it. +/// +/// Inputs: +/// - note that this function does *not* take a config object as it is intended for use to validate +/// an invitation before constructing the keys config objects. +/// - `groupid` -- the group id/pubkey, in hex, beginning with "03". +/// - `session_ed25519_secretkey` -- the user's Session ID secret key (64 bytes). +/// - `signing_value` -- the 100-byte subaccount signing value to validate +/// +/// The key will require read and write access to be acceptable. (See the _flags version if you +/// need something else). +/// +/// Outputs: +/// - `true` if `signing_value` is a valid subaccount signing value for `groupid` with (at least) +/// read and write permissions, `false` if the signing value does not validate or does not meet +/// the requirements. +LIBSESSION_EXPORT bool groups_keys_swarm_verify_subaccount( + const char* group_id, + const unsigned char* session_ed25519_secretkey, + const unsigned char* signing_value); + +/// API: groups/groups_keys_swarm_verify_subaccount_flags +/// +/// Same as groups_keys_swarm_verify_subaccount, except that you can specify whether you want to +/// require the write and or delete flags. +/// +/// Inputs: +/// - same as groups_keys_swarm_verify_subaccount +/// - `write` -- if true, require that the signing_value has write permission (i.e. that the +/// user will be allowed to post messages). +/// - `del` -- if true, required that the signing_value has delete permissions (i.e. that the +/// user will be allowed to remove storage messages from the group's swarm). Note that this +/// permission is about forcible swarm message deletion, and has no effect on an ability to +/// submit a deletion meta-message to the group (which only requires writing a message). +LIBSESSION_EXPORT bool groups_keys_swarm_verify_subaccount_flags( + const char* group_id, + const unsigned char* session_ed25519_secretkey, + const unsigned char* signing_value, + bool write, + bool del); + +/// API: groups/groups_keys_swarm_subaccount_sign +/// +/// This helper function generates the required signature for swarm subaccount authentication, +/// given the user's keys and swarm auth keys (as provided by an admin, produced via +/// `groups_keys_swarm_make_subaccount`). +/// +/// Storage server subaccount authentication requires passing the three values in the returned +/// struct in the storage server request. +/// +/// This version of the function writes base64-encoded values to the output parameters; there is +/// also a `_binary` version that writes raw values. +/// +/// Inputs: +/// - `conf` -- the keys config object +/// - `msg` -- the binary data that needs to be signed (which depends on the storage server request +/// being made; for example, "retrieve9991234567890123" for a retrieve request to namespace 999 +/// made at unix time 1234567890.123; see storage server RPC documentation for details). +/// - `msg_len` -- the length of the `msg` buffer +/// - `signing_value` -- the 100-byte subaccount signing value, as produced by an admin's +/// `swarm_make_subaccount` and provided to this member. +/// - `subaccount` -- [out] a C string buffer of *at least* 49 bytes where the null-terminated +/// 48-byte base64-encoded subaccount value will be written. This is the value to pass as +/// `subaccount` for storage server subaccount authentication. +/// - `subaccount_sig` -- [out] a C string buffer of *at least* 89 bytes where the null-terminated, +/// 88-ascii-character base64-encoded version of the 64-byte admin signature authorizing this +/// subaccount will be written. This is the value to be passed as `subaccount_sig` for storage +/// server subaccount authentication. +/// - `signature` -- [out] a C string buffer of *at least* 89 bytes where the null-terminated, +/// 88-character request signature will be written, base64 encoded. This is passes as the +/// `signature` value, alongside `subaccount`/`subaccoung_sig` to perform subaccount signature +/// authentication. +/// +/// Outputs: +/// - true if the values were written, false if an error occured (e.g. from an invalid signing_value +/// or cryptography error). +LIBSESSION_EXPORT bool groups_keys_swarm_subaccount_sign( + config_group_keys* conf, + const unsigned char* msg, + size_t msg_len, + const unsigned char* signing_value, + + char* subaccount, + char* subaccount_sig, + char* signature); + +/// API: groups/groups_keys_swarm_subaccount_sign_binary +/// +/// Does exactly the same as groups_keys_swarm_subaccount_sign except that the subaccount, +/// subaccount_sig, and signature values are written in binary (without null termination) of exactly +/// 36, 64, and 64 bytes, respectively. +/// +/// Inputs: +/// - see groups_keys_swarm_subaccount_sign +/// - `subaccount`, `subaccount_sig`, and `signature` are binary output buffers of size 36, 64, and +/// 64, respectively. +/// +/// Outputs: +/// See groups_keys_swarm_subaccount. +LIBSESSION_EXPORT bool groups_keys_swarm_subaccount_sign_binary( + config_group_keys* conf, + const unsigned char* msg, + size_t msg_len, + const unsigned char* signing_value, + + unsigned char* subaccount, + unsigned char* subaccount_sig, + unsigned char* signature); + +/// API: groups/groups_keys_swarm_subaccount_token +/// +/// Constructs the subaccount token for a session id. The main use of this is to submit a swarm +/// token revocation; for issuing subaccount tokens you want to use +/// `groups_keys_swarm_make_subaccount` instead. This will produce the same subaccount token that +/// `groups_keys_swarm_make_subaccount` implicitly creates that can be passed to a swarm to add a +/// revocation for that subaccount. +/// +/// This is recommended to be used when removing a non-admin member to prevent their access. +/// (Note, however, that there are circumstances where this can fail to prevent access, and so +/// should be combined with proper member removal and key rotation so that even if the member +/// gains access to messages, they cannot read them). +/// +/// Inputs: +/// - `conf` -- the keys config object +/// - `session_id` -- the session ID of the member (in hex) +/// - `token` -- [out] a 36-byte buffer into which to write the subaccount token. +/// +/// Outputs: +/// - true if the call succeeded, false if an error occured. +LIBSESSION_EXPORT bool groups_keys_swarm_subaccount_token( + config_group_keys* conf, const char* session_id, unsigned char* token); + +/// API: groups/groups_keys_swarm_subaccount_token_flags +/// +/// Same as `groups_keys_swarm_subaccount_token`, but takes `write` and `del` flags for creating a +/// token matching a user with non-standard permissions. +/// +/// Inputs: +/// - `conf` -- the keys config object +/// - `session_id` -- the session ID of the member (in hex) +/// - `write`, `del` -- see groups_keys_swarm_make_subaccount_flags +/// - `token` -- [out] a 36-byte buffer into which to write the subaccount token. +/// +/// Outputs: +/// - true if the call succeeded, false if an error occured. +LIBSESSION_EXPORT bool groups_keys_swarm_subaccount_token_flags( + config_group_keys* conf, + const char* session_id, + bool write, + bool del, + unsigned char* token); + +/// API: groups/groups_keys_encrypt_message +/// +/// Encrypts a message using the most recent group encryption key of this object. The message will +/// be compressed (if that reduces the size) before being encrypted. Decryption (and decompression, +/// if compression was applied) is performed by passing such a message into +/// groups_keys_decrypt_message. +/// +/// Note: this method can fail if there are no encryption keys at all, or if the incoming message +/// decompresses to a huge value (more than 1MB). If it fails then `ciphertext_out` is set to NULL +/// and should not be read or free()d. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `plaintext_in` -- [in] Pointer to a data buffer containing the unencrypted data. +/// - `plaintext_len` -- [in] Length of `plaintext_in` +/// - `ciphertext_out` -- [out] Pointer-pointer to an output buffer; a new buffer is allocated, the +/// encrypted data written to it, and then the pointer to that buffer is stored here. This +/// buffer must be `free()`d by the caller when done with it! +/// - `ciphertext_len` -- [out] Pointer to a size_t where the length of `ciphertext_out` is stored. +LIBSESSION_EXPORT void groups_keys_encrypt_message( + const config_group_keys* conf, + const unsigned char* plaintext_in, + size_t plaintext_len, + unsigned char** ciphertext_out, + size_t* ciphertext_len); + +/// API: groups/groups_keys_decrypt_message +/// +/// Attempts to decrypt a message using all of the known active encryption keys of this object. The +/// message will be decompressed after decryption, if required. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `ciphertext_in` -- [in] Pointer to a data buffer containing the encrypted data (as was +/// produced by `groups_keys_encrypt_message`). +/// - `ciphertext_len` -- [in] Length of `ciphertext_in` +/// - `plaintext_out` -- [out] Pointer-pointer to an output buffer; a new buffer is allocated, the +/// decrypted/decompressed data written to it, and then the pointer to that buffer is stored here. +/// This buffer must be `free()`d by the caller when done with it! +/// - `plaintext_len` -- [out] Pointer to a size_t where the length of `plaintext_out` is stored. +/// +/// Outputs: +/// - `bool` -- True if the message was successfully decrypted, false if decryption (or parsing or +/// decompression) failed with all of our known keys. +LIBSESSION_EXPORT bool groups_keys_decrypt_message( + const config_group_keys* conf, + const unsigned char* cipherext_in, + size_t cipherext_len, + unsigned char** plaintext_out, + size_t* plaintext_len); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp new file mode 100644 index 00000000..4424bde4 --- /dev/null +++ b/include/session/config/groups/keys.hpp @@ -0,0 +1,651 @@ +#pragma once + +#include +#include +#include + +#include "../../config.hpp" +#include "../base.hpp" +#include "../namespaces.hpp" +#include "../profile_pic.hpp" +#include "members.hpp" + +namespace session::config::groups { + +class Members; +class Info; + +using namespace std::literals; + +/// This "config" isn't exactly a regular config type that inherits from ConfigBase; in particular: +/// - it doesn't encrypt the message (but merely contains encrypted elements within it) +/// - it doesn't merge +/// - it does have a concept analogous to the message seqno +/// - conflict resolution involves regenerating and distributing new keys; nothing gets merged. +/// - it cares strongly about when new configs were pushed (configs expire after having been +/// replaced for a certain amount of time, not by being updated). +/// - its internal state isn't fully serialized when pushing updates +/// - messages don't contain the outer layer of config messages (where config metadata, references +/// to other objects, etc.) that ConfigBase-derived type hold. +/// - it isn't compressed (since most of the data fields are encrypted or random, compression +/// reduction would be minimal). +/// +/// Fields used (in ascii order): +/// # -- 24-byte nonce used for all the encrypted values in this message; required. +/// +/// For non-supplemental messages: +/// +/// G -- monotonically incrementing counter identifying key generation changes +/// K -- encrypted copy of the key for admins (omitted for `+` incremental key messages) +/// k -- packed bytes of encrypted keys for non-admin members; this is a single byte string in which +/// each 48 bytes is a separate encrypted value. +/// +/// For supplemental messages: +/// + -- encrypted supplemental key info list; this is a list of encrypted values, encrypted for +/// each member to whom keys are being disclosed. The *decrypted* value of these entries are +/// the same value (encrypted separately for each member) which is a bt-encoded list of dicts +/// where each dict contains keys: +/// - g -- the key generation +/// - k -- the key itself (32 bytes). +/// - t -- the storage timestamp of the key (so that recipients know when keys expire) +/// G -- the maximum generation of the keys included in this message; this is used to track when +/// this message can be allowed to expire. +/// +/// And finally, for both types: +/// +/// ~ -- signature of the message signed by the group's master keypair, signing the message value up +/// to but not including the ~ keypair. The signature must be the last key in the dict (thus +/// `~` since it is the largest 7-bit ascii character value). Note that this signature +/// mechanism works exactly the same as the signature on regular config messages. +/// +/// Some extra details: +/// +/// - each copy of the encryption key uses xchacha20_poly1305 using the `#` nonce +/// - the `k` members list gets padded with junk entries up to the next multiple of 75 (for +/// non-supplemental messages). +/// - the decryption key for the admin version of the key is H(admin_seed, +/// key="SessionGroupKeyAdmin") +/// - the encryption key for a member is H(a'B || A' || B, key="SessionGroupKeyMember") where a'/A' +/// is the group Ed25519 master key converted to X25519, and b/B is the member's X25519 keypair +/// (i.e. B is the non-05-prefixed session_id). +/// - the decryption key is calculated by the member using `bA' || A' || B` +/// - A new key and nonce is created from a 56-byte H(M0 || M1 || ... || Mn || g || S, +/// key="SessionGroupKeyGen"), where S = H(group_seed, key="SessionGroupKeySeed"). + +class Keys final : public ConfigSig { + + Ed25519Secret user_ed25519_sk; + + struct key_info { + std::array key; + std::chrono::system_clock::time_point timestamp; // millisecond precision + int64_t generation; + + auto cmpval() const { return std::tie(generation, timestamp, key); } + bool operator<(const key_info& b) const { return cmpval() < b.cmpval(); } + bool operator>(const key_info& b) const { return cmpval() > b.cmpval(); } + bool operator<=(const key_info& b) const { return cmpval() <= b.cmpval(); } + bool operator>=(const key_info& b) const { return cmpval() >= b.cmpval(); } + bool operator==(const key_info& b) const { return cmpval() == b.cmpval(); } + bool operator!=(const key_info& b) const { return cmpval() != b.cmpval(); } + }; + + /// Vector of keys that is kept sorted by generation/timestamp/key. This gets pruned as keys + /// have been superceded by another key for a sufficient amount of time (see KEY_EXPIRY). + sodium_vector keys_; + + /// Hashes of messages we have successfully parsed; used for deciding what needs to be renewed. + std::map> active_msgs_; + + sodium_cleared> pending_key_; + sodium_vector pending_key_config_; + int64_t pending_gen_ = -1; + + bool needs_dump_ = false; + + ConfigMessage::verify_callable verifier_; + ConfigMessage::sign_callable signer_; + + void set_verifier(ConfigMessage::verify_callable v) override { verifier_ = std::move(v); } + void set_signer(ConfigMessage::sign_callable s) override { signer_ = std::move(s); } + + // Checks for and drops expired keys. + void remove_expired(); + + // Loads existing state from a previous dump of keys data + void load_dump(ustring_view dump); + + // Inserts a key into the correct place in `keys_`. + void insert_key(std::string_view message_hash, key_info&& key); + + // Returned the blinding factor for a given session X25519 pubkey. This depends on the group's + // seed and thus is only obtainable by an admin account. + std::array subaccount_blind_factor( + const std::array& session_xpk) const; + + public: + /// The multiple of members keys we include in the message; we add junk entries to the key list + /// to reach a multiple of this. 75 is chosen because it's a decently large human-round number + /// that should still fit within 4kiB page size on the storage server (allowing for some extra + /// row field storage). + static constexpr int MESSAGE_KEY_MULTIPLE = 75; + + // 75 because: + // 2 // for the 'de' delimiters of the outer dict + // + 3 + 2 + 12 // for the `1:g` and `iNNNNNNNNNNe` generation keypair + // + 3 + 3 + 24 // for the `1:n`, `24:`, and 24 byte nonce + // + 3 + 3 + 48 // for the `1:K`, `48:`, and 48 byte ciphertexted key + // + 3 + 6 // for the `1:k` and `NNNNN:` key and prefix of the keys pair + // + N * 48 // for the packed encryption keys + // + 3 + 3 + 64; // for the `1:~` and `64:` and 64 byte signature + // = 177 + 48N + // + // and N=75 puts us a little bit under 4kiB (which is sqlite's default page size). + + /// A key expires when it has been surpassed by another key for at least this amount of time. + /// We default this to double the 30 days that we strictly need to avoid race conditions with + /// 30-day old config messages that might need the key for a client that is only very rarely + /// online. + static constexpr auto KEY_EXPIRY = 2 * 30 * 24h; + + /// The maximum uncompressed message size we allow in message decryption/encryption. + static constexpr size_t MAX_PLAINTEXT_MESSAGE_SIZE = 1'000'000; + + // No default constructor + Keys() = delete; + + /// API: groups/Keys::Keys + /// + /// Constructs a group members config object from existing data (stored from `dump()`) and a + /// list of encryption keys for encrypting new and decrypting existing messages. + /// + /// To construct a blank info object (i.e. with no pre-existing dumped data to load) pass + /// `std::nullopt` as the last argument. + /// + /// Inputs: + /// - `user_ed25519_secretkey` is the ed25519 secret key backing the current user's session ID, + /// and is used to decrypt incoming keys. It is required. + /// - `group_ed25519_pubkey` is the public key of the group, used to verify message signatures + /// on key updates. Required. Should not include the `03` prefix. + /// - `group_ed25519_secretkey` is the secret key of the group, used to encrypt, decrypt, and + /// sign config messages. This is only possessed by the group admin(s), and must be provided + /// in order to make and push config changes. + /// - `dumped` -- either `std::nullopt` to construct a new, empty object; or binary state data + /// that was previously dumped from an instance of this class by calling `dump()`. + /// - `info` and `members` -- will be loaded with the group keys, if present in the dump. + /// Otherwise, if this is an admin Keys object, with a new one constructed for the initial + /// Keys object; or with no keys loaded at all if this is a non-admin, non-dump construction. + /// (Keys will also be loaded later into this and the info/members objects, when rekey()ing or + /// loading keys via received config messages). + Keys(ustring_view user_ed25519_secretkey, + ustring_view group_ed25519_pubkey, + std::optional group_ed25519_secretkey, + std::optional dumped, + Info& info, + Members& members); + + /// API: groups/Keys::storage_namespace + /// + /// Returns the Keys namespace. Is constant, will always return Namespace::GroupKeys + /// + /// Inputs: None + /// + /// Outputs: + /// - `Namespace` - Will return Namespace::GroupKeys + Namespace storage_namespace() const { return Namespace::GroupKeys; } + + /// API: groups/Keys::encryption_domain + /// + /// Returns the encryption domain used when encrypting messages of this type. + /// + /// Inputs: None + /// + /// Outputs: + /// - `const char*` - Will return "groups::Keys" + const char* encryption_domain() const { return "groups::Keys"; } + + /// API: groups/Keys::group_keys + /// + /// Returns all the unexpired decryption keys that we know about. Keys are returned ordered + /// from most-recent to least-recent (and so the first one is meant to be used as the encryption + /// key), including a pending key if this object is in the process of pushing a new keys + /// message. + /// + /// This isn't typically directly needed: this object manages the key lists in the `info` and + /// `members` objects itself. + /// + /// Inputs: none. + /// + /// Outputs: + /// - `std::vector` - vector of encryption keys. + std::vector group_keys() const; + + /// API: groups/Keys::size + /// + /// Returns the number of distinct decryption keys that we know about. Mainly for + /// debugging/information purposes. + /// + /// Inputs: none + /// + /// Outputs: + /// - `size_t` of the number of keys we know about + size_t size() const; + + /// API: groups/Keys::encryption_key + /// + /// Accesses the current encryption key: that is, the most current group decryption key. Throws + /// if there are no encryption keys at all. (This is essentially the same as `group_keys()[0]`, + /// except for the throwing and avoiding needing to constructor a vector). + /// + /// You normally don't need to call this; you can just use encrypt_message() instead. + /// + /// Inputs: none. + /// + /// Outputs: + /// - `ustring_view` of the most current group encryption key. + ustring_view group_enc_key() const; + + /// API: groups/Keys::is_admin + /// + /// True if we have admin permissions (i.e. we know the group's master secret key). + /// + /// Inputs: none. + /// + /// Outputs: + /// - `true` if this object knows the group's master key + bool admin() const { return _sign_sk && _sign_pk; } + + /// API: groups/Keys::rekey + /// + /// Generate a new encryption key for the group and returns an encrypted key message to be + /// pushed to the swarm containing the key, encrypted for the members of the given + /// config::groups::Members object. This can only be done by an admin account (i.e. we must + /// have the group's private key). + /// + /// This method is intended to be called in these situations: + /// - potentially after loading new keys config messages (see `needs_rekey()`) + /// - when removing a member to switch to a new encryption key for the group that excludes that + /// member. + /// - when adding a member *and* switching to a new encryption key (without making the old key + /// available to the member) so that the new member cannot decipher pre-existing configs and + /// messages. + /// + /// This method is closely coupled to the group's Info and Members configs: it updates their + /// encryption keys and sets them as dirty, requiring a re-push to re-encrypt each of them. + /// Typically a rekey is performed as follows: + /// + /// - `rekey()` is called, returning the new keys config. + /// - `info.push()` is called to get the new info config (re-encrypted with the new key) + /// - `members.push()` is called to get the new members config (using the new key) + /// - all three new configs are pushed (ideally all at once, in a single batch request). + /// + /// Inputs: + /// - `Info` - the group's Info; it will be dirtied after the rekey and will require a push. + /// - `Members` - the current Members config for the group. When removing one or more members + /// this should be the list of members with the specific members already removed. The members + /// config will be dirtied after the rekey and will require a push. + /// + /// Outputs: + /// - `ustring_view` containing the data that needs to be pushed to the config keys namespace + /// for the group. (This can be re-obtained from `pending_config()` if needed until it has + /// been confirmed or superceded). This data must be consumed or copied from the returned + /// string_view immediately: it will not be valid past other calls on the Keys config object. + ustring_view rekey(Info& info, Members& members); + + /// API: groups/Keys::key_supplement + /// + /// Generates a supplemental key message for one or more session IDs. This is used to + /// distribute existing active keys to a new member so that that member can access existing + /// keys, configs, and messages. Only admins can call this. + /// + /// The recommended order of operations for adding such a member is: + /// - add the member to Members + /// - generate the key supplement + /// - push new members & key supplement (ideally in a batch) + /// - send invite details, auth signature, etc. to the new user + /// + /// To add a member *without* giving them access you would use rekey() instead of this method. + /// + /// Inputs: + /// - `sid` or `sids` -- session ID(s) of the members to generate a supplemental key for (there + /// are two versions of this function, one taking a single ID and one taking a vector). + /// Session IDs are specified in hex. + /// + /// Outputs: + /// - `ustring` containing the message that should be pushed to the swarm containing encrypted + /// keys for the given user(s). + ustring key_supplement(const std::vector& sids) const; + ustring key_supplement(std::string sid) const { + return key_supplement(std::vector{{std::move(sid)}}); + } + + /// API: groups/Keys::swarm_make_subaccount + /// + /// Constructs a swarm subaccount signing value that a member can use to access messages in the + /// swarm. Requires group admins keys. + /// + /// Inputs: + /// - `session_id` -- the session ID of the member (in hex) + /// - `write` -- if true (which is the default if omitted) then the member shall be allowed to + /// submit messages into the group account of the swarm and extend (but not shorten) the + /// expiry of messages in the group account. If false then the user can only retrieve + /// messages. + /// - `del` -- if true (default is false) then the user shall be allowed to delete messages + /// from the swarm. This permission can be used to appoint a sort of "moderator" who can + /// delete messages without having the full admin group keys. + /// + /// Outputs: + /// - `ustring` -- contains a subaccount swarm signing value; this can be passed (by the user) + /// into `swarm_subaccount_sign` to sign a value suitable for swarm authentication. + /// (Internally this packs the flags, blinding factor, and group admin signature together and + /// will be 4 + 32 + 64 = 100 bytes long). + /// + /// This value must be provided to the user so that they can authentication. The user should + /// call `swarm_verify_subaccount` to verify that the signing value was indeed signed by a + /// group admin before using/storing it. + /// + /// The signing value produced will be the same (for a given `session_id`/`write`/`del` + /// values) when constructed by any admin of the group. + ustring swarm_make_subaccount( + std::string_view session_id, bool write = true, bool del = false) const; + + /// API: groups/Keys::swarm_verify_subaccount + /// + /// Verifies that a received subaccount signing value (allegedly produced by + /// swarm_make_subaccount) is a valid subaccount signing value for the given group pubkey, + /// including a proper signature by an admin of the group. The signing value must have read + /// permission, but parameters can be given to also require write or delete permissions. A + /// subaccount signing value should always be checked for validity using this before creating a + /// group that would depend on it. + /// + /// There are two versions of this function: a static one callable without having a Keys + /// instance that takes the group id and user's session Ed25519 secret key as arguments; and a + /// member function that omits these first two arguments (using the ones from the Keys + /// instance). + /// + /// Inputs: + /// - `groupid` -- the group id/pubkey, in hex, beginning with "03". + /// - `session_ed25519_secretkey` -- the user's Session ID secret key. + /// - `signing_value` -- the subaccount signing value to validate + /// - `write` -- if true, require that the signing_value has write permission (i.e. that the + /// user will be allowed to post messages). + /// - `del` -- if true, required that the signing_value has delete permissions (i.e. that the + /// user will be allowed to remove storage messages from the group's swarm). Note that this + /// permission is about forcible swarm message deletion, and has no effect on an ability to + /// submit a deletion meta-message to the group (which only requires writing a message). + /// + /// Outputs: + /// - `true` if `signing_value` is a valid subaccount signing value for `groupid` with read (and + /// possible write and/or del permissions, if requested). `false` if the signing value does + /// not validate or does not meet the requirements. + static bool swarm_verify_subaccount( + std::string group_id, + ustring_view session_ed25519_secretkey, + ustring_view signing_value, + bool write = false, + bool del = false); + bool swarm_verify_subaccount( + ustring_view signing_value, bool write = false, bool del = false) const; + + /// API: groups/Keys::swarm_auth + /// + /// This struct containing the storage server authentication values for subaccount + /// authentication. The three strings in this struct may be either raw bytes, or base64 + /// encoded, depending on the `binary` parameter passed to `swarm_subaccount_sign`. + /// + /// `.subaccount` is the value to be passed as the "subaccount" authentication parameter. (It + /// consists of permission flags followed by a blinded public key.) + /// + /// `.subaccount_sig` is the value to be passed as the "subaccount_sig" authentication + /// parameter. (It consists of an admin-produced signature of the subaccount, providing + /// permission for that token to be used for authentication). + /// + /// `.signature` is the value to be passed as the "signature" authentication parameter. (It is + /// an Ed25519 signature that validates using the blinded public key inside `subaccount`). + /// + /// Inputs: none. + struct swarm_auth { + std::string subaccount; + std::string subaccount_sig; + std::string signature; + }; + + /// API: groups/Keys::swarm_subaccount_sign + /// + /// This helper function generates the required signature for swarm subaccount authentication, + /// given the user's keys and swarm auth keys (as provided by an admin, produced via + /// `swarm_make_subaccount`). + /// + /// Storage server subaccount authentication requires passing the three values in the returned + /// struct in the storage server request. (See Keys::swarm_auth for details). + /// + /// Inputs: + /// - `msg` -- the data that needs to be signed (which depends on the storage server request + /// being made; for example, "retrieve9991234567890123" for a retrieve request to namespace + /// 999 made at unix time 1234567890.123; see storage server RPC documentation for details). + /// - `signing_value` -- the 100-byte subaccount signing value, as produced by an admin's + /// `swarm_make_subaccount` and provided to this member. + /// - `binary` -- if set to true then the returned values will be binary. If omitted (or + /// explicitly false), the returned struct values will be base64-encoded suitable for direct + /// passing as JSON values to the storage server without further encoding/modification. + /// + /// Outputs: + /// - struct containing three binary values enabling swarm authentication (see description + /// above). + swarm_auth swarm_subaccount_sign( + ustring_view msg, ustring_view signing_value, bool binary = false) const; + + /// API: groups/Keys::swarm_subaccount_token + /// + /// Constructs the subaccount token for a session id. The main use of this is to submit a swarm + /// token revocation; for issuing subaccount tokens you want to use `swarm_make_subaccount` + /// instead. This will produce the same subaccount token that `swarm_make_subaccount` + /// implicitly creates that can be passed to a swarm to add a revocation for that subaccount. + /// + /// This is recommended to be used when removing a non-admin member to prevent their access. + /// (Note, however, that there are circumstances where this can fail to prevent access, and so + /// should be combined with proper member removal and key rotation so that even if the member + /// gains access to messages, they cannot read them). + /// + /// Inputs: + /// - `session_id` -- the session ID of the member (in hex) + /// - `write`, `del` -- optional; see `swarm_make_subaccount`. The same arguments should be + /// provided (or omitted) as were used in `swarm_make_subaccount`. + /// + /// Outputs: + /// - 36 byte token that can be used for swarm token revocation. + ustring swarm_subaccount_token( + std::string_view session_id, bool write = true, bool del = false) const; + + /// API: groups/Keys::pending_config + /// + /// If a rekey has been performed but not yet confirmed then this will contain the config + /// message to be pushed to the swarm. If there is no push current pending then this returns + /// nullopt. The value should be used immediately (i.e. the ustring_view may not remain valid + /// if other calls to the config object are made). + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::optional` -- returns a populated config message that should be pushed, + /// if not yet confirmed, otherwise when no pending update is present this returns nullopt. + std::optional pending_config() const; + + /// API: groups/Keys::pending_key + /// + /// After calling rekey() this contains the new group encryption key *before* it is confirmed + /// pushed into the swarm. This is primarily intended for internal use as this key is generally + /// already propagated to the member/info lists when rekeying occurs. + /// + /// The pending key is dropped when an incoming keys message is successfully loaded with either + /// the pending key itself, or a keys message with a higher generation. + /// + /// Inputs: None + /// + /// Outputs: + /// - `std::optional` the encryption key generated by the last `rekey()` call. + /// This is set to a new key when `rekey()` is called, and is cleared when any config message + /// is successfully loaded by `load_key`. + std::optional pending_key() const; + + /// API: groups/Keys::load_key + /// + /// Loads a key pulled down from the swarm into this Keys object. + /// + /// A Session client must process messages from the keys namespace *before* other group config + /// messages as new key messages may contain encryption keys needed to decrypt the other group + /// config message types. + /// + /// It is safe to load the same config multiple times, and to load expired configs; such cases + /// would typically not change the keys, but are allowed anyway. + /// + /// This method should always be wrapped in a `try/catch`: if the given configuration data is + /// malformed or is not properly signed an exception will be raised (but the Keys object remains + /// usable). + /// + /// Inputs: + /// - `hash` - the message hash from the swarm + /// - `data` - the full stored config message value + /// - `timestamp_ms` - the timestamp (from the swarm) when this message was stored (used to + /// track when other keys expire). + /// - `info` - the given group::Info object's en/decryption key list will be updated to match + /// this object's key list. + /// - `members` - the given group::Members object's en/decryption key list will be updated to + /// match this object's key list. + /// + /// Outputs: + /// - throws `std::runtime_error` (typically a subclass thereof) on failure to parse. + /// - returns true if we found a key for us in the message, false if we did not. Note that this + /// is mainly informative and does not signal an error: false could mean, for instance, be a + /// supplemental message that wasn't for us. Note also that true doesn't mean keys changed: + /// it could mean we decrypted one for us, but already had it. + bool load_key_message( + std::string_view hash, + ustring_view data, + int64_t timestamp_ms, + Info& info, + Members& members); + + /// API: groups/Keys::current_hashes + /// + /// Returns a set of message hashes of messages that contain currently active decryption keys. + /// These are the messages that should be periodically renewed by clients with write access to + /// keep them alive for other accounts (or devices) who might need them in the future. + /// + /// Inputs: none + /// + /// Outputs: + /// - vector of message hashes + std::unordered_set current_hashes() const; + + /// API: groups/Keys::needs_rekey + /// + /// Returns true if the key list requires a new key to be generated and pushed to the server (by + /// calling `rekey()`). This will only be true for admin accounts (as only admin accounts can + /// call rekey()). Note that this value will also remain true until the pushed data is fetched + /// and loaded via `load_key_message`. + /// + /// Note that this not only tracks when an automatic `rekey()` is needed because of a key + /// collision (such as two admins removing different members at the same time); there are other + /// situations in which rekey() should also be called (such as when kicking a member) that are + /// not reflected by this flag. + /// + /// The recommended use of this method is to call it immediately after fetching messages from + /// the group config namespace of the swarm, whether or not new configs were retrieved, but + /// after processing incoming new config messages that were pulled down. + /// + /// Unlike regular config messages, there is no need to confirm the push: confirmation (and + /// adoption of the new keys) happens when the new keys arrived back down from the swarm in the + /// next fetch. + /// + /// Inputs: None + /// + /// Outputs: + /// - `true` if a rekey is needed, `false` otherwise. + bool needs_rekey() const; + + /// API: groups/Keys::needs_dump + /// + /// Returns true if this Keys config has changes, either made directly or from incoming configs, + /// that need to be dumped to the database (made since the last call to `dump()`), false if no + /// changes have been made. + /// + /// Inputs: None + /// + /// Outputs: + /// - `true` if state needs to be dumped, `false` if state hasn't changed since the last + /// call to `dump()`. + bool needs_dump() const; + + /// API: groups/Keys::dump + /// + /// Returns a dump of the current state of this keys config that allows the Keys object to be + /// reinstantiated from scratch. + /// + /// Although this can be called at any time, it is recommended to only do so when + /// `needs_dump()` returns true. + /// + /// Inputs: None + /// + /// Outputs: + /// - opaque binary data containing the group keys and other Keys config data that can be passed + /// to the `Keys` constructor to reinitialize a Keys object with the current state. + ustring dump(); + + /// API: groups/Keys::encrypt_message + /// + /// Encrypts group message content; this is passed a binary value to encrypt and + /// encodes/encrypts it for the group using the latest encryption key this object knows about. + /// Such encrypted messages are intended to be passed to `decrypt_message` to decrypt them. + /// + /// The current implementation uses XChaCha20-Poly1305 and returns an encoded value where the + /// first byte indicates the encryption type ('x', or 'X' currently for uncompressed or + /// compressed XChaCha20), the next 24 bytes are the encryption nonce, and the remainder is the + /// ciphertext. The returned value will be 41 bytes larger than the plaintext, at most + /// (potentially less if compression is permitted). + /// + /// When compression is enabled (by omitting the `compress` argument or specifying it as true) + /// then ZSTD compression will be *attempted* on the plaintext message and will be used if the + /// compressed data is smaller than the uncompressed data. If disabled, or if compression does + /// not reduce the size (i.e. because it is not compressible), then the message will not be + /// compressed. + /// + /// Future versions may change this to support other encryption algorithms. + /// + /// This method will throw on failure, which can happen in two cases: + /// - if there no encryption keys are available at all (which should not occur in normal use). + /// - if given a plaintext buffer larger than 1MB (even if the compressed version would be much + /// smaller). It is recommended that clients impose their own limits much smaller than this; + /// this limited is here to match the `decrypt_message` limit which is merely intended to + /// guard against decompression memory exhaustion attacks. + /// + /// Inputs: + /// - `plaintext` -- the binary message to encrypt. + /// - `compress` -- can be specified as `false` to forcibly disable compression. Normally + /// omitted, to use compression if and only if it reduces the size. + /// + /// Outputs: + /// - `ciphertext` -- the encrypted ciphertext of the message + ustring encrypt_message(ustring_view plaintext, bool compress = true) const; + + /// API: groups/Keys::decrypt_message + /// + /// Decrypts group message content that was presumably encrypted with `encrypt_message`. This + /// will attempt decryption using *all* of the known group encryption keys and, if necessary, + /// decompressing the message. + /// + /// To prevent against memory exhaustion attacks, this method will fail if the value is + /// a compressed value that would decompress to a value larger than 1MB. + /// + /// Inputs: + /// - `ciphertext` -- a encoded, encrypted, (possibly) compressed message as produced by + /// `encrypt_message()`. + /// + /// Outputs: + /// - `std::optional` -- the decrypted, decompressed plaintext message if encryption + /// and decompression succeeds; otherwise returns `std::nullopt` if parsing, decryption, or + /// decompression fails. + std::optional decrypt_message(ustring_view ciphertext) const; +}; + +} // namespace session::config::groups diff --git a/include/session/config/groups/members.h b/include/session/config/groups/members.h new file mode 100644 index 00000000..fa9ad181 --- /dev/null +++ b/include/session/config/groups/members.h @@ -0,0 +1,191 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "../base.h" +#include "../profile_pic.h" +#include "../util.h" + +enum groups_members_invite_status { INVITE_SENT = 1, INVITE_FAILED = 2 }; + +typedef struct config_group_member { + char session_id[67]; // in hex; 66 hex chars + null terminator. + + // These two will be 0-length strings when unset: + char name[101]; + user_profile_pic profile_pic; + + bool admin; + int invited; // 0 == unset, INVITE_SENT = invited, INVITED_FAILED = invite failed to send + int promoted; // same value as `invited`, but for promotion-to-admin + +} config_group_member; + +/// API: groups/groups_members_init +/// +/// Constructs a group members config object and sets a pointer to it in `conf`. +/// +/// When done with the object the `config_object` must be destroyed by passing the pointer to +/// config_free() (in `session/config/base.h`). +/// +/// Inputs: +/// - `conf` -- [out] Pointer to the config object +/// - `ed25519_pubkey` -- [in] 32-byte pointer to the group's public key +/// - `ed25519_secretkey` -- [in] optional 64-byte pointer to the group's secret key +/// (libsodium-style 64 byte value). Pass as NULL for a non-admin member. +/// - `dump` -- [in] if non-NULL this restores the state from the dumped byte string produced by a +/// past instantiation's call to `dump()`. To construct a new, empty object this should be NULL. +/// - `dumplen` -- [in] the length of `dump` when restoring from a dump, or 0 when `dump` is NULL. +/// - `error` -- [out] the pointer to a buffer in which we will write an error string if an error +/// occurs; error messages are discarded if this is given as NULL. If non-NULL this must be a +/// buffer of at least 256 bytes. +/// +/// Outputs: +/// - `int` -- Returns 0 on success; returns a non-zero error code and write the exception message +/// as a C-string into `error` (if not NULL) on failure. +LIBSESSION_EXPORT int groups_members_init( + config_object** conf, + const unsigned char* ed25519_pubkey, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, + size_t dumplen, + char* error) __attribute__((warn_unused_result)); + +/// API: groups/groups_members_get +/// +/// Fills `member` with the member info given a session ID (specified as a null-terminated hex +/// string), if the member exists, and returns true. If the member does not exist then `member` +/// is left unchanged and false is returned. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `member` -- [out] the member info data +/// - `session_id` -- [in] null terminated hex string +/// +/// Output: +/// - `bool` -- Returns true if member exists +LIBSESSION_EXPORT bool groups_members_get( + config_object* conf, config_group_member* member, const char* session_id) + __attribute__((warn_unused_result)); + +/// API: groups/groups_members_get_or_construct +/// +/// Same as the above `groups_members_get()` except that when the member does not exist, this sets +/// all the member fields to defaults and loads it with the given session_id. +/// +/// Returns true as long as it is given a valid session_id. A false return is considered an error, +/// and means the session_id was not a valid session_id. +/// +/// This is the method that should usually be used to create or update a member, followed by +/// setting fields in the member, and then giving it to groups_members_set(). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `member` -- [out] the member info data +/// - `session_id` -- [in] null terminated hex string +/// +/// Output: +/// - `bool` -- Returns true if the call succeeds, false if an error occurs (e.g. because of an +/// invalid session_id). +LIBSESSION_EXPORT bool groups_members_get_or_construct( + config_object* conf, config_group_member* member, const char* session_id) + __attribute__((warn_unused_result)); + +/// API: groups/groups_members_set +/// +/// Adds or updates a member from the given member info struct. +/// +/// Inputs: +/// - `conf` -- [in, out] Pointer to the config object +/// - `member` -- [in] Pointer containing the member info data +LIBSESSION_EXPORT void groups_members_set(config_object* conf, const config_group_member* member); + +/// API: groups/groups_members_erase +/// +/// Erases a member from the member list. session_id is in hex. Returns true if the member was +/// found and removed, false if the member was not present. You must not call this during +/// iteration; see details below. +/// +/// Typically this should be followed by a group rekey (so that the removed member cannot read the +/// group). +/// +/// Inputs: +/// - `conf` -- [in, out] Pointer to the config object +/// - `session_id` -- [in] Text containing null terminated hex string +/// +/// Outputs: +/// - `bool` -- True if erasing was successful +LIBSESSION_EXPORT bool groups_members_erase(config_object* conf, const char* session_id); + +/// API: groups/groups_members_size +/// +/// Returns the number of group members. +/// +/// Inputs: +/// - `conf` -- input - Pointer to the config object +/// +/// Outputs: +/// - `size_t` -- number of contacts +LIBSESSION_EXPORT size_t groups_members_size(const config_object* conf); + +typedef struct groups_members_iterator { + void* _internals; +} groups_members_iterator; + +/// API: groups/groups_members_iterator_new +/// +/// Starts a new iterator. +/// +/// Functions for iterating through the entire member list, in sorted order. Intended use is: +/// +/// group_member m; +/// groups_members_iterator *it = groups_members_iterator_new(group); +/// for (; !groups_members_iterator_done(it, &c); groups_members_iterator_advance(it)) { +/// // c.session_id, c.name, etc. are loaded +/// } +/// groups_members_iterator_free(it); +/// +/// It is NOT permitted to add/remove/modify members while iterating. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `groups_members_iterator*` -- pointer to the new iterator +LIBSESSION_EXPORT groups_members_iterator* groups_members_iterator_new(const config_object* conf); + +/// API: groups/groups_members_iterator_free +/// +/// Frees an iterator once no longer needed. +/// +/// Inputs: +/// - `it` -- [in] Pointer to the groups_members_iterator +LIBSESSION_EXPORT void groups_members_iterator_free(groups_members_iterator* it); + +/// API: groups/groups_members_iterator_done +/// +/// Returns true if iteration has reached the end. Otherwise `m` is populated and false is +/// returned. +/// +/// Inputs: +/// - `it` -- [in] Pointer to the groups_members_iterator +/// - `m` -- [out] Pointer to the config_group_member, will be populated if false is returned +/// +/// Outputs: +/// - `bool` -- True if iteration has reached the end +LIBSESSION_EXPORT bool groups_members_iterator_done( + groups_members_iterator* it, config_group_member* m); + +/// API: groups/groups_members_iterator_advance +/// +/// Advances the iterator. +/// +/// Inputs: +/// - `it` -- [in] Pointer to the groups_members_iterator +LIBSESSION_EXPORT void groups_members_iterator_advance(groups_members_iterator* it); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/include/session/config/groups/members.hpp b/include/session/config/groups/members.hpp new file mode 100644 index 00000000..34a06eb3 --- /dev/null +++ b/include/session/config/groups/members.hpp @@ -0,0 +1,410 @@ +#pragma once + +#include +#include +#include + +#include "../base.hpp" +#include "../namespaces.hpp" +#include "../profile_pic.hpp" + +struct config_group_member; + +namespace session::config::groups { + +using namespace std::literals; + +/// keys used in this config, either currently or in the past (so that we don't reuse): +/// +/// m - dict of members; each key is the member session id (33 bytes), each value is a dict +/// containing subkeys: +/// n - member name; this will always be set in the encoded message to prevent dict pruning, but +/// will be an empty string if there is no name. +/// p - member profile pic url +/// q - member profile pic decryption key (binary) +/// I - invite status; this will be one of: +/// - 1 if the invite has been issued but not yet accepted. +/// - 2 if an invite was created but failed to send for some reason (and thus can be resent) +/// - omitted once an invite is accepted. (This also gets omitted if the `A` admin flag gets +/// set). +/// A - flag set to 1 if the member is an admin, omitted otherwise. +/// P - promotion (to admin) status; this will be one of: +/// - 1 if a promotion has been sent. +/// - 2 if a promotion was created but failed to send for some reason (and thus should be +/// resent) +/// - omitted once the promotion is accepted (i.e. once `A` gets set). + +constexpr int INVITE_SENT = 1, INVITE_FAILED = 2; + +/// Struct containing member details +struct member { + static constexpr size_t MAX_NAME_LENGTH = 100; + + explicit member(std::string sid); + + // Internal ctor/method for C API implementations: + explicit member(const config_group_member& c); // From c struct + + /// API: groups/member::session_id + /// + /// Member variable + /// + /// The member's session ID, in hex. + std::string session_id; + + /// API: groups/member::name + /// + /// Member variable + /// + /// The member's human-readable name. Optional. This is used by other members of the group to + /// display a member's details before having seen a message from that member. + std::string name; + + /// API: groups/member::profile_picture + /// + /// Member variable + /// + /// The member's profile picture (URL & decryption key). Optional. This is used by other + /// members of the group to display a member's details before having seen a message from that + /// member. + profile_pic profile_picture; + + /// API: groups/member::admin + /// + /// Member variable + /// + /// Flag that is set to indicate to the group that this member is an admin. + /// + /// Note that this is only informative but isn't a permission gate: someone could still possess + /// the admin keys without this (e.g. if they cleared the flag to appear invisible), or could + /// have lost (or never had) the keys even if this is set. + /// + /// See also `promoted()` if you want to check for either an admin or someone being promoted to + /// admin. + bool admin = 0; + + // Flags to track an invited user. This value is typically not used directly, but rather via + // the `set_invited()`, `invite_pending()` and similar methods. + int invite_status = 0; + + /// API: groups/member::set_invited + /// + /// Sets the "invited" flag for this user. This marks the user as having a pending invitation + /// to the group. The optional `failed` parameter can be specified as true if the invitation + /// was issued but failed to send for some reason (this is intended as a signal to other clients + /// that the invitation should be reissued). + /// + /// Inputs: + /// - `failed` can be specified and set to `true` to the invite status to "failed-to-send"; + /// otherwise omitting it or giving as `false` sets the invite status to "sent." + void set_invited(bool failed = false) { invite_status = failed ? INVITE_FAILED : INVITE_SENT; } + + /// API: groups/members::set_accepted + /// + /// This clears the "invited" flag for this user, thus indicating that the user has accepted an + /// invitation and is now a regular member of the group. + /// + /// Inputs: none + void set_accepted() { invite_status = 0; } + + /// API: groups/member::invite_pending + /// + /// Returns whether the user currently has a pending invitation. Returns true if so (whether or + /// not that invitation has failed). + /// + /// Inputs: none + /// + /// Outputs: + /// - `bool` -- true if the user has a pending invitation, false otherwise. + bool invite_pending() const { return invite_status > 0; } + + /// API: groups/member::invite_failed + /// + /// Returns true if the user has a pending invitation that is marked as failed (and thus should + /// be re-sent). + /// + /// Inputs: none + /// + /// Outputs: + /// - `bool` -- true if the user has a failed pending invitation + bool invite_failed() const { return invite_status == INVITE_FAILED; } + + // Flags to track a promoted-to-admin user. This value is typically not used directly, but + // rather via the `set_promoted()`, `promotion_pending()` and similar methods. + int promotion_status = 0; + + /// API: groups/member::set_promoted + /// + /// Sets the "promoted" flag for this user. This marks the user as having a pending + /// promotion-to-admin in the group. The optional `failed` parameter can be specified as true + /// if the promotion was issued but failed to send for some reason (this is intended as a signal + /// to other clients that the promotion should be reissued). + /// + /// Note that this flag is ignored when the `admin` field is set to true. + /// + /// Inputs: + /// - `failed`: can be specified as true to mark the promotion status as "failed-to-send". If + /// omitted or false then the promotion status is set to "sent". + void set_promoted(bool failed = false) { + promotion_status = failed ? INVITE_FAILED : INVITE_SENT; + } + + /// API: groups/member::promotion_pending + /// + /// Returns whether the user currently has a pending invitation/promotion to admin status. + /// Returns true if so (whether or not that invitation has failed). + /// + /// Inputs: None + /// + /// Outputs: + /// - `bool` -- true if the user has a pending promotion, false otherwise. + bool promotion_pending() const { return !admin && promotion_status > 0; } + + /// API: groups/member::promotion_failed + /// + /// Returns true if the user has a pending promotion-to-admin that is marked as failed (and thus + /// should be re-sent). + /// + /// Inputs: None + /// + /// Outputs: + /// - `bool` -- true if the user has a failed pending promotion + bool promotion_failed() const { return !admin && promotion_status == INVITE_FAILED; } + + /// API: groups/member::promoted + /// + /// Returns true if the user is already an admin *or* has a pending promotion to admin. + /// + /// Inputs: none. + /// + /// Outputs: + /// - `bool` -- true if the member is promoted (or promotion-in-progress) + bool promoted() const { return admin || promotion_pending(); } + + /// API: groups/member::into + /// + /// Converts the member info into a C struct. + /// + /// Inputs: + /// - `m` -- Reference to C struct to fill with group member info. + void into(config_group_member& m) const; + + /// API: groups/member::set_name + /// + /// Sets a name; this is exactly the same as assigning to .name directly, except that we throw + /// an exception if the given name is longer than MAX_NAME_LENGTH. + /// + /// Note that you can set a longer name directly into the `.name` member, but it will be + /// truncated when serializing the record. + /// + /// Inputs: + /// - `name` -- Name to assign to the contact + void set_name(std::string name); + + private: + friend class Members; + void load(const dict& info_dict); +}; + +class Members final : public ConfigBase { + + public: + // No default constructor + Members() = delete; + + /// API: groups/Members::Members + /// + /// Constructs a group members config object from existing data (stored from `dump()`) and a + /// list of encryption keys for encrypting new and decrypting existing messages. + /// + /// To construct a blank info object (i.e. with no pre-existing dumped data to load) pass + /// `std::nullopt` as the third argument. + /// + /// Encryption keys must be loaded before the Info object can be modified or parse other Info + /// messages, and are typically loaded by providing the `Info` object to the `Keys` class. + /// + /// Inputs: + /// - `ed25519_pubkey` is the public key of this group, used to validate config messages. + /// Config messages not signed with this key will be rejected. + /// - `ed25519_secretkey` is the secret key of the group, used to sign pushed config messages. + /// This is only possessed by the group admin(s), and must be provided in order to make and + /// push config changes. + /// - `dumped` -- either `std::nullopt` to construct a new, empty object; or binary state data + /// that was previously dumped from an instance of this class by calling `dump()`. + Members(ustring_view ed25519_pubkey, + std::optional ed25519_secretkey, + std::optional dumped); + + /// API: groups/Members::storage_namespace + /// + /// Returns the Members namespace. Is constant, will always return Namespace::GroupMembers + /// + /// Inputs: None + /// + /// Outputs: + /// - `Namespace` - Will return Namespace::GroupMembers + Namespace storage_namespace() const override { return Namespace::GroupMembers; } + + /// API: groups/Members::encryption_domain + /// + /// Returns the encryption domain used when encrypting messages of this type. + /// + /// Inputs: None + /// + /// Outputs: + /// - `const char*` - Will return "groups::Members" + const char* encryption_domain() const override { return "groups::Members"; } + + /// API: groups/Members::get + /// + /// Looks up and returns a member by hex session ID. Returns nullopt if the session ID was + /// not found, otherwise returns a filled out `member`. + /// + /// Inputs: + /// - `pubkey_hex` -- hex string of the session id + /// + /// Outputs: + /// - `std::optional` - Returns nullopt if session ID was not found, otherwise a + /// filled out `member` struct. + std::optional get(std::string_view pubkey_hex) const; + + /// API: groups/Members::get_or_construct + /// + /// Similar to get(), but if the session ID does not exist this returns a filled-out member + /// containing the session_id (all other fields will be empty/defaulted). This is intended to + /// be combined with `set` to set-or-create a record. + /// + /// NB: calling this does *not* add the session id to the member list when called: that requires + /// also calling `set` with this value. + /// + /// Inputs: + /// - `pubkey_hex` -- hex string of the session id + /// + /// Outputs: + /// - `member` - Returns a filled out member struct + member get_or_construct(std::string_view pubkey_hex) const; + + /// API: groups/Members::set + /// + /// Sets or updates the various values associated with a member with the given info. The usual + /// use is to access the current info, change anything desired, then pass it back into set, + /// e.g.: + /// + /// ```cpp + /// auto m = members.get_or_construct(pubkey); + /// m.name = "Session User 42"; + /// members.set(m); + /// ``` + /// + /// Inputs: + /// - `member` -- member value to set + void set(const member& member); + + /// API: groups/Members::erase + /// + /// Removes a session ID from the member list, if present. + /// + /// Typically this call should be coupled with a re-key of the group's encryption key so that + /// the removed member cannot read the group. For example: + /// + /// bool removed = members.erase("050123456789abcdef..."); + /// // You can remove more than one at a time, if needed: + /// removed |= members.erase("050000111122223333..."); + /// + /// if (removed) { + /// auto new_keys_conf = keys.rekey(members); + /// members.add_key(*keys.pending_key(), true); + /// auto [seqno, new_memb_conf, obs] = members.push(); + /// + /// // Send the two new configs to the swarm (via a seqence of two `store`s): + /// // - new_keys_conf goes into the keys namespace + /// // - new_memb_conf goes into the members namespace + /// } + /// + /// Inputs: + /// - `session_id` the hex session ID of the member to remove + /// + /// Outputs: + /// - true if the member was found (and removed); false if the member was not in the list. + bool erase(std::string_view session_id); + + /// API: groups/Members::size + /// + /// Returns the number of members in the group. + /// + /// Inputs: None + /// + /// Outputs: + /// - `size_t` - number of members + size_t size() const; + + struct iterator; + /// API: groups/Members::begin + /// + /// Iterators for iterating through all members. Typically you access this implicit via a for + /// loop over the `Members` object: + /// + ///```cpp + /// for (auto& member : members) { + /// // use member.session_id, member.name, etc. + /// } + ///``` + /// + /// This iterates in sorted order through the session_ids. + /// + /// It is NOT permitted to add/modify/remove records while iterating; instead such modifications + /// require two passes: an iterator loop to collect the required modifications, then a second + /// pass to apply the modifications. + /// + /// Inputs: None + /// + /// Outputs: + /// - `iterator` - Returns an iterator for the beginning of the members + iterator begin() const { return iterator{data["m"].dict()}; } + + /// API: groups/Members::end + /// + /// Iterator for passing the end of the members + /// + /// Inputs: None + /// + /// Outputs: + /// - `iterator` - Returns an iterator for the end of the members + iterator end() const { return iterator{nullptr}; } + + using iterator_category = std::input_iterator_tag; + using value_type = member; + using reference = value_type&; + using pointer = value_type*; + using difference_type = std::ptrdiff_t; + + struct iterator { + private: + std::shared_ptr _val; + dict::const_iterator _it; + const dict* _members; + void _load_info(); + iterator(const dict* members) : _members{members} { + if (_members) { + _it = _members->begin(); + _load_info(); + } + } + friend class Members; + + public: + bool operator==(const iterator& other) const; + bool operator!=(const iterator& other) const { return !(*this == other); } + bool done() const; // Equivalent to comparing against the end iterator + member& operator*() const { return *_val; } + member* operator->() const { return _val.get(); } + iterator& operator++(); + iterator operator++(int) { + auto copy{*this}; + ++*this; + return copy; + } + }; +}; + +} // namespace session::config::groups diff --git a/include/session/config/namespaces.hpp b/include/session/config/namespaces.hpp index 394617c0..c5c29ec5 100644 --- a/include/session/config/namespaces.hpp +++ b/include/session/config/namespaces.hpp @@ -9,6 +9,14 @@ enum class Namespace : std::int16_t { Contacts = 3, ConvoInfoVolatile = 4, UserGroups = 5, + + // Messages sent to a closed group: + GroupMessages = 11, + // Groups config namespaces (i.e. for shared config of the group itself, not one user's group + // settings) + GroupKeys = 12, + GroupInfo = 13, + GroupMembers = 14, }; } // namespace session::config diff --git a/include/session/config/user_groups.h b/include/session/config/user_groups.h index e7187a44..b17bf2f7 100644 --- a/include/session/config/user_groups.h +++ b/include/session/config/user_groups.h @@ -27,7 +27,7 @@ typedef struct ugroups_legacy_group_info { unsigned char enc_seckey[32]; // If `have_enc_keys`, this is the 32-byte secret key (no NULL // terminator). - int64_t disappearing_timer; // Minutes. 0 == disabled. + int64_t disappearing_timer; // Seconds. 0 == disabled. int priority; // pinned message priority; 0 = unpinned, negative = hidden, positive = pinned // (with higher meaning pinned higher). int64_t joined_at; // unix timestamp when joined (or re-joined) @@ -35,11 +35,40 @@ typedef struct ugroups_legacy_group_info { int64_t mute_until; // Mute notifications until this timestamp (overrides `notifications` // setting until the timestamp) + bool invited; // True if this is in the invite-but-not-accepted state. + // For members use the ugroups_legacy_group_members and associated calls. void* _internal; // Internal storage, do not touch. } ugroups_legacy_group_info; +/// Struct holding (non-legacy) group info; this struct owns allocated memory and *must* be freed +/// via either `ugroups_group_free()` or `ugroups_set_free_group()` when finished with it. +typedef struct ugroups_group_info { + char id[67]; // in hex; 66 hex chars + null terminator + + char name[101]; // Null-terminated C string (human-readable). Max length is 100 (plus 1 for + // null). Will always be set (even if an empty string). + + bool have_secretkey; // Will be true if the `secretkey` is populated + unsigned char secretkey[64]; // If `have_secretkey` is set then this is the libsodium-style + // "secret key" for the group (i.e. 32 byte seed + 32 byte pubkey) + bool have_auth_data; // Will be true if the `auth_data` is populated + unsigned char auth_data[100]; // If `have_auth_data` is set then this is the authentication + // signing value that can be used to produce signature values to + // access the swarm. + + int priority; // pinned message priority; 0 = unpinned, negative = hidden, positive = pinned + // (with higher meaning pinned higher). + int64_t joined_at; // unix timestamp when joined (or re-joined) + CONVO_NOTIFY_MODE notifications; // When the user wants notifications + int64_t mute_until; // Mute notifications until this timestamp (overrides `notifications` + // setting until the timestamp) + + bool invited; // True if this is in the invite-but-not-accepted state. + +} ugroups_group_info; + typedef struct ugroups_community_info { char base_url[268]; // null-terminated (max length 267), normalized (i.e. always lower-case, // only has port if non-default, has trailing / removed) @@ -54,6 +83,9 @@ typedef struct ugroups_community_info { CONVO_NOTIFY_MODE notifications; // When the user wants notifications int64_t mute_until; // Mute notifications until this timestamp (overrides `notifications` // setting until the timestamp) + + bool invited; // True if this is in the invite-but-not-accepted state. + } ugroups_community_info; /// API: user_groups/user_groups_init @@ -87,12 +119,42 @@ LIBSESSION_EXPORT int user_groups_init( size_t dumplen, char* error) __attribute__((warn_unused_result)); +/// API: user_groups/user_groups_get_group +/// +/// Gets (non-legacy) group info into `group`, if the group was found. `group_id` is a +/// null-terminated C string containing the 66 character group id in hex (beginning with "03"). +/// +/// Inputs: +/// `conf` -- pointer to the group config object +/// `group` -- [out] `ugroups_group_info` struct into which to store the group info. +/// `group_id` -- C string containing the hex group id (starting with "03") +/// +/// Outputs: +/// Returns `true` and populates `group` if the group was found; returns false otherwise. +LIBSESSION_EXPORT bool user_groups_get_group( + config_object* conf, ugroups_group_info* group, const char* group_id); + +/// API: user_groups/user_groups_get_or_construct_group +/// +/// Gets (non-legacy) group info into `group`, if the group was found. Otherwise initialize `group` +/// to default values (and set its `.id` appropriately). +/// +/// Inputs: +/// `conf` -- pointer to the group config object +/// `group` -- [out] `ugroups_group_info` struct into which to store the group info. +/// `group_id` -- C string containing the hex group id (starting with "03") +/// +/// Outputs: +/// Returns `true` on success, `false` upon error (such as when given an invalid group id). +LIBSESSION_EXPORT bool user_groups_get_or_construct_group( + config_object* conf, ugroups_group_info* group, const char* group_id); + /// API: user_groups/user_groups_get_community /// /// Gets community conversation info into `comm`, if the community info was found. `base_url` and -/// `room` are null-terminated c strings; pubkey is 32 bytes. base_url will be -/// normalized/lower-cased; room is case-insensitive for the lookup: note that this may well return -/// a community info with a different room capitalization than the one provided to the call. +/// `room` are null-terminated c strings. base_url will be normalized/lower-cased; room is +/// case-insensitive for the lookup: note that this may well return a community info with a +/// different room capitalization than the one provided to the call. /// /// Returns true if the community was found and `comm` populated; false otherwise. A false return /// can either be because it didn't exist (`conf->last_error` will be NULL) or because of some error @@ -256,6 +318,15 @@ LIBSESSION_EXPORT void ugroups_legacy_group_free(ugroups_legacy_group_info* grou LIBSESSION_EXPORT void user_groups_set_community( config_object* conf, const ugroups_community_info* group); +/// API: user_groups/user_groups_set_group +/// +/// Adds or updates a (non-legacy) group conversation from the given group info +/// +/// Inputs: +/// - `conf` -- [in] Pointer to config_object object +/// - `group` -- [in] Pointer to a group info object +LIBSESSION_EXPORT void user_groups_set_group(config_object* conf, const ugroups_group_info* group); + /// API: user_groups/user_groups_set_legacy_group /// /// Adds or updates a legacy group conversation from the into. This version of the method should @@ -322,6 +393,28 @@ LIBSESSION_EXPORT void user_groups_set_free_legacy_group( LIBSESSION_EXPORT bool user_groups_erase_community( config_object* conf, const char* base_url, const char* room); +/// API: user_groups/user_groups_erase_group +/// +/// Erases a group conversation from the conversation list. Returns true if the conversation was +/// found and removed, false if the conversation was not present. You must not call this during +/// iteration; see details below. +/// +/// Declaration: +/// ```cpp +/// BOOL user_groups_erase_group( +/// [in] config_object* conf, +/// [in] const char* group_id +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to config_object object +/// - `group_id` -- [in] null terminated string of the hex group id (starting with "03") +/// +/// Outputs: +/// - `bool` -- Returns True if conversation was found and removed +LIBSESSION_EXPORT bool user_groups_erase_group(config_object* conf, const char* group_id); + /// API: user_groups/user_groups_erase_legacy_group /// /// Erases a conversation from the conversation list. Returns true if the conversation was found @@ -344,6 +437,25 @@ LIBSESSION_EXPORT bool user_groups_erase_community( /// - `bool` -- Returns True if conversation was found and removed LIBSESSION_EXPORT bool user_groups_erase_legacy_group(config_object* conf, const char* group_id); +/// API: user_groups/ugroups_group_set_kicked +/// +/// Call when we have been kicked from a group; this clears group's secret key and auth key from the +/// group config setting. +/// +/// Inputs: +/// - `group` -- [in] pointer to the group info which we should set to kicked +/// +LIBSESSION_EXPORT void ugroups_group_set_kicked(ugroups_group_info* group); + +/// API: user_groups/ugroups_group_is_kicked +/// +/// Returns true if we have been kicked (i.e. our secret key and auth data are empty). +/// +/// Inputs: +/// - `group` -- [in] pointer to the group info to query +/// +LIBSESSION_EXPORT bool ugroups_group_is_kicked(const ugroups_group_info* group); + typedef struct ugroups_legacy_members_iterator ugroups_legacy_members_iterator; /// API: user_groups/ugroups_legacy_members_begin @@ -540,7 +652,7 @@ LIBSESSION_EXPORT size_t user_groups_size(const config_object* conf); /// API: user_groups/user_groups_size_communities /// -/// Returns the number of conversations of the specific type. +/// Returns the number of community conversations. /// /// Declaration: /// ```cpp @@ -556,9 +668,27 @@ LIBSESSION_EXPORT size_t user_groups_size(const config_object* conf); /// - `size_t` -- Returns the number of conversations LIBSESSION_EXPORT size_t user_groups_size_communities(const config_object* conf); +/// API: user_groups/user_groups_size_groups +/// +/// Returns the number of (non-legacy) group conversations. +/// +/// Declaration: +/// ```cpp +/// SIZE_T user_groups_size_groups( +/// [in] const config_object* conf +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to config_object object +/// +/// Outputs: +/// - `size_t` -- Returns the number of conversations +LIBSESSION_EXPORT size_t user_groups_size_groups(const config_object* conf); + /// API: user_groups/user_groups_size_legacy_groups /// -/// Returns the number of conversations of the specific type. +/// Returns the number of legacy group conversations. /// /// Declaration: /// ```cpp @@ -584,12 +714,15 @@ typedef struct user_groups_iterator user_groups_iterator; /// ```cpp /// ugroups_community_info c2; /// ugroups_legacy_group_info c3; +/// ugroups_group_info c4; /// user_groups_iterator *it = user_groups_iterator_new(my_groups); /// for (; !user_groups_iterator_done(it); user_groups_iterator_advance(it)) { /// if (user_groups_it_is_community(it, &c2)) { /// // use c2.whatever /// } else if (user_groups_it_is_legacy_group(it, &c3)) { /// // use c3.whatever +/// } else if (user_groups_it_is_group(it, &c4)) { +/// // use c4.whatever /// } /// } /// user_groups_iterator_free(it); @@ -657,6 +790,20 @@ LIBSESSION_EXPORT user_groups_iterator* user_groups_iterator_new_communities( LIBSESSION_EXPORT user_groups_iterator* user_groups_iterator_new_legacy_groups( const config_object* conf); +/// API: user_groups/user_groups_iterator_new_groups +/// +/// The same as `user_groups_iterator_new` except that this iterates *only* over one type of +/// conversation: non-legacy groups. You still need to use `user_groups_it_is_group` to load the +/// data in each pass of the loop. (You can, however, safely ignore the bool return value of the +/// `it_is_group` function: it will always be true for iterations for this iterator). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to config_object object +/// +/// Outputs: +/// - `user_groups_iterator*` -- The Iterator +LIBSESSION_EXPORT user_groups_iterator* user_groups_iterator_new_groups(const config_object* conf); + /// API: user_groups/user_groups_iterator_free /// /// Frees an iterator once no longer needed. @@ -719,7 +866,7 @@ LIBSESSION_EXPORT void user_groups_iterator_advance(user_groups_iterator* it); /// ``` /// /// Inputs: -/// - `it` -- [in, out] The Iterator +/// - `it` -- [in] The iterator /// - `c` -- [out] sets details of community into here if true /// /// Outputs: @@ -727,7 +874,21 @@ LIBSESSION_EXPORT void user_groups_iterator_advance(user_groups_iterator* it); LIBSESSION_EXPORT bool user_groups_it_is_community( user_groups_iterator* it, ugroups_community_info* c); -/// API: user_groups/user_groups_it_is_community +/// API: user_groups/user_groups_it_is_group +/// +/// If the current iterator record is a non-legacy group conversation this sets the details into +/// `group` and returns true. Otherwise it returns false. +/// +/// Inputs: +/// - `it` -- [in] The Iterator +/// - `group` -- [out] sets details of the group into here (if true is returned) +/// +/// Outputs: +/// - `bool` -- Returns `true` and sets `group` if the group is a non-legacy group (aka closed +/// group). +LIBSESSION_EXPORT bool user_groups_it_is_group(user_groups_iterator* it, ugroups_group_info* group); + +/// API: user_groups/user_groups_it_is_legacy_group /// /// If the current iterator record is a legacy group conversation this sets the details into /// `c` and returns true. Otherwise it returns false. @@ -741,7 +902,7 @@ LIBSESSION_EXPORT bool user_groups_it_is_community( /// ``` /// /// Inputs: -/// - `it` -- [in, out] The Iterator +/// - `it` -- [in] The iterator /// - `c` -- [out] sets details of legacy group into here if true /// /// Outputs: diff --git a/include/session/config/user_groups.hpp b/include/session/config/user_groups.hpp index 0809df50..5187af1a 100644 --- a/include/session/config/user_groups.hpp +++ b/include/session/config/user_groups.hpp @@ -12,6 +12,7 @@ #include "notify.hpp" extern "C" { +struct ugroups_group_info; struct ugroups_legacy_group_info; struct ugroups_community_info; } @@ -20,26 +21,36 @@ namespace session::config { /// keys used in this config, either currently or in the past (so that we don't reuse): /// -/// C - dict of legacy groups; within this dict each key is the group pubkey (binary, 33 bytes) and -/// value is a dict containing keys: +/// *Within* the group dicts (i.e. not at the top level), we use these common values: /// -/// n - name (string). Always set, even if empty. -/// k - encryption public key (32 bytes). Optional. -/// K - encryption secret key (32 bytes). Optional. -/// m - set of member session ids (each 33 bytes). -/// a - set of admin session ids (each 33 bytes). -/// E - disappearing messages duration, in seconds, > 0. Omitted if disappearing messages is -/// disabled. (Note that legacy groups only support expire after-read) /// @ - notification setting (int). Omitted = use default setting; 1 = all, 2 = disabled, 3 = /// mentions-only. /// ! - mute timestamp: if set then don't show notifications for this contact's messages until /// this unix timestamp (i.e. overriding the current notification setting until the given /// time). -/// + - the conversation priority, for pinned/hidden messages. Integer. Omitted means not -/// pinned; -1 means hidden, and a positive value is a pinned message for which higher -/// priority values means the conversation is meant to appear earlier in the pinned -/// conversation list. +/// + - the conversation priority, for pinning/hiding this group in the conversation list. +/// Integer. Omitted means not pinned; -1 means hidden, and a positive value is a pinned +/// message for which higher priority values means the conversation is meant to appear +/// earlier in the pinned conversation list. +/// i - 1 if this is a pending invite (i.e. we have a request but haven't yet joined it), +/// deleted once joined. /// j - joined at unix timestamp. Omitted if 0. +/// n - the room/group/etc. friendly name. See details for each group type below. +/// +/// Top-level keys: +/// +/// g - dict of groups (AKA closed groups) for new-style closed groups (i.e. not legacy closed +/// groups; see below for those). Each key is the group's public key (without 0x03 prefix). +/// +/// K - group seed, if known (i.e. an admin). This is just the seed, which is just the first +/// half (32 bytes) of the 64-byte libsodium-style Ed25519 secret key value (i.e. it omits +/// the cached public key in the second half). This field is always set, but will be empty +/// if the seed is not known. +/// s - authentication signature; this is used by non-admins to authenticate. Omitted when K is +/// non-empty. +/// n - the room name, from a the group invitation; this is intended to be removed once the +/// invitation has been accepted, as the name contained in the group info supercedes this). +/// @, !, +, i, j -- see common values, above. /// /// o - dict of communities (AKA open groups); within this dict (which deliberately has the same /// layout as convo_info_volatile) each key is the SOGS base URL (in canonical form), and value @@ -51,34 +62,43 @@ namespace session::config { /// appropriate). For instance, a room name SudokuSolvers would be "sudokusolvers" in /// the outer key, with the capitalization variation in use ("SudokuSolvers") in this /// key. This key is *always* present (to keep the room dict non-empty). -/// @ - notification setting (see above). -/// ! - mute timestamp (see above). -/// + - the conversation priority, for pinned messages. Omitted means not pinned; -1 means -/// hidden; otherwise an integer value >0, where a higher priority means the -/// conversation is meant to appear earlier in the pinned conversation list. -/// j - joined at unix timestamp. Omitted if 0. +/// @, !, +, i, j - see common values, above. /// -/// c - reserved for future storage of new-style group info. +/// C - dict of legacy groups; within this dict each key is the group pubkey (binary, 33 bytes) and +/// value is a dict containing keys: +/// +/// n - name (string). Always set, even if empty (to make sure there is always something set to +/// keep the entry alive). +/// k - encryption public key (32 bytes). Optional. +/// K - encryption secret key (32 bytes). Optional. +/// m - set of member session ids (each 33 bytes). +/// a - set of admin session ids (each 33 bytes). +/// E - disappearing messages duration, in seconds, > 0. Omitted if disappearing messages is +/// disabled. (Note that legacy groups only support expire after-read) +/// @, !, +, i, j - see common values, above. /// Common base type with fields shared by all the groups struct base_group_info { + static constexpr size_t NAME_MAX_LENGTH = 100; // in bytes; name will be truncated if exceeded + int priority = 0; // The priority; 0 means unpinned, -1 means hidden, positive means // pinned higher (i.e. higher priority conversations come first). int64_t joined_at = 0; // unix timestamp (seconds) when the group was joined (or re-joined) notify_mode notifications = notify_mode::defaulted; // When the user wants notifications int64_t mute_until = 0; // unix timestamp (seconds) until which notifications are disabled + std::string name; // human-readable; always set for a legacy closed group, only used before + // joining a new closed group (after joining the group info provide the name) + + bool invited = false; // True if this is currently in the invite-but-not-accepted state. + protected: void load(const dict& info_dict); }; /// Struct containing legacy group info (aka "closed groups"). struct legacy_group_info : base_group_info { - static constexpr size_t NAME_MAX_LENGTH = 100; // in bytes; name will be truncated if exceeded - - std::string session_id; // The legacy group "session id" (33 bytes). - std::string name; // human-readable; this should normally always be set, but in theory could be - // set to an empty string. + std::string session_id; // The legacy group "session id" (33 bytes). ustring enc_pubkey; // bytes (32 or empty) ustring enc_seckey; // bytes (32 or empty) std::chrono::seconds disappearing_timer{0}; // 0 == disabled. @@ -162,6 +182,43 @@ struct legacy_group_info : base_group_info { void load(const dict& info_dict); }; +/// Struct containing new group info (aka "closed groups v2"). +struct group_info : base_group_info { + std::string id; // The group pubkey (66 hex digits); this is an ed25519 key, prefixed with "03" + // (to distinguish it from a 05 x25519 pubkey session id). + + /// Group secret key (64 bytes); this is only possessed by admins. + ustring secretkey; + + /// Group authentication signing value (100 bytes); this is used by non-admins to authenticate + /// (using the swarm key generation functions in config::groups::Keys). This value will be + /// dropped when serializing an updated config message if `secretkey` is non-empty (i.e. if it + /// is an admin), and so does not need to be explicitly cleared when being promoted to admin. + /// + /// Producing and using this value is done with the groups::Keys `swarm` methods. + ustring auth_data; + + /// Constructs a new group info from an hex id (03 + pubkey). Throws if id is invalid. + explicit group_info(std::string gid); + + // Internal ctor/method for C API implementations: + group_info(const struct ugroups_group_info& c); // From c struct + void into(struct ugroups_group_info& c) const; // Into c struct + + /// Shortcut for clearing both secretkey and auth_data, which indicates that we were kicked from + /// the group. + void setKicked(); + + /// Returns true if we don't have room access, i.e. we were kicked and both secretkey and + /// auth_data are empty. + bool kicked() const; + + private: + friend class UserGroups; + + void load(const dict& info_dict); +}; + /// Community (aka open group) info struct community_info : base_group_info, community { // Note that *changing* url/room/pubkey and then doing a set inserts a new room under the given @@ -181,7 +238,7 @@ struct community_info : base_group_info, community { friend class comm_iterator_helper; }; -using any_group_info = std::variant; +using any_group_info = std::variant; class UserGroups : public ConfigBase { @@ -209,12 +266,12 @@ class UserGroups : public ConfigBase { /// API: user_groups/UserGroups::storage_namespace /// - /// Returns the Contacts namespace. Is constant, will always return 5 + /// Returns the Contacts namespace. /// /// Inputs: None /// /// Outputs: - /// - `Namespace` - Returns 5 + /// - `Namespace` - Returns Namespace::UserGroups Namespace storage_namespace() const override { return Namespace::UserGroups; } /// API: user_groups/UserGroups::encryption_domain @@ -264,6 +321,20 @@ class UserGroups : public ConfigBase { /// found std::optional get_legacy_group(std::string_view pubkey_hex) const; + /// API: user_groups/UserGroups::get_group + /// + /// Looks up and returns a group (aka new closed group) by group ID (hex, looks like a Session + /// ID but starting with 03). Returns nullopt if the group was not found, otherwise returns a + /// filled out `group_info`. + /// + /// Inputs: + /// - `pubkey_hex` -- group ID (hex, looks like a session ID but starting 03 instead of 05) + /// + /// Outputs: + /// - `std::optional` - Returns the filled out group_info struct if found, nullopt + /// if not found. + std::optional get_group(std::string_view pubkey_hex) const; + /// API: user_groups/UserGroups::get_or_construct_community /// /// Same as `get_community`, except if the community isn't found a new blank one is created for @@ -324,6 +395,30 @@ class UserGroups : public ConfigBase { /// - `legacy_group_info` - Returns the filled out legacy_group_info struct legacy_group_info get_or_construct_legacy_group(std::string_view pubkey_hex) const; + /// API: user_groups/UserGroups::get_or_construct_group + /// + /// Gets or constructs a blank group_info for the given group id. + /// + /// Inputs: + /// - `pubkey_hex` -- group ID (hex, looks like a session ID) + /// + /// Outputs: + /// - `group_info` - Returns the filled out group_info struct + group_info get_or_construct_group(std::string_view pubkey_hex) const; + + /// API: user_groups/UserGroups::create_group + /// + /// Constructs a `group_info` object with newly generated (random) keys and returns the + /// group_info containing these keys. The group will have the id and secretkey populated; other + /// fields are defaulted. You still need to pass this to `set()` to store it, after setting any + /// other fields as desired. + /// + /// Inputs: None + /// + /// Outputs: + /// - `group_info` - Returns a filled out group_info struct for a new, randomly generated group. + group_info create_group() const; + /// API: user_groups/UserGroups::set /// /// Inserts or replaces existing group info. For example, to update the info for a community @@ -336,12 +431,15 @@ class UserGroups : public ConfigBase { /// /// Declaration: /// ```cpp + /// void set(const group_info& info); /// void set(const community_info& info); /// void set(const legacy_group_info& info); /// ``` /// /// Inputs: - /// - `info` -- group info struct to insert. Can be either community_info or legacy_group_info + /// - `info` -- group info struct to insert. Can be `community_info`, `group_info`, or + /// `legacy_group_info`. + void set(const group_info& info); void set(const community_info& info); void set(const legacy_group_info& info); @@ -366,6 +464,18 @@ class UserGroups : public ConfigBase { /// - `bool` - Returns true if found and removed, false otherwise bool erase_community(std::string_view base_url, std::string_view room); + /// API: user_groups/UserGroups::erase_group + /// + /// Removes a (new, closed) group conversation. Returns true if found and removed, false if not + /// present. + /// + /// Inputs: + /// - `pubkey_hex` -- group ID (hex, looks like a session ID, but starts with 03) + /// + /// Outputs: + /// - `bool` - Returns true if found and removed, false otherwise + bool erase_group(std::string_view pubkey_hex); + /// API: user_groups/UserGroups::erase_legacy_group /// /// Removes a legacy group conversation. Returns true if found and removed, false if not @@ -380,11 +490,12 @@ class UserGroups : public ConfigBase { /// API: user_groups/UserGroups::erase /// - /// Removes a conversation taking the community_info or legacy_group_info instance (rather than - /// the pubkey/url) for convenience. + /// Removes a conversation taking the community_info, group_info, or legacy_group_info instance + /// (rather than the pubkey/url) for convenience. /// /// Declaration: /// ```cpp + /// bool erase(const group_info& g); /// bool erase(const community_info& g); /// bool erase(const legacy_group_info& c); /// bool erase(const any_group_info& info); @@ -395,6 +506,7 @@ class UserGroups : public ConfigBase { /// /// Outputs: /// - `bool` - Returns true if found and removed, false otherwise + bool erase(const group_info& g); bool erase(const community_info& g); bool erase(const legacy_group_info& c); bool erase(const any_group_info& info); @@ -409,6 +521,16 @@ class UserGroups : public ConfigBase { /// - `size_t` - Returns the number of groups size_t size() const; + /// API: user_groups/UserGroups::size_groups + /// + /// Returns the number of (non-legacy) groups + /// + /// Inputs: None + /// + /// Outputs: + /// - `size_t` - Returns the number of groups + size_t size_groups() const; + /// API: user_groups/UserGroups::size_communities /// /// Returns the number of communities @@ -419,7 +541,7 @@ class UserGroups : public ConfigBase { /// - `size_t` - Returns the number of groups size_t size_communities() const; - /// API: user_groups/UserGroups::size_communities + /// API: user_groups/UserGroups::size_legacy_groups /// /// Returns the number of legacy groups /// @@ -454,8 +576,8 @@ class UserGroups : public ConfigBase { /// } /// ``` /// - /// This iterates through all groups in sorted order (sorted first by convo type, then by - /// id within the type). + /// This iterates through all groups in sorted order (sorted first by convo type [groups, + /// communities, legacy groups], then by id within the type). /// /// It is NOT permitted to add/remove/modify records while iterating. If such is needed it must /// be done in two passes: once to collect the modifications, then a loop applying the collected @@ -480,13 +602,35 @@ class UserGroups : public ConfigBase { template struct subtype_iterator; - /// Returns an iterator that iterates only through one type of conversations. (The regular - /// `.end()` iterator is valid for testing the end of these iterations). + /// API: user_groups/UserGroups::begin_groups + /// + /// Inputs: None + /// + /// Outputs: + /// - an iterator that iterates only through group conversations. (The regular `.end()` + /// iterator is valid for testing the end of this iterator). + subtype_iterator begin_groups() const { return {data}; } + + /// API: user_groups/UserGroups::begin_communities + /// + /// Inputs: None + /// + /// Outputs: + /// - an iterator that iterates only through community conversations. (The regular `.end()` + /// iterator is valid for testing the end of this iterator). subtype_iterator begin_communities() const { return {data}; } + + /// API: user_groups/UserGroups::begin_legacy_groups + /// + /// Inputs: None + /// + /// Outputs: + /// - an iterator that iterates only through legacy group conversations. (The regular `.end()` + /// iterator is valid for testing the end of this iterator). subtype_iterator begin_legacy_groups() const { return {data}; } using iterator_category = std::input_iterator_tag; - using value_type = std::variant; + using value_type = std::variant; using reference = value_type&; using pointer = value_type*; using difference_type = std::ptrdiff_t; @@ -494,13 +638,19 @@ class UserGroups : public ConfigBase { struct iterator { protected: std::shared_ptr _val; + std::optional _it_group, _end_group; std::optional _it_comm; std::optional _it_legacy, _end_legacy; void _load_val(); iterator() = default; // Constructs an end tombstone explicit iterator( - const DictFieldRoot& data, bool communities = true, bool legacy_closed = true); + const DictFieldRoot& data, + bool communities = true, + bool legacy_closed = true, + bool groups = true); friend class UserGroups; + template + bool check_it(); public: bool operator==(const iterator& other) const; @@ -522,6 +672,7 @@ class UserGroups : public ConfigBase { subtype_iterator(const DictFieldRoot& data) : iterator( data, + std::is_same_v, std::is_same_v, std::is_same_v) {} friend class UserGroups; diff --git a/include/session/util.hpp b/include/session/util.hpp index 6cfa77e2..322756c9 100644 --- a/include/session/util.hpp +++ b/include/session/util.hpp @@ -1,4 +1,11 @@ #pragma once +#include +#include +#include +#include +#include +#include + #include "types.hpp" namespace session { @@ -23,6 +30,14 @@ inline ustring_view to_unsigned_sv(std::string_view v) { inline std::string_view from_unsigned_sv(ustring_view v) { return {from_unsigned(v.data()), v.size()}; } +template +inline std::string_view from_unsigned_sv(const std::array& v) { + return {from_unsigned(v.data()), v.size()}; +} +template +inline std::string_view from_unsigned_sv(const std::vector& v) { + return {from_unsigned(v.data()), v.size()}; +} /// Returns true if the first string is equal to the second string, compared case-insensitively. inline bool string_iequal(std::string_view s1, std::string_view s2) { @@ -41,4 +56,222 @@ inline constexpr bool end_with(std::string_view str, std::string_view suffix) { return str.size() >= suffix.size() && str.substr(str.size() - suffix.size()) == suffix; } +// Calls sodium_malloc for secure allocation; throws a std::bad_alloc on allocation failure +void* sodium_buffer_allocate(size_t size); +// Frees a pointer constructed with sodium_buffer_allocate. Does nothing if `p` is nullptr. +void sodium_buffer_deallocate(void* p); +// Calls sodium_memzero to zero a buffer +void sodium_zero_buffer(void* ptr, size_t size); + +// Works similarly to a unique_ptr, but allocations and free go via libsodium (which is slower, but +// more secure for sensitive data). +template +struct sodium_ptr { + private: + T* x; + + public: + sodium_ptr() : x{nullptr} {} + sodium_ptr(std::nullptr_t) : sodium_ptr{} {} + ~sodium_ptr() { reset(x); } + + // Allocates and constructs a new `T` in-place, forwarding any given arguments to the `T` + // constructor. If this sodium_ptr already has an object, `reset()` is first called implicitly + // to destruct and deallocate the existing object. + template + T& emplace(Args&&... args) { + if (x) + reset(); + x = static_cast(sodium_buffer_allocate(sizeof(T))); + new (x) T(std::forward(args)...); + return *x; + } + + void reset() { + if (x) { + x->~T(); + sodium_buffer_deallocate(x); + x = nullptr; + } + } + void operator=(std::nullptr_t) { reset(); } + + T& operator*() { return *x; } + const T& operator*() const { return *x; } + + T* operator->() { return x; } + const T* operator->() const { return x; } + + explicit operator bool() const { return x != nullptr; } +}; + +// Wrapper around a type that uses `sodium_memzero` to zero the container on destruction; may only +// be used with trivially destructible types. +template >> +struct sodium_cleared : T { + using T::T; + + ~sodium_cleared() { sodium_zero_buffer(this, sizeof(*this)); } +}; + +// This is an optional (i.e. can be empty) fixed-size (at construction) buffer that does allocation +// and freeing via libsodium. It is slower and heavier than a regular allocation type but takes +// extra precautions, intended for storing sensitive values. +template +struct sodium_array { + private: + T* buf; + size_t len; + + public: + // Default constructor: makes an empty object (that is, has no buffer and has `.size()` of 0). + sodium_array() : buf{nullptr}, len{0} {} + + // Constructs an array with a given size, default-constructing the individual elements. + template >> + explicit sodium_array(size_t length) : + buf{length == 0 ? nullptr + : static_cast(sodium_buffer_allocate(length * sizeof(T)))}, + len{0} { + + if (length > 0) { + if constexpr (std::is_trivial_v) { + std::memset(buf, 0, length * sizeof(T)); + len = length; + } else if constexpr (std::is_nothrow_default_constructible_v) { + for (; len < length; len++) + new (buf[len]) T(); + } else { + try { + for (; len < length; len++) + new (buf[len]) T(); + } catch (...) { + reset(); + throw; + } + } + } + } + + ~sodium_array() { reset(); } + + // Moveable: ownership is transferred to the new object and the old object becomes empty. + sodium_array(sodium_array&& other) : buf{other.buf}, len{other.len} { + other.buf = nullptr; + other.len = 0; + } + sodium_array& operator=(sodium_array&& other) { + sodium_buffer_deallocate(buf); + buf = other.buf; + len = other.len; + other.buf = nullptr; + other.len = 0; + } + + // Non-copyable + sodium_array(const sodium_array&) = delete; + sodium_array& operator=(const sodium_array&) = delete; + + // Destroys the held array; after destroying elements the allocated space is overwritten with + // 0s before being deallocated. + void reset() { + if (buf) { + if constexpr (!std::is_trivially_destructible_v) + while (len > 0) + buf[--len].~T(); + + sodium_buffer_deallocate(buf); + } + buf = nullptr; + len = 0; + } + + // Calls reset() to destroy the current value (if any) and then allocates a new + // default-constructed one of the given size. + template >> + void reset(size_t length) { + reset(); + if (length > 0) { + buf = static_cast(sodium_buffer_allocate(length * sizeof(T))); + if constexpr (std::is_trivial_v) { + std::memset(buf, 0, length * sizeof(T)); + len = length; + } else { + for (; len < length; len++) + new (buf[len]) T(); + } + } + } + + // Loads the array from a pointer and size; this first resets a value (if present), allocates a + // new array of the given size, the copies the given value(s) into the new buffer. T must be + // copyable. This is *not* safe to use if `buf` points into the currently allocated data. + template >> + void load(const T* data, size_t length) { + reset(length); + if (length == 0) + return; + + if constexpr (std::is_trivially_copyable_v) + std::memcpy(buf, data, sizeof(T) * length); + else + for (; len < length; len++) + new (buf[len]) T(data[len]); + } + + const T& operator[](size_t i) const { + assert(i < len); + return buf[i]; + } + T& operator[](size_t i) { + assert(i < len); + return buf[i]; + } + + T* data() { return buf; } + const T* data() const { return buf; } + + size_t size() const { return len; } + bool empty() const { return len == 0; } + explicit operator bool() const { return !empty(); } + + T* begin() { return buf; } + const T* begin() const { return buf; } + T* end() { return buf + len; } + const T* end() const { return buf + len; } + + using difference_type = ptrdiff_t; + using value_type = T; + using pointer = value_type*; + using reference = value_type&; + using iterator_category = std::random_access_iterator_tag; +}; + +// sodium Allocator wrapper; this allocates/frees via libsodium, which is designed for dealing with +// sensitive data. It is as a result slower and has more overhead than a standard allocator and +// intended for use with a container (such as std::vector) when storing keys. +template +struct sodium_allocator { + using value_type = T; + + [[nodiscard]] static T* allocate(std::size_t n) { + return static_cast(sodium_buffer_allocate(n * sizeof(T))); + } + + static void deallocate(T* p, std::size_t) { sodium_buffer_deallocate(p); } + + template + bool operator==(const sodium_allocator&) const noexcept { + return true; + } + template + bool operator!=(const sodium_allocator&) const noexcept { + return false; + } +}; + +/// Vector that uses sodium's secure (but heavy) memory allocations +template +using sodium_vector = std::vector>; + } // namespace session diff --git a/include/session/xed25519.hpp b/include/session/xed25519.hpp index 9889113c..2c55679f 100644 --- a/include/session/xed25519.hpp +++ b/include/session/xed25519.hpp @@ -35,4 +35,18 @@ std::array pubkey(ustring_view curve25519_pubkey); /// "Softer" version that takes/returns strings of regular chars std::string pubkey(std::string_view curve25519_pubkey); +/// Utility function that provides a constant-time `if (b) f = g;` implementation for byte arrays. +template +void constant_time_conditional_assign( + std::array& f, const std::array& g, bool b) { + std::array x; + for (size_t i = 0; i < x.size(); i++) + x[i] = f[i] ^ g[i]; + unsigned char mask = (unsigned char)(-(signed char)b); + for (size_t i = 0; i < x.size(); i++) + x[i] &= mask; + for (size_t i = 0; i < x.size(); i++) + f[i] ^= x[i]; +} + } // namespace session::xed25519 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f4f836b5..041afbc8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,10 +22,14 @@ add_library(config config/convo_info_volatile.cpp config/encrypt.cpp config/error.c + config/groups/info.cpp + config/groups/members.cpp + config/groups/keys.cpp config/internal.cpp config/user_groups.cpp config/user_profile.cpp fields.cpp + util.cpp ) set_target_properties( config diff --git a/src/config.cpp b/src/config.cpp index 2410908c..99957bad 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -15,6 +15,7 @@ #include #include +#include "config/internal.hpp" #include "session/bt_merge.hpp" #include "session/util.hpp" @@ -347,44 +348,6 @@ namespace { return std::string_view{reinterpret_cast(hash.data()), hash.size()}; } - oxenc::bt_dict::iterator append_unknown( - oxenc::bt_dict_producer& out, - oxenc::bt_dict::iterator it, - oxenc::bt_dict::iterator end, - std::string_view until) { - for (; it != end && it->first < until; ++it) - out.append_bt(it->first, it->second); - - assert(!(it != end && it->first == until)); - return it; - } - - /// Extracts and unknown keys in the top-level dict into `unknown` that have keys (strictly) - /// between previous and until. - void load_unknowns( - oxenc::bt_dict& unknown, - oxenc::bt_dict_consumer& in, - std::string_view previous, - std::string_view until) { - while (!in.is_finished() && in.key() < until) { - std::string key{in.key()}; - if (key <= previous || (!unknown.empty() && key <= unknown.rbegin()->first)) - throw oxenc::bt_deserialize_invalid{"top-level keys are out of order"}; - if (in.is_string()) - unknown.emplace_hint(unknown.end(), std::move(key), in.consume_string()); - else if (in.is_negative_integer()) - unknown.emplace_hint(unknown.end(), std::move(key), in.consume_integer()); - else if (in.is_integer()) - unknown.emplace_hint(unknown.end(), std::move(key), in.consume_integer()); - else if (in.is_list()) - unknown.emplace_hint(unknown.end(), std::move(key), in.consume_list()); - else if (in.is_dict()) - unknown.emplace_hint(unknown.end(), std::move(key), in.consume_dict()); - else - throw oxenc::bt_deserialize_invalid{"invalid bencoded value type"}; - } - } - hash_t& hash_msg(hash_t& into, ustring_view serialized) { crypto_generichash_blake2b( into.data(), into.size(), serialized.data(), serialized.size(), nullptr, 0); @@ -466,6 +429,50 @@ namespace { } } // namespace +void verify_config_sig( + oxenc::bt_dict_consumer dict, + ustring_view config_msg, + const ConfigMessage::verify_callable& verifier, + std::optional>* verified_signature, + bool trust_signature) { + ustring_view to_verify, sig; + dict.skip_until("~"); + if (!dict.is_finished() && dict.key() == "~") { + // We get the key string_view here because it points into the buffer that we need. + // Currently it will be pointing at the "~", i.e.: + // + // [...previousdata...]1:~64:[sigdata] + // ^-- here + // + // but what we need is the data up to the end of `]`, so we subtract 2 off that to + // figure out the range of the full serialized data that should have been signed: + + auto key = dict.key(); + assert(to_unsigned(key.data()) > config_msg.data() && + to_unsigned(key.data()) < config_msg.data() + config_msg.size()); + to_verify = config_msg.substr(0, to_unsigned(key.data()) - config_msg.data() - 2); + sig = to_unsigned_sv(dict.consume_string_view()); + } + + if (!dict.is_finished()) + throw config_parse_error{"Invalid config: dict has invalid key(s) after \"~\""}; + + if (verifier || trust_signature) { + if (sig.empty()) { + if (!trust_signature) + throw missing_signature{"Config signature is missing"}; + } else if (sig.size() != 64) + throw signature_error{"Config signature is invalid (not 64B)"}; + else if (verifier && !verifier(to_verify, sig)) + throw signature_error{"Config signature failed verification"}; + else if (verified_signature) { + if (!*verified_signature) + verified_signature->emplace(); + std::memcpy((*verified_signature)->data(), sig.data(), 64); + } + } +} + bool MutableConfigMessage::prune() { return prune_(data_).second; } @@ -541,7 +548,7 @@ ConfigMessage::ConfigMessage( verify_callable verifier_, sign_callable signer_, int lag, - bool signature_optional) : + bool trust_signature) : verifier{std::move(verifier_)}, signer{std::move(signer_)}, lag{lag} { oxenc::bt_dict_consumer dict{from_unsigned_sv(serialized)}; @@ -568,35 +575,7 @@ ConfigMessage::ConfigMessage( load_unknowns(unknown_, dict, "=", "~"); - ustring_view to_verify, sig; - if (!dict.is_finished() && dict.key() == "~") { - // We get the key string_view here because it points into the buffer that we need. - // Currently it will be pointing at the "~", i.e.: - // - // [...previousdata...]1:~64:[sigdata] - // ^-- here - // - // but what we need is the data up to the end of `]`, so we subtract 2 off that to - // figure out the range of the full serialized data that should have been signed: - - auto key = dict.key(); - assert(to_unsigned(key.data()) > serialized.data() && - to_unsigned(key.data()) < serialized.data() + serialized.size()); - to_verify = serialized.substr(0, to_unsigned(key.data()) - serialized.data() - 2); - sig = to_unsigned_sv(dict.consume_string_view()); - } - - if (!dict.is_finished()) - throw config_parse_error{"Invalid config: dict has invalid key(s) after \"~\""}; - - if (verifier) { - if (sig.empty()) { - if (!signature_optional) - throw missing_signature{"Config signature is missing"}; - } else if (verified_signature_ = verifier(to_verify, sig); !verified_signature_) { - throw signature_error{"Config signature failed verification"}; - } - } + verify_config_sig(dict, serialized, verifier, &verified_signature_, trust_signature); } catch (const oxenc::bt_deserialize_invalid& err) { throw config_parse_error{"Failed to parse config file: "s + err.what()}; } @@ -607,7 +586,6 @@ ConfigMessage::ConfigMessage( verify_callable verifier_, sign_callable signer_, int lag, - bool signature_optional, std::function error_handler) : verifier{std::move(verifier_)}, signer{std::move(signer_)}, lag{lag} { @@ -615,7 +593,7 @@ ConfigMessage::ConfigMessage( for (size_t i = 0; i < serialized_confs.size(); i++) { const auto& data = serialized_confs[i]; try { - ConfigMessage m{data, verifier, signer, lag, signature_optional}; + ConfigMessage m{data, verifier, signer, lag}; configs.emplace_back(std::move(m), false); } catch (const config_error& e) { if (error_handler) @@ -657,7 +635,7 @@ ConfigMessage::ConfigMessage( assert(curr_confs >= 1); if (curr_confs == 1) { - // We have just one config left after all that, so we become it directly as-is + // We have just one non-redundant config left after all that, so we become it directly as-is for (int i = 0; i < configs.size(); i++) { if (!configs[i].second) { *this = std::move(configs[i].first); @@ -665,12 +643,31 @@ ConfigMessage::ConfigMessage( return; } } - assert(false); + assert(!"we counted one good config but couldn't find it?!"); + } + + // Otherwise we have more than one valid config, so have to merge them. + + // ... Unless we require signature verification but can't sign, in which case we can't actually + // produce a proper merge, so we will just keep the highest (highest seqno, hash) config and use + // that, dropping the rest. Someone else (with signing power) will have to merge and push the + // merge out to us. + if (verifier && !signer) { + auto best_it = + std::max_element(configs.begin(), configs.end(), [](const auto& a, const auto& b) { + if (a.second != b.second) // Exactly one of the two is redundant + return a.second; // a < b iff a is redundant + return a.first.seqno_hash_ < b.first.seqno_hash_; + }); + *this = std::move(best_it->first); + unmerged_ = std::distance(configs.begin(), best_it); + return; } unmerged_ = -1; - // Clear any redundant messages + // Clear any redundant messages. (we do it *here* rather than above because, in the + // single-good-config case, above, we need the index of the good config for `unmerged_`). configs.erase( std::remove_if(configs.begin(), configs.end(), [](const auto& c) { return c.second; }), configs.end()); @@ -718,31 +715,24 @@ MutableConfigMessage::MutableConfigMessage( verify_callable verifier, sign_callable signer, int lag, - bool signature_optional, std::function error_handler) : ConfigMessage{ serialized_confs, std::move(verifier), std::move(signer), lag, - signature_optional, std::move(error_handler)} { if (!merged()) increment_impl(); } MutableConfigMessage::MutableConfigMessage( - ustring_view config, - verify_callable verifier, - sign_callable signer, - int lag, - bool signature_optional) : + ustring_view config, verify_callable verifier, sign_callable signer, int lag) : MutableConfigMessage{ std::vector{{config}}, std::move(verifier), std::move(signer), lag, - signature_optional, [](size_t, const config_error& e) { throw e; }} {} const oxenc::bt_dict& ConfigMessage::diff() { @@ -750,6 +740,7 @@ const oxenc::bt_dict& ConfigMessage::diff() { } const oxenc::bt_dict& MutableConfigMessage::diff() { + verified_signature_.reset(); prune(); diff_ = diff_impl(orig_data_, data_).value_or(oxenc::bt_dict{}); return diff_; @@ -792,7 +783,15 @@ ustring ConfigMessage::serialize_impl(const oxenc::bt_dict& curr_diff, bool enab unknown_it = append_unknown(outer, unknown_it, unknown_.end(), "~"); assert(unknown_it == unknown_.end()); - if (signer && enable_signing) { + if (verified_signature_) { + // We have the signature attached to the current message, so use it. (This will get cleared + // if we do anything that changes the config). + outer.append( + "~", + std::string_view{ + reinterpret_cast(verified_signature_->data()), + verified_signature_->size()}); + } else if (signer && enable_signing) { auto to_sign = to_unsigned_sv(outer.view()); // The view contains the trailing "e", but we don't sign it (we are going to append the // signature there instead): diff --git a/src/config/base.cpp b/src/config/base.cpp index 3f80d5e2..d22c3b14 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -1,14 +1,18 @@ #include "session/config/base.hpp" +#include +#include #include #include +#include +#include #include -#include #include #include #include +#include "internal.hpp" #include "session/config/base.h" #include "session/config/encrypt.hpp" #include "session/export.h" @@ -19,6 +23,9 @@ using namespace std::literals; namespace session::config { void ConfigBase::set_state(ConfigState s) { + if (s == ConfigState::Dirty && is_readonly()) + throw std::runtime_error{"Unable to make changes to a read-only config object"}; + if (_state == ConfigState::Clean && !_curr_hash.empty()) { _old_hashes.insert(std::move(_curr_hash)); _curr_hash.clear(); @@ -55,7 +62,7 @@ std::unique_ptr make_config_message(bool from_dirty, Args&&... ar int ConfigBase::merge(const std::vector>& configs) { - if (_keys_size == 0) + if (_keys.empty()) throw std::logic_error{"Cannot merge configs without any decryption keys"}; const auto old_seqno = _config->seqno(); @@ -63,12 +70,21 @@ int ConfigBase::merge(const std::vector>& c std::vector all_confs; all_hashes.reserve(configs.size() + 1); all_confs.reserve(configs.size() + 1); + // We serialize our current config and include it in the list of configs to be merged, as if it // had already been pushed to the server (so that this code will be identical whether or not the // value was pushed). - auto mine = _config->serialize(); - all_hashes.emplace_back(_curr_hash); - all_confs.emplace_back(mine); + // + // (We skip this for seqno=0, but that's just a default-constructed, nothing-in-the-config case + // for which we also can't have or produce a signature, so there's no point in even trying to + // merge it). + + ustring mine; + if (old_seqno != 0 || is_dirty()) { + mine = _config->serialize(); + all_hashes.emplace_back(_curr_hash); + all_confs.emplace_back(mine); + } std::vector> plaintexts; @@ -82,9 +98,8 @@ int ConfigBase::merge(const std::vector>& c // - element 4 is a chunk of the data. for (size_t ci = 0; ci < configs.size(); ci++) { auto& [hash, conf] = configs[ci]; - std::optional plaintext; bool decrypted = false; - for (size_t i = 0; !decrypted && i < _keys_size; i++) { + for (size_t i = 0; !decrypted && i < _keys.size(); i++) { try { plaintexts.emplace_back(hash, decrypt(conf, key(i), encryption_domain())); decrypted = true; @@ -120,33 +135,14 @@ int ConfigBase::merge(const std::vector>& c // 'z' prefix indicates zstd-compressed data: if (plain[0] == 'z') { - struct zstd_decomp_freer { - void operator()(ZSTD_DStream* z) const { ZSTD_freeDStream(z); } - }; - std::unique_ptr z_decompressor{ZSTD_createDStream()}; - auto* zds = z_decompressor.get(); - - ZSTD_initDStream(zds); - ZSTD_inBuffer input{/*.src=*/plain.data() + 1, /*.size=*/plain.size() - 1, /*.pos=*/0}; - unsigned char out_buf[4096]; - ZSTD_outBuffer output{/*.dst=*/out_buf, /*.size=*/sizeof(out_buf)}; - bool failed = false; - size_t ret; - ustring decompressed; - do { - output.pos = 0; - ret = ZSTD_decompressStream(zds, &output, &input); - if (ZSTD_isError(ret)) { - failed = true; - break; - } - decompressed += ustring_view{out_buf, output.pos}; - } while (ret > 0 || input.pos < input.size); - if (failed || decompressed.empty()) { + if (auto decompressed = + zstd_decompress(ustring_view{plain.data() + 1, plain.size() - 1}); + decompressed && !decompressed->empty()) + plain = std::move(*decompressed); + else { log(LogLevel::warning, "Invalid config message: decompression failed"); continue; } - plain = std::move(decompressed); } if (plain[0] != 'd') @@ -165,10 +161,9 @@ int ConfigBase::merge(const std::vector>& c auto new_conf = make_config_message( _state == ConfigState::Dirty, all_confs, - nullptr, /* FIXME for signed messages: verifier */ - nullptr, /* FIXME for signed messages: signer */ + _config->verifier, + _config->signer, config_lags(), - false, /* signature not optional (if we have a verifier) */ [&](size_t i, const config_error& e) { log(LogLevel::warning, e.what()); assert(i > 0); // i == 0 means we can't deserialize our own serialization @@ -208,7 +203,8 @@ int ConfigBase::merge(const std::vector>& c /* do nothing */ } else { _config = std::move(new_conf); - assert(_config->unmerged_index() >= 1 && _config->unmerged_index() < all_hashes.size()); + assert(((old_seqno == 0 && mine.empty()) || _config->unmerged_index() >= 1) && + _config->unmerged_index() < all_hashes.size()); set_state(ConfigState::Clean); _curr_hash = all_hashes[_config->unmerged_index()]; } @@ -219,7 +215,7 @@ int ConfigBase::merge(const std::vector>& c } return all_confs.size() - bad_confs.size() - - 1; // -1 because we don't count the first one (reparsing ourself). + (mine.empty() ? 0 : 1); // -1 because we don't count the first one (reparsing ourself). } std::vector ConfigBase::current_hashes() const { @@ -239,21 +235,14 @@ bool ConfigBase::needs_push() const { void compress_message(ustring& msg, int level) { if (!level) return; - ustring compressed; - compressed.resize(1 + ZSTD_compressBound(msg.size())); - compressed[0] = 'z'; // our zstd compression marker prefix byte - auto size = ZSTD_compress( - compressed.data() + 1, compressed.size() - 1, msg.data(), msg.size(), level); - if (ZSTD_isError(size)) - throw std::runtime_error{ - "Unable to compress message: " + std::string{ZSTD_getErrorName(size)}}; - compressed.resize(size + 1); + // "z" is our zstd compression marker prefix byte + ustring compressed = zstd_compress(msg, level, to_unsigned_sv("z"sv)); if (compressed.size() < msg.size()) msg = std::move(compressed); } std::tuple> ConfigBase::push() { - if (_keys_size == 0) + if (_keys.empty()) throw std::logic_error{"Cannot push data without an encryption key!"}; std::tuple> ret{ @@ -262,6 +251,7 @@ std::tuple> ConfigBase::push() { auto& [seqno, msg, obs] = ret; if (auto lvl = compression_level()) compress_message(msg, *lvl); + pad_message(msg); // Prefix pad with nulls encrypt_inplace(msg, key(), encryption_domain()); @@ -271,8 +261,9 @@ std::tuple> ConfigBase::push() { if (is_dirty()) set_state(ConfigState::Waiting); - for (auto& old : _old_hashes) - obs.push_back(std::move(old)); + if (!is_readonly()) + for (auto& old : _old_hashes) + obs.push_back(std::move(old)); _old_hashes.clear(); return ret; @@ -291,37 +282,61 @@ ustring ConfigBase::dump() { auto data = _config->serialize(false /* disable signing for local storage */); auto data_sv = from_unsigned_sv(data); oxenc::bt_list old_hashes; - for (auto& old : _old_hashes) - old_hashes.emplace_back(old); - oxenc::bt_dict d{ - {"!", static_cast(_state)}, - {"$", data_sv}, - {"(", _curr_hash}, - {")", std::move(old_hashes)}, - }; + + oxenc::bt_dict_producer d; + d.append("!", static_cast(_state)); + d.append("$", data_sv); + d.append("(", _curr_hash); + + if (is_readonly()) + _old_hashes.clear(); + d.append_list(")").append(_old_hashes.begin(), _old_hashes.end()); + if (auto extra = extra_data(); !extra.empty()) - d.emplace("+", std::move(extra)); + d.append_bt("+", std::move(extra)); _needs_dump = false; - auto dumped = oxenc::bt_serialize(d); - return ustring{to_unsigned_sv(dumped)}; + return ustring{to_unsigned_sv(d.view())}; } -ConfigBase::ConfigBase(std::optional dump) { +ConfigBase::ConfigBase( + std::optional dump, + std::optional ed25519_pubkey, + std::optional ed25519_secretkey) { + if (sodium_init() == -1) throw std::runtime_error{"libsodium initialization failed!"}; - if (!dump) { + + if (dump) + init_from_dump(from_unsigned_sv(*dump)); + else _config = std::make_unique(); - return; + + init_sig_keys(ed25519_pubkey, ed25519_secretkey); +} + +void ConfigSig::init_sig_keys( + std::optional ed25519_pubkey, std::optional ed25519_secretkey) { + if (ed25519_secretkey) { + if (ed25519_pubkey && *ed25519_pubkey != ed25519_secretkey->substr(32)) + throw std::invalid_argument{"Invalid signing keys: secret key and pubkey do not match"}; + set_sig_keys(*ed25519_secretkey); + } else if (ed25519_pubkey) { + set_sig_pubkey(*ed25519_pubkey); + } else { + clear_sig_keys(); } +} - oxenc::bt_dict_consumer d{from_unsigned_sv(*dump)}; +void ConfigBase::init_from_dump(std::string_view dump) { + oxenc::bt_dict_consumer d{dump}; if (!d.skip_until("!")) throw std::runtime_error{"Unable to parse dumped config data: did not find '!' state key"}; _state = static_cast(d.consume_integer()); if (!d.skip_until("$")) throw std::runtime_error{"Unable to parse dumped config data: did not find '$' data key"}; + auto data = to_unsigned_sv(d.consume_string_view()); if (_state == ConfigState::Dirty) // If we dumped dirty data then we need to reload it as a mutable config message so that the // seqno gets incremented. This "wastes" one seqno value (since we didn't send the old @@ -329,15 +344,17 @@ ConfigBase::ConfigBase(std::optional dump) { // is a little more robust against failure if we actually sent it but got killed before we // could store a dump. _config = std::make_unique( - to_unsigned_sv(d.consume_string_view()), - nullptr, // FIXME: verifier; but maybe want to delay setting this since it - // shouldn't be signed? - nullptr, // FIXME: signer - config_lags(), - true /* signature optional because we don't sign the dump */); + data, + nullptr, // We omit verifier and signer for now because we don't want this dump to + nullptr, // be signed (since it's just a dump). + config_lags()); else _config = std::make_unique( - to_unsigned_sv(d.consume_string_view()), nullptr, nullptr, config_lags(), true); + data, + nullptr, + nullptr, + config_lags(), + /*trust_signature=*/true); if (d.skip_until("(")) { _curr_hash = d.consume_string(); @@ -352,12 +369,8 @@ ConfigBase::ConfigBase(std::optional dump) { load_extra_data(std::move(extra)); } -ConfigBase::~ConfigBase() { - sodium_free(_keys); -} - int ConfigBase::key_count() const { - return _keys_size; + return _keys.size(); } bool ConfigBase::has_key(ustring_view key) const { @@ -365,86 +378,104 @@ bool ConfigBase::has_key(ustring_view key) const { throw std::invalid_argument{"invalid key given to has_key(): not 32-bytes"}; auto* keyptr = key.data(); - for (size_t i = 0; i < _keys_size; i++) - if (sodium_memcmp(keyptr, _keys[i].data(), KEY_SIZE) == 0) + for (const auto& key : _keys) + if (sodium_memcmp(keyptr, key.data(), KEY_SIZE) == 0) return true; return false; } std::vector ConfigBase::get_keys() const { std::vector ret; - ret.reserve(_keys_size); - for (size_t i = 0; i < _keys_size; i++) - ret.emplace_back(_keys[i].data(), _keys[i].size()); + ret.reserve(_keys.size()); + for (const auto& key : _keys) + ret.emplace_back(key.data(), key.size()); return ret; } -void ConfigBase::add_key(ustring_view key, bool high_priority) { +void ConfigBase::add_key(ustring_view key, bool high_priority, bool dirty_config) { static_assert( sizeof(Key) == KEY_SIZE, "std::array appears to have some overhead which seems bad"); if (key.size() != KEY_SIZE) throw std::invalid_argument{"add_key failed: key size must be 32 bytes"}; - if (_keys_size > 0 && sodium_memcmp(_keys[0].data(), key.data(), KEY_SIZE) == 0) + if (!_keys.empty() && sodium_memcmp(_keys.front().data(), key.data(), KEY_SIZE) == 0) return; else if (!high_priority && has_key(key)) return; - if (_keys_capacity == 0) { + if (_keys.capacity() == 0) // There's not a lot of point in starting this off really small: sodium is likely going to // use at least a page size anyway. - _keys_capacity = 16; - _keys = static_cast(sodium_allocarray(_keys_capacity, KEY_SIZE)); - } - - if (_keys_size >= _keys_capacity) { - _keys_capacity *= 2; - auto new_keys = static_cast(sodium_allocarray(_keys_capacity, 32)); - if (high_priority) { - std::memcpy(new_keys[0].data(), key.data(), KEY_SIZE); - std::memcpy(&new_keys[1], _keys, _keys_size * KEY_SIZE); - } else { - std::memcpy(&new_keys[0], _keys, _keys_size * KEY_SIZE); - std::memcpy(new_keys[_keys_size].data(), key.data(), KEY_SIZE); - } - sodium_free(_keys); - _keys = new_keys; - } else if (high_priority) { - // shift everything up so we can insert at beginning - std::memmove(&_keys[1], &_keys[0], _keys_size * KEY_SIZE); - std::memcpy(_keys[0].data(), key.data(), KEY_SIZE); - } else { - // add at the end - std::memcpy(_keys[_keys_size].data(), key.data(), KEY_SIZE); - } - _keys_size++; + _keys.reserve(64); - // *Slightly* suboptimal in that we might change buffers above even when we didn't need to, but - // not worth worrying about optimizing. if (high_priority) remove_key(key, 1); + + auto& newkey = *_keys.emplace(high_priority ? _keys.begin() : _keys.end()); + std::memcpy(newkey.data(), key.data(), KEY_SIZE); + + if (dirty_config && !is_readonly() && (_keys.size() == 1 || high_priority)) + dirty(); } -int ConfigBase::clear_keys() { - int ret = _keys_size; - _keys_size = 0; +int ConfigBase::clear_keys(bool dirty_config) { + int ret = _keys.size(); + _keys.clear(); + _keys.shrink_to_fit(); + + if (dirty_config && !is_readonly() && ret > 0) + dirty(); + return ret; } -bool ConfigBase::remove_key(ustring_view key, size_t from) { - bool removed = false; - - for (size_t i = from; i < _keys_size; i++) { - if (sodium_memcmp(key.data(), _keys[i].data(), KEY_SIZE) == 0) { - if (i + 1 < _keys_size) - std::memmove(&_keys[i], &_keys[i + 1], (_keys_size - i - 1) * KEY_SIZE); - _keys_size--; - removed = true; - // Don't break, in case there are somehow duplicates in here - } +void ConfigBase::replace_keys(const std::vector& new_keys, bool dirty_config) { + if (new_keys.empty()) { + if (_keys.empty()) + return; + clear_keys(dirty_config); + return; } - return removed; + + for (auto& k : new_keys) + if (k.size() != KEY_SIZE) + throw std::invalid_argument{"replace_keys failed: keys must be 32 bytes"}; + + dirty_config = dirty_config && !is_readonly() && + (_keys.empty() || + sodium_memcmp(_keys.front().data(), new_keys.front().data(), KEY_SIZE) != 0); + + _keys.clear(); + for (auto& k : new_keys) + add_key(k, /*high_priority=*/false); // The first key gets the high priority spot even + // with `false` since we just emptied the list + + if (dirty_config) + dirty(); +} + +bool ConfigBase::remove_key(ustring_view key, size_t from, bool dirty_config) { + auto starting_size = _keys.size(); + if (from >= starting_size) + return false; + + dirty_config = dirty_config && !is_readonly() && + sodium_memcmp(key.data(), _keys.front().data(), KEY_SIZE) == 0; + + _keys.erase( + std::remove_if( + _keys.begin() + from, + _keys.end(), + [&key](const auto& k) { + return sodium_memcmp(key.data(), k.data(), KEY_SIZE) == 0; + }), + _keys.end()); + + if (dirty_config) + dirty(); + + return _keys.size() < starting_size; } void ConfigBase::load_key(ustring_view ed25519_secretkey) { @@ -455,6 +486,70 @@ void ConfigBase::load_key(ustring_view ed25519_secretkey) { add_key(ed25519_secretkey.substr(0, 32)); } +void ConfigSig::set_sig_keys(ustring_view secret) { + if (secret.size() != 64) + throw std::invalid_argument{"Invalid sodium secret: expected 64 bytes"}; + clear_sig_keys(); + _sign_sk.reset(64); + std::memcpy(_sign_sk.data(), secret.data(), secret.size()); + _sign_pk.emplace(); + crypto_sign_ed25519_sk_to_pk(_sign_pk->data(), _sign_sk.data()); + + set_verifier([this](ustring_view data, ustring_view sig) { + return 0 == crypto_sign_ed25519_verify_detached( + sig.data(), data.data(), data.size(), _sign_pk->data()); + }); + set_signer([this](ustring_view data) { + ustring sig; + sig.resize(64); + if (0 != crypto_sign_ed25519_detached( + sig.data(), nullptr, data.data(), data.size(), _sign_sk.data())) + throw std::runtime_error{"Internal error: config signing failed!"}; + return sig; + }); +} + +void ConfigSig::set_sig_pubkey(ustring_view pubkey) { + if (pubkey.size() != 32) + throw std::invalid_argument{"Invalid pubkey: expected 32 bytes"}; + _sign_pk.emplace(); + std::memcpy(_sign_pk->data(), pubkey.data(), 32); + + set_verifier([this](ustring_view data, ustring_view sig) { + return 0 == crypto_sign_ed25519_verify_detached( + sig.data(), data.data(), data.size(), _sign_pk->data()); + }); +} + +void ConfigSig::clear_sig_keys() { + _sign_pk.reset(); + _sign_sk.reset(); + set_signer(nullptr); + set_verifier(nullptr); +} + +void ConfigBase::set_verifier(ConfigMessage::verify_callable v) { + _config->verifier = std::move(v); +} + +void ConfigBase::set_signer(ConfigMessage::sign_callable s) { + _config->signer = std::move(s); +} + +std::array ConfigSig::seed_hash(std::string_view key) const { + if (!_sign_sk) + throw std::runtime_error{"Cannot make a seed hash without a signing secret key"}; + std::array out; + crypto_generichash_blake2b( + out.data(), + out.size(), + _sign_sk.data(), + 32, // Just the seed part of the value, not the last half (which is just the pubkey) + reinterpret_cast(key.data()), + std::min(key.size(), 64)); + return out; +} + void set_error(config_object* conf, std::string e) { auto& error = unbox(conf).error; error = std::move(e); @@ -548,26 +643,25 @@ LIBSESSION_EXPORT bool config_needs_dump(const config_object* conf) { } LIBSESSION_EXPORT config_string_list* config_current_hashes(const config_object* conf) { - auto hashes = unbox(conf)->current_hashes(); - size_t sz = sizeof(config_string_list) + hashes.size() * sizeof(char*); - for (auto& h : hashes) - sz += h.size() + 1; - void* buf = std::malloc(sz); - auto* ret = static_cast(buf); - ret->len = hashes.size(); - - static_assert(alignof(config_string_list) >= alignof(char*)); - ret->value = reinterpret_cast(ret + 1); - char** next_ptr = ret->value; - char* next_str = reinterpret_cast(next_ptr + ret->len); - - for (size_t i = 0; i < ret->len; i++) { - *(next_ptr++) = next_str; - std::memcpy(next_str, hashes[i].c_str(), hashes[i].size() + 1); - next_str += hashes[i].size() + 1; + return make_string_list(unbox(conf)->current_hashes()); +} + +LIBSESSION_EXPORT unsigned char* config_get_keys(const config_object* conf, size_t* len) { + const auto keys = unbox(conf)->get_keys(); + assert(std::count_if(keys.begin(), keys.end(), [](const auto& k) { return k.size() == 32; }) == + keys.size()); + assert(len); + *len = keys.size(); + if (keys.empty()) + return nullptr; + auto* buf = static_cast(std::malloc(32 * keys.size())); + auto* cur = buf; + for (const auto& k : keys) { + std::memcpy(cur, k.data(), 32); + cur += 32; } - return ret; + return buf; } LIBSESSION_EXPORT void config_add_key(config_object* conf, const unsigned char* key) { @@ -596,6 +690,25 @@ LIBSESSION_EXPORT const char* config_encryption_domain(const config_object* conf return unbox(conf)->encryption_domain(); } +LIBSESSION_EXPORT void config_set_sig_keys(config_object* conf, const unsigned char* secret) { + unbox(conf)->set_sig_keys({secret, 64}); +} + +LIBSESSION_EXPORT void config_set_sig_pubkey(config_object* conf, const unsigned char* pubkey) { + unbox(conf)->set_sig_pubkey({pubkey, 32}); +} + +LIBSESSION_EXPORT const unsigned char* config_get_sig_pubkey(const config_object* conf) { + const auto& pk = unbox(conf)->get_sig_pubkey(); + if (pk) + return pk->data(); + return nullptr; +} + +LIBSESSION_EXPORT void config_clear_sig_keys(config_object* conf) { + unbox(conf)->clear_sig_keys(); +} + LIBSESSION_EXPORT void config_set_logger( config_object* conf, void (*callback)(config_log_level, const char*, void*), void* ctx) { if (!callback) diff --git a/src/config/contacts.cpp b/src/config/contacts.cpp index 6e973504..6c2ca1ce 100644 --- a/src/config/contacts.cpp +++ b/src/config/contacts.cpp @@ -30,22 +30,6 @@ static_assert(CONVO_NOTIFY_ALL == static_cast(notify_mode::all)); static_assert(CONVO_NOTIFY_DISABLED == static_cast(notify_mode::disabled)); static_assert(CONVO_NOTIFY_MENTIONS_ONLY == static_cast(notify_mode::mentions_only)); -namespace { - -void check_session_id(std::string_view session_id) { - if (session_id.size() != 66 || !oxenc::is_hex(session_id)) - throw std::invalid_argument{ - "Invalid pubkey: expected 66 hex digits, got " + std::to_string(session_id.size()) + - " and/or not hex"}; -} - -std::string session_id_to_bytes(std::string_view session_id) { - check_session_id(session_id); - return oxenc::from_hex(session_id); -} - -} // namespace - LIBSESSION_C_API bool session_id_is_valid(const char* session_id) { return std::strlen(session_id) == 66 && oxenc::is_hex(session_id, session_id + 66); } @@ -354,7 +338,6 @@ void Contacts::iterator::_load_info() { if (_it->first.size() == 33) { if (auto* info_dict = std::get_if(&_it->second)) { _val = std::make_shared(oxenc::to_hex(_it->first)); - auto hex = oxenc::to_hex(_it->first); _val->load(*info_dict); return; } @@ -372,6 +355,9 @@ bool Contacts::iterator::operator==(const iterator& other) const { if (!other._contacts) // other is an "end" tombstone: return whether we are at the end return _it == _contacts->end(); + if (!_contacts) + // we are an "end" tombstone: return whether the other one is at the end + return other._it == other._contacts->end(); return _it == other._it; } diff --git a/src/config/convo_info_volatile.cpp b/src/config/convo_info_volatile.cpp index 27d40b19..3ce3dae1 100644 --- a/src/config/convo_info_volatile.cpp +++ b/src/config/convo_info_volatile.cpp @@ -31,7 +31,7 @@ namespace convo { one_to_one::one_to_one(std::string_view sid) : session_id{sid} { check_session_id(session_id); } - one_to_one::one_to_one(const struct convo_info_volatile_1to1& c) : + one_to_one::one_to_one(const convo_info_volatile_1to1& c) : base{c.last_read, c.unread}, session_id{c.session_id, 66} {} void one_to_one::into(convo_info_volatile_1to1& c) const { @@ -54,13 +54,28 @@ namespace convo { c.unread = unread; } + group::group(std::string&& cgid) : id{std::move(cgid)} { + check_session_id(id, "03"); + } + group::group(std::string_view cgid) : id{cgid} { + check_session_id(id, "03"); + } + group::group(const convo_info_volatile_group& c) : + base{c.last_read, c.unread}, id{c.group_id, 66} {} + + void group::into(convo_info_volatile_group& c) const { + std::memcpy(c.group_id, id.c_str(), 67); + c.last_read = last_read; + c.unread = unread; + } + legacy_group::legacy_group(std::string&& cgid) : id{std::move(cgid)} { check_session_id(id); } legacy_group::legacy_group(std::string_view cgid) : id{cgid} { check_session_id(id); } - legacy_group::legacy_group(const struct convo_info_volatile_legacy_group& c) : + legacy_group::legacy_group(const convo_info_volatile_legacy_group& c) : base{c.last_read, c.unread}, id{c.group_id, 66} {} void legacy_group::into(convo_info_volatile_legacy_group& c) const { @@ -158,6 +173,25 @@ convo::community ConvoInfoVolatile::get_or_construct_community( return result; } +std::optional ConvoInfoVolatile::get_group(std::string_view pubkey_hex) const { + std::string pubkey = session_id_to_bytes(pubkey_hex, "03"); + + auto* info_dict = data["g"][pubkey].dict(); + if (!info_dict) + return std::nullopt; + + auto result = std::make_optional(std::string{pubkey_hex}); + result->load(*info_dict); + return result; +} + +convo::group ConvoInfoVolatile::get_or_construct_group(std::string_view pubkey_hex) const { + if (auto maybe = get_group(pubkey_hex)) + return *std::move(maybe); + + return convo::group{std::string{pubkey_hex}}; +} + std::optional ConvoInfoVolatile::get_legacy_group( std::string_view pubkey_hex) const { std::string pubkey = session_id_to_bytes(pubkey_hex); @@ -242,6 +276,11 @@ void ConvoInfoVolatile::set(const convo::community& c) { set_base(c, info); } +void ConvoInfoVolatile::set(const convo::group& c) { + auto info = data["g"][session_id_to_bytes(c.id, "03")]; + set_base(c, info); +} + void ConvoInfoVolatile::set(const convo::legacy_group& c) { auto info = data["C"][session_id_to_bytes(c.id)]; set_base(c, info); @@ -270,6 +309,9 @@ bool ConvoInfoVolatile::erase(const convo::community& c) { } return gone; } +bool ConvoInfoVolatile::erase(const convo::group& c) { + return erase_impl(data["g"][session_id_to_bytes(c.id, "03")]); +} bool ConvoInfoVolatile::erase(const convo::legacy_group& c) { return erase_impl(data["C"][session_id_to_bytes(c.id)]); } @@ -283,6 +325,9 @@ bool ConvoInfoVolatile::erase_1to1(std::string_view session_id) { bool ConvoInfoVolatile::erase_community(std::string_view base_url, std::string_view room) { return erase(convo::community{base_url, room}); } +bool ConvoInfoVolatile::erase_group(std::string_view id) { + return erase(convo::group{id}); +} bool ConvoInfoVolatile::erase_legacy_group(std::string_view id) { return erase(convo::legacy_group{id}); } @@ -309,6 +354,12 @@ size_t ConvoInfoVolatile::size_communities() const { return count; } +size_t ConvoInfoVolatile::size_groups() const { + if (auto* d = data["g"].dict()) + return d->size(); + return 0; +} + size_t ConvoInfoVolatile::size_legacy_groups() const { if (auto* d = data["C"].dict()) return d->size(); @@ -316,11 +367,11 @@ size_t ConvoInfoVolatile::size_legacy_groups() const { } size_t ConvoInfoVolatile::size() const { - return size_1to1() + size_communities() + size_legacy_groups(); + return size_1to1() + size_communities() + size_legacy_groups() + size_groups(); } ConvoInfoVolatile::iterator::iterator( - const DictFieldRoot& data, bool oneto1, bool communities, bool legacy_groups) { + const DictFieldRoot& data, bool oneto1, bool communities, bool groups, bool legacy_groups) { if (oneto1) if (auto* d = data["1"].dict()) { _it_11 = d->begin(); @@ -329,6 +380,11 @@ ConvoInfoVolatile::iterator::iterator( if (communities) if (auto* d = data["o"].dict()) _it_comm.emplace(d->begin(), d->end()); + if (groups) + if (auto* d = data["g"].dict()) { + _it_group = d->begin(); + _end_group = d->end(); + } if (legacy_groups) if (auto* d = data["C"].dict()) { _it_lgroup = d->begin(); diff --git a/src/config/encrypt.cpp b/src/config/encrypt.cpp index 0694fa09..8729131b 100644 --- a/src/config/encrypt.cpp +++ b/src/config/encrypt.cpp @@ -40,7 +40,8 @@ static std::array ma // We hash the key because we're using a deterministic nonce: the `key_base` value is expected // to be a long-term value for which nonce reuse (via hash collision) would be bad: by // incorporating the domain and message size we at least vary the key to further restrict the - // nonce reuse concern to messages of identical sizes and identical domain. + // nonce reuse concern so that you would not only have to hash collide but also have it happen + // on messages of identical sizes and identical domain. std::array key{0}; crypto_generichash_blake2b_state state; crypto_generichash_blake2b_init(&state, nullptr, 0, key.size()); diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp new file mode 100644 index 00000000..be29e581 --- /dev/null +++ b/src/config/groups/info.cpp @@ -0,0 +1,330 @@ +#include "session/config/groups/info.hpp" + +#include +#include + +#include + +#include "../internal.hpp" +#include "session/config/error.h" +#include "session/config/groups/info.h" +#include "session/export.h" +#include "session/types.hpp" +#include "session/util.hpp" + +using namespace std::literals; + +namespace session::config::groups { + +Info::Info( + ustring_view ed25519_pubkey, + std::optional ed25519_secretkey, + std::optional dumped) : + ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey}, + id{"03" + oxenc::to_hex(ed25519_pubkey.begin(), ed25519_pubkey.end())} {} + +std::optional Info::get_name() const { + if (auto* s = data["n"].string(); s && !s->empty()) + return *s; + return std::nullopt; +} + +void Info::set_name(std::string_view new_name) { + set_nonempty_str(data["n"], new_name); +} + +profile_pic Info::get_profile_pic() const { + profile_pic pic{}; + if (auto* url = data["p"].string(); url && !url->empty()) + pic.url = *url; + if (auto* key = data["q"].string(); key && key->size() == 32) + pic.key = {reinterpret_cast(key->data()), 32}; + return pic; +} + +void Info::set_profile_pic(std::string_view url, ustring_view key) { + set_pair_if(!url.empty() && key.size() == 32, data["p"], url, data["q"], key); +} + +void Info::set_profile_pic(profile_pic pic) { + set_profile_pic(pic.url, pic.key); +} + +std::optional Info::get_expiry_timer() const { + if (auto exp = data["E"].integer()) + return *exp * 1s; + return std::nullopt; +} + +void Info::set_expiry_timer(std::chrono::seconds expiration_timer) { + set_positive_int(data["E"], expiration_timer.count()); +} + +void Info::set_created(int64_t timestamp) { + set_positive_int(data["c"], timestamp); +} + +std::optional Info::get_created() const { + if (auto* ts = data["c"].integer()) + return *ts; + return std::nullopt; +} + +void Info::set_delete_before(int64_t timestamp) { + set_positive_int(data["d"], timestamp); +} + +std::optional Info::get_delete_before() const { + if (auto* ts = data["d"].integer()) + return *ts; + return std::nullopt; +} + +void Info::set_delete_attach_before(int64_t timestamp) { + set_positive_int(data["D"], timestamp); +} + +std::optional Info::get_delete_attach_before() const { + if (auto* ts = data["D"].integer()) + return *ts; + return std::nullopt; +} + +void Info::destroy_group() { + set_flag(data["!"], true); +} + +bool Info::is_destroyed() const { + if (auto* ts = data["!"].integer(); ts && *ts > 0) + return true; + return false; +} + +} // namespace session::config::groups + +using namespace session; +using namespace session::config; + +LIBSESSION_C_API int groups_info_init( + config_object** conf, + const unsigned char* ed25519_pubkey, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, + size_t dumplen, + char* error) { + return c_group_wrapper_init( + conf, ed25519_pubkey, ed25519_secretkey, dump, dumplen, error); +} + +/// API: groups_info/groups_info_get_name +/// +/// Returns a pointer to the currently-set name (null-terminated), or NULL if there is no name at +/// all. Should be copied right away as the pointer may not remain valid beyond other API calls. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `char*` -- Pointer to the currently-set name as a null-terminated string, or NULL if there is +/// no name +LIBSESSION_C_API const char* groups_info_get_name(const config_object* conf) { + if (auto s = unbox(conf)->get_name()) + return s->data(); + return nullptr; +} + +/// API: groups_info/groups_info_set_name +/// +/// Sets the group's name to the null-terminated C string. Returns 0 on success, non-zero on +/// error (and sets the config_object's error string). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `name` -- [in] Pointer to the name as a null-terminated C string +/// +/// Outputs: +/// - `int` -- Returns 0 on success, non-zero on error +LIBSESSION_C_API int groups_info_set_name(config_object* conf, const char* name) { + try { + unbox(conf)->set_name(name); + } catch (const std::exception& e) { + return set_error(conf, SESSION_ERR_BAD_VALUE, e); + } + return 0; +} + +/// API: groups_info/groups_info_get_pic +/// +/// Obtains the current profile pic. The pointers in the returned struct will be NULL if a profile +/// pic is not currently set, and otherwise should be copied right away (they will not be valid +/// beyond other API calls on this config object). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `user_profile_pic` -- Pointer to the currently-set profile pic (despite the "user_profile" in +/// the struct name, this is the group's profile pic). +LIBSESSION_C_API user_profile_pic groups_info_get_pic(const config_object* conf) { + user_profile_pic p; + if (auto pic = unbox(conf)->get_profile_pic(); pic) { + copy_c_str(p.url, pic.url); + std::memcpy(p.key, pic.key.data(), 32); + } else { + p.url[0] = 0; + } + return p; +} + +/// API: groups_info/groups_info_set_pic +/// +/// Sets a user profile +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `pic` -- [in] Pointer to the pic +/// +/// Outputs: +/// - `int` -- Returns 0 on success, non-zero on error +LIBSESSION_C_API int groups_info_set_pic(config_object* conf, user_profile_pic pic) { + std::string_view url{pic.url}; + ustring_view key; + if (!url.empty()) + key = {pic.key, 32}; + + try { + unbox(conf)->set_profile_pic(url, key); + } catch (const std::exception& e) { + return set_error(conf, SESSION_ERR_BAD_VALUE, e); + } + + return 0; +} + +/// API: groups_info/groups_info_get_expiry_timer +/// +/// Gets the group's message expiry timer (seconds). Returns 0 if not set. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int` -- Returns the expiry timer in seconds. Returns 0 if not set +LIBSESSION_C_API int groups_info_get_expiry_timer(const config_object* conf) { + if (auto t = unbox(conf)->get_expiry_timer(); t && *t > 0s) + return t->count(); + return 0; +} + +/// API: groups_info/groups_info_set_expiry_timer +/// +/// Sets the group's message expiry timer (seconds). Setting 0 (or negative) will clear the current +/// timer. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `expiry` -- [in] Integer of the expiry timer in seconds +LIBSESSION_C_API void groups_info_set_expiry_timer(config_object* conf, int expiry) { + unbox(conf)->set_expiry_timer(std::max(0, expiry) * 1s); +} + +/// API: groups_info/groups_info_get_created +/// +/// Returns the timestamp (unix time, in seconds) when the group was created. Returns 0 if unset. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` -- Unix timestamp when the group was created (if set by an admin). +LIBSESSION_C_API int64_t groups_info_get_created(const config_object* conf) { + return unbox(conf)->get_created().value_or(0); +} + +/// API: groups_info/groups_info_set_created +/// +/// Sets the creation time (unix timestamp, in seconds) when the group was created. Setting 0 +/// clears the value. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `ts` -- [in] the unix timestamp, or 0 to clear a current value. +LIBSESSION_C_API void groups_info_set_created(config_object* conf, int64_t ts) { + unbox(conf)->set_created(std::max(0, ts)); +} + +/// API: groups_info/groups_info_get_delete_before +/// +/// Returns the delete-before timestamp (unix time, in seconds); clients should deleted all messages +/// from the group with timestamps earlier than this value, if set. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` -- Unix timestamp before which messages should be deleted. Returns 0 if not set. +LIBSESSION_C_API int64_t groups_info_get_delete_before(const config_object* conf) { + return unbox(conf)->get_delete_before().value_or(0); +} + +/// API: groups_info/groups_info_set_delete_before +/// +/// Sets the delete-before time (unix timestamp, in seconds) before which messages should be delete. +/// Setting 0 clears the value. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `ts` -- [in] the unix timestamp, or 0 to clear a current value. +LIBSESSION_C_API void groups_info_set_delete_before(config_object* conf, int64_t ts) { + unbox(conf)->set_delete_before(std::max(0, ts)); +} + +/// API: groups_info/groups_info_get_attach_delete_before +/// +/// Returns the delete-before timestamp (unix time, in seconds) for attachments; clients should drop +/// all attachments from messages from the group with timestamps earlier than this value, if set. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `int64_t` -- Unix timestamp before which messages should be deleted. Returns 0 if not set. +LIBSESSION_C_API int64_t groups_info_get_attach_delete_before(const config_object* conf) { + return unbox(conf)->get_delete_attach_before().value_or(0); +} + +/// API: groups_info/groups_info_set_attach_delete_before +/// +/// Sets the delete-before time (unix timestamp, in seconds) for attachments; attachments should be +/// dropped from messages older than this value. Setting 0 clears the value. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `ts` -- [in] the unix timestamp, or 0 to clear a current value. +LIBSESSION_C_API void groups_info_set_attach_delete_before(config_object* conf, int64_t ts) { + unbox(conf)->set_delete_attach_before(std::max(0, ts)); +} + +/// API: groups_info/groups_info_is_destroyed(const config_object* conf); +/// +/// Returns true if this group has been marked destroyed by an admin, which indicates to a receiving +/// client that they should destroy it locally. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `true` if the group has been nuked, `false` otherwise. +LIBSESSION_C_API bool groups_info_is_destroyed(const config_object* conf) { + return unbox(conf)->is_destroyed(); +} + +/// API: groups_info/groups_info_destroy_group(const config_object* conf); +/// +/// Nukes a group from orbit. This is permanent (i.e. there is no removing this setting once set). +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +LIBSESSION_C_API void groups_info_destroy_group(config_object* conf) { + unbox(conf)->destroy_group(); +} diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp new file mode 100644 index 00000000..1f99909a --- /dev/null +++ b/src/config/groups/keys.cpp @@ -0,0 +1,1594 @@ +#include "session/config/groups/keys.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "../internal.hpp" +#include "session/config/groups/info.hpp" +#include "session/config/groups/keys.h" +#include "session/config/groups/members.hpp" +#include "session/xed25519.hpp" + +using namespace std::literals; + +namespace session::config::groups { + +static auto sys_time_from_ms(int64_t milliseconds_since_epoch) { + return std::chrono::system_clock::time_point{milliseconds_since_epoch * 1ms}; +} + +Keys::Keys( + ustring_view user_ed25519_secretkey, + ustring_view group_ed25519_pubkey, + std::optional group_ed25519_secretkey, + std::optional dumped, + Info& info, + Members& members) { + + if (sodium_init() == -1) + throw std::runtime_error{"libsodium initialization failed!"}; + + if (user_ed25519_secretkey.size() != 64) + throw std::invalid_argument{"Invalid Keys construction: invalid user ed25519 secret key"}; + if (group_ed25519_pubkey.size() != 32) + throw std::invalid_argument{"Invalid Keys construction: invalid group ed25519 public key"}; + if (group_ed25519_secretkey && group_ed25519_secretkey->size() != 64) + throw std::invalid_argument{"Invalid Keys construction: invalid group ed25519 secret key"}; + + init_sig_keys(group_ed25519_pubkey, group_ed25519_secretkey); + + user_ed25519_sk.load(user_ed25519_secretkey.data(), 64); + + if (dumped) { + load_dump(*dumped); + auto key_list = group_keys(); + members.replace_keys(key_list, /*dirty=*/false); + info.replace_keys(key_list, /*dirty=*/false); + } else if (admin()) { + rekey(info, members); + } +} + +bool Keys::needs_dump() const { + return needs_dump_; +} + +ustring Keys::dump() { + oxenc::bt_dict_producer d; + { + auto active = d.append_list("active"); + for (const auto& [gen, hashes] : active_msgs_) { + auto lst = active.append_list(); + lst.append(gen); + for (const auto& h : hashes) + lst.append(h); + } + } + + { + auto keys = d.append_list("keys"); + for (auto& k : keys_) { + auto ki = keys.append_dict(); + // NB: Keys must be in sorted order + ki.append("g", k.generation); + ki.append("k", from_unsigned_sv(k.key)); + ki.append( + "t", + std::chrono::duration_cast( + k.timestamp.time_since_epoch()) + .count()); + } + } + + if (!pending_key_config_.empty()) { + auto pending = d.append_dict("pending"); + // NB: Keys must be in sorted order + pending.append("c", from_unsigned_sv(pending_key_config_)); + pending.append("g", pending_gen_); + pending.append("k", from_unsigned_sv(pending_key_)); + } + + needs_dump_ = false; + return ustring{to_unsigned_sv(d.view())}; +} + +void Keys::load_dump(ustring_view dump) { + oxenc::bt_dict_consumer d{from_unsigned_sv(dump)}; + + if (d.skip_until("active")) { + auto active = d.consume_list_consumer(); + while (!active.is_finished()) { + auto lst = active.consume_list_consumer(); + auto& hashes = active_msgs_[lst.consume_integer()]; + while (!lst.is_finished()) + hashes.insert(lst.consume_string()); + } + } else { + throw config_value_error{"Invalid Keys dump: `active` not found"}; + } + + if (d.skip_until("keys")) { + auto keys = d.consume_list_consumer(); + while (!keys.is_finished()) { + auto kd = keys.consume_dict_consumer(); + auto& key = keys_.emplace_back(); + + if (!kd.skip_until("g")) + throw config_value_error{"Invalid Keys dump: found key without generation (g)"}; + key.generation = kd.consume_integer(); + + if (!kd.skip_until("k")) + throw config_value_error{"Invalid Keys dump: found key without key bytes (k)"}; + auto key_bytes = kd.consume_string_view(); + if (key_bytes.size() != key.key.size()) + throw config_value_error{ + "Invalid Keys dump: found key with invalid size (" + + std::to_string(key_bytes.size()) + ")"}; + std::memcpy(key.key.data(), key_bytes.data(), key.key.size()); + + if (!kd.skip_until("t")) + throw config_value_error{"Invalid Keys dump: found key without timestamp (t)"}; + key.timestamp = sys_time_from_ms(kd.consume_integer()); + + if (keys_.size() > 1 && *std::prev(keys_.end(), 2) >= key) + throw config_value_error{"Invalid Keys dump: keys are not in proper sorted order"}; + } + } else { + throw config_value_error{"Invalid Keys dump: `keys` not found"}; + } + + if (d.skip_until("pending")) { + auto pending = d.consume_dict_consumer(); + + if (!pending.skip_until("c")) + throw config_value_error{"Invalid Keys dump: found pending without config (c)"}; + auto pc = pending.consume_string_view(); + pending_key_config_.clear(); + pending_key_config_.resize(pc.size()); + std::memcpy(pending_key_config_.data(), pc.data(), pc.size()); + + if (!pending.skip_until("g")) + throw config_value_error{"Invalid Keys dump: found pending without generation (g)"}; + pending_gen_ = pending.consume_integer(); + + if (!pending.skip_until("k")) + throw config_value_error{"Invalid Keys dump: found pending without key (k)"}; + auto pk = pending.consume_string_view(); + if (pk.size() != pending_key_.size()) + throw config_value_error{ + "Invalid Keys dump: found pending key (k) with invalid size (" + + std::to_string(pk.size()) + ")"}; + std::memcpy(pending_key_.data(), pk.data(), pending_key_.size()); + } +} + +size_t Keys::size() const { + return keys_.size() + !pending_key_config_.empty(); +} + +std::vector Keys::group_keys() const { + std::vector ret; + ret.reserve(size()); + + if (!pending_key_config_.empty()) + ret.emplace_back(pending_key_.data(), 32); + + for (auto it = keys_.rbegin(); it != keys_.rend(); ++it) + ret.emplace_back(it->key.data(), 32); + + return ret; +} + +ustring_view Keys::group_enc_key() const { + if (!pending_key_config_.empty()) + return {pending_key_.data(), 32}; + if (keys_.empty()) + throw std::runtime_error{"group_enc_key failed: Keys object has no keys at all!"}; + + auto& key = keys_.back().key; + return {key.data(), key.size()}; +} + +static std::array compute_xpk(const unsigned char* ed25519_pk) { + std::array xpk; + if (0 != crypto_sign_ed25519_pk_to_curve25519(xpk.data(), ed25519_pk)) + throw std::runtime_error{ + "An error occured while attempting to convert Ed25519 pubkey to X25519; " + "is the pubkey valid?"}; + return xpk; +} + +static constexpr auto seed_hash_key = "SessionGroupKeySeed"sv; +static const ustring_view enc_key_hash_key = to_unsigned_sv("SessionGroupKeyGen"sv); +static constexpr auto enc_key_admin_hash_key = "SessionGroupKeyAdminKey"sv; +static const ustring_view enc_key_member_hash_key = to_unsigned_sv("SessionGroupKeyMemberKey"sv); +static const ustring_view junk_seed_hash_key = to_unsigned_sv("SessionGroupJunkMembers"sv); + +ustring_view Keys::rekey(Info& info, Members& members) { + if (!admin()) + throw std::logic_error{ + "Unable to issue a new group encryption key without the main group keys"}; + + // For members we calculate the outer encryption key as H(aB || A || B). But because we only + // have `B` (the session id) as an x25519 pubkey, we do this in x25519 space, which means we + // have to use the x25519 conversion of a/A rather than the group's ed25519 pubkey. + auto group_xpk = compute_xpk(_sign_pk->data()); + + sodium_cleared> group_xsk; + crypto_sign_ed25519_sk_to_curve25519(group_xsk.data(), _sign_sk.data()); + + // We need quasi-randomness: full secure random would be great, except that different admins + // encrypting for the same update would always create different keys, but we want it + // deterministic so that that doesn't happen. + // + // So we use: + // + // H1(member0 || member1 || ... || memberN || generation || H2(group_secret_key)) + // + // where: + // - H1(.) = 56-byte BLAKE2b keyed hash with key "SessionGroupKeyGen" + // - memberI is each members full session ID, expressed in hex (66 chars), in sorted order (note + // that this includes *all* members, not only non-admins). + // - generation is the new generation value, expressed as a base 10 string (e.g. "123") + // - H2(.) = 32-byte BLAKE2b keyed hash of the sodium group secret key seed (just the 32 byte, + // not the full 64 byte with the pubkey in the second half), key "SessionGroupKeySeed" + // + // And then from this 56-byte hash we use the first 32 bytes as the new group key and the last + // 24 bytes as the encryption nonce. + // + // If we have to append junk member keys (for padding) them we reuse H1 with H(H1 || a) to + // produce a sodium pseudo-RNG seed for deterministic junk value generation. + // + // To encrypt this we have one key encrypted for all admins, plus one encryption per non-admin + // member. For admins we encrypt using a 32-byte blake2b keyed hash of the group secret key + // seed, just like H2, but with key "SessionGroupKeyAdminKey". + + std::array h2 = seed_hash(seed_hash_key); + + std::array h1; + + crypto_generichash_blake2b_state st; + + crypto_generichash_blake2b_init( + &st, enc_key_hash_key.data(), enc_key_hash_key.size(), h1.size()); + for (const auto& m : members) + crypto_generichash_blake2b_update( + &st, to_unsigned(m.session_id.data()), m.session_id.size()); + + auto gen = keys_.empty() ? 0 : keys_.back().generation + 1; + auto gen_str = std::to_string(gen); + crypto_generichash_blake2b_update(&st, to_unsigned(gen_str.data()), gen_str.size()); + + crypto_generichash_blake2b_update(&st, h2.data(), 32); + + crypto_generichash_blake2b_final(&st, h1.data(), h1.size()); + + ustring_view enc_key{h1.data(), 32}; + ustring_view nonce{h1.data() + 32, 24}; + + oxenc::bt_dict_producer d{}; + + d.append("#", from_unsigned_sv(nonce)); + + static_assert(crypto_aead_xchacha20poly1305_ietf_KEYBYTES == 32); + static_assert(crypto_aead_xchacha20poly1305_ietf_ABYTES == 16); + std::array< + unsigned char, + crypto_aead_xchacha20poly1305_ietf_KEYBYTES + crypto_aead_xchacha20poly1305_ietf_ABYTES> + encrypted; + std::string_view enc_sv{reinterpret_cast(encrypted.data()), encrypted.size()}; + + // Shared key for admins + auto member_k = seed_hash(enc_key_admin_hash_key); + static_assert(member_k.size() == crypto_aead_xchacha20poly1305_ietf_KEYBYTES); + crypto_aead_xchacha20poly1305_ietf_encrypt( + encrypted.data(), + nullptr, + enc_key.data(), + enc_key.size(), + nullptr, + 0, + nullptr, + nonce.data(), + member_k.data()); + + d.append("G", gen); + d.append("K", enc_sv); + + { + auto member_keys = d.append_list("k"); + int member_count = 0; + for (const auto& m : members) { + auto m_xpk = session_id_pk(m.session_id); + // Calculate the encryption key: H(aB || A || B) + if (0 != crypto_scalarmult_curve25519(member_k.data(), group_xsk.data(), m_xpk.data())) + continue; // The scalarmult failed; maybe a bad session id? + + crypto_generichash_blake2b_init( + &st, + enc_key_member_hash_key.data(), + enc_key_member_hash_key.size(), + member_k.size()); + crypto_generichash_blake2b_update(&st, member_k.data(), member_k.size()); + crypto_generichash_blake2b_update(&st, group_xpk.data(), group_xpk.size()); + crypto_generichash_blake2b_update(&st, m_xpk.data(), m_xpk.size()); + crypto_generichash_blake2b_final(&st, member_k.data(), member_k.size()); + + crypto_aead_xchacha20poly1305_ietf_encrypt( + encrypted.data(), + nullptr, + enc_key.data(), + enc_key.size(), + nullptr, + 0, + nullptr, + nonce.data(), + member_k.data()); + + member_keys.append(enc_sv); + member_count++; + } + + // Pad it out with junk entries to the next MESSAGE_KEY_MULTIPLE + if (member_count % MESSAGE_KEY_MULTIPLE) { + int n_junk = MESSAGE_KEY_MULTIPLE - (member_count % MESSAGE_KEY_MULTIPLE); + std::vector junk_data; + junk_data.resize(encrypted.size() * n_junk); + + std::array rng_seed; + crypto_generichash_blake2b_init( + &st, junk_seed_hash_key.data(), junk_seed_hash_key.size(), rng_seed.size()); + crypto_generichash_blake2b_update(&st, h1.data(), h1.size()); + crypto_generichash_blake2b_update(&st, _sign_sk.data(), _sign_sk.size()); + crypto_generichash_blake2b_final(&st, rng_seed.data(), rng_seed.size()); + + randombytes_buf_deterministic(junk_data.data(), junk_data.size(), rng_seed.data()); + std::string_view junk_view{ + reinterpret_cast(junk_data.data()), junk_data.size()}; + while (!junk_view.empty()) { + member_keys.append(junk_view.substr(0, encrypted.size())); + junk_view.remove_prefix(encrypted.size()); + } + } + } + + // Finally we sign the message at put it as the ~ key (which is 0x7f, and thus comes later than + // any other ascii key). + auto to_sign = to_unsigned_sv(d.view()); + // The view contains the trailing "e", but we don't sign it (we are going to append the + // signature there instead): + to_sign.remove_suffix(1); + auto sig = signer_(to_sign); + if (sig.size() != 64) + throw std::logic_error{"Invalid signature: signing function did not return 64 bytes"}; + + d.append("~", from_unsigned_sv(sig)); + + // Load this key/config/gen into our pending variables + pending_gen_ = gen; + std::memcpy(pending_key_.data(), enc_key.data(), pending_key_.size()); + pending_key_config_.clear(); + auto conf = d.view(); + pending_key_config_.resize(conf.size()); + std::memcpy(pending_key_config_.data(), conf.data(), conf.size()); + + auto new_key_list = group_keys(); + // We want to dirty the member/info lists so that they get re-encrypted and re-pushed with the + // new key: + members.replace_keys(new_key_list, /*dirty=*/true); + info.replace_keys(new_key_list, /*dirty=*/true); + + needs_dump_ = true; + + return ustring_view{pending_key_config_.data(), pending_key_config_.size()}; +} + +ustring Keys::key_supplement(const std::vector& sids) const { + if (!admin()) + throw std::logic_error{ + "Unable to issue supplemental group encryption keys without the main group keys"}; + + if (keys_.empty()) + throw std::logic_error{ + "Unable to create supplemental keys: this object has no keys at all"}; + + // For members we calculate the outer encryption key as H(aB || A || B). But because we only + // have `B` (the session id) as an x25519 pubkey, we do this in x25519 space, which means we + // have to use the x25519 conversion of a/A rather than the group's ed25519 pubkey. + auto group_xpk = compute_xpk(_sign_pk->data()); + + sodium_cleared> group_xsk; + crypto_sign_ed25519_sk_to_curve25519(group_xsk.data(), _sign_sk.data()); + + // We need quasi-randomness here for the nonce: full secure random would be great, except that + // different admins encrypting for the same update would always create different keys, but we + // want it deterministic so that that doesn't happen. + // + // So we use a nonce of: + // + // H1(member0 || member1 || ... || memberN || keysdata || H2(group_secret_key)) + // + // where: + // - H1(.) = 24-byte BLAKE2b keyed hash with key "SessionGroupKeyGen" + // - memberI is the full session ID of each member included in this key update, expressed in hex + // (66 chars), in sorted order. + // - keysdata is the unencrypted inner value that we are encrypting for each supplemental member + // - H2(.) = 32-byte BLAKE2b keyed hash of the sodium group secret key seed (just the 32 byte, + // not the full 64 byte with the pubkey in the second half), key "SessionGroupKeySeed" + + std::string supp_keys; + { + oxenc::bt_list_producer supp; + for (auto& ki : keys_) { + auto d = supp.append_dict(); + d.append("g", ki.generation); + d.append("k", from_unsigned_sv(ki.key)); + d.append( + "t", + std::chrono::duration_cast( + ki.timestamp.time_since_epoch()) + .count()); + } + supp_keys = std::move(supp).str(); + } + + std::array h1; + + crypto_generichash_blake2b_state st; + + crypto_generichash_blake2b_init( + &st, enc_key_hash_key.data(), enc_key_hash_key.size(), h1.size()); + + for (const auto& sid : sids) + crypto_generichash_blake2b_update(&st, to_unsigned(sid.data()), sid.size()); + + crypto_generichash_blake2b_update(&st, to_unsigned(supp_keys.data()), supp_keys.size()); + + std::array h2 = seed_hash(seed_hash_key); + crypto_generichash_blake2b_update(&st, h2.data(), h2.size()); + + crypto_generichash_blake2b_final(&st, h1.data(), h1.size()); + + ustring_view nonce{h1.data(), h1.size()}; + + oxenc::bt_dict_producer d{}; + + d.append("#", from_unsigned_sv(nonce)); + + { + auto list = d.append_list("+"); + std::vector encrypted; + encrypted.resize(supp_keys.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES); + + size_t member_count = 0; + + for (auto& sid : sids) { + auto m_xpk = session_id_pk(sid); + + // Calculate the encryption key: H(aB || A || B) + std::array member_k; + if (0 != crypto_scalarmult_curve25519(member_k.data(), group_xsk.data(), m_xpk.data())) + continue; // The scalarmult failed; maybe a bad session id? + + crypto_generichash_blake2b_init( + &st, + enc_key_member_hash_key.data(), + enc_key_member_hash_key.size(), + member_k.size()); + crypto_generichash_blake2b_update(&st, member_k.data(), member_k.size()); + crypto_generichash_blake2b_update(&st, group_xpk.data(), group_xpk.size()); + crypto_generichash_blake2b_update(&st, m_xpk.data(), m_xpk.size()); + crypto_generichash_blake2b_final(&st, member_k.data(), member_k.size()); + + crypto_aead_xchacha20poly1305_ietf_encrypt( + encrypted.data(), + nullptr, + to_unsigned(supp_keys.data()), + supp_keys.size(), + nullptr, + 0, + nullptr, + nonce.data(), + member_k.data()); + + list.append(from_unsigned_sv(encrypted)); + + member_count++; + } + + if (member_count == 0) + throw std::runtime_error{ + "Unable to construct supplemental messages: invalid session ids given"}; + } + + d.append("G", keys_.back().generation); + + // Finally we sign the message at put it as the ~ key (which is 0x7f, and thus comes later than + // any other ascii key). + auto to_sign = to_unsigned_sv(d.view()); + // The view contains the trailing "e", but we don't sign it (we are going to append the + // signature there instead): + to_sign.remove_suffix(1); + auto sig = signer_(to_sign); + if (sig.size() != 64) + throw std::logic_error{"Invalid signature: signing function did not return 64 bytes"}; + + d.append("~", from_unsigned_sv(sig)); + + return ustring{to_unsigned_sv(d.view())}; +} + +// Blinding factor for subaccounts: H(sessionid || groupid) mod L, where H is 64-byte blake2b, using +// a hash key derived from the group's seed. +std::array Keys::subaccount_blind_factor( + const std::array& session_xpk) const { + + auto mask = seed_hash("SessionGroupSubaccountMask"); + static_assert(mask.size() == crypto_generichash_blake2b_KEYBYTES); + + std::array h; + crypto_generichash_blake2b_state st; + crypto_generichash_blake2b_init(&st, mask.data(), mask.size(), h.size()); + crypto_generichash_blake2b_update(&st, to_unsigned("\x05"), 1); + crypto_generichash_blake2b_update(&st, session_xpk.data(), session_xpk.size()); + crypto_generichash_blake2b_update(&st, to_unsigned("\x03"), 1); + crypto_generichash_blake2b_update(&st, _sign_pk->data(), _sign_pk->size()); + crypto_generichash_blake2b_final(&st, h.data(), h.size()); + + std::array out; + crypto_core_ed25519_scalar_reduce(out.data(), h.data()); + return out; +} + +namespace { + + // These constants are defined and explains in more detail in oxen-storage-server + constexpr unsigned char SUBACC_FLAG_READ = 0b0001; + constexpr unsigned char SUBACC_FLAG_WRITE = 0b0010; + constexpr unsigned char SUBACC_FLAG_DEL = 0b0100; + constexpr unsigned char SUBACC_FLAG_ANY_PREFIX = 0b1000; + + constexpr unsigned char subacc_flags(bool write, bool del) { + return SUBACC_FLAG_READ | (write ? SUBACC_FLAG_WRITE : 0) | (del ? SUBACC_FLAG_DEL : 0); + } + +} // namespace + +ustring Keys::swarm_make_subaccount(std::string_view session_id, bool write, bool del) const { + if (!admin()) + throw std::logic_error{"Cannot make subaccount signature: admin keys required"}; + + // This gets a wee bit complicated because we only have a session_id, but we really need an + // Ed25519 pubkey. So we do the signal-style XEd25519 thing here where we start with the + // positive alternative behind their x25519 pubkey and work from there. This means, + // unfortunately, that making a signature needs to muck around since this is the proper public + // only half the time. + + // Terminology/variables (a/A indicates private/public keys) + // - s/S are the Ed25519 underlying Session keys (neither is observed in this context) + // - x/X are the X25519 conversions of s/S (x, similarly, is not observed, but X is: it's in the + // session_id). + // - T = |S|, i.e. the positive of the two alternatives we get from inverting the Ed -> X + // pubkey. + // - c/C is the group's Ed25519 + // - k is the blinding factor, which is: H(\x05...[sessionid]\x03...[groupid], key=M) mod L, + // where: H is 64-byte blake2b; M is `subaccount_blind_factor` (see above). + // - p is the account network prefix (03) + // - f are the flag bits, determined by `write` and `del` arguments + + auto X = session_id_pk(session_id); + auto& c = _sign_sk; + auto& C = *_sign_pk; + + auto k = subaccount_blind_factor(X); + + // T = |S| + auto T = xed25519::pubkey(ustring_view{X.data(), X.size()}); + + // kT is the user's Ed25519 blinded pubkey: + std::array kT; + + if (0 != crypto_scalarmult_ed25519_noclamp(kT.data(), k.data(), T.data())) + throw std::runtime_error{"scalarmult failed: perhaps an invalid session id?"}; + + ustring out; + out.resize(4 + 32 + 64); + out[0] = 0x03; // network prefix + out[1] = subacc_flags(write, del); // permission flags + out[2] = 0; // reserved 1 + out[3] = 0; // reserved 2 + // The next 32 bytes are k (NOT kT; the user can go make kT themselves): + std::memcpy(&out[4], k.data(), k.size()); + + // And then finally, we append a group signature of: p || f || 0 || 0 || kT + std::array to_sign; + std::memcpy(&to_sign[0], out.data(), 4); // first 4 bytes are the same as out + std::memcpy(&to_sign[4], kT.data(), 32); // but then we have kT instead of k + crypto_sign_ed25519_detached(&out[36], nullptr, to_sign.data(), to_sign.size(), c.data()); + + return out; +} + +ustring Keys::swarm_subaccount_token(std::string_view session_id, bool write, bool del) const { + if (!admin()) + throw std::logic_error{"Cannot make subaccount signature: admin keys required"}; + + // Similar to the above, but we only care about getting flags || kT + + auto X = session_id_pk(session_id); + auto& c = _sign_sk; + auto& C = *_sign_pk; + + auto k = subaccount_blind_factor(X); + + // T = |S| + auto T = xed25519::pubkey(ustring_view{X.data(), X.size()}); + + ustring out; + out.resize(4 + 32); + out[0] = 0x03; // network prefix + out[1] = subacc_flags(write, del); // permission flags + out[2] = 0; // reserved 1 + out[3] = 0; // reserved 2 + if (0 != crypto_scalarmult_ed25519_noclamp(&out[4], k.data(), T.data())) + throw std::runtime_error{"scalarmult failed: perhaps an invalid session id?"}; + return out; +} + +Keys::swarm_auth Keys::swarm_subaccount_sign( + ustring_view msg, ustring_view sign_val, bool binary) const { + if (sign_val.size() != 100) + throw std::logic_error{"Invalid signing value: size is wrong"}; + + if (!_sign_pk) + throw std::logic_error{"Unable to verify: group pubkey is not set (!?)"}; + + Keys::swarm_auth result; + auto& [token, sub_sig, sig] = result; + + // (see above for variable/crypto notation) + + ustring_view k = sign_val.substr(4, 32); + + // our token is the first 4 bytes of `sign_val` (flags, etc.), followed by kT which we have to + // compute: + token.resize(36); + std::memcpy(token.data(), sign_val.data(), 4); + + // T = |S|, i.e. we have to clear the sign bit from our pubkey + std::array T; + crypto_sign_ed25519_sk_to_pk(T.data(), user_ed25519_sk.data()); + bool neg = T[31] & 0x80; + T[31] &= 0x7f; + if (0 != crypto_scalarmult_ed25519_noclamp(to_unsigned(token.data() + 4), k.data(), T.data())) + throw std::runtime_error{"scalarmult failed: perhaps an invalid session id or seed?"}; + + // token is now set: flags || kT + ustring_view kT{to_unsigned(token.data() + 4), 32}; + + // sub_sig is just the admin's signature, sitting at the end of sign_val (after 4f || k): + sub_sig = from_unsigned_sv(sign_val.substr(36)); + + // Our signing private scalar is kt, where t = ±s according to whether we had to negate S to + // make T + std::array s, s_neg; + crypto_sign_ed25519_sk_to_curve25519(s.data(), user_ed25519_sk.data()); + crypto_core_ed25519_scalar_negate(s_neg.data(), s.data()); + xed25519::constant_time_conditional_assign(s, s_neg, neg); + + auto& t = s; + + std::array kt; + crypto_core_ed25519_scalar_mul(kt.data(), k.data(), t.data()); + + // We now have kt, kT, our privkey/public. (Note that kt is a scalar, not a seed). + + // We're going to get *close* to standard Ed25519 here, except: + // + // where Ed25519 uses + // + // r = SHA512(SHA512(seed)[32:64] || M) mod L + // + // we're instead going to use: + // + // r = H64(H32(seed, key="SubaccountSeed") || kT || M, key="SubaccountSig") mod L + // + // where H64 and H32 are BLAKE2b keyed hashes of 64 and 32 bytes, respectively, thus + // differentiating the signature for both different seeds and different blinded kT pubkeys. + // + // From there, we follow the standard EdDSA construction: + // + // R = rB + // S = r + H(R || kT || M) kt (mod L) + // + // (using the standard Ed25519 SHA-512 here for H) + + constexpr auto seed_hash_key = "SubaccountSeed"sv; + constexpr auto r_hash_key = "SubaccountSig"sv; + std::array hseed; + crypto_generichash_blake2b( + hseed.data(), + hseed.size(), + user_ed25519_sk.data(), + 32, + reinterpret_cast(seed_hash_key.data()), + seed_hash_key.size()); + + std::array tmp; + crypto_generichash_blake2b_state st; + crypto_generichash_blake2b_init( + &st, + reinterpret_cast(r_hash_key.data()), + r_hash_key.size(), + tmp.size()); + crypto_generichash_blake2b_update(&st, hseed.data(), hseed.size()); + crypto_generichash_blake2b_update(&st, kT.data(), kT.size()); + crypto_generichash_blake2b_update(&st, msg.data(), msg.size()); + crypto_generichash_blake2b_final(&st, tmp.data(), tmp.size()); + + std::array r; + crypto_core_ed25519_scalar_reduce(r.data(), tmp.data()); + + sig.resize(64); + unsigned char* R = to_unsigned(sig.data()); + unsigned char* S = to_unsigned(sig.data() + 32); + // R = rB + crypto_scalarmult_ed25519_base_noclamp(R, r.data()); + + // Compute S = r + H(R || A || M) a mod L: (with A = kT, a = kt) + crypto_hash_sha512_state shast; + crypto_hash_sha512_init(&shast); + crypto_hash_sha512_update(&shast, R, 32); + crypto_hash_sha512_update(&shast, kT.data(), kT.size()); // A = pubkey, that is, kT + crypto_hash_sha512_update(&shast, msg.data(), msg.size()); + std::array hram; + crypto_hash_sha512_final(&shast, hram.data()); // S = H(R||A||M) + crypto_core_ed25519_scalar_reduce(S, hram.data()); // S %= L + crypto_core_ed25519_scalar_mul(S, S, kt.data()); // S *= a + crypto_core_ed25519_scalar_add(S, S, r.data()); // S += r + + // sig is now set to the desired R || S, with S = r + H(R || A || M)a (all mod L) + + if (!binary) { + token = oxenc::to_base64(token); + sub_sig = oxenc::to_base64(sub_sig); + sig = oxenc::to_base64(sig); + } + + return result; +} + +bool Keys::swarm_verify_subaccount(ustring_view sign_val, bool write, bool del) const { + if (!_sign_pk) + return false; + return swarm_verify_subaccount( + "03" + oxenc::to_hex(_sign_pk->begin(), _sign_pk->end()), + ustring_view{user_ed25519_sk.data(), user_ed25519_sk.size()}, + sign_val, + write, + del); +} + +bool Keys::swarm_verify_subaccount( + std::string group_id, + ustring_view user_ed_sk, + ustring_view sign_val, + bool write, + bool del) { + auto group_pk = session_id_pk(group_id, "03"); + + if (sign_val.size() != 100) + return false; + + ustring_view prefix = sign_val.substr(0, 4); + if (prefix[0] != 0x03 && !(prefix[1] & SUBACC_FLAG_ANY_PREFIX)) + return false; // require either 03 prefix match, or the "any prefix" flag + + if (!(prefix[1] & SUBACC_FLAG_READ)) + return false; // missing the read flag + + if (write && !(prefix[1] & SUBACC_FLAG_WRITE)) + return false; // we require write, but it isn't set + // + if (del && !(prefix[1] & SUBACC_FLAG_DEL)) + return false; // we require delete, but it isn't set + + ustring_view k = sign_val.substr(4, 32); + ustring_view sig = sign_val.substr(36); + + // T = |S|, i.e. we have to clear the sign bit from our pubkey + std::array T; + crypto_sign_ed25519_sk_to_pk(T.data(), user_ed_sk.data()); + T[31] &= 0x7f; + + // Compute kT, then reconstruct the `flags || kT` value the admin should have provided a + // signature for + std::array kT; + if (0 != crypto_scalarmult_ed25519_noclamp(kT.data(), k.data(), T.data())) + throw std::runtime_error{"scalarmult failed: perhaps an invalid session id or seed?"}; + + std::array to_verify; + std::memcpy(&to_verify[0], sign_val.data(), 4); // prefix, flags, 2x future use bytes + std::memcpy(&to_verify[4], kT.data(), 32); + + // Verify it! + return 0 == crypto_sign_ed25519_verify_detached( + sig.data(), to_verify.data(), to_verify.size(), group_pk.data()); +} + +std::optional Keys::pending_config() const { + if (pending_key_config_.empty()) + return std::nullopt; + return ustring_view{pending_key_config_.data(), pending_key_config_.size()}; +} + +void Keys::insert_key(std::string_view msg_hash, key_info&& new_key) { + // Find all keys with the same generation and see if our key is in there (that is: we are + // deliberately ignoring timestamp so that we don't add the same key with slight timestamp + // variations). + const auto [gen_begin, gen_end] = + std::equal_range(keys_.begin(), keys_.end(), new_key, [](const auto& a, const auto& b) { + return a.generation < b.generation; + }); + for (auto it = gen_begin; it != gen_end; ++it) + if (it->key == new_key.key) { + active_msgs_[new_key.generation].emplace(msg_hash); + return; + } + + auto it = std::lower_bound(keys_.begin(), keys_.end(), new_key); + + if (keys_.size() >= 2 && it == keys_.begin() && new_key.generation < keys_.front().generation && + keys_.front().timestamp + KEY_EXPIRY < keys_.back().timestamp) + // The new one is older than the front one, and the front one is already more than + // KEY_EXPIRY before the last one, so this new one is stale. + return; + + active_msgs_[new_key.generation].emplace(msg_hash); + keys_.insert(it, std::move(new_key)); + remove_expired(); + needs_dump_ = true; +} + +bool Keys::load_key_message( + std::string_view hash, + ustring_view data, + int64_t timestamp_ms, + Info& info, + Members& members) { + + oxenc::bt_dict_consumer d{from_unsigned_sv(data)}; + + if (!_sign_pk || !verifier_) + throw std::logic_error{"Group pubkey is not set; unable to load config message"}; + + auto group_xpk = compute_xpk(_sign_pk->data()); + + if (!d.skip_until("#")) + throw config_value_error{"Key message has no nonce"}; + auto nonce = to_unsigned_sv(d.consume_string_view()); + + sodium_vector new_keys; + std::optional max_gen; // If set then associate the message with this generation + // value, even if we didn't find a key for us. + + sodium_cleared> member_dec_key; + if (!admin()) { + sodium_cleared> member_xsk; + crypto_sign_ed25519_sk_to_curve25519(member_xsk.data(), user_ed25519_sk.data()); + auto member_xpk = compute_xpk(user_ed25519_sk.data() + 32); + + // Calculate the encryption key: H(bA || A || B) [A = group, B = member] + if (0 != crypto_scalarmult_curve25519( + member_dec_key.data(), member_xsk.data(), group_xpk.data())) + throw std::runtime_error{ + "Unable to compute member decryption key; invalid group or member keys?"}; + + crypto_generichash_blake2b_state st; + crypto_generichash_blake2b_init( + &st, + enc_key_member_hash_key.data(), + enc_key_member_hash_key.size(), + member_dec_key.size()); + crypto_generichash_blake2b_update(&st, member_dec_key.data(), member_dec_key.size()); + crypto_generichash_blake2b_update(&st, group_xpk.data(), group_xpk.size()); + crypto_generichash_blake2b_update(&st, member_xpk.data(), member_xpk.size()); + crypto_generichash_blake2b_final(&st, member_dec_key.data(), member_dec_key.size()); + } + + if (d.skip_until("+")) { + // This is a supplemental keys message, not a full one + auto supp = d.consume_list_consumer(); + + while (!supp.is_finished()) { + + int member_key_count = 0; + for (; !supp.is_finished(); member_key_count++) { + auto encrypted = to_unsigned_sv(supp.consume_string_view()); + // Expect an encrypted message like this, which has a minimum valid size (if both g + // and t are 0 for some reason) of: + // d -- 1 + // 1:k 32:... -- +38 + // 1:g i1e -- + 6 + // 1:t iXe -- + 6 + // e + 1 + // --- + // 52 + if (encrypted.size() < 52 + crypto_aead_xchacha20poly1305_ietf_ABYTES) + throw config_value_error{ + "Supplemental key message has invalid key info size at index " + + std::to_string(member_key_count)}; + + if (!new_keys.empty() || admin()) + continue; // Keep parsing, just to ensure validity of the whole message + + ustring plaintext; + plaintext.resize(encrypted.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES); + + if (0 == crypto_aead_xchacha20poly1305_ietf_decrypt( + plaintext.data(), + nullptr, + nullptr, + encrypted.data(), + encrypted.size(), + nullptr, + 0, + nonce.data(), + member_dec_key.data())) { + // Decryption success, we found our key list! + + oxenc::bt_list_consumer key_infos{from_unsigned_sv(plaintext)}; + while (!key_infos.is_finished()) { + auto& new_key = new_keys.emplace_back(); + auto keyinf = key_infos.consume_dict_consumer(); + if (!keyinf.skip_until("g")) + throw config_value_error{ + "Invalid supplemental key message: no `g` generation"}; + new_key.generation = keyinf.consume_integer(); + if (!keyinf.skip_until("k")) + throw config_value_error{ + "Invalid supplemental key message: no `k` key data"}; + auto key_val = keyinf.consume_string_view(); + if (key_val.size() != 32) + throw config_value_error{ + "Invalid supplemental key message: `k` key has wrong size"}; + std::memcpy(new_key.key.data(), key_val.data(), 32); + if (!keyinf.skip_until("t")) + throw config_value_error{ + "Invalid supplemental key message: no `t` timestamp"}; + new_key.timestamp = sys_time_from_ms(keyinf.consume_integer()); + } + } + } + } + + if (!d.skip_until("G")) + throw config_value_error{ + "Supplemental key message missing required max generation field (G)"}; + max_gen = d.consume_integer(); + + } else { // Full message (i.e. not supplemental) + + bool found_key = false; + auto& new_key = new_keys.emplace_back(); + new_key.timestamp = sys_time_from_ms(timestamp_ms); + + if (!d.skip_until("G")) + throw config_value_error{"Key message missing required generation (G) field"}; + + new_key.generation = d.consume_integer(); + if (new_key.generation < 0) + throw config_value_error{"Key message contains invalid negative generation"}; + + if (!d.skip_until("K")) + throw config_value_error{ + "Non-supplemental key message is missing required admin key (K)"}; + + auto admin_key = to_unsigned_sv(d.consume_string_view()); + if (admin_key.size() != 32 + crypto_aead_xchacha20poly1305_ietf_ABYTES) + throw config_value_error{"Key message has invalid admin key length"}; + + if (admin()) { + auto k = seed_hash(enc_key_admin_hash_key); + + if (0 != crypto_aead_xchacha20poly1305_ietf_decrypt( + new_key.key.data(), + nullptr, + nullptr, + admin_key.data(), + admin_key.size(), + nullptr, + 0, + nonce.data(), + k.data())) + throw config_value_error{"Failed to decrypt admin key from key message"}; + + found_key = true; + } + + // Even if we're already found a key we still parse these, so that admins and all users have + // the same error conditions for rejecting an invalid config message. + if (!d.skip_until("k")) + throw config_value_error{"Config is missing member keys list (k)"}; + auto key_list = d.consume_list_consumer(); + + int member_key_count = 0; + for (; !key_list.is_finished(); member_key_count++) { + auto member_key = to_unsigned_sv(key_list.consume_string_view()); + if (member_key.size() != 32 + crypto_aead_xchacha20poly1305_ietf_ABYTES) + throw config_value_error{ + "Key message has invalid member key length at index " + + std::to_string(member_key_count)}; + + if (found_key) + continue; + + if (0 == crypto_aead_xchacha20poly1305_ietf_decrypt( + new_key.key.data(), + nullptr, + nullptr, + member_key.data(), + member_key.size(), + nullptr, + 0, + nonce.data(), + member_dec_key.data())) { + // Decryption success, we found our key! + found_key = true; + } + } + + if (member_key_count % MESSAGE_KEY_MULTIPLE != 0) + throw config_value_error{"Member key list has wrong size (missing junk key padding?)"}; + + if (!found_key) { + max_gen = new_key.generation; + new_keys.pop_back(); + } + } + + verify_config_sig(d, data, verifier_); + + // If this is our pending config or this has a later generation than our pending config then + // drop our pending status. + if (admin() && !new_keys.empty() && !pending_key_config_.empty() && + (new_keys[0].generation > pending_gen_ || new_keys[0].key == pending_key_)) { + pending_key_config_.clear(); + needs_dump_ = true; + } + + if (!new_keys.empty()) { + for (auto& k : new_keys) + insert_key(hash, std::move(k)); + + auto new_key_list = group_keys(); + members.replace_keys(new_key_list, /*dirty=*/false); + info.replace_keys(new_key_list, /*dirty=*/false); + return true; + } else if (max_gen) { + active_msgs_[*max_gen].emplace(hash); + remove_expired(); + needs_dump_ = true; + } + + return false; +} + +std::unordered_set Keys::current_hashes() const { + std::unordered_set hashes; + for (const auto& [g, hash] : active_msgs_) + hashes.insert(hash.begin(), hash.end()); + return hashes; +} + +void Keys::remove_expired() { + if (keys_.size() >= 2) { + // When we're done, this will point at the first element we want to keep (i.e. we want to + // remove everything in `[ begin(), lapsed_end )`). + auto lapsed_end = keys_.begin(); + + for (auto it = keys_.begin(); it != keys_.end();) { + // Advance `it` if the next element is an alternate key (with a later timestamp) from + // the same generation. When we finish this little loop, `it` is the last element of + // this generation and `it2` is the first element of the next generation. + auto it2 = std::next(it); + while (it2 != keys_.end() && it2->generation == it->generation) + it = it2++; + if (it2 == keys_.end()) + break; + + // it2 points at the lowest-timestamp value of the next-largest generation: if there is + // something more than 30 days newer than it2, then that tells us that `it`'s generation + // is no longer needed since a newer generation passed it more than 30 days ago. (We + // actually use 60 days for paranoid safety, but the logic is the same). + // + // NB: We don't trust the local system clock here (and the `timestamp` values are + // swarm-provided), because devices are notoriously imprecise, which means that since we + // only invalidate keys when new keys come in, we can hold onto one obsolete generation + // indefinitely (but this is a tiny overhead and not worth trying to build a + // system-clock-is-broken workaround to avoid). + if (it2->timestamp + KEY_EXPIRY < keys_.back().timestamp) + lapsed_end = it2; + else + break; + it = it2; + } + + if (lapsed_end != keys_.begin()) + keys_.erase(keys_.begin(), lapsed_end); + } + + // Drop any active message hashes for generations we are no longer keeping around + if (!keys_.empty()) + active_msgs_.erase( + active_msgs_.begin(), active_msgs_.lower_bound(keys_.front().generation)); + else + // Keys is empty, which means we aren't keep *any* keys around (or they are all invalid or + // something) and so it isn't really up to us to keep them alive, since that's a history of + // the group we apparently don't have access to. + active_msgs_.clear(); +} + +bool Keys::needs_rekey() const { + if (!admin() || keys_.size() < 2) + return false; + + // We rekey if the max generation value is being used across multiple keys (which indicates some + // sort of rekey collision, somewhat analagous to merge configs in regular config messages). + auto last_it = std::prev(keys_.end()); + auto second_it = std::prev(last_it); + return last_it->generation == second_it->generation; +} + +std::optional Keys::pending_key() const { + if (!pending_key_config_.empty()) + return ustring_view{pending_key_.data(), pending_key_.size()}; + return std::nullopt; +} + +static constexpr size_t OVERHEAD = 1 // encryption type indicator + + crypto_aead_xchacha20poly1305_ietf_NPUBBYTES + + crypto_aead_xchacha20poly1305_ietf_ABYTES; + +ustring Keys::encrypt_message(ustring_view plaintext, bool compress) const { + if (plaintext.size() > MAX_PLAINTEXT_MESSAGE_SIZE) + throw std::runtime_error{"Cannot encrypt plaintext: message size is too large"}; + ustring _compressed; + if (compress) { + _compressed = zstd_compress(plaintext); + if (_compressed.size() < plaintext.size()) + plaintext = _compressed; + else { + _compressed.clear(); + compress = false; + } + } + + ustring ciphertext; + ciphertext.resize(OVERHEAD + plaintext.size()); + ciphertext[0] = compress ? 'X' : 'x'; + randombytes_buf(ciphertext.data() + 1, crypto_aead_xchacha20poly1305_ietf_NPUBBYTES); + ustring_view nonce{ciphertext.data() + 1, crypto_aead_xchacha20poly1305_ietf_NPUBBYTES}; + if (0 != crypto_aead_xchacha20poly1305_ietf_encrypt( + ciphertext.data() + 1 + crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, + nullptr, + plaintext.data(), + plaintext.size(), + nullptr, + 0, + nullptr, + nonce.data(), + group_enc_key().data())) + throw std::runtime_error{"Encryption failed"}; + + return ciphertext; +} + +std::optional Keys::decrypt_message(ustring_view ciphertext) const { + if (ciphertext.size() < OVERHEAD) + return std::nullopt; + + ustring plain; + + bool success = false; + bool compressed = false; + char type = static_cast(ciphertext[0]); + ciphertext.remove_prefix(1); + switch (type) { + case 'X': compressed = true; [[fallthrough]]; + case 'x': { + auto nonce = ciphertext.substr(0, crypto_aead_xchacha20poly1305_ietf_NPUBBYTES); + ciphertext.remove_prefix(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES); + plain.resize(ciphertext.size() - OVERHEAD); + for (auto& k : keys_) { + if (0 == crypto_aead_xchacha20poly1305_ietf_decrypt( + plain.data(), + nullptr, + nullptr, + ciphertext.data(), + ciphertext.size(), + nullptr, + 0, + nonce.data(), + k.key.data())) { + success = true; + break; + } + } + break; + } + + default: + // Don't know how to handle this type (or it's garbage) + return std::nullopt; + } + + if (!success) // none of the keys worked + return std::nullopt; + + if (compressed) { + if (auto decomp = zstd_decompress(plain, MAX_PLAINTEXT_MESSAGE_SIZE)) + plain = std::move(*decomp); + else + // Decompression failed + return std::nullopt; + } + + return std::move(plain); +} + +} // namespace session::config::groups + +using namespace session; +using namespace session::config; + +namespace { +groups::Keys& unbox(config_group_keys* conf) { + assert(conf && conf->internals); + return *static_cast(conf->internals); +} +const groups::Keys& unbox(const config_group_keys* conf) { + assert(conf && conf->internals); + return *static_cast(conf->internals); +} + +void set_error(config_group_keys* conf, std::string_view e) { + if (e.size() > 255) + e.remove_suffix(e.size() - 255); + std::memcpy(conf->_error_buf, e.data(), e.size()); + conf->_error_buf[e.size()] = 0; + conf->last_error = conf->_error_buf; +} +} // namespace + +LIBSESSION_C_API int groups_keys_init( + config_group_keys** conf, + const unsigned char* user_ed25519_secretkey, + const unsigned char* group_ed25519_pubkey, + const unsigned char* group_ed25519_secretkey, + config_object* cinfo, + config_object* cmembers, + const unsigned char* dump, + size_t dumplen, + char* error) { + + assert(user_ed25519_secretkey && group_ed25519_pubkey && cinfo && cmembers); + + ustring_view user_sk{user_ed25519_secretkey, 64}; + ustring_view group_pk{group_ed25519_pubkey, 32}; + std::optional group_sk; + if (group_ed25519_secretkey) + group_sk.emplace(group_ed25519_secretkey, 64); + std::optional dumped; + if (dump && dumplen) + dumped.emplace(dump, dumplen); + + auto& info = *unbox(cinfo); + auto& members = *unbox(cmembers); + auto c_conf = std::make_unique(); + + try { + c_conf->internals = new groups::Keys{user_sk, group_pk, group_sk, dumped, info, members}; + } catch (const std::exception& e) { + if (error) { + std::string msg = e.what(); + if (msg.size() > 255) + msg.resize(255); + std::memcpy(error, msg.c_str(), msg.size() + 1); + } + return SESSION_ERR_INVALID_DUMP; + } + + c_conf->last_error = nullptr; + *conf = c_conf.release(); + return SESSION_ERR_NONE; +} + +LIBSESSION_C_API size_t groups_keys_size(const config_group_keys* conf) { + return unbox(conf).size(); +} + +LIBSESSION_C_API const unsigned char* group_keys_get_key(const config_group_keys* conf, size_t N) { + auto keys = unbox(conf).group_keys(); + if (N >= keys.size()) + return nullptr; + return keys[N].data(); +} + +LIBSESSION_C_API bool groups_keys_is_admin(const config_group_keys* conf) { + return unbox(conf).admin(); +} + +LIBSESSION_C_API bool groups_keys_rekey( + config_group_keys* conf, + config_object* info, + config_object* members, + const unsigned char** out, + size_t* outlen) { + assert(info && members && out && outlen); + auto& keys = unbox(conf); + ustring_view to_push; + try { + to_push = keys.rekey(*unbox(info), *unbox(members)); + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } + *out = to_push.data(); + *outlen = to_push.size(); + return true; +} + +LIBSESSION_C_API bool groups_keys_pending_config( + const config_group_keys* conf, const unsigned char** out, size_t* outlen) { + assert(out && outlen); + if (auto pending = unbox(conf).pending_config()) { + *out = pending->data(); + *outlen = pending->size(); + return true; + } + return false; +} + +LIBSESSION_C_API bool groups_keys_load_message( + config_group_keys* conf, + const char* msg_hash, + const unsigned char* data, + size_t datalen, + int64_t timestamp_ms, + config_object* info, + config_object* members) { + assert(data && info && members); + try { + unbox(conf).load_key_message( + msg_hash, + ustring_view{data, datalen}, + timestamp_ms, + *unbox(info), + *unbox(members)); + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } + return true; +} + +LIBSESSION_C_API config_string_list* groups_keys_current_hashes(const config_group_keys* conf) { + return make_string_list(unbox(conf).current_hashes()); +} + +LIBSESSION_C_API bool groups_keys_needs_rekey(const config_group_keys* conf) { + return unbox(conf).needs_rekey(); +} + +LIBSESSION_C_API bool groups_keys_needs_dump(const config_group_keys* conf) { + return unbox(conf).needs_dump(); +} + +LIBSESSION_C_API void groups_keys_dump( + config_group_keys* conf, unsigned char** out, size_t* outlen) { + assert(out && outlen); + auto dump = unbox(conf).dump(); + *out = static_cast(std::malloc(dump.size())); + std::memcpy(*out, dump.data(), dump.size()); + *outlen = dump.size(); +} + +LIBSESSION_C_API void groups_keys_encrypt_message( + const config_group_keys* conf, + const unsigned char* plaintext_in, + size_t plaintext_len, + unsigned char** ciphertext_out, + size_t* ciphertext_len) { + assert(plaintext_in && ciphertext_out && ciphertext_len); + + ustring ciphertext; + try { + ciphertext = unbox(conf).encrypt_message(ustring_view{plaintext_in, plaintext_len}); + *ciphertext_out = static_cast(std::malloc(ciphertext.size())); + std::memcpy(*ciphertext_out, ciphertext.data(), ciphertext.size()); + *ciphertext_len = ciphertext.size(); + } catch (...) { + *ciphertext_out = nullptr; + *ciphertext_len = 0; + } +} + +LIBSESSION_C_API bool groups_keys_decrypt_message( + const config_group_keys* conf, + const unsigned char* ciphertext_in, + size_t ciphertext_len, + unsigned char** plaintext_out, + size_t* plaintext_len) { + assert(ciphertext_in && plaintext_out && plaintext_len); + + auto plaintext = unbox(conf).decrypt_message(ustring_view{ciphertext_in, ciphertext_len}); + if (!plaintext) + return false; + + *plaintext_out = static_cast(std::malloc(plaintext->size())); + std::memcpy(*plaintext_out, plaintext->data(), plaintext->size()); + *plaintext_len = plaintext->size(); + return true; +} + +LIBSESSION_C_API bool groups_keys_key_supplement( + config_group_keys* conf, + const char** sids, + size_t sids_len, + unsigned char** message, + size_t* message_len) { + assert(sids && message && message_len); + + std::vector session_ids; + for (size_t i = 0; i < sids_len; i++) + session_ids.emplace_back(sids[i]); + try { + auto msg = unbox(conf).key_supplement(session_ids); + *message = static_cast(malloc(msg.size())); + *message_len = msg.size(); + std::memcpy(*message, msg.data(), msg.size()); + return true; + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } +} + +LIBSESSION_C_API bool groups_keys_swarm_make_subaccount_flags( + config_group_keys* conf, + const char* session_id, + bool write, + bool del, + unsigned char* sign_value) { + assert(sign_value); + try { + auto val = unbox(conf).swarm_make_subaccount(session_id, write, del); + assert(val.size() == 100); + std::memcpy(sign_value, val.data(), val.size()); + return true; + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } +} + +LIBSESSION_C_API bool groups_keys_swarm_make_subaccount( + config_group_keys* conf, const char* session_id, unsigned char* sign_value) { + return groups_keys_swarm_make_subaccount_flags(conf, session_id, true, false, sign_value); +} + +LIBSESSION_C_API bool groups_keys_swarm_verify_subaccount_flags( + const char* group_id, + const unsigned char* session_ed25519_secretkey, + const unsigned char* signing_value, + bool write, + bool del) { + try { + return groups::Keys::swarm_verify_subaccount( + group_id, + ustring_view{session_ed25519_secretkey, 64}, + ustring_view{signing_value, 100}, + write, + del); + } catch (...) { + return false; + } +} + +LIBSESSION_C_API bool groups_keys_swarm_verify_subaccount( + const char* group_id, + const unsigned char* session_ed25519_secretkey, + const unsigned char* signing_value) { + return groups::Keys::swarm_verify_subaccount( + group_id, + ustring_view{session_ed25519_secretkey, 64}, + ustring_view{signing_value, 100}); +} + +LIBSESSION_C_API bool groups_keys_swarm_subaccount_sign( + config_group_keys* conf, + const unsigned char* msg, + size_t msg_len, + const unsigned char* signing_value, + + char* subaccount, + char* subaccount_sig, + char* signature) { + assert(msg && signing_value && subaccount && subaccount_sig && signature); + try { + auto auth = unbox(conf).swarm_subaccount_sign( + ustring_view{msg, msg_len}, ustring_view{signing_value, 100}); + assert(auth.subaccount.size() == 48); + assert(auth.subaccount_sig.size() == 88); + assert(auth.signature.size() == 88); + std::memcpy(subaccount, auth.subaccount.c_str(), auth.subaccount.size() + 1); + std::memcpy(subaccount_sig, auth.subaccount_sig.c_str(), auth.subaccount_sig.size() + 1); + std::memcpy(signature, auth.signature.c_str(), auth.signature.size() + 1); + return true; + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } +} + +LIBSESSION_C_API bool groups_keys_swarm_subaccount_sign_binary( + config_group_keys* conf, + const unsigned char* msg, + size_t msg_len, + const unsigned char* signing_value, + + unsigned char* subaccount, + unsigned char* subaccount_sig, + unsigned char* signature) { + assert(msg && signing_value && subaccount && subaccount_sig && signature); + try { + auto auth = unbox(conf).swarm_subaccount_sign( + ustring_view{msg, msg_len}, ustring_view{signing_value, 100}, true); + assert(auth.subaccount.size() == 36); + assert(auth.subaccount_sig.size() == 64); + assert(auth.signature.size() == 64); + std::memcpy(subaccount, auth.subaccount.data(), 36); + std::memcpy(subaccount_sig, auth.subaccount_sig.data(), 36); + std::memcpy(signature, auth.signature.data(), 36); + return true; + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } +} + +LIBSESSION_C_API bool groups_keys_swarm_subaccount_token_flags( + config_group_keys* conf, + const char* session_id, + bool write, + bool del, + unsigned char* token) { + try { + auto tok = unbox(conf).swarm_subaccount_token(session_id, write, del); + assert(tok.size() == 36); + std::memcpy(token, tok.data(), 36); + return true; + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } +} + +LIBSESSION_C_API bool groups_keys_swarm_subaccount_token( + config_group_keys* conf, const char* session_id, unsigned char* token) { + return groups_keys_swarm_subaccount_token_flags(conf, session_id, true, false, token); +} diff --git a/src/config/groups/members.cpp b/src/config/groups/members.cpp new file mode 100644 index 00000000..9d330d7f --- /dev/null +++ b/src/config/groups/members.cpp @@ -0,0 +1,242 @@ +#include "session/config/groups/members.hpp" + +#include + +#include "../internal.hpp" +#include "session/config/groups/members.h" + +namespace session::config::groups { + +Members::Members( + ustring_view ed25519_pubkey, + std::optional ed25519_secretkey, + std::optional dumped) : + ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey} {} + +std::optional Members::get(std::string_view pubkey_hex) const { + std::string pubkey = session_id_to_bytes(pubkey_hex); + + auto* info_dict = data["m"][pubkey].dict(); + if (!info_dict) + return std::nullopt; + + auto result = std::make_optional(std::string{pubkey_hex}); + result->load(*info_dict); + return result; +} + +member Members::get_or_construct(std::string_view pubkey_hex) const { + if (auto maybe = get(pubkey_hex)) + return *std::move(maybe); + + return member{std::string{pubkey_hex}}; +} + +void Members::set(const member& mem) { + + std::string pk = session_id_to_bytes(mem.session_id); + auto info = data["m"][pk]; + + // Always set the name, even if empty, to keep the dict from getting pruned if there are no + // other entries. + info["n"] = mem.name.substr(0, member::MAX_NAME_LENGTH); + + set_pair_if( + mem.profile_picture, + info["p"], + mem.profile_picture.url, + info["q"], + mem.profile_picture.key); + + set_flag(info["A"], mem.admin); + set_positive_int(info["P"], mem.admin ? 0 : mem.promotion_status); + set_positive_int(info["I"], mem.admin ? 0 : mem.invite_status); +} + +void member::load(const dict& info_dict) { + name = maybe_string(info_dict, "n").value_or(""); + + auto url = maybe_string(info_dict, "p"); + auto key = maybe_ustring(info_dict, "q"); + if (url && key && !url->empty() && key->size() == 32) { + profile_picture.url = std::move(*url); + profile_picture.key = std::move(*key); + } else { + profile_picture.clear(); + } + + admin = maybe_int(info_dict, "A").value_or(0); + invite_status = admin ? 0 : maybe_int(info_dict, "I").value_or(0); + promotion_status = admin ? 0 : maybe_int(info_dict, "P").value_or(0); +} + +/// Load _val from the current iterator position; if it is invalid, skip to the next key until we +/// find one that is valid (or hit the end). +void Members::iterator::_load_info() { + while (_it != _members->end()) { + if (_it->first.size() == 33) { + if (auto* info_dict = std::get_if(&_it->second)) { + _val = std::make_shared(oxenc::to_hex(_it->first)); + _val->load(*info_dict); + return; + } + } + + // We found something we don't understand (wrong pubkey size, or not a dict value) so skip + // it. + ++_it; + } +} + +bool Members::iterator::operator==(const iterator& other) const { + if (!_members && !other._members) + return true; // Both are end tombstones + if (!other._members) + // other is an "end" tombstone: return whether we are at the end + return _it == _members->end(); + if (!_members) + // we are an "end" tombstone: return whether the other one is at the end + return other._it == other._members->end(); + return _it == other._it; +} + +bool Members::iterator::done() const { + return !_members || _it == _members->end(); +} + +Members::iterator& Members::iterator::operator++() { + ++_it; + _load_info(); + return *this; +} + +bool Members::erase(std::string_view session_id) { + std::string pk = session_id_to_bytes(session_id); + auto info = data["m"][pk]; + bool ret = info.exists(); + info.erase(); + return ret; +} + +size_t Members::size() const { + if (auto d = data["m"].dict()) + return d->size(); + return 0; +} + +member::member(std::string sid) : session_id{std::move(sid)} { + check_session_id(session_id); +} + +member::member(const config_group_member& m) : session_id{m.session_id, 66} { + assert(std::strlen(m.name) <= MAX_NAME_LENGTH); + name = m.name; + assert(std::strlen(m.profile_pic.url) <= profile_pic::MAX_URL_LENGTH); + if (std::strlen(m.profile_pic.url)) { + profile_picture.url = m.profile_pic.url; + profile_picture.key = {m.profile_pic.key, 32}; + } + admin = m.admin; + invite_status = (m.invited == INVITE_SENT || m.invited == INVITE_FAILED) ? m.invited : 0; + promotion_status = (m.promoted == INVITE_SENT || m.promoted == INVITE_FAILED) ? m.promoted : 0; +} + +void member::into(config_group_member& m) const { + std::memcpy(m.session_id, session_id.data(), 67); + copy_c_str(m.name, name); + if (profile_picture) { + copy_c_str(m.profile_pic.url, profile_picture.url); + std::memcpy(m.profile_pic.key, profile_picture.key.data(), 32); + } else { + copy_c_str(m.profile_pic.url, ""); + } + m.admin = admin; + static_assert(groups::INVITE_SENT == ::INVITE_SENT); + static_assert(groups::INVITE_FAILED == ::INVITE_FAILED); + m.invited = invite_status; + m.promoted = promotion_status; +} + +} // namespace session::config::groups + +using namespace session; +using namespace session::config; + +LIBSESSION_C_API int groups_members_init( + config_object** conf, + const unsigned char* ed25519_pubkey, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, + size_t dumplen, + char* error) { + return c_group_wrapper_init( + conf, ed25519_pubkey, ed25519_secretkey, dump, dumplen, error); +} + +LIBSESSION_C_API bool groups_members_get( + config_object* conf, config_group_member* member, const char* session_id) { + try { + conf->last_error = nullptr; + if (auto c = unbox(conf)->get(session_id)) { + c->into(*member); + return true; + } + } catch (const std::exception& e) { + copy_c_str(conf->_error_buf, e.what()); + conf->last_error = conf->_error_buf; + } + return false; +} + +LIBSESSION_C_API bool groups_members_get_or_construct( + config_object* conf, config_group_member* member, const char* session_id) { + try { + conf->last_error = nullptr; + unbox(conf)->get_or_construct(session_id).into(*member); + return true; + } catch (const std::exception& e) { + copy_c_str(conf->_error_buf, e.what()); + conf->last_error = conf->_error_buf; + return false; + } +} + +LIBSESSION_C_API void groups_members_set(config_object* conf, const config_group_member* member) { + unbox(conf)->set(groups::member{*member}); +} + +LIBSESSION_C_API bool groups_members_erase(config_object* conf, const char* session_id) { + try { + return unbox(conf)->erase(session_id); + } catch (...) { + return false; + } +} + +LIBSESSION_C_API size_t groups_members_size(const config_object* conf) { + return unbox(conf)->size(); +} + +LIBSESSION_C_API groups_members_iterator* groups_members_iterator_new(const config_object* conf) { + auto* it = new groups_members_iterator{}; + it->_internals = new groups::Members::iterator{unbox(conf)->begin()}; + return it; +} + +LIBSESSION_C_API void groups_members_iterator_free(groups_members_iterator* it) { + delete static_cast(it->_internals); + delete it; +} + +LIBSESSION_C_API bool groups_members_iterator_done( + groups_members_iterator* it, config_group_member* c) { + auto& real = *static_cast(it->_internals); + if (real.done()) + return true; + real->into(*c); + return false; +} + +LIBSESSION_C_API void groups_members_iterator_advance(groups_members_iterator* it) { + ++*static_cast(it->_internals); +} diff --git a/src/config/internal.cpp b/src/config/internal.cpp index 48ba1ec2..c5b4421c 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -2,26 +2,36 @@ #include #include +#include #include +#include #include #include namespace session::config { -void check_session_id(std::string_view session_id) { - if (!(session_id.size() == 66 && oxenc::is_hex(session_id) && session_id[0] == '0' && - session_id[1] == '5')) +void check_session_id(std::string_view session_id, std::string_view prefix) { + if (!(session_id.size() == 64 + prefix.size() && oxenc::is_hex(session_id) && + session_id.substr(0, prefix.size()) == prefix)) throw std::invalid_argument{ - "Invalid session ID: expected 66 hex digits starting with 05; got " + - std::string{session_id}}; + "Invalid session ID: expected 66 hex digits starting with " + std::string{prefix} + + "; got " + std::string{session_id}}; } -std::string session_id_to_bytes(std::string_view session_id) { - check_session_id(session_id); +std::string session_id_to_bytes(std::string_view session_id, std::string_view prefix) { + check_session_id(session_id, prefix); return oxenc::from_hex(session_id); } +std::array session_id_pk(std::string_view session_id, std::string_view prefix) { + check_session_id(session_id, prefix); + std::array pk; + session_id.remove_prefix(2); + oxenc::from_hex(session_id.begin(), session_id.end(), pk.begin()); + return pk; +} + void check_encoded_pubkey(std::string_view pk) { if (!((pk.size() == 64 && oxenc::is_hex(pk)) || ((pk.size() == 43 || (pk.size() == 44 && pk.back() == '=')) && oxenc::is_base64(pk)) || @@ -125,4 +135,99 @@ void set_nonempty_str(ConfigBase::DictFieldProxy&& field, std::string_view val) field.erase(); } +/// Writes all the dict elements in `[it, E)` into `out`; E is whichever of `end` or an element with +/// a key >= `until` comes first. +oxenc::bt_dict::iterator append_unknown( + oxenc::bt_dict_producer& out, + oxenc::bt_dict::iterator it, + oxenc::bt_dict::iterator end, + std::string_view until) { + for (; it != end && it->first < until; ++it) + out.append_bt(it->first, it->second); + + assert(!(it != end && it->first == until)); + return it; +} + +/// Extracts and unknown keys in the top-level dict into `unknown` that have keys (strictly) +/// between previous and until. +void load_unknowns( + oxenc::bt_dict& unknown, + oxenc::bt_dict_consumer& in, + std::string_view previous, + std::string_view until) { + while (!in.is_finished() && in.key() < until) { + std::string key{in.key()}; + if (key <= previous || (!unknown.empty() && key <= unknown.rbegin()->first)) + throw oxenc::bt_deserialize_invalid{"top-level keys are out of order"}; + if (in.is_string()) + unknown.emplace_hint(unknown.end(), std::move(key), in.consume_string()); + else if (in.is_negative_integer()) + unknown.emplace_hint(unknown.end(), std::move(key), in.consume_integer()); + else if (in.is_integer()) + unknown.emplace_hint(unknown.end(), std::move(key), in.consume_integer()); + else if (in.is_list()) + unknown.emplace_hint(unknown.end(), std::move(key), in.consume_list()); + else if (in.is_dict()) + unknown.emplace_hint(unknown.end(), std::move(key), in.consume_dict()); + else + throw oxenc::bt_deserialize_invalid{"invalid bencoded value type"}; + } +} + +namespace { + struct zstd_decomp_freer { + void operator()(ZSTD_DStream* z) const { ZSTD_freeDStream(z); } + }; + + using zstd_decomp_ptr = std::unique_ptr; +} // namespace + +ustring zstd_compress(ustring_view data, int level, ustring_view prefix) { + ustring compressed; + if (prefix.empty()) + compressed.resize(ZSTD_compressBound(data.size())); + else { + compressed.resize(prefix.size() + ZSTD_compressBound(data.size())); + compressed.replace(0, prefix.size(), prefix); + } + auto size = ZSTD_compress( + compressed.data() + prefix.size(), + compressed.size() - prefix.size(), + data.data(), + data.size(), + level); + if (ZSTD_isError(size)) + throw std::runtime_error{"Compression failed: " + std::string{ZSTD_getErrorName(size)}}; + + compressed.resize(prefix.size() + size); + return compressed; +} + +std::optional zstd_decompress(ustring_view data, size_t max_size) { + zstd_decomp_ptr z_decompressor{ZSTD_createDStream()}; + auto* zds = z_decompressor.get(); + + ZSTD_initDStream(zds); + ZSTD_inBuffer input{/*.src=*/data.data(), /*.size=*/data.size(), /*.pos=*/0}; + std::array out_buf; + ZSTD_outBuffer output{/*.dst=*/out_buf.data(), /*.size=*/out_buf.size()}; + + ustring decompressed; + + size_t ret; + do { + output.pos = 0; + if (ret = ZSTD_decompressStream(zds, &output, &input); ZSTD_isError(ret)) + return std::nullopt; + + if (max_size > 0 && decompressed.size() + output.pos > max_size) + return std::nullopt; + + decompressed.append(out_buf.data(), output.pos); + } while (ret > 0 || input.pos < input.size); + + return decompressed; +} + } // namespace session::config diff --git a/src/config/internal.hpp b/src/config/internal.hpp index 5bbab837..8872ea4c 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -1,31 +1,26 @@ #pragma once +#include + #include #include +#include +#include +#include "session/config/base.h" #include "session/config/base.hpp" #include "session/config/error.h" #include "session/types.hpp" namespace session::config { -template -[[nodiscard]] int c_wrapper_init( - config_object** conf, - const unsigned char* ed25519_secretkey_bytes, - const unsigned char* dumpstr, - size_t dumplen, - char* error) { - assert(ed25519_secretkey_bytes); - ustring_view ed25519_secretkey{ed25519_secretkey_bytes, 32}; - auto c_conf = std::make_unique(); +template +[[nodiscard]] int c_wrapper_init_generic(config_object** conf, char* error, Args&&... args) { auto c = std::make_unique>(); - std::optional dump; - if (dumpstr && dumplen) - dump.emplace(dumpstr, dumplen); + auto c_conf = std::make_unique(); try { - c->config = std::make_unique(ed25519_secretkey, dump); + c->config = std::make_unique(std::forward(args)...); } catch (const std::exception& e) { if (error) { std::string msg = e.what(); @@ -42,6 +37,43 @@ template return SESSION_ERR_NONE; } +template +[[nodiscard]] int c_wrapper_init( + config_object** conf, + const unsigned char* ed25519_secretkey_bytes, + const unsigned char* dumpstr, + size_t dumplen, + char* error) { + assert(ed25519_secretkey_bytes); + ustring_view ed25519_secretkey{ed25519_secretkey_bytes, 64}; + std::optional dump; + if (dumpstr && dumplen) + dump.emplace(dumpstr, dumplen); + return c_wrapper_init_generic(conf, error, ed25519_secretkey, dump); +} + +template +[[nodiscard]] int c_group_wrapper_init( + config_object** conf, + const unsigned char* ed25519_pubkey_bytes, + const unsigned char* ed25519_secretkey_bytes, + const unsigned char* dump_bytes, + size_t dumplen, + char* error) { + + assert(ed25519_pubkey_bytes); + + ustring_view ed25519_pubkey{ed25519_pubkey_bytes, 32}; + std::optional ed25519_secretkey; + if (ed25519_secretkey_bytes) + ed25519_secretkey.emplace(ed25519_secretkey_bytes, 64); + std::optional dump; + if (dump_bytes && dumplen) + dump.emplace(dump_bytes, dumplen); + + return c_wrapper_init_generic(conf, error, ed25519_pubkey, ed25519_secretkey, dump); +} + template void copy_c_str(char (&dest)[N], std::string_view src) { if (src.size() >= N) @@ -50,11 +82,61 @@ void copy_c_str(char (&dest)[N], std::string_view src) { dest[src.size()] = 0; } -// Throws std::invalid_argument if session_id doesn't look valid -void check_session_id(std::string_view session_id); +// Copies a container of std::strings into a self-contained malloc'ed config_string_list for +// returning to C code with the strings and pointers of the string list in the same malloced space, +// hanging off the end (so that everything, including string values, is freed by a single `free()`). +template < + typename Container, + typename = std::enable_if_t>> +config_string_list* make_string_list(Container vals) { + // We malloc space for the config_string_list struct itself, plus the required number of string + // pointers to store its strings, and the space to actually contain a copy of the string data. + // When we're done, the malloced memory we grab is going to look like this: + // + // {config_string_list} + // {pointer1}{pointer2}... + // {string data 1\0}{string data 2\0}... + // + // where config_string_list.value points at the beginning of {pointer1}, and each pointerN + // points at the beginning of the {string data N\0} c string. + // + // Since we malloc it all at once, when the user frees it, they also free the entire thing. + size_t sz = sizeof(config_string_list) + vals.size() * sizeof(char*); + // plus, for each string, the space to store it (including the null) + for (auto& v : vals) + sz += v.size() + 1; + + auto* ret = static_cast(std::malloc(sz)); + ret->len = vals.size(); + + static_assert(alignof(config_string_list) >= alignof(char*)); + + // value points at the space immediately after the struct itself, which is the first element in + // the array of c string pointers. + ret->value = reinterpret_cast(ret + 1); + char** next_ptr = ret->value; + char* next_str = reinterpret_cast(next_ptr + ret->len); + + for (const auto& v : vals) { + *(next_ptr++) = next_str; + std::memcpy(next_str, v.c_str(), v.size() + 1); + next_str += v.size() + 1; + } + + return ret; +} + +// Throws std::invalid_argument if session_id doesn't look valid. Can optionally be passed a prefix +// byte for id's that aren't starting with 0x05 (e.g. 0x03 for non-legacy group ids). +void check_session_id(std::string_view session_id, std::string_view prefix = "05"); // Checks the session_id (throwing if invalid) then returns it as bytes -std::string session_id_to_bytes(std::string_view session_id); +std::string session_id_to_bytes(std::string_view session_id, std::string_view prefix = "05"); + +// Checks the session_id (throwing if invalid) then returns it as bytes, omitting the 05 (or +// whatever) prefix, which is a pubkey (x25519 for 05 session_ids, ed25519 for other prefixes). +std::array session_id_pk( + std::string_view session_id, std::string_view prefix = "05"); // Validates an open group pubkey; we accept it in hex, base32z, or base64 (padded or unpadded). // Throws std::invalid_argument if invalid. @@ -113,4 +195,26 @@ void set_pair_if( } } +oxenc::bt_dict::iterator append_unknown( + oxenc::bt_dict_producer& out, + oxenc::bt_dict::iterator it, + oxenc::bt_dict::iterator end, + std::string_view until); + +/// Extracts and unknown keys in the top-level dict into `unknown` that have keys (strictly) +/// between previous and until. +void load_unknowns( + oxenc::bt_dict& unknown, + oxenc::bt_dict_consumer& in, + std::string_view previous, + std::string_view until); + +/// ZSTD-compresses a value. `prefix` can be prepended on the returned value, if needed. Throws on +/// serious error. +ustring zstd_compress(ustring_view data, int level = 1, ustring_view prefix = {}); + +/// ZSTD-decompresses a value. Returns nullopt if decompression fails. If max_size is non-zero +/// then this returns nullopt if the decompressed size would exceed that limit. +std::optional zstd_decompress(ustring_view data, size_t max_size = 0); + } // namespace session::config diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index aa689653..4119d322 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -38,6 +39,7 @@ static void base_into(const base_group_info& self, T& c) { c.joined_at = self.joined_at; c.notifications = static_cast(self.notifications); c.mute_until = self.mute_until; + c.invited = self.invited; } template @@ -46,6 +48,11 @@ static void base_from(base_group_info& self, const T& c) { self.joined_at = c.joined_at; self.notifications = static_cast(c.notifications); self.mute_until = c.mute_until; + self.invited = c.invited; +} + +group_info::group_info(std::string sid) : id{std::move(sid)} { + check_session_id(id, "03"); } legacy_group_info::legacy_group_info(std::string sid) : session_id{std::move(sid)} { @@ -66,10 +73,12 @@ void community_info::into(ugroups_community_info& c) const { std::memcpy(c.pubkey, pubkey().data(), 32); } -static_assert(sizeof(ugroups_legacy_group_info::name) == legacy_group_info::NAME_MAX_LENGTH + 1); +static_assert(sizeof(ugroups_legacy_group_info::name) == base_group_info::NAME_MAX_LENGTH + 1); +static_assert(sizeof(ugroups_group_info::name) == base_group_info::NAME_MAX_LENGTH + 1); legacy_group_info::legacy_group_info(const ugroups_legacy_group_info& c, impl_t) : - session_id{c.session_id, 66}, name{c.name}, disappearing_timer{c.disappearing_timer} { + session_id{c.session_id, 66}, disappearing_timer{c.disappearing_timer} { + name = c.name; assert(name.size() <= NAME_MAX_LENGTH); // Otherwise the caller messed up base_from(*this, c); if (c.have_enc_keys) { @@ -129,14 +138,17 @@ void base_group_info::load(const dict& info_dict) { notifications = notify_mode::defaulted; mute_until = maybe_int(info_dict, "!").value_or(0); + + invited = maybe_int(info_dict, "i").value_or(0); } void legacy_group_info::load(const dict& info_dict) { base_group_info::load(info_dict); if (auto n = maybe_string(info_dict, "n")) - name = *n; - // otherwise leave the current `name` alone at whatever the object was constructed with + name = std::move(*n); + else + name.clear(); auto enc_pub = maybe_ustring(info_dict, "k"); auto enc_sec = maybe_ustring(info_dict, "K"); @@ -190,11 +202,63 @@ bool legacy_group_info::erase(const std::string& session_id) { return members_.erase(session_id); } +group_info::group_info(const ugroups_group_info& c) : id{c.id, 66} { + base_from(*this, c); + + name = c.name; + assert(name.size() <= NAME_MAX_LENGTH); // Otherwise the caller messed up + + if (c.have_secretkey) + secretkey.assign(c.secretkey, 64); + if (c.have_auth_data) + auth_data.assign(c.auth_data, sizeof(c.auth_data)); +} + +void group_info::into(ugroups_group_info& c) const { + assert(id.size() == 66); + base_into(*this, c); + copy_c_str(c.id, id); + copy_c_str(c.name, name); + if ((c.have_secretkey = secretkey.size() == 64)) + std::memcpy(c.secretkey, secretkey.data(), 64); + if ((c.have_auth_data = auth_data.size() == 100)) + std::memcpy(c.auth_data, auth_data.data(), 100); +} + +void group_info::load(const dict& info_dict) { + base_group_info::load(info_dict); + + if (auto n = maybe_string(info_dict, "n")) + name = std::move(*n); + else + name.clear(); + + if (auto seed = maybe_ustring(info_dict, "K"); seed && seed->size() == 32) { + std::array pk; + pk[0] = 0x03; + secretkey.resize(64); + crypto_sign_seed_keypair(pk.data() + 1, secretkey.data(), seed->data()); + if (id != oxenc::to_hex(pk.begin(), pk.end())) + secretkey.clear(); + } + if (auto sig = maybe_ustring(info_dict, "s"); sig && sig->size() == 100) + auth_data = std::move(*sig); +} + +void group_info::setKicked() { + secretkey.clear(); + auth_data.clear(); +} + +bool group_info::kicked() const { + return secretkey.empty() && auth_data.empty(); +} + void community_info::load(const dict& info_dict) { base_group_info::load(info_dict); if (auto n = maybe_string(info_dict, "n")) - set_room(*n); + set_room(std::move(*n)); } UserGroups::UserGroups(ustring_view ed25519_secretkey, std::optional dumped) : @@ -277,6 +341,40 @@ legacy_group_info UserGroups::get_or_construct_legacy_group(std::string_view pub return legacy_group_info{std::string{pubkey_hex}}; } +std::optional UserGroups::get_group(std::string_view pubkey_hex) const { + std::string pubkey = session_id_to_bytes(pubkey_hex, "03"); + + auto* info_dict = data["g"][pubkey].dict(); + if (!info_dict) + return std::nullopt; + + auto result = std::make_optional(std::string{pubkey_hex}); + result->load(*info_dict); + return result; +} + +group_info UserGroups::get_or_construct_group(std::string_view pubkey_hex) const { + if (auto maybe = get_group(pubkey_hex)) + return *std::move(maybe); + + return group_info{std::string{pubkey_hex}}; +} + +group_info UserGroups::create_group() const { + std::array pk; + ustring sk; + sk.resize(64); + crypto_sign_keypair(pk.data(), sk.data()); + std::string pk_hex; + pk_hex.reserve(66); + pk_hex += "03"; + oxenc::to_hex(pk.begin(), pk.end(), std::back_inserter(pk_hex)); + + group_info gr{std::move(pk_hex)}; + gr.secretkey = std::move(sk); + return gr; +} + void UserGroups::set(const community_info& c) { data["o"][c.base_url()]["#"] = c.pubkey(); auto info = community_field(c); // data["o"][base]["R"][lc_room] @@ -289,15 +387,14 @@ void UserGroups::set_base(const base_group_info& bg, DictFieldProxy& info) const set_positive_int(info["j"], bg.joined_at); set_positive_int(info["@"], static_cast(bg.notifications)); set_positive_int(info["!"], bg.mute_until); + set_flag(info["i"], bg.invited); + // We don't set n here because it's subtly different in the three group types } void UserGroups::set(const legacy_group_info& g) { auto info = data["C"][session_id_to_bytes(g.session_id)]; set_base(g, info); - if (g.name.size() > legacy_group_info::NAME_MAX_LENGTH) - info["n"] = g.name.substr(0, legacy_group_info::NAME_MAX_LENGTH); - else - info["n"] = g.name; + info["n"] = std::string_view{g.name}.substr(0, legacy_group_info::NAME_MAX_LENGTH); set_pair_if( g.enc_pubkey.size() == 32 && g.enc_seckey.size() == 32, @@ -316,6 +413,28 @@ void UserGroups::set(const legacy_group_info& g) { set_positive_int(info["E"], g.disappearing_timer.count()); } +void UserGroups::set(const group_info& g) { + auto pk_bytes = session_id_to_bytes(g.id, "03"); + auto info = data["g"][pk_bytes]; + set_base(g, info); + + set_nonempty_str( + info["n"], std::string_view{g.name}.substr(0, legacy_group_info::NAME_MAX_LENGTH)); + + if (g.secretkey.size() == 64 && + // Make sure the secretkey's embedded pubkey matches the group id: + ustring_view{g.secretkey.data() + 32, 32} == + ustring_view{ + reinterpret_cast(pk_bytes.data() + 1), + pk_bytes.size() - 1}) + info["K"] = ustring_view{g.secretkey.data(), 32}; + else { + info["K"] = ustring_view{}; + if (g.auth_data.size() == 100) + info["s"] = g.auth_data; + } +} + template static bool erase_impl(Field convo) { bool ret = convo.exists(); @@ -337,6 +456,9 @@ bool UserGroups::erase(const community_info& c) { } return gone; } +bool UserGroups::erase(const group_info& c) { + return erase_impl(data["g"][session_id_to_bytes(c.id, "03")]); +} bool UserGroups::erase(const legacy_group_info& c) { return erase_impl(data["C"][session_id_to_bytes(c.session_id)]); } @@ -350,6 +472,9 @@ bool UserGroups::erase_community(std::string_view base_url, std::string_view roo bool UserGroups::erase_legacy_group(std::string_view id) { return erase(legacy_group_info{std::string{id}}); } +bool UserGroups::erase_group(std::string_view id) { + return erase(group_info{std::string{id}}); +} size_t UserGroups::size_communities() const { size_t count = 0; @@ -373,11 +498,23 @@ size_t UserGroups::size_legacy_groups() const { return 0; } +size_t UserGroups::size_groups() const { + if (auto* d = data["g"].dict()) + return d->size(); + return 0; +} + size_t UserGroups::size() const { - return size_communities() + size_legacy_groups(); + return size_communities() + size_legacy_groups() + size_groups(); } -UserGroups::iterator::iterator(const DictFieldRoot& data, bool communities, bool legacy_groups) { +UserGroups::iterator::iterator( + const DictFieldRoot& data, bool groups, bool communities, bool legacy_groups) { + if (groups) + if (auto* d = data["g"].dict()) { + _it_group = d->begin(); + _end_group = d->end(); + } if (communities) if (auto* d = data["o"].dict()) _it_comm.emplace(d->begin(), d->end()); @@ -389,50 +526,70 @@ UserGroups::iterator::iterator(const DictFieldRoot& data, bool communities, bool _load_val(); } -/// Load _val from the current iterator position; if it is invalid, skip to the next key until we -/// find one that is valid (or hit the end). We also span across three different iterators: first -/// we exhaust communities, then legacy groups. -/// -/// We *always* call this after incrementing the iterators (and after iterator initialization), and -/// this is responsible for making sure that the the _it variables are set up as required. -void UserGroups::iterator::_load_val() { - if (_it_comm) { - if (_it_comm->load(_val)) - return; - else - _it_comm.reset(); - } +template +bool UserGroups::iterator::check_it() { + static_assert( + std::is_same_v || std::is_same_v); + constexpr bool legacy = std::is_same_v; + auto& it = legacy ? _it_legacy : _it_group; + auto& end = legacy ? _end_legacy : _end_group; - while (_it_legacy) { - if (*_it_legacy == *_end_legacy) { - _it_legacy.reset(); - _end_legacy.reset(); + constexpr char prefix = legacy ? 0x05 : 0x03; + while (it) { + if (*it == *end) { + it.reset(); + end.reset(); break; } - auto& [k, v] = **_it_legacy; + auto& [k, v] = **it; - if (k.size() == 33 && k[0] == 0x05) { + if (k.size() == 33 && k[0] == prefix) { if (auto* info_dict = std::get_if(&v)) { - _val = std::make_shared(legacy_group_info{oxenc::to_hex(k)}); - std::get(*_val).load(*info_dict); - return; + _val = std::make_shared(GroupInfo{oxenc::to_hex(k)}); + std::get(*_val).load(*info_dict); + return true; } } - ++*_it_legacy; + ++*it; + } + return false; +} + +/// Load _val from the current iterator position; if it is invalid, skip to the next key until +/// we find one that is valid (or hit the end). We also span across three different iterators: +/// first we exhaust communities, then legacy groups. +/// +/// We *always* call this after incrementing the iterators (and after iterator initialization), +/// and this is responsible for making sure that the the _it variables are set up as required. +void UserGroups::iterator::_load_val() { + if (check_it()) + return; + + if (_it_comm) { + if (_it_comm->load(_val)) + return; + else + _it_comm.reset(); } + + if (check_it()) + return; } bool UserGroups::iterator::operator==(const iterator& other) const { - return _it_comm == other._it_comm && _it_legacy == other._it_legacy; + return _it_group == other._it_group && _it_comm == other._it_comm && + _it_legacy == other._it_legacy; } bool UserGroups::iterator::done() const { - return !_it_comm && !_it_legacy; + return !_it_group && !_it_comm && !_it_legacy; } UserGroups::iterator& UserGroups::iterator::operator++() { - if (_it_comm) + if (_it_group) + ++*_it_group; + else if (_it_comm) _it_comm->advance(); else { assert(_it_legacy); @@ -494,6 +651,30 @@ LIBSESSION_C_API bool user_groups_get_or_construct_community( return false; } } +LIBSESSION_C_API bool user_groups_get_group( + config_object* conf, ugroups_group_info* group, const char* group_id) { + try { + conf->last_error = nullptr; + if (auto g = unbox(conf)->get_group(group_id)) { + g->into(*group); + return true; + } + } catch (const std::exception& e) { + set_error(conf, e.what()); + } + return false; +} +LIBSESSION_C_API bool user_groups_get_or_construct_group( + config_object* conf, ugroups_group_info* group, const char* group_id) { + try { + conf->last_error = nullptr; + unbox(conf)->get_or_construct_group(group_id).into(*group); + return true; + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } +} LIBSESSION_C_API void ugroups_legacy_group_free(ugroups_legacy_group_info* group) { if (group && group->_internal) { @@ -538,6 +719,9 @@ LIBSESSION_C_API void user_groups_set_community( config_object* conf, const ugroups_community_info* comm) { unbox(conf)->set(community_info{*comm}); } +LIBSESSION_C_API void user_groups_set_group(config_object* conf, const ugroups_group_info* group) { + unbox(conf)->set(group_info{*group}); +} LIBSESSION_C_API void user_groups_set_legacy_group( config_object* conf, const ugroups_legacy_group_info* group) { unbox(conf)->set(legacy_group_info{*group}); @@ -555,6 +739,13 @@ LIBSESSION_C_API bool user_groups_erase_community( return false; } } +LIBSESSION_C_API bool user_groups_erase_group(config_object* conf, const char* group_id) { + try { + return unbox(conf)->erase_group(group_id); + } catch (...) { + return false; + } +} LIBSESSION_C_API bool user_groups_erase_legacy_group(config_object* conf, const char* group_id) { try { return unbox(conf)->erase_legacy_group(group_id); @@ -563,6 +754,15 @@ LIBSESSION_C_API bool user_groups_erase_legacy_group(config_object* conf, const } } +LIBSESSION_C_API void ugroups_group_set_kicked(ugroups_group_info* group) { + assert(group); + group->have_auth_data = false; + group->have_secretkey = false; +} +LIBSESSION_C_API bool ugroups_group_is_kicked(const ugroups_group_info* group) { + return !(group->have_auth_data || group->have_secretkey); +} + struct ugroups_legacy_members_iterator { using map_t = std::map; map_t& members; @@ -653,6 +853,9 @@ LIBSESSION_C_API size_t user_groups_size(const config_object* conf) { LIBSESSION_C_API size_t user_groups_size_communities(const config_object* conf) { return unbox(conf)->size_communities(); } +LIBSESSION_C_API size_t user_groups_size_groups(const config_object* conf) { + return unbox(conf)->size_groups(); +} LIBSESSION_C_API size_t user_groups_size_legacy_groups(const config_object* conf) { return unbox(conf)->size_legacy_groups(); } @@ -665,6 +868,9 @@ LIBSESSION_C_API user_groups_iterator* user_groups_iterator_new_communities( const config_object* conf) { return new user_groups_iterator{{unbox(conf)->begin_communities()}}; } +LIBSESSION_C_API user_groups_iterator* user_groups_iterator_new_groups(const config_object* conf) { + return new user_groups_iterator{{unbox(conf)->begin_groups()}}; +} LIBSESSION_C_API user_groups_iterator* user_groups_iterator_new_legacy_groups( const config_object* conf) { return new user_groups_iterator{{unbox(conf)->begin_legacy_groups()}}; @@ -699,6 +905,10 @@ LIBSESSION_C_API bool user_groups_it_is_community( return user_groups_it_is_impl(it, c); } +LIBSESSION_C_API bool user_groups_it_is_group(user_groups_iterator* it, ugroups_group_info* g) { + return user_groups_it_is_impl(it, g); +} + LIBSESSION_C_API bool user_groups_it_is_legacy_group( user_groups_iterator* it, ugroups_legacy_group_info* g) { return user_groups_it_is_impl(it, g); diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 00000000..8a4d5b44 --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,22 @@ +#include + +#include + +namespace session { +void* sodium_buffer_allocate(size_t length) { + if (auto* p = sodium_malloc(length)) + return p; + throw std::bad_alloc{}; +} + +void sodium_buffer_deallocate(void* p) { + if (p) + sodium_free(p); +} + +void sodium_zero_buffer(void* ptr, size_t size) { + if (ptr) + sodium_memzero(ptr, size); +} + +} // namespace session diff --git a/src/xed25519.cpp b/src/xed25519.cpp index d5c69fcc..e885ff1a 100644 --- a/src/xed25519.cpp +++ b/src/xed25519.cpp @@ -20,19 +20,6 @@ using bytes = std::array; namespace { - // constant time `if (b) f = g;` implementation - template - void constant_time_conditional_assign(bytes& f, const bytes& g, bool b) { - bytes x; - for (size_t i = 0; i < x.size(); i++) - x[i] = f[i] ^ g[i]; - unsigned char mask = (unsigned char)(-(signed char)b); - for (size_t i = 0; i < x.size(); i++) - x[i] &= mask; - for (size_t i = 0; i < x.size(); i++) - f[i] ^= x[i]; - } - void fe25519_montx_to_edy(fe25519 y, const fe25519 u) { fe25519 one; crypto_internal_fe25519_1(one); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dcc4dfa8..3bf47602 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,9 @@ add_executable(testAll test_config_contacts.cpp test_config_convo_info_volatile.cpp test_encrypt.cpp + test_group_keys.cpp + test_group_info.cpp + test_group_members.cpp test_xed25519.cpp ) @@ -18,3 +21,6 @@ target_link_libraries(testAll PRIVATE Catch2::Catch2WithMain) add_custom_target(check COMMAND testAll) + +add_executable(swarm-auth-test EXCLUDE_FROM_ALL swarm-auth-test.cpp) +target_link_libraries(swarm-auth-test PRIVATE config) diff --git a/tests/swarm-auth-test.cpp b/tests/swarm-auth-test.cpp new file mode 100644 index 00000000..3d235f15 --- /dev/null +++ b/tests/swarm-auth-test.cpp @@ -0,0 +1,162 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils.hpp" + +using namespace std::literals; +using namespace oxenc::literals; + +static constexpr int64_t created_ts = 1680064059; + +using namespace session::config; + +static std::array sk_from_seed(ustring_view seed) { + std::array ignore; + std::array sk; + crypto_sign_ed25519_seed_keypair(ignore.data(), sk.data(), seed.data()); + return sk; +} + +static std::string session_id_from_ed(ustring_view ed_pk) { + std::string sid; + std::array xpk; + int rc = crypto_sign_ed25519_pk_to_curve25519(xpk.data(), ed_pk.data()); + assert(rc == 0); + sid.reserve(66); + sid += "05"; + oxenc::to_hex(xpk.begin(), xpk.end(), std::back_inserter(sid)); + return sid; +} + +// Hacky little class that implements `[n]` on a std::list. This is inefficient (since it access +// has to iterate n times through the list) but we only use it on small lists in this test code so +// convenience wins over efficiency. (Why not just use a vector? Because vectors requires `T` to +// be moveable, so we'd either have to use std::unique_ptr for members, which is also annoying). +template +struct hacky_list : std::list { + T& operator[](size_t n) { return *std::next(std::begin(*this), n); } +}; + +struct pseudo_client { + std::array secret_key; + const ustring_view public_key{secret_key.data() + 32, 32}; + std::string session_id{session_id_from_ed(public_key)}; + + groups::Info info; + groups::Members members; + groups::Keys keys; + + pseudo_client( + ustring_view seed, + bool admin, + const unsigned char* gpk, + std::optional gsk) : + secret_key{sk_from_seed(seed)}, + info{ustring_view{gpk, 32}, + admin ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt}, + members{ustring_view{gpk, 32}, + admin ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt}, + keys{to_usv(secret_key), + ustring_view{gpk, 32}, + admin ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt, + info, + members} {} +}; + +int main() { + + const ustring group_seed = + "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes; + const ustring admin_seed = + "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + const ustring member_seed = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"_hexbytes; + + std::array group_pk; + std::array group_sk; + + crypto_sign_ed25519_seed_keypair(group_pk.data(), group_sk.data(), group_seed.data()); + + pseudo_client admin{admin_seed, true, group_pk.data(), group_sk.data()}; + pseudo_client member{member_seed, false, group_pk.data(), std::nullopt}; + session::config::UserGroups member_groups{member_seed, std::nullopt}; + + auto auth_data = admin.keys.swarm_make_subaccount(member.session_id); + { + auto g = member_groups.get_or_construct_group(member.info.id); + g.auth_data = auth_data; + member_groups.set(g); + } + + session::config::UserGroups member_gr2{member_seed, std::nullopt}; + auto [seqno, push, obs] = member_groups.push(); + + std::vector> gr_conf; + gr_conf.emplace_back("fakehash1", push); + + member_gr2.merge(gr_conf); + + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + auto msg = to_usv("hello world"); + std::array store_sig; + ustring store_to_sign; + store_to_sign += to_usv("store999"); + store_to_sign += to_usv(std::to_string(now)); + crypto_sign_ed25519_detached( + store_sig.data(), nullptr, store_to_sign.data(), store_to_sign.size(), group_sk.data()); + + nlohmann::json store{ + {"method", "store"}, + {"params", + {{"pubkey", member.info.id}, + {"namespace", 999}, + {"timestamp", now}, + {"ttl", 3600'000}, + {"data", oxenc::to_base64(msg)}, + {"signature", oxenc::to_base64(store_sig.begin(), store_sig.end())}}}}; + + std::cout << "STORE:\n\n" << store.dump() << "\n\n"; + + ustring retrieve_to_sign; + retrieve_to_sign += to_usv("retrieve999"); + retrieve_to_sign += to_usv(std::to_string(now)); + auto subauth = member.keys.swarm_subaccount_sign(retrieve_to_sign, auth_data); + + nlohmann::json retrieve{ + {"method", "retrieve"}, + {"params", + { + {"pubkey", member.info.id}, + {"namespace", 999}, + {"timestamp", now}, + {"subaccount", subauth.subaccount}, + {"subaccount_sig", subauth.subaccount_sig}, + {"signature", subauth.signature}, + }}}; + + std::cout << "RETRIEVE:\n\n" << retrieve.dump() << "\n\n"; +} diff --git a/tests/test_config_convo_info_volatile.cpp b/tests/test_config_convo_info_volatile.cpp index 0edc9720..9e4ddf22 100644 --- a/tests/test_config_convo_info_volatile.cpp +++ b/tests/test_config_convo_info_volatile.cpp @@ -35,6 +35,9 @@ TEST_CASE("Conversations", "[config][conversations]") { constexpr auto definitely_real_id = "055000000000000000000000000000000000000000000000000000000000000000"sv; + constexpr auto benders_nightmare_group = + "030111101001001000101010011011010010101010111010000110100001210000"sv; + CHECK_FALSE(convos.get_1to1(definitely_real_id)); CHECK(convos.empty()); @@ -79,6 +82,17 @@ TEST_CASE("Conversations", "[config][conversations]") { // The new data doesn't get stored until we call this: convos.set(og); + CHECK_FALSE(convos.get_group(benders_nightmare_group)); + + auto g = convos.get_or_construct_group(benders_nightmare_group); + CHECK(g.id == benders_nightmare_group); + CHECK(g.last_read == 0); + CHECK_FALSE(g.unread); + + g.last_read = now_ms; + g.unread = true; + convos.set(g); + auto [seqno, to_push, obs] = convos.push(); CHECK(seqno == 1); @@ -111,6 +125,11 @@ TEST_CASE("Conversations", "[config][conversations]") { CHECK(x2->pubkey_hex() == to_hex(open_group_pubkey)); CHECK(x2->unread); + auto x3 = convos2.get_group(benders_nightmare_group); + REQUIRE(x3); + CHECK(x3->last_read == now_ms); + CHECK(x3->unread); + auto another_id = "051111111111111111111111111111111111111111111111111111111111111111"sv; auto c2 = convos.get_or_construct_1to1(another_id); c2.unread = true; @@ -143,7 +162,7 @@ TEST_CASE("Conversations", "[config][conversations]") { for (auto* conv : {&convos, &convos2}) { // Iterate through and make sure we got everything we expected seen.clear(); - CHECK(conv->size() == 4); + CHECK(conv->size() == 5); CHECK(conv->size_1to1() == 2); CHECK(conv->size_communities() == 1); CHECK(conv->size_legacy_groups() == 1); @@ -174,8 +193,9 @@ TEST_CASE("Conversations", "[config][conversations]") { CHECK_FALSE(convos.needs_push()); convos.erase_1to1("055000000000000000000000000000000000000000000000000000000000000000"); CHECK(convos.needs_push()); - CHECK(convos.size() == 3); + CHECK(convos.size() == 4); CHECK(convos.size_1to1() == 1); + CHECK(convos.size_groups() == 1); // Check the single-type iterators: seen.clear(); diff --git a/tests/test_config_user_groups.cpp b/tests/test_config_user_groups.cpp index cccf18bf..5e433baa 100644 --- a/tests/test_config_user_groups.cpp +++ b/tests/test_config_user_groups.cpp @@ -8,6 +8,7 @@ #include #include +#include "session/config/notify.hpp" #include "utils.hpp" using namespace std::literals; @@ -197,6 +198,10 @@ TEST_CASE("User Groups", "[config][groups]") { // The new data doesn't get stored until we call this: groups.set(og); + auto fake_group_id = "030101010101010101010101010101010101010101010101010101010101010101"s; + auto ggg = groups.get_or_construct_group(fake_group_id); + groups.set(ggg); + auto [seqno, to_push, obs] = groups.push(); auto to_push1 = to_push; @@ -225,9 +230,10 @@ TEST_CASE("User Groups", "[config][groups]") { CHECK(obs.empty()); CHECK(g2.current_hashes() == std::vector{{"fakehash1"s}}); - CHECK(g2.size() == 2); + CHECK(g2.size() == 3); CHECK(g2.size_communities() == 1); CHECK(g2.size_legacy_groups() == 1); + CHECK(g2.size_groups() == 1); auto x1 = g2.get_legacy_group(definitely_real_id); REQUIRE(x1); @@ -259,12 +265,15 @@ TEST_CASE("User Groups", "[config][groups]") { std::to_string(members) + " members"); } else if (auto* og = std::get_if(&group)) { seen.push_back("community: " + og->base_url() + "/r/" + og->room()); + } else if (auto* g = std::get_if(&group)) { + seen.push_back("group: " + g->id); } else { seen.push_back("unknown"); } } CHECK(seen == std::vector{ + "group: " + fake_group_id, "community: http://example.org:5678/r/SudokuRoom", "legacy: Englishmen, 1 admins, 2 members", }); @@ -310,9 +319,10 @@ TEST_CASE("User Groups", "[config][groups]") { REQUIRE(x3.has_value()); CHECK(x3->room() == "sudokuRoom"); // We picked up the capitalization change - CHECK(groups.size() == 2); + CHECK(groups.size() == 3); CHECK(groups.size_communities() == 1); CHECK(groups.size_legacy_groups() == 1); + CHECK(groups.size_groups() == 1); CHECK(c1.insert(users[4], false)); CHECK(c1.insert(users[5], true)); @@ -347,9 +357,10 @@ TEST_CASE("User Groups", "[config][groups]") { to_merge.clear(); to_merge.emplace_back("fakehash3", to_push); groups.merge(to_merge); - CHECK(groups.size() == 1); + CHECK(groups.size() == 2); CHECK(groups.size_communities() == 0); CHECK(groups.size_legacy_groups() == 1); + CHECK(groups.size_groups() == 1); int prio = 0; auto beanstalk_pubkey = "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"; @@ -360,9 +371,10 @@ TEST_CASE("User Groups", "[config][groups]") { groups.set(g); } - CHECK(groups.size() == 5); + CHECK(groups.size() == 6); CHECK(groups.size_communities() == 4); CHECK(groups.size_legacy_groups() == 1); + CHECK(groups.size_groups() == 1); std::tie(seqno, to_push, obs) = groups.push(); groups.confirm_pushed(seqno, "fakehash4"); @@ -393,12 +405,15 @@ TEST_CASE("User Groups", "[config][groups]") { std::to_string(members) + " members"); } else if (auto* og = std::get_if(&group)) { seen.push_back("community: " + og->base_url() + "/r/" + og->room()); + } else if (auto* g = std::get_if(&group)) { + seen.push_back("group: " + g->id); } else { seen.push_back("unknown"); } } CHECK(seen == std::vector{ + "group: " + fake_group_id, "community: http://jacksbeanstalk.org/r/fee", "community: http://jacksbeanstalk.org/r/fi", "community: http://jacksbeanstalk.org/r/fo", @@ -408,6 +423,141 @@ TEST_CASE("User Groups", "[config][groups]") { } } +TEST_CASE("User Groups -- (non-legacy) groups", "[config][groups][new]") { + + const auto seed = "0123456789abcdef0123456789abcdef00000000000000000000000000000000"_hexbytes; + std::array ed_pk, curve_pk; + std::array ed_sk; + crypto_sign_ed25519_seed_keypair( + ed_pk.data(), ed_sk.data(), reinterpret_cast(seed.data())); + int rc = crypto_sign_ed25519_pk_to_curve25519(curve_pk.data(), ed_pk.data()); + REQUIRE(rc == 0); + + REQUIRE(oxenc::to_hex(ed_pk.begin(), ed_pk.end()) == + "4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7"); + REQUIRE(oxenc::to_hex(curve_pk.begin(), curve_pk.end()) == + "d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72"); + CHECK(oxenc::to_hex(seed.begin(), seed.end()) == + oxenc::to_hex(ed_sk.begin(), ed_sk.begin() + 32)); + + session::config::UserGroups groups{ustring_view{seed}, std::nullopt}; + + constexpr auto definitely_real_id = + "035000000000000000000000000000000000000000000000000000000000000000"sv; + + int64_t now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + CHECK_FALSE(groups.get_group(definitely_real_id)); + + CHECK(groups.empty()); + CHECK(groups.size() == 0); + + auto c = groups.get_or_construct_group(definitely_real_id); + + CHECK(c.secretkey.empty()); + CHECK(c.id == definitely_real_id); + CHECK(c.priority == 0); + CHECK(c.joined_at == 0); + CHECK(c.notifications == session::config::notify_mode::defaulted); + CHECK(c.mute_until == 0); + + c.secretkey = to_usv(ed_sk); // This *isn't* the right secret key for the group, so won't + // propagate, and so auth data will: + c.auth_data = + "01020304050000000000000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000"_hexbytes; + + groups.set(c); + + CHECK(groups.needs_push()); + CHECK(groups.needs_dump()); + + auto [seqno, to_push, obs] = groups.push(); + groups.confirm_pushed(seqno, "fakehash1"); + + auto d1 = groups.dump(); + + session::config::UserGroups g2{ustring_view{seed}, d1}; + + auto c2 = g2.get_group(definitely_real_id); + REQUIRE(c2.has_value()); + + CHECK(c2->id == definitely_real_id); + CHECK(c2->priority == 0); + CHECK(c2->joined_at == 0); + CHECK(c2->notifications == session::config::notify_mode::defaulted); + CHECK(c2->mute_until == 0); + CHECK_FALSE(c2->invited); + CHECK(c2->name == ""); + + c2->priority = 123; + c2->joined_at = 1234567890; + c2->notifications = session::config::notify_mode::mentions_only; + c2->mute_until = 456789012; + c2->invited = true; + c2->name = "Magic Special Room"; + + g2.set(*c2); + + auto c2b = g2.get_or_construct_group("03" + oxenc::to_hex(ed_pk.begin(), ed_pk.end())); + c2b.secretkey = to_usv(ed_sk); // This one does match the group ID, so should propagate + c2b.auth_data = // should get ignored, since we have a valid secret key set: + "01020304050000000000000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000"_hexbytes; + g2.set(c2b); + + std::tie(seqno, to_push, obs) = g2.push(); + g2.confirm_pushed(seqno, "fakehash2"); + + std::vector> to_merge; + to_merge.emplace_back("fakehash2", to_push); + groups.merge(to_merge); + + auto c3 = groups.get_group(definitely_real_id); + REQUIRE(c3.has_value()); + CHECK(c3->secretkey.empty()); + CHECK(to_hex(c3->auth_data) == + "01020304050000000000000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000"); + CHECK(c3->id == definitely_real_id); + CHECK(c3->priority == 123); + CHECK(c3->joined_at == 1234567890); + CHECK(c3->notifications == session::config::notify_mode::mentions_only); + CHECK(c3->mute_until == 456789012); + CHECK(c3->invited); + CHECK(c3->name == "Magic Special Room"); + + groups.erase(*c3); + + auto c3b = groups.get_group("03" + oxenc::to_hex(ed_pk.begin(), ed_pk.end())); + REQUIRE(c3b); + CHECK(c3b->auth_data.empty()); + CHECK(to_hex(c3b->secretkey) == to_hex(seed) + oxenc::to_hex(ed_pk.begin(), ed_pk.end())); + CHECK_FALSE(c3b->kicked()); + c3b->auth_data.resize(100); + CHECK_FALSE(c3b->kicked()); + c3b->setKicked(); + CHECK(c3b->kicked()); + CHECK(c3b->secretkey.empty()); + CHECK(c3b->auth_data.empty()); + c3b->auth_data.resize(100); + CHECK_FALSE(c3b->kicked()); + c3b->auth_data.clear(); + + auto gg = groups.get_or_construct_group( + "030303030303030303030303030303030303030303030303030303030303030303"); + groups.set(gg); + CHECK(groups.erase_group("030303030303030303030303030303030303030303030303030303030303030303")); + CHECK_FALSE( + groups.erase_group("03030303030303030303030303030303030303030303030303030303030303030" + "3")); +} + TEST_CASE("User Groups members C API", "[config][groups][c]") { const auto seed = "0123456789abcdef0123456789abcdef00000000000000000000000000000000"_hexbytes; @@ -540,6 +690,11 @@ TEST_CASE("User Groups members C API", "[config][groups][c]") { CHECK(hashes->value[0] == "fakehash1"sv); free(hashes); + size_t key_len; + unsigned char* keys = config_get_keys(conf, &key_len); + REQUIRE(keys); + REQUIRE(key_len == 1); + session::config::UserGroups c2{ustring_view{seed}, std::nullopt}; std::vector> to_merge; diff --git a/tests/test_config_userprofile.cpp b/tests/test_config_userprofile.cpp index 47c547b2..79e2941a 100644 --- a/tests/test_config_userprofile.cpp +++ b/tests/test_config_userprofile.cpp @@ -12,6 +12,14 @@ using namespace std::literals; using namespace oxenc::literals; +void log_msg(config_log_level lvl, const char* msg, void*) { + INFO((lvl == LOG_LEVEL_ERROR ? "ERROR" + : lvl == LOG_LEVEL_WARNING ? "Warning" + : lvl == LOG_LEVEL_INFO ? "Info" + : "debug") + << ": " << msg); +} + TEST_CASE("user profile C API", "[config][user_profile][c]") { const auto seed = "0123456789abcdef0123456789abcdef00000000000000000000000000000000"_hex; diff --git a/tests/test_configdata.cpp b/tests/test_configdata.cpp index 82fbbe68..0a2b1eed 100644 --- a/tests/test_configdata.cpp +++ b/tests/test_configdata.cpp @@ -397,6 +397,7 @@ TEST_CASE("config message signature", "[config][signing]") { CHECK(msg.hash() == m.hash()); CHECK(printable(msg.serialize()) == printable(m_expected)); + // Deliberately modify the signature to break it: auto m_broken = m_expected; REQUIRE(m_broken[m_broken.size() - 2] == 0x07); m_broken[m_broken.size() - 2] = 0x17; @@ -422,7 +423,6 @@ TEST_CASE("config message signature", "[config][signing]") { verifier, nullptr, ConfigMessage::DEFAULT_DIFF_LAGS, - false, [](size_t, const auto& exc) { throw exc; }), config::config_error, Message("Config signature failed verification")); @@ -432,20 +432,6 @@ TEST_CASE("config message signature", "[config][signing]") { ConfigMessage(m_unsigned, verifier), config::missing_signature, Message("Config signature is missing")); - - ConfigMessage m_no_sig{m_unsigned, verifier, nullptr, ConfigMessage::DEFAULT_DIFF_LAGS, true}; - CHECK(m_no_sig.seqno() == 10); - CHECK(m_no_sig.data() == m.data()); - // The hash will differ because of the lack of signature - CHECK(m_no_sig.hash() != m.hash()); - - CHECK(printable(m_no_sig.serialize()) == printable(m_unsigned)); - - // If we set a signer and serialize again, we're going to get the *signed* message. (This is - // not something that should be done, really, because this message does not agree with the - // hash). - m_no_sig.signer = signer; - CHECK(printable(m_no_sig.serialize()) == printable(m_expected)); } const config::dict data118{ diff --git a/tests/test_group_info.cpp b/tests/test_group_info.cpp new file mode 100644 index 00000000..ce21534a --- /dev/null +++ b/tests/test_group_info.cpp @@ -0,0 +1,361 @@ +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "utils.hpp" + +using namespace std::literals; +using namespace oxenc::literals; + +static constexpr int64_t created_ts = 1680064059; + +using namespace session::config; + +TEST_CASE("Group Info settings", "[config][groups][info]") { + + const auto seed = "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + std::array ed_pk; + std::array ed_sk; + crypto_sign_ed25519_seed_keypair( + ed_pk.data(), ed_sk.data(), reinterpret_cast(seed.data())); + + REQUIRE(oxenc::to_hex(ed_pk.begin(), ed_pk.end()) == + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece"); + CHECK(oxenc::to_hex(seed.begin(), seed.end()) == + oxenc::to_hex(ed_sk.begin(), ed_sk.begin() + 32)); + + std::vector enc_keys{ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes}; + + groups::Info ginfo1{to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + // This is just for testing: normally you don't load keys manually but just make a groups::Keys + // object that loads the keys into the Members object for you. + for (const auto& k : enc_keys) + ginfo1.add_key(k, false); + + enc_keys.insert( + enc_keys.begin(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); + enc_keys.push_back("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"_hexbytes); + enc_keys.push_back("dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"_hexbytes); + groups::Info ginfo2{to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + for (const auto& k : enc_keys) // Just for testing, as above. + ginfo2.add_key(k, false); + + ginfo1.set_name("GROUP Name"); + CHECK(ginfo1.is_dirty()); + CHECK(ginfo1.needs_push()); + CHECK(ginfo1.needs_dump()); + + auto [s1, p1, o1] = ginfo1.push(); + + CHECK(s1 == 1); + CHECK(p1.size() == 256); + CHECK(o1.empty()); + + ginfo1.confirm_pushed(s1, "fakehash1"); + CHECK(ginfo1.needs_dump()); + CHECK_FALSE(ginfo1.needs_push()); + + std::vector> merge_configs; + merge_configs.emplace_back("fakehash1", p1); + CHECK(ginfo2.merge(merge_configs) == 1); + CHECK_FALSE(ginfo2.needs_push()); + + CHECK(ginfo2.get_name() == "GROUP Name"); + + ginfo2.set_profile_pic( + "http://example.com/12345", + "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes); + ginfo2.set_expiry_timer(1h); + constexpr int64_t create_time{1682529839}; + ginfo2.set_created(create_time); + ginfo2.set_delete_before(create_time + 50 * 86400); + ginfo2.set_delete_attach_before(create_time + 70 * 86400); + ginfo2.destroy_group(); + + auto [s2, p2, o2] = ginfo2.push(); + CHECK(s2 == 2); + CHECK(p2.size() == 512); + CHECK(o2 == std::vector{"fakehash1"s}); + + ginfo2.confirm_pushed(s2, "fakehash2"); + + ginfo1.set_name("Better name!"); + + merge_configs.clear(); + merge_configs.emplace_back("fakehash2", p2); + + // This fails because ginfo1 doesn't yet have the new key that ginfo2 used (bbb...) + CHECK(ginfo1.merge(merge_configs) == 0); + + ginfo1.add_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); + ginfo1.add_key( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"_hexbytes, + /*prepend=*/false); + + CHECK(ginfo1.merge(merge_configs) == 1); + + CHECK(ginfo1.needs_push()); + auto [s3, p3, o3] = ginfo1.push(); + + CHECK(ginfo1.get_name() == "Better name!"); + CHECK(ginfo1.get_profile_pic().url == "http://example.com/12345"); + CHECK(ginfo1.get_profile_pic().key == + "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes); + CHECK(ginfo1.get_expiry_timer() == 1h); + CHECK(ginfo1.get_created() == create_time); + CHECK(ginfo1.get_delete_before() == create_time + 50 * 86400); + CHECK(ginfo1.get_delete_attach_before() == create_time + 70 * 86400); + CHECK(ginfo1.is_destroyed()); + + ginfo1.confirm_pushed(s3, "fakehash3"); + + merge_configs.clear(); + merge_configs.emplace_back("fakehash3", p3); + CHECK(ginfo2.merge(merge_configs) == 1); + CHECK(ginfo2.get_name() == "Better name!"); + CHECK(ginfo2.get_profile_pic().url == "http://example.com/12345"); + CHECK(ginfo2.get_profile_pic().key == + "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes); + CHECK(ginfo2.get_expiry_timer() == 1h); + CHECK(ginfo2.get_created() == create_time); + CHECK(ginfo2.get_delete_before() == create_time + 50 * 86400); + CHECK(ginfo2.get_delete_attach_before() == create_time + 70 * 86400); + CHECK(ginfo2.is_destroyed()); +} + +TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { + + const auto seed = "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + std::array ed_pk; + std::array ed_sk; + crypto_sign_ed25519_seed_keypair( + ed_pk.data(), ed_sk.data(), reinterpret_cast(seed.data())); + + REQUIRE(oxenc::to_hex(ed_pk.begin(), ed_pk.end()) == + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece"); + CHECK(oxenc::to_hex(seed.begin(), seed.end()) == + oxenc::to_hex(ed_sk.begin(), ed_sk.begin() + 32)); + + std::vector enc_keys1; + enc_keys1.push_back( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes); + std::vector enc_keys2; + enc_keys2.push_back( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); + enc_keys2.push_back( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes); + + // This Info object has only the public key, not the priv key, and so cannot modify things: + groups::Info ginfo{to_usv(ed_pk), std::nullopt, std::nullopt}; + + for (const auto& k : enc_keys1) // Just for testing, as above. + ginfo.add_key(k, false); + + REQUIRE_THROWS_WITH( + ginfo.set_name("Super Group!"), "Unable to make changes to a read-only config object"); + REQUIRE_THROWS_WITH( + ginfo.set_name("Super Group!"), "Unable to make changes to a read-only config object"); + CHECK(!ginfo.is_dirty()); + + // This one is good and has the right signature: + groups::Info ginfo_rw{to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + for (const auto& k : enc_keys1) // Just for testing, as above. + ginfo_rw.add_key(k, false); + + ginfo_rw.set_name("Super Group!!"); + CHECK(ginfo_rw.is_dirty()); + CHECK(ginfo_rw.needs_push()); + CHECK(ginfo_rw.needs_dump()); + + auto [seqno, to_push, obs] = ginfo_rw.push(); + + CHECK(seqno == 1); + + ginfo_rw.confirm_pushed(seqno, "fakehash1"); + CHECK(ginfo_rw.needs_dump()); + CHECK_FALSE(ginfo_rw.needs_push()); + + std::vector> merge_configs; + merge_configs.emplace_back("fakehash1", to_push); + CHECK(ginfo.merge(merge_configs) == 1); + CHECK_FALSE(ginfo.needs_push()); + + groups::Info ginfo_rw2{to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + for (const auto& k : enc_keys1) // Just for testing, as above. + ginfo_rw2.add_key(k, false); + + CHECK(ginfo_rw2.merge(merge_configs) == 1); + CHECK_FALSE(ginfo.needs_push()); + + CHECK(ginfo.get_name() == "Super Group!!"); + + REQUIRE_THROWS_WITH( + ginfo.set_name("Super Group11"), "Unable to make changes to a read-only config object"); + // This shouldn't throw because it isn't *actually* changing a config value (i.e. re-setting the + // same value does not dirty the config). It isn't clear why you'd need to do this, but still. + ginfo.set_name("Super Group!!"); + + // Deliberately use the wrong signing key so that what we produce encrypts successfully but + // doesn't verify + const auto seed_bad1 = + "0023456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + std::array ed_pk_bad1; + std::array ed_sk_bad1; + crypto_sign_ed25519_seed_keypair( + ed_pk_bad1.data(), + ed_sk_bad1.data(), + reinterpret_cast(seed_bad1.data())); + + groups::Info ginfo_bad1{to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + for (const auto& k : enc_keys1) // Just for testing, as above. + ginfo_bad1.add_key(k, false); + + ginfo_bad1.merge(merge_configs); + ginfo_bad1.set_sig_keys(to_usv(ed_sk_bad1)); + ginfo_bad1.set_name("Bad name, BAD!"); + auto [s_bad, p_bad, o_bad] = ginfo_bad1.push(); + + merge_configs.clear(); + merge_configs.emplace_back("badhash1", p_bad); + + CHECK(ginfo.merge(merge_configs) == 0); + CHECK_FALSE(ginfo.needs_push()); + + // Now let's get more complicated: we will have *two* valid signers who submit competing updates + ginfo_rw2.set_name("Super Group 2"); + ginfo_rw2.set_created(12345); + ginfo_rw.set_name("Super Group 3"); + ginfo_rw.set_expiry_timer(365 * 24h); + + CHECK(ginfo_rw.needs_push()); + CHECK(ginfo_rw2.needs_push()); + + auto [s2, tp2, o2] = ginfo_rw2.push(); + auto [s3, tp3, o3] = ginfo_rw.push(); + + merge_configs.clear(); + merge_configs.emplace_back("fakehash2", tp2); + merge_configs.emplace_back("fakehash3", tp3); + + CHECK(ginfo.merge(merge_configs) == 2); + CHECK(ginfo.is_clean()); + + CHECK(s2 == 2); + CHECK(s3 == 2); + CHECK_FALSE(ginfo.needs_push()); + + CHECK(ginfo_rw.merge(merge_configs) == 2); + CHECK(ginfo_rw2.merge(merge_configs) == 2); + + CHECK(ginfo_rw.needs_push()); + CHECK(ginfo_rw2.needs_push()); + + auto [s23, t23, o23] = ginfo_rw.push(); + auto [s32, t32, o32] = ginfo_rw2.push(); + + CHECK(s23 == s32); + CHECK(t23 == t32); + CHECK(o23 == o32); + + ginfo_rw.confirm_pushed(s23, "fakehash23"); + ginfo_rw2.confirm_pushed(s32, "fakehash23"); + + merge_configs.clear(); + merge_configs.emplace_back("fakehash23", t23); + + CHECK(ginfo.merge(merge_configs) == 1); + CHECK(ginfo_rw.merge(merge_configs) == 1); + CHECK(ginfo_rw2.merge(merge_configs) == 1); + + CHECK_FALSE(ginfo.needs_push()); + CHECK_FALSE(ginfo_rw.needs_push()); + CHECK_FALSE(ginfo_rw2.needs_push()); + + auto test = [](groups::Info& g) { + auto n = g.get_name(); + REQUIRE(n); + CHECK(*n == "Super Group 2"); + auto c = g.get_created(); + REQUIRE(c); + CHECK(*c == 12345); + auto et = g.get_expiry_timer(); + REQUIRE(et); + CHECK(*et == 365 * 24h); + }; + test(ginfo); + test(ginfo_rw); + test(ginfo_rw2); + + CHECK(ginfo.needs_dump()); + auto dump = ginfo.dump(); + groups::Info ginfo2{to_usv(ed_pk), std::nullopt, dump}; + + for (const auto& k : enc_keys1) // Just for testing, as above. + ginfo2.add_key(k, false); + + CHECK(!ginfo.needs_dump()); + CHECK(!ginfo2.needs_dump()); + + auto [s4, t4, o4] = ginfo.push(); + auto [s5, t5, o5] = ginfo.push(); + CHECK(s4 == s23); + CHECK(s4 == s5); + CHECK(t4 == t23); + CHECK(t4 == t5); + CHECK(o4.empty()); + CHECK(o5.empty()); + + // This account has a different primary decryption key + groups::Info ginfo_rw3{to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + for (const auto& k : enc_keys2) // Just for testing, as above. + ginfo_rw3.add_key(k, false); + + CHECK(ginfo_rw3.merge(merge_configs) == 1); + CHECK(ginfo_rw3.get_name() == "Super Group 2"); + + auto [s6, t6, o6] = ginfo_rw3.push(); + CHECK(to_hex(ginfo_rw3.key(0)) == + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + REQUIRE(ginfo_rw3.key_count() == 2); + CHECK(to_hex(ginfo_rw3.key(1)) == + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + CHECK(s6 == s5); + CHECK(t6.size() == t23.size()); + CHECK(t6 != t23); + + ginfo_rw3.set_profile_pic( + "http://example.com/12345", + "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes); + CHECK(ginfo_rw3.needs_push()); + auto [s7, t7, o7] = ginfo_rw3.push(); + CHECK(s7 == s6 + 1); + CHECK(t7 != t6); + CHECK(o7 == std::vector{{"fakehash23"s}}); + + merge_configs.clear(); + merge_configs.emplace_back("fakehash7", t7); + // If we don't have the new "bbb" key loaded yet, this will fail: + CHECK(ginfo.merge(merge_configs) == 0); + + ginfo.add_key(enc_keys2.front()); + CHECK(ginfo.merge(merge_configs) == 1); + + auto pic = ginfo.get_profile_pic(); + CHECK_FALSE(pic.empty()); + CHECK(pic.url == "http://example.com/12345"); + CHECK(pic.key == "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes); +} diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp new file mode 100644 index 00000000..111307f9 --- /dev/null +++ b/tests/test_group_keys.cpp @@ -0,0 +1,842 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils.hpp" + +using namespace std::literals; +using namespace oxenc::literals; + +static constexpr int64_t created_ts = 1680064059; + +using namespace session::config; + +static std::array sk_from_seed(ustring_view seed) { + std::array ignore; + std::array sk; + crypto_sign_ed25519_seed_keypair(ignore.data(), sk.data(), seed.data()); + return sk; +} + +static std::string session_id_from_ed(ustring_view ed_pk) { + std::string sid; + std::array xpk; + int rc = crypto_sign_ed25519_pk_to_curve25519(xpk.data(), ed_pk.data()); + REQUIRE(rc == 0); + sid.reserve(66); + sid += "05"; + oxenc::to_hex(xpk.begin(), xpk.end(), std::back_inserter(sid)); + return sid; +} + +// Hacky little class that implements `[n]` on a std::list. This is inefficient (since it access +// has to iterate n times through the list) but we only use it on small lists in this test code so +// convenience wins over efficiency. (Why not just use a vector? Because vectors requires `T` to +// be moveable, so we'd either have to use std::unique_ptr for members, which is also annoying). +template +struct hacky_list : std::list { + T& operator[](size_t n) { return *std::next(std::begin(*this), n); } +}; + +struct pseudo_client { + std::array secret_key; + const ustring_view public_key{secret_key.data() + 32, 32}; + std::string session_id{session_id_from_ed(public_key)}; + + groups::Info info; + groups::Members members; + groups::Keys keys; + + pseudo_client( + ustring_view seed, + bool admin, + const unsigned char* gpk, + std::optional gsk, + std::optional info_dump = std::nullopt, + std::optional members_dump = std::nullopt, + std::optional keys_dump = std::nullopt) : + secret_key{sk_from_seed(seed)}, + info{ustring_view{gpk, 32}, + admin ? std::make_optional({*gsk, 64}) : std::nullopt, + info_dump}, + members{ustring_view{gpk, 32}, + admin ? std::make_optional({*gsk, 64}) : std::nullopt, + members_dump}, + keys{to_usv(secret_key), + ustring_view{gpk, 32}, + admin ? std::make_optional({*gsk, 64}) : std::nullopt, + keys_dump, + info, + members} {} +}; + +TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { + + const ustring group_seed = + "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes; + const ustring admin1_seed = + "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + const ustring admin2_seed = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"_hexbytes; + const std::array member_seeds = { + "000111222333444555666777888999aaabbbcccdddeeefff0123456789abcdef"_hexbytes, // member1 + "00011122435111155566677788811263446552465222efff0123456789abcdef"_hexbytes, // member2 + "00011129824754185548239498168169316979583253efff0123456789abcdef"_hexbytes, // member3 + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"_hexbytes, // member4 + "3333333333333333333333333333333333333333333333333333333333333333"_hexbytes, // member3b + "4444444444444444444444444444444444444444444444444444444444444444"_hexbytes, // member4b + }; + + std::array group_pk; + std::array group_sk; + + crypto_sign_ed25519_seed_keypair(group_pk.data(), group_sk.data(), group_seed.data()); + REQUIRE(oxenc::to_hex(group_seed.begin(), group_seed.end()) == + oxenc::to_hex(group_sk.begin(), group_sk.begin() + 32)); + + // Using list instead of vector so that `psuedo_client` doesn't have to be moveable, which lets + // us put the Info/Member/Keys directly inside it (rather than having to use a unique_ptr, which + // would also be annoying). + hacky_list admins; + hacky_list members; + + // Initialize admin and member objects + admins.emplace_back(admin1_seed, true, group_pk.data(), group_sk.data()); + admins.emplace_back(admin2_seed, true, group_pk.data(), group_sk.data()); + + for (int i = 0; i < 4; ++i) + members.emplace_back(member_seeds[i], false, group_pk.data(), std::nullopt); + + REQUIRE(admins[0].session_id == + "05f1e8b64bbf761edf8f7b47e3a1f369985644cce0a62adb8e21604474bdd49627"); + REQUIRE(admins[1].session_id == + "05c5ba413c336f2fe1fb9a2c525f8a86a412a1db128a7841b4e0e217fa9eb7fd5e"); + REQUIRE(members[0].session_id == + "05ece06dd8e02fb2f7d9497f956a1996e199953c651f4016a2f79a3b3e38d55628"); + REQUIRE(members[1].session_id == + "053ac269b71512776b0bd4a1234aaf93e67b4e9068a2c252f3b93a20acb590ae3c"); + REQUIRE(members[2].session_id == + "05a2b03abdda4df8316f9d7aed5d2d1e483e9af269d0b39191b08321b8495bc118"); + REQUIRE(members[3].session_id == + "050a41669a06c098f22633aee2eba03764ef6813bd4f770a3a2b9033b868ca470d"); + + for (const auto& a : admins) + REQUIRE(a.members.size() == 0); + for (const auto& m : members) + REQUIRE(m.members.size() == 0); + + std::vector> info_configs; + std::vector> mem_configs; + + // add admin account, re-key, distribute + auto& admin1 = admins[0]; + + auto m = admin1.members.get_or_construct(admin1.session_id); + m.admin = true; + m.name = "Admin1"; + admin1.members.set(m); + + CHECK(admin1.members.needs_push()); + + auto maybe_key_config = admin1.keys.pending_config(); + REQUIRE(maybe_key_config); + auto new_keys_config1 = *maybe_key_config; + + auto [iseq1, new_info_config1, iobs1] = admin1.info.push(); + admin1.info.confirm_pushed(iseq1, "fakehash1"); + info_configs.emplace_back("fakehash1", new_info_config1); + + auto [mseq1, new_mem_config1, mobs1] = admin1.members.push(); + admin1.members.confirm_pushed(mseq1, "fakehash1"); + mem_configs.emplace_back("fakehash1", new_mem_config1); + + /* Even though we have only added one admin, admin2 will still be able to see group info + like group size and merge all configs. This is because they have loaded the key config + message, which they can decrypt with the group secret key. + */ + for (auto& a : admins) { + a.keys.load_key_message( + "keyhash1", new_keys_config1, get_timestamp_ms(), a.info, a.members); + CHECK(a.info.merge(info_configs) == 1); + CHECK(a.members.merge(mem_configs) == 1); + CHECK(a.members.size() == 1); + CHECK(a.keys.current_hashes() == std::unordered_set{{"keyhash1"s}}); + } + + /* All attempts to merge non-admin members will throw, as none of the non admin members + will be able to decrypt the new info/member configs using the updated keys + */ + for (auto& m : members) { + m.keys.load_key_message( + "keyhash1", new_keys_config1, get_timestamp_ms(), m.info, m.members); + CHECK_THROWS(m.info.merge(info_configs)); + CHECK_THROWS(m.members.merge(mem_configs)); + CHECK(m.members.size() == 0); + CHECK(m.keys.current_hashes().empty()); + } + + info_configs.clear(); + mem_configs.clear(); + + // add non-admin members, re-key, distribute + for (int i = 0; i < members.size(); ++i) { + auto m = admin1.members.get_or_construct(members[i].session_id); + m.admin = false; + m.name = "Member" + std::to_string(i); + admin1.members.set(m); + } + + CHECK(admin1.members.needs_push()); + + auto new_keys_config2 = admin1.keys.rekey(admin1.info, admin1.members); + CHECK(not new_keys_config2.empty()); + + auto [iseq2, new_info_config2, iobs2] = admin1.info.push(); + admin1.info.confirm_pushed(iseq2, "fakehash2"); + info_configs.emplace_back("fakehash2", new_info_config2); + + auto [mseq2, new_mem_config2, mobs2] = admin1.members.push(); + admin1.members.confirm_pushed(mseq2, "fakehash2"); + mem_configs.emplace_back("fakehash2", new_mem_config2); + + for (auto& a : admins) { + a.keys.load_key_message( + "keyhash2", new_keys_config2, get_timestamp_ms(), a.info, a.members); + CHECK(a.info.merge(info_configs) == 1); + CHECK(a.members.merge(mem_configs) == 1); + CHECK(a.members.size() == 5); + CHECK(a.keys.current_hashes() == std::unordered_set{{"keyhash1"s, "keyhash2"s}}); + } + + for (auto& m : members) { + m.keys.load_key_message( + "keyhash2", new_keys_config2, get_timestamp_ms(), m.info, m.members); + CHECK(m.info.merge(info_configs) == 1); + CHECK(m.members.merge(mem_configs) == 1); + CHECK(m.members.size() == 5); + CHECK(m.keys.current_hashes() == std::unordered_set{{"keyhash2"s}}); + } + + info_configs.clear(); + mem_configs.clear(); + + // change group info, re-key, distribute + admin1.info.set_name("tomatosauce"s); + + CHECK(admin1.info.needs_push()); + + auto new_keys_config3 = admin1.keys.rekey(admin1.info, admin1.members); + CHECK(not new_keys_config3.empty()); + + auto [iseq3, new_info_config3, iobs3] = admin1.info.push(); + admin1.info.confirm_pushed(iseq3, "fakehash3"); + info_configs.emplace_back("fakehash3", new_info_config3); + + auto [mseq3, new_mem_config3, mobs3] = admin1.members.push(); + admin1.members.confirm_pushed(mseq3, "fakehash3"); + mem_configs.emplace_back("fakehash3", new_mem_config3); + + for (auto& a : admins) { + a.keys.load_key_message( + "keyhash3", new_keys_config3, get_timestamp_ms(), a.info, a.members); + CHECK(a.info.merge(info_configs) == 1); + CHECK(a.members.merge(mem_configs) == 1); + CHECK(a.info.get_name() == "tomatosauce"s); + CHECK(a.keys.current_hashes() == + std::unordered_set{{"keyhash1"s, "keyhash2"s, "keyhash3"s}}); + } + + for (auto& m : members) { + m.keys.load_key_message( + "keyhash3", new_keys_config3, get_timestamp_ms(), m.info, m.members); + CHECK(m.info.merge(info_configs) == 1); + CHECK(m.members.merge(mem_configs) == 1); + CHECK(m.info.get_name() == "tomatosauce"s); + CHECK(m.keys.current_hashes() == std::unordered_set{{"keyhash2"s, "keyhash3"s}}); + } + + info_configs.clear(); + mem_configs.clear(); + + // remove members, re-key, distribute + CHECK(admin1.members.size() == 5); + CHECK(admin1.members.erase(members[3].session_id)); + CHECK(admin1.members.erase(members[2].session_id)); + CHECK(admin1.members.size() == 3); + + CHECK(admin1.members.needs_push()); + + ustring old_key{admin1.keys.group_enc_key()}; + auto new_keys_config4 = admin1.keys.rekey(admin1.info, admin1.members); + CHECK(not new_keys_config4.empty()); + + CHECK(old_key != admin1.keys.group_enc_key()); + + auto [iseq4, new_info_config4, iobs4] = admin1.info.push(); + admin1.info.confirm_pushed(iseq4, "fakehash4"); + info_configs.emplace_back("fakehash4", new_info_config4); + + auto [mseq4, new_mem_config4, mobs4] = admin1.members.push(); + admin1.members.confirm_pushed(mseq4, "fakehash4"); + mem_configs.emplace_back("fakehash4", new_mem_config4); + + for (auto& a : admins) { + CHECK(a.keys.load_key_message( + "keyhash4", new_keys_config4, get_timestamp_ms(), a.info, a.members)); + CHECK(a.info.merge(info_configs) == 1); + CHECK(a.members.merge(mem_configs) == 1); + CHECK(a.members.size() == 3); + CHECK(a.keys.current_hashes() == + std::unordered_set{{"keyhash1"s, "keyhash2"s, "keyhash3"s, "keyhash4"s}}); + } + + for (int i = 0; i < members.size(); i++) { + auto& m = members[i]; + bool found_key = m.keys.load_key_message( + "keyhash4", new_keys_config2, get_timestamp_ms(), m.info, m.members); + + CHECK(m.keys.current_hashes() == + std::unordered_set{{"keyhash2"s, "keyhash3"s, "keyhash4"s}}); + if (i < 2) { // We should still be in the group + CHECK(found_key); + CHECK(m.info.merge(info_configs) == 1); + CHECK(m.members.merge(mem_configs) == 1); + CHECK(m.members.size() == 3); + } else { + CHECK_FALSE(found_key); + CHECK(m.info.merge(info_configs) == 0); + CHECK(m.members.merge(mem_configs) == 0); + CHECK(m.members.size() == 5); + } + } + + members.pop_back(); + members.pop_back(); + + info_configs.clear(); + mem_configs.clear(); + + // middle-out time + auto msg = "hello to all my friends sitting in the tomato sauce"s; + + for (int i = 0; i < 5; ++i) + msg += msg; + + auto compressed = admin1.keys.encrypt_message(to_usv(msg)); + auto uncompressed = admin1.keys.encrypt_message(to_usv(msg), false); + + CHECK(compressed.size() < msg.size()); + CHECK(compressed.size() < uncompressed.size()); + + // Add two new members and send them supplemental keys + for (int i = 0; i < 2; ++i) { + auto& m = members.emplace_back(member_seeds[4 + i], false, group_pk.data(), std::nullopt); + + auto memb = admin1.members.get_or_construct(m.session_id); + memb.set_invited(); + memb.name = i == 0 ? "fred" : "JOHN"; + admin1.members.set(memb); + + CHECK_FALSE(m.keys.admin()); + } + + REQUIRE(members[2].session_id == + "054eb4fafee2bd3018a24e310de8106333c2b364eaed029a7f05d7b45ccc77683a"); + REQUIRE(members[3].session_id == + "057ce31baa9a04b5cfb83ab7ccdd7b669b911a082d29883d6aad3256294a0a5e0c"); + + // We actually send supplemental keys to members 1, as well, by mistake just to make sure it + // doesn't do or hurt anything to get a supplemental key you already have. + std::vector supp_sids; + std::transform( + std::next(members.begin()), members.end(), std::back_inserter(supp_sids), [](auto& m) { + return m.session_id; + }); + auto supp = admin1.keys.key_supplement(supp_sids); + CHECK(admin1.members.needs_push()); + CHECK_FALSE(admin1.info.needs_push()); + auto [mseq5, mpush5, mobs5] = admin1.members.push(); + mem_configs.emplace_back("fakehash5", mpush5); + admin1.members.confirm_pushed(mseq5, "fakehash5"); + info_configs.emplace_back("fakehash4", new_info_config4); + + for (auto& a : admins) { + CHECK_FALSE( + a.keys.load_key_message("keyhash5", supp, get_timestamp_ms(), a.info, a.members)); + } + + for (size_t i = 0; i < members.size(); i++) { + auto& m = members[i]; + bool found_key = + m.keys.load_key_message("keyhash5", supp, get_timestamp_ms(), m.info, m.members); + + if (i < 1) { + // This supp key wasn't for us + CHECK_FALSE(found_key); + CHECK(m.keys.size() == 3); + CHECK(m.keys.group_keys().size() == 3); + } else { + CHECK(found_key); + // new_keys_config1 never went to the initial members, but did go out in the + // supplement, which is why we have the extra key here. + CHECK(m.keys.size() == 4); + CHECK(m.keys.group_keys().size() == 4); + } + + CHECK(m.info.merge(info_configs) == 1); + CHECK(m.members.merge(mem_configs) == 1); + REQUIRE(m.info.get_name()); + CHECK(*m.info.get_name() == "tomatosauce"sv); + CHECK(m.members.size() == 5); + + if (i < 2) + CHECK(m.keys.current_hashes() == + std::unordered_set{{"keyhash2"s, "keyhash3"s, "keyhash4"s, "keyhash5"s}}); + else + CHECK(m.keys.current_hashes() == std::unordered_set{{"keyhash5"s}}); + } + + // Duplicate members[1] from dumps + auto& m1b = members.emplace_back( + member_seeds[1], + false, + group_pk.data(), + std::nullopt, + members[1].info.dump(), + members[1].members.dump(), + members[1].keys.dump()); + CHECK(m1b.keys.size() == 4); + CHECK(m1b.keys.group_keys().size() == 4); + CHECK(m1b.keys.current_hashes() == + std::unordered_set{{"keyhash2"s, "keyhash3"s, "keyhash4"s, "keyhash5"s}}); + CHECK(m1b.members.size() == 5); + auto m1b_m2 = m1b.members.get(members[2].session_id); + REQUIRE(m1b_m2); + CHECK(m1b_m2->invite_pending()); + CHECK(m1b_m2->name == "fred"); + + // Rekey after 10d, then again after 71d (10+61) and everything except those two new gens should + // get dropped as stale. + info_configs.clear(); + mem_configs.clear(); + ustring new_keys_config6{admin1.keys.rekey(admin1.info, admin1.members)}; + auto [iseq6, ipush6, iobs6] = admin1.info.push(); + info_configs.emplace_back("ifakehash6", ipush6); + admin1.info.confirm_pushed(iseq6, "ifakehash6"); + auto [mseq6, mpush6, mobs6] = admin1.members.push(); + mem_configs.emplace_back("mfakehash6", mpush6); + admin1.members.confirm_pushed(mseq6, "mfakehash6"); + + for (auto& a : admins) { + CHECK(a.keys.load_key_message( + "keyhash6", + new_keys_config6, + get_timestamp_ms() + 10LL * 86400 * 1000, + a.info, + a.members)); + CHECK(a.info.merge(info_configs) == 1); + CHECK(a.members.merge(mem_configs) == 1); + CHECK(a.members.size() == 5); + CHECK(a.keys.current_hashes() == std::unordered_set{ + {"keyhash1"s, + "keyhash2"s, + "keyhash3"s, + "keyhash4"s, + "keyhash5"s, + "keyhash6"s}}); + } + + ustring new_keys_config7{admin1.keys.rekey(admin1.info, admin1.members)}; + auto [iseq7, ipush7, iobs7] = admin1.info.push(); + info_configs.emplace_back("ifakehash7", ipush7); + admin1.info.confirm_pushed(iseq7, "ifakehash7"); + auto [mseq7, mpush7, mobs7] = admin1.members.push(); + mem_configs.emplace_back("mfakehash7", mpush7); + admin1.members.confirm_pushed(mseq7, "mfakehash7"); + + for (auto& a : admins) { + CHECK(a.keys.load_key_message( + "keyhash7", + new_keys_config7, + get_timestamp_ms() + 71LL * 86400 * 1000, + a.info, + a.members)); + CHECK(a.info.merge(info_configs) == 2); + CHECK(a.members.merge(mem_configs) == 2); + CHECK(a.members.size() == 5); + CHECK(a.keys.current_hashes() == std::unordered_set{{"keyhash6"s, "keyhash7"s}}); + } + + for (int i = 0; i < members.size(); i++) { + auto& m = members[i]; + CHECK(m.keys.load_key_message( + "keyhash6", + new_keys_config6, + get_timestamp_ms() + 10LL * 86400 * 1000, + m.info, + m.members)); + CHECK(m.keys.load_key_message( + "keyhash7", + new_keys_config7, + get_timestamp_ms() + 71LL * 86400 * 1000, + m.info, + m.members)); + CHECK(m.info.merge(info_configs) == 2); + CHECK(m.members.merge(mem_configs) == 2); + CHECK(m.members.size() == 5); + CHECK(m.keys.current_hashes() == std::unordered_set{{"keyhash6"s, "keyhash7"s}}); + } + + // Make sure keys propagate on dump restore to info/members: + pseudo_client admin1b{ + admin1_seed, + true, + group_pk.data(), + group_sk.data(), + admin1.info.dump(), + admin1.members.dump(), + admin1.keys.dump()}; + admin1b.info.set_name("Test New Name"); + CHECK_NOTHROW(admin1b.info.push()); + admin1b.members.set( + admin1b.members.get_or_construct("05124076571076017981235497801235098712093870981273590" + "8746387172343")); + CHECK_NOTHROW(admin1b.members.push()); +} + +TEST_CASE("Group Keys - C API", "[config][groups][keys][c]") { + struct pseudo_client { + std::array secret_key; + const ustring_view public_key{secret_key.data() + 32, 32}; + std::string session_id{session_id_from_ed(public_key)}; + + config_group_keys* keys; + config_object* info; + config_object* members; + + pseudo_client( + ustring seed, + bool is_admin, + unsigned char* gpk, + std::optional gsk) : + secret_key{sk_from_seed(seed)} { + int rv = groups_members_init(&members, gpk, is_admin ? *gsk : NULL, NULL, 0, NULL); + REQUIRE(rv == 0); + + rv = groups_info_init(&info, gpk, is_admin ? *gsk : NULL, NULL, 0, NULL); + REQUIRE(rv == 0); + + rv = groups_keys_init( + &keys, + secret_key.data(), + gpk, + is_admin ? *gsk : NULL, + info, + members, + NULL, + 0, + NULL); + REQUIRE(rv == 0); + } + + ~pseudo_client() { + config_free(info); + config_free(members); + } + }; + + const ustring group_seed = + "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes; + const ustring admin1_seed = + "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + const ustring admin2_seed = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"_hexbytes; + const std::array member_seeds = { + "000111222333444555666777888999aaabbbcccdddeeefff0123456789abcdef"_hexbytes, // member1 + "00011122435111155566677788811263446552465222efff0123456789abcdef"_hexbytes, // member2 + "00011129824754185548239498168169316979583253efff0123456789abcdef"_hexbytes, // member3 + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"_hexbytes // member4 + }; + + std::array group_pk; + std::array group_sk; + + crypto_sign_ed25519_seed_keypair( + group_pk.data(), + group_sk.data(), + reinterpret_cast(group_seed.data())); + REQUIRE(oxenc::to_hex(group_seed.begin(), group_seed.end()) == + oxenc::to_hex(group_sk.begin(), group_sk.begin() + 32)); + + hacky_list admins; + hacky_list members; + + // Initialize admin and member objects + admins.emplace_back(admin1_seed, true, group_pk.data(), group_sk.data()); + admins.emplace_back(admin2_seed, true, group_pk.data(), group_sk.data()); + + for (int i = 0; i < 4; ++i) + members.emplace_back(member_seeds[i], false, group_pk.data(), std::nullopt); + + REQUIRE(admins[0].session_id == + "05f1e8b64bbf761edf8f7b47e3a1f369985644cce0a62adb8e21604474bdd49627"); + REQUIRE(admins[1].session_id == + "05c5ba413c336f2fe1fb9a2c525f8a86a412a1db128a7841b4e0e217fa9eb7fd5e"); + REQUIRE(members[0].session_id == + "05ece06dd8e02fb2f7d9497f956a1996e199953c651f4016a2f79a3b3e38d55628"); + REQUIRE(members[1].session_id == + "053ac269b71512776b0bd4a1234aaf93e67b4e9068a2c252f3b93a20acb590ae3c"); + REQUIRE(members[2].session_id == + "05a2b03abdda4df8316f9d7aed5d2d1e483e9af269d0b39191b08321b8495bc118"); + REQUIRE(members[3].session_id == + "050a41669a06c098f22633aee2eba03764ef6813bd4f770a3a2b9033b868ca470d"); + + for (const auto& a : admins) + REQUIRE(groups_members_size(a.members) == 0); + for (const auto& m : members) + REQUIRE(groups_members_size(m.members) == 0); + + // add admin account, re-key, distribute + auto& admin1 = admins[0]; + config_group_member new_admin1; + + REQUIRE(groups_members_get_or_construct( + admin1.members, &new_admin1, admin1.session_id.c_str())); + + new_admin1.admin = true; + groups_members_set(admin1.members, &new_admin1); + + CHECK(config_needs_push(admin1.members)); + + const unsigned char* new_keys_config_1; + size_t key_len1; + REQUIRE(groups_keys_pending_config(admin1.keys, &new_keys_config_1, &key_len1)); + + config_push_data* new_info_config1 = config_push(admin1.info); + CHECK(new_info_config1->seqno == 1); + + config_push_data* new_mem_config1 = config_push(admin1.members); + CHECK(new_mem_config1->seqno == 1); + + const char* merge_hash1[1]; + const unsigned char* merge_data1[2]; + size_t merge_size1[2]; + + merge_hash1[0] = "fakehash1"; + + merge_data1[0] = new_info_config1->config; + merge_size1[0] = new_info_config1->config_len; + + merge_data1[1] = new_mem_config1->config; + merge_size1[1] = new_mem_config1->config_len; + + /* Even though we have only added one admin, admin2 will still be able to see group info + like group size and merge all configs. This is because they have loaded the key config + message, which they can decrypt with the group secret key. + */ + for (auto& a : admins) { + REQUIRE(groups_keys_load_message( + a.keys, + "fakekeyshash1", + new_keys_config_1, + key_len1, + get_timestamp_ms(), + a.info, + a.members)); + REQUIRE(config_merge(a.info, merge_hash1, &merge_data1[0], &merge_size1[0], 1)); + config_confirm_pushed(a.info, new_info_config1->seqno, "fakehash1"); + + REQUIRE(config_merge(a.members, merge_hash1, &merge_data1[1], &merge_size1[1], 1)); + config_confirm_pushed(a.members, new_mem_config1->seqno, "fakehash1"); + + REQUIRE(groups_members_size(a.members) == 1); + } + + /* All attempts to merge non-admin members will throw, as none of the non admin members + will be able to decrypt the new info/member configs using the updated keys + */ + for (auto& m : members) { + // this will return true if the message was parsed successfully, NOT if the keys were + // decrypted + REQUIRE(groups_keys_load_message( + m.keys, + "fakekeyshash1", + new_keys_config_1, + key_len1, + get_timestamp_ms(), + m.info, + m.members)); + REQUIRE_THROWS(config_merge(m.info, merge_hash1, &merge_data1[0], &merge_size1[0], 1)); + REQUIRE_THROWS(config_merge(m.members, merge_hash1, &merge_data1[1], &merge_size1[1], 1)); + + REQUIRE(groups_members_size(m.members) == 0); + } + + free(new_info_config1); + free(new_mem_config1); + + for (int i = 0; i < members.size(); ++i) { + config_group_member new_mem; + + REQUIRE(groups_members_get_or_construct( + members[i].members, &new_mem, members[i].session_id.c_str())); + new_mem.admin = false; + groups_members_set(admin1.members, &new_mem); + } + + CHECK(config_needs_push(admin1.members)); + + const unsigned char* new_keys_config_2; + size_t key_len2; + REQUIRE(groups_keys_rekey( + admin1.keys, admin1.info, admin1.members, &new_keys_config_2, &key_len2)); + + config_push_data* new_info_config2 = config_push(admin1.info); + CHECK(new_info_config2->seqno == 2); + + config_push_data* new_mem_config2 = config_push(admin1.members); + CHECK(new_mem_config2->seqno == 2); + + const char* merge_hash2[1]; + const unsigned char* merge_data2[2]; + size_t merge_size2[2]; + + merge_hash2[0] = "fakehash2"; + + merge_data2[0] = new_info_config2->config; + merge_size2[0] = new_info_config2->config_len; + + merge_data2[1] = new_mem_config2->config; + merge_size2[1] = new_mem_config2->config_len; + + for (auto& a : admins) { + REQUIRE(groups_keys_load_message( + a.keys, + "fakekeyshash2", + new_keys_config_2, + key_len2, + get_timestamp_ms(), + a.info, + a.members)); + REQUIRE(config_merge(a.info, merge_hash2, &merge_data2[0], &merge_size2[0], 1)); + config_confirm_pushed(a.info, new_info_config2->seqno, "fakehash2"); + + REQUIRE(config_merge(a.members, merge_hash2, &merge_data2[1], &merge_size2[1], 1)); + config_confirm_pushed(a.members, new_mem_config2->seqno, "fakehash2"); + + REQUIRE(groups_members_size(a.members) == 5); + } + + free(new_info_config2); + free(new_mem_config2); +} + +TEST_CASE("Group Keys - swarm authentication", "[config][groups][keys][swarm]") { + + const ustring group_seed = + "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes; + const ustring admin_seed = + "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + const ustring member_seed = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"_hexbytes; + + std::array group_pk; + std::array group_sk; + + crypto_sign_ed25519_seed_keypair(group_pk.data(), group_sk.data(), group_seed.data()); + REQUIRE(oxenc::to_hex(group_seed.begin(), group_seed.end()) == + oxenc::to_hex(group_sk.begin(), group_sk.begin() + 32)); + + CHECK(oxenc::to_hex(group_pk.begin(), group_pk.end()) == + "c50cb3ae956947a8de19135b5be2685ff348afc63fc34a837aca12bc5c1f5625"); + + pseudo_client admin{admin_seed, true, group_pk.data(), group_sk.data()}; + pseudo_client member{member_seed, false, group_pk.data(), std::nullopt}; + session::config::UserGroups member_groups{member_seed, std::nullopt}; + + CHECK(admin.session_id == "05f1e8b64bbf761edf8f7b47e3a1f369985644cce0a62adb8e21604474bdd49627"); + + CHECK(member.session_id == + "05c5ba413c336f2fe1fb9a2c525f8a86a412a1db128a7841b4e0e217fa9eb7fd5" + "e"); + CHECK(oxenc::to_hex(group_pk.begin(), group_pk.end()) == + "c50cb3ae956947a8de19135b5be2685ff348afc63fc34a837aca12bc5c1f5625"); + CHECK(member.info.id == "03c50cb3ae956947a8de19135b5be2685ff348afc63fc34a837aca12bc5c1f5625"); + + auto auth_data = admin.keys.swarm_make_subaccount(member.session_id); + { + auto g = member_groups.get_or_construct_group(member.info.id); + g.auth_data = auth_data; + member_groups.set(g); + } + + session::config::UserGroups member_gr2{member_seed, std::nullopt}; + auto [seqno, push, obs] = member_groups.push(); + + std::vector> gr_conf; + gr_conf.emplace_back("fakehash1", push); + + member_gr2.merge(gr_conf); + + auto g = member_groups.get_group(member.info.id); + REQUIRE(g); + CHECK(g->id == member.info.id); + CHECK(g->auth_data == auth_data); + + auto to_sign = to_usv("retrieve9991693340111000"); + auto subauth_b64 = member.keys.swarm_subaccount_sign(to_sign, auth_data); + + CHECK(subauth_b64.subaccount == "AwMAAIWvMR2nJXCFnK5+hNahNecWqMC39/TVVLjaR3imNug5"); + CHECK(subauth_b64.subaccount_sig == + "6brvv/" + "2jfciBAJeRKMGSepNJLullyrVVHijyVDE+8GC5Oc89UNxjNrq1kVV1P+pkUIRDOew24gSLFgLZfdl+BQ=="); + CHECK(subauth_b64.signature == + "c3PJ4g29v5RivKm8Tdg49vGU2/" + "6kVd0yONnpz5U5zePMYptqW3iYQ0TYf2rEzv3qqkPhS5p67M5GAccHoBHGDQ=="); + + auto subauth = member.keys.swarm_subaccount_sign(to_sign, auth_data, true); + CHECK(oxenc::to_base64(subauth.subaccount) == subauth_b64.subaccount); + CHECK(oxenc::to_base64(subauth.subaccount_sig) == subauth_b64.subaccount_sig); + CHECK(oxenc::to_base64(subauth.signature) == subauth_b64.signature); + + CHECK(0 == + crypto_sign_ed25519_verify_detached( + reinterpret_cast(subauth.signature.data()), + to_sign.data(), + to_sign.size(), + reinterpret_cast(subauth.subaccount.substr(4).data()))); + + CHECK(member.keys.swarm_verify_subaccount(auth_data)); + CHECK(session::config::groups::Keys::swarm_verify_subaccount( + member.info.id, to_usv(member.secret_key), auth_data)); + + // Try flipping a bit in each position of the auth data and make sure it fails to validate: + for (int i = 0; i < auth_data.size(); i++) { + for (int b = 0; b < 8; b++) { + if (i == 35 && b == 7) // This is the sign bit of k, which can be flipped but gets + // flipped back when dealing with the missing X->Ed conversion + // sign bit, so won't actually change anything if it flips. + continue; + auto auth_data2 = auth_data; + auth_data2[i] ^= 1 << b; + CHECK_FALSE(session::config::groups::Keys::swarm_verify_subaccount( + member.info.id, to_usv(member.secret_key), auth_data2)); + } + } +} diff --git a/tests/test_group_members.cpp b/tests/test_group_members.cpp new file mode 100644 index 00000000..e78d6723 --- /dev/null +++ b/tests/test_group_members.cpp @@ -0,0 +1,247 @@ +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "utils.hpp" + +using namespace std::literals; +using namespace oxenc::literals; + +static constexpr int64_t created_ts = 1680064059; + +using namespace session::config; + +constexpr bool is_prime100(int i) { + constexpr std::array p100 = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, + 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97}; + for (auto p : p100) + if (p >= i) + return p == i; + return false; +} + +TEST_CASE("Group Members", "[config][groups][members]") { + + const auto seed = "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + std::array ed_pk; + std::array ed_sk; + crypto_sign_ed25519_seed_keypair( + ed_pk.data(), ed_sk.data(), reinterpret_cast(seed.data())); + + REQUIRE(oxenc::to_hex(ed_pk.begin(), ed_pk.end()) == + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece"); + CHECK(oxenc::to_hex(seed.begin(), seed.end()) == + oxenc::to_hex(ed_sk.begin(), ed_sk.begin() + 32)); + + std::vector enc_keys{ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes}; + + groups::Members gmem1{to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + // This is just for testing: normally you don't load keys manually but just make a groups::Keys + // object that loads the keys into the Members object for you. + for (const auto& k : enc_keys) + gmem1.add_key(k, false); + + enc_keys.insert( + enc_keys.begin(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); + enc_keys.push_back("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"_hexbytes); + enc_keys.push_back("dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"_hexbytes); + groups::Members gmem2{to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + for (const auto& k : enc_keys) // Just for testing, as above. + gmem2.add_key(k, false); + + std::vector sids; + while (sids.size() < 256) { + std::array sid; + for (auto& s : sid) + s = sids.size(); + sid[0] = 0x05; + sids.push_back(oxenc::to_hex(sid.begin(), sid.end())); + } + + // 10 admins: + for (int i = 0; i < 10; i++) { + auto m = gmem1.get_or_construct(sids[i]); + m.admin = true; + m.name = "Admin " + std::to_string(i); + m.profile_picture.url = "http://example.com/" + std::to_string(i); + m.profile_picture.key = + "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes; + gmem1.set(m); + } + // 10 members: + for (int i = 10; i < 20; i++) { + auto m = gmem1.get_or_construct(sids[i]); + m.name = "Member " + std::to_string(i); + m.profile_picture.url = "http://example.com/" + std::to_string(i); + m.profile_picture.key = + "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes; + gmem1.set(m); + } + // 5 members with no attributes (not even a name): + for (int i = 20; i < 25; i++) { + auto m = gmem1.get_or_construct(sids[i]); + gmem1.set(m); + } + + CHECK(gmem1.needs_push()); + auto [s1, p1, o1] = gmem1.push(); + CHECK(p1.size() == 768); + + gmem1.confirm_pushed(s1, "fakehash1"); + CHECK(gmem1.needs_dump()); + CHECK_FALSE(gmem1.needs_push()); + + std::vector> merge_configs; + merge_configs.emplace_back("fakehash1", p1); + CHECK(gmem2.merge(merge_configs) == 1); + CHECK_FALSE(gmem2.needs_push()); + + for (int i = 0; i < 25; i++) + CHECK(gmem2.get(sids[i]).has_value()); + + { + int i = 0; + for (auto& m : gmem2) { + CHECK(m.session_id == sids[i]); + CHECK_FALSE(m.invite_pending()); + CHECK_FALSE(m.invite_failed()); + CHECK_FALSE(m.promotion_pending()); + CHECK_FALSE(m.promotion_failed()); + if (i < 10) { + CHECK(m.admin); + CHECK(m.name == "Admin " + std::to_string(i)); + CHECK_FALSE(m.profile_picture.empty()); + CHECK(m.promoted()); + } else { + CHECK_FALSE(m.admin); + CHECK_FALSE(m.promoted()); + if (i < 20) { + CHECK(m.name == "Member " + std::to_string(i)); + CHECK_FALSE(m.profile_picture.empty()); + } else { + CHECK(m.name.empty()); + CHECK(m.profile_picture.empty()); + } + } + i++; + } + CHECK(i == 25); + } + + for (int i = 22; i < 50; i++) { + auto m = gmem2.get_or_construct(sids[i]); + m.name = "Member " + std::to_string(i); + gmem2.set(m); + } + for (int i = 50; i < 55; i++) { + auto m = gmem2.get_or_construct(sids[i]); + m.set_invited(); + gmem2.set(m); + } + for (int i = 55; i < 58; i++) { + auto m = gmem2.get_or_construct(sids[i]); + m.set_invited(true); + gmem2.set(m); + } + for (int i = 58; i < 62; i++) { + auto m = gmem2.get_or_construct(sids[i]); + m.set_promoted(i >= 60); + gmem2.set(m); + } + + CHECK(gmem2.get(sids[23]).value().name == "Member 23"); + + auto [s2, p2, o2] = gmem2.push(); + gmem2.confirm_pushed(s2, "fakehash2"); + merge_configs.emplace_back("fakehash2", p2); // not clearing it first! + CHECK(gmem1.merge(merge_configs) == 1); + gmem1.add_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); + CHECK(gmem1.merge(merge_configs) == 2); + + CHECK(gmem1.get(sids[23]).value().name == "Member 23"); + + { + int i = 0; + for (auto& m : gmem1) { + CHECK(m.session_id == sids[i]); + CHECK(m.admin == i < 10); + CHECK(m.name == ((i == 20 || i == 21 || i >= 50) ? "" + : i < 10 ? "Admin " + std::to_string(i) + : "Member " + std::to_string(i))); + CHECK(m.profile_picture.key == + (i < 20 ? "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes + : ""_hexbytes)); + CHECK(m.profile_picture.url == + (i < 20 ? "http://example.com/" + std::to_string(i) : "")); + CHECK(m.invite_pending() == (50 <= i && i < 58)); + CHECK(m.invite_failed() == (55 <= i && i < 58)); + CHECK(m.promoted() == (i < 10 || (i >= 58 && i < 62))); + CHECK(m.promotion_pending() == (i >= 58 && i < 62)); + CHECK(m.promotion_failed() == (i >= 60 && i < 62)); + i++; + } + CHECK(i == 62); + } + + for (int i = 0; i < 100; i++) { + if (is_prime100(i)) + gmem1.erase(sids[i]); + else if (i >= 50 && i <= 56) { + auto m = gmem1.get(sids[i]).value(); + if (i >= 55) + m.set_invited(); + else + m.set_accepted(); + gmem1.set(m); + } else if (i == 58) { + auto m = gmem1.get(sids[i]).value(); + m.admin = true; + gmem1.set(m); + } else if (i == 59) { + auto m = gmem1.get(sids[i]).value(); + m.set_promoted(); + gmem1.set(m); + } + } + + auto [s3, p3, o3] = gmem1.push(); + gmem1.confirm_pushed(s3, "fakehash3"); + merge_configs.clear(); + merge_configs.emplace_back("fakehash3", p3); + CHECK(gmem2.merge(merge_configs) == 1); + + { + int i = 0; + for (auto& m : gmem2) { + CHECK(m.session_id == sids[i]); + CHECK(m.admin == (i < 10 || i == 58)); + CHECK(m.name == ((i == 20 || i == 21 || i >= 50) ? "" + : i < 10 ? "Admin " + std::to_string(i) + : "Member " + std::to_string(i))); + CHECK(m.profile_picture.key == + (i < 20 ? "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"_hexbytes + : ""_hexbytes)); + CHECK(m.profile_picture.url == + (i < 20 ? "http://example.com/" + std::to_string(i) : "")); + CHECK(m.invite_pending() == (55 <= i && i < 58)); + CHECK(m.invite_failed() == (i == 57)); + CHECK(m.promoted() == (i < 10 || (i >= 58 && i < 62))); + CHECK(m.promotion_pending() == (i >= 59 && i <= 61)); + CHECK(m.promotion_failed() == (i >= 60 && i <= 61)); + do + i++; + while (is_prime100(i)); + } + CHECK(i == 62); + } +} diff --git a/tests/utils.hpp b/tests/utils.hpp index aff40513..76b145c1 100644 --- a/tests/utils.hpp +++ b/tests/utils.hpp @@ -2,11 +2,13 @@ #include -#include +#include +#include #include #include #include #include +#include #include "session/config/base.h" @@ -32,12 +34,22 @@ inline constexpr auto operator""_kiB(unsigned long long kiB) { return kiB * 1024; } +inline int64_t get_timestamp_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + inline std::string_view to_sv(ustring_view x) { return {reinterpret_cast(x.data()), x.size()}; } inline ustring_view to_usv(std::string_view x) { return {reinterpret_cast(x.data()), x.size()}; } +template +ustring_view to_usv(const std::array& data) { + return {data.data(), N}; +} inline std::string printable(ustring_view x) { std::string p; @@ -57,14 +69,6 @@ inline std::string printable(const unsigned char* x, size_t n) { return printable({x, n}); } -inline void log_msg(config_log_level lvl, const char* msg, void*) { - INFO((lvl == LOG_LEVEL_ERROR ? "ERROR" - : lvl == LOG_LEVEL_WARNING ? "Warning" - : lvl == LOG_LEVEL_INFO ? "Info" - : "debug") - << ": " << msg); -} - template std::set as_set(const Container& c) { return {c.begin(), c.end()}; @@ -74,3 +78,13 @@ template std::set> make_set(T&&... args) { return {std::forward(args)...}; } + +template +std::vector> view_vec(std::vector>&& v) = delete; +template +std::vector> view_vec(const std::vector>& v) { + std::vector> vv; + vv.reserve(v.size()); + std::copy(v.begin(), v.end(), std::back_inserter(vv)); + return vv; +}