From 5d10667082ca258f3c1bdcb6223d374285357859 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 2 Aug 2023 21:15:35 -0300 Subject: [PATCH 01/55] Group metadata; signed & read-only configs support Adds a group info config type, which tracks distributed info for v2 groups. This requires introducing/using a few new concepts not currently used for user config messages: - Multiple decryption keys. User config doesn't do this at all (rather it generates a single decryption key from the private key for each namespace). This doesn't yet add support for sharing and distributing those keys, just for having being able to load a config with a list of multiple possible keys. - Config signing and verification. For user configs this isn't done, since only the owner can actually encrypt/decrypt a config message, just being able to decrypt it is authentication enough. This required various modifications to make the config library properly prevent modifications when we can't modify, and to properly follow that through in terms of merging, updates, etc. --- external/oxen-encoding | 2 +- include/session/config.hpp | 4 +- include/session/config/base.h | 51 +++++ include/session/config/base.hpp | 116 ++++++++++- include/session/config/groups/info.h | 269 +++++++++++++++++++++++++ include/session/config/groups/info.hpp | 267 ++++++++++++++++++++++++ include/session/config/namespaces.hpp | 3 + src/CMakeLists.txt | 1 + src/config.cpp | 44 +++- src/config/base.cpp | 129 ++++++++++-- src/config/encrypt.cpp | 3 +- src/config/groups/info.cpp | 107 ++++++++++ tests/CMakeLists.txt | 1 + tests/test_group_info.cpp | 187 +++++++++++++++++ tests/utils.hpp | 15 ++ 15 files changed, 1173 insertions(+), 26 deletions(-) create mode 100644 include/session/config/groups/info.h create mode 100644 include/session/config/groups/info.hpp create mode 100644 src/config/groups/info.cpp create mode 100644 tests/test_group_info.cpp diff --git a/external/oxen-encoding b/external/oxen-encoding index fc85dfd3..3bca3ac2 160000 --- a/external/oxen-encoding +++ b/external/oxen-encoding @@ -1 +1 @@ -Subproject commit fc85dfd352e8474bc7195b0ba881838bd72ebea6 +Subproject commit 3bca3ac22dac31258a4dd158e1e6568aa2315c75 diff --git a/include/session/config.hpp b/include/session/config.hpp index 45044615..f02fe961 100644 --- a/include/session/config.hpp +++ b/include/session/config.hpp @@ -87,7 +87,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 @@ -214,7 +214,7 @@ class ConfigMessage { /// 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_; } + bool verified_signature() const { return verified_signature_.has_value(); } /// 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. diff --git a/include/session/config/base.h b/include/session/config/base.h index de9806e3..522445f1 100644 --- a/include/session/config/base.h +++ b/include/session/config/base.h @@ -446,6 +446,57 @@ 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. +/// +/// 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..1c230b60 100644 --- a/include/session/config/base.hpp +++ b/include/session/config/base.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +56,8 @@ 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() @@ -65,6 +68,15 @@ class ConfigBase { size_t _keys_size = 0; size_t _keys_capacity = 0; + // 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!) + using Ed25519PubKey = std::array; + using Ed25519Secret = std::array; + std::optional _sign_pk = std::nullopt; + Ed25519Secret* _sign_sk = nullptr; + // 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 // removed by the next push. @@ -78,7 +90,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). @@ -684,6 +704,13 @@ class ConfigBase { public: virtual ~ConfigBase(); + // 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 +821,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 @@ -996,6 +1060,56 @@ class ConfigBase { assert(i < _keys_size); return {_keys[i].data(), _keys[i].size()}; } + + /// API: base/ConfigBase::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/ConfigBase::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/ConfigBase::get_sig_pubkey + /// + /// Returns a const reference to the 32-byte Ed25519 signing pubkey, if set. + /// + /// 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/ConfigBase::clear_sig_keys + /// + /// Drops the signature pubkey and/or secret key, if the object has them. + /// + /// Inputs: none. + void clear_sig_keys(); }; // The C++ struct we hold opaquely inside the C internals struct. This is designed so that any diff --git a/include/session/config/groups/info.h b/include/session/config/groups/info.h new file mode 100644 index 00000000..d5326192 --- /dev/null +++ b/include/session/config/groups/info.h @@ -0,0 +1,269 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "../base.h" +#include "../profile_pic.h" +#include "../util.h" + +/// API: groups/group_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`). +/// +/// Declaration: +/// ```cpp +/// INT group_info_init( +/// [out] config_object** conf, +/// [in] const unsigned char** keys, +/// [in] size_t keylen, +/// [in] const unsigned char* dump, +/// [in] size_t dumplen, +/// [out] char* error +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [out] Pointer to the config object +/// - `keys` -- pointer to the beginning of an array of 32-byte encryption/decryption keys for this +/// group info. These should be specified in most-recent-to-least-recent order; the *first* key +/// will be the one used for encryption when pushing an update. +/// - `keylen` -- the number of the `keys` array +/// - `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 contacts_init( + config_object** conf, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, + size_t dumplen, + char* error) __attribute__((warn_unused_result)); + +/// API: contacts/contacts_get +/// +/// Fills `contact` with the contact info given a session ID (specified as a null-terminated hex +/// string), if the contact exists, and returns true. If the contact does not exist then `contact` +/// is left unchanged and false is returned. +/// +/// Declaration: +/// ```cpp +/// BOOL contacts_get( +/// [in] config_object* conf, +/// [out] contacts_contact* contact, +/// [in] const char* session_id +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `contact` -- [out] the contact info data +/// - `session_id` -- [in] null terminated hex string +/// +/// Output: +/// - `bool` -- Returns true if contact exsts +LIBSESSION_EXPORT bool contacts_get( + config_object* conf, contacts_contact* contact, const char* session_id) + __attribute__((warn_unused_result)); + +/// API: contacts/contacts_get_or_construct +/// +/// Same as the above `contacts_get()` except that when the contact does not exist, this sets all +/// the contact 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 contact, followed by +/// setting fields in the contact, and then giving it to contacts_set(). +/// +/// Declaration: +/// ```cpp +/// BOOL contacts_get_or_construct( +/// [in] config_object* conf, +/// [out] contacts_contact* contact, +/// [in] const char* session_id +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `contact` -- [out] the contact info data +/// - `session_id` -- [in] null terminated hex string +/// +/// Output: +/// - `bool` -- Returns true if contact exsts +LIBSESSION_EXPORT bool contacts_get_or_construct( + config_object* conf, contacts_contact* contact, const char* session_id) + __attribute__((warn_unused_result)); + +/// API: contacts/contacts_set +/// +/// Adds or updates a contact from the given contact info struct. +/// +/// Declaration: +/// ```cpp +/// VOID contacts_set( +/// [in, out] config_object* conf, +/// [in] const contacts_contact* contact +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in, out] Pointer to the config object +/// - `contact` -- [in] Pointer containing the contact info data +/// +/// Output: +/// - `void` -- Returns Nothing +LIBSESSION_EXPORT void contacts_set(config_object* conf, const contacts_contact* contact); + +// NB: wrappers for set_name, set_nickname, etc. C++ methods are deliberately omitted as they would +// save very little in actual calling code. The procedure for updating a single field without them +// is simple enough; for example to update `approved` and leave everything else unchanged: +// +// contacts_contact c; +// if (contacts_get_or_construct(conf, &c, some_session_id)) { +// const char* new_nickname = "Joe"; +// c.approved = new_nickname; +// contacts_set_or_create(conf, &c); +// } else { +// // some_session_id was invalid! +// } + +/// API: contacts/contacts_erase +/// +/// Erases a contact from the contact list. session_id is in hex. Returns true if the contact was +/// found and removed, false if the contact was not present. You must not call this during +/// iteration; see details below. +/// +/// Declaration: +/// ```cpp +/// BOOL contacts_erase( +/// [in, out] config_object* conf, +/// [in] const char* session_id +/// ); +/// ``` +/// +/// 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 contacts_erase(config_object* conf, const char* session_id); + +/// API: contacts/contacts_size +/// +/// Returns the number of contacts. +/// +/// Declaration: +/// ```cpp +/// SIZE_T contacts_size( +/// [in] const config_object* conf +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- input - Pointer to the config object +/// +/// Outputs: +/// - `size_t` -- number of contacts +LIBSESSION_EXPORT size_t contacts_size(const config_object* conf); + +typedef struct contacts_iterator { + void* _internals; +} contacts_iterator; + +/// API: contacts/contacts_iterator_new +/// +/// Starts a new iterator. +/// +/// Functions for iterating through the entire contact list, in sorted order. Intended use is: +/// +/// contacts_contact c; +/// contacts_iterator *it = contacts_iterator_new(contacts); +/// for (; !contacts_iterator_done(it, &c); contacts_iterator_advance(it)) { +/// // c.session_id, c.nickname, etc. are loaded +/// } +/// contacts_iterator_free(it); +/// +/// It is NOT permitted to add/remove/modify records while iterating. +/// +/// Declaration: +/// ```cpp +/// CONTACTS_ITERATOR* contacts_iterator_new( +/// [in] const config_object* conf +/// ); +/// ``` +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// +/// Outputs: +/// - `contacts_iterator*` -- pointer to the iterator +LIBSESSION_EXPORT contacts_iterator* contacts_iterator_new(const config_object* conf); + +/// API: contacts/contacts_iterator_free +/// +/// Frees an iterator once no longer needed. +/// +/// Declaration: +/// ```cpp +/// VOID contacts_iterator_free( +/// [in] contacts_iterator* it +/// ); +/// ``` +/// +/// Inputs: +/// - `it` -- [in] Pointer to the contacts_iterator +LIBSESSION_EXPORT void contacts_iterator_free(contacts_iterator* it); + +/// API: contacts/contacts_iterator_done +/// +/// Returns true if iteration has reached the end. Otherwise `c` is populated and false is +/// returned. +/// +/// Declaration: +/// ```cpp +/// BOOL contacts_iterator_done( +/// [in] contacts_iterator* it, +/// [out] contacts_contact* c +/// ); +/// ``` +/// +/// Inputs: +/// - `it` -- [in] Pointer to the contacts_iterator +/// - `c` -- [out] Pointer to the contact, will be populated if false +/// +/// Outputs: +/// - `bool` -- True if iteration has reached the end +LIBSESSION_EXPORT bool contacts_iterator_done(contacts_iterator* it, contacts_contact* c); + +/// API: contacts/contacts_iterator_advance +/// +/// Advances the iterator. +/// +/// Declaration: +/// ```cpp +/// VOID contacts_iterator_advance( +/// [in] contacts_iterator* it +/// ); +/// ``` +/// +/// Inputs: +/// - `it` -- [in] Pointer to the contacts_iterator +LIBSESSION_EXPORT void contacts_iterator_advance(contacts_iterator* it); + +#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..5276bdaf --- /dev/null +++ b/include/session/config/groups/info.hpp @@ -0,0 +1,267 @@ +#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()`) 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 second argument. + /// + /// Inputs: + /// - `keys` -- contains the possible 32-byte en/decryption keys that may be used for incoming + /// messages. These are *not* Ed25519 secret keys, but rather symmetric encryption keys used + /// for encryption (generally generated using a cryptographically secure random generator). + /// The *first* key in this list will be used to encrypt outgoing config messages (and so, in + /// general, should be the most current key). There must always be at least one key present + /// (either provided at construction or via add_keys) before you can push a config. + /// Post-construction you can add or remove keys via add_key/remove_key/clear_keys from + /// ConfigBase. + /// - `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(const std::vector& keys, + 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::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_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_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. + /// + /// Outputs: + /// - `true` if the group has been destroyed, `false` otherwise. + bool is_destroyed() const; +}; + +} // namespace session::config::groups diff --git a/include/session/config/namespaces.hpp b/include/session/config/namespaces.hpp index 394617c0..c80957f9 100644 --- a/include/session/config/namespaces.hpp +++ b/include/session/config/namespaces.hpp @@ -9,6 +9,9 @@ enum class Namespace : std::int16_t { Contacts = 3, ConvoInfoVolatile = 4, UserGroups = 5, + + // Groups namespaces (i.e. for config of the group itself, not one user's group settings) + GroupInfo = 11, }; } // namespace session::config diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f4f836b5..1b5dcdeb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,7 @@ add_library(config config/convo_info_volatile.cpp config/encrypt.cpp config/error.c + config/groups/info.cpp config/internal.cpp config/user_groups.cpp config/user_profile.cpp diff --git a/src/config.cpp b/src/config.cpp index 2410908c..83abde0a 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -593,9 +593,12 @@ ConfigMessage::ConfigMessage( if (sig.empty()) { if (!signature_optional) throw missing_signature{"Config signature is missing"}; - } else if (verified_signature_ = verifier(to_verify, sig); !verified_signature_) { + } else if (sig.size() != 64) + throw signature_error{"Config signature is invalid (not 64B)"}; + else if (!verifier(to_verify, sig)) throw signature_error{"Config signature failed verification"}; - } + else + std::memcpy(verified_signature_.emplace().data(), sig.data(), 64); } } catch (const oxenc::bt_deserialize_invalid& err) { throw config_parse_error{"Failed to parse config file: "s + err.what()}; @@ -657,7 +660,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 +668,32 @@ 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 && !signature_optional) { + 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()); @@ -750,6 +773,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 +816,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..5364b810 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -19,6 +20,10 @@ 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(); @@ -63,12 +68,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; @@ -165,8 +179,8 @@ 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) { @@ -219,7 +233,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 { @@ -271,8 +285,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,6 +306,10 @@ 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; + + if (is_readonly()) + _old_hashes.clear(); + for (auto& old : _old_hashes) old_hashes.emplace_back(old); oxenc::bt_dict d{ @@ -307,15 +326,30 @@ ustring ConfigBase::dump() { return ustring{to_unsigned_sv(dumped)}; } -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; + + if (ed25519_secretkey) { + if (ed25519_pubkey) + assert(*ed25519_pubkey == ed25519_secretkey->substr(32)); + set_sig_keys(*ed25519_secretkey); + } else if (ed25519_pubkey) { + set_sig_pubkey(*ed25519_pubkey); } +} - 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()); @@ -330,9 +364,8 @@ ConfigBase::ConfigBase(std::optional dump) { // 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 + nullptr, // verifier, set later + nullptr, // signer, set later config_lags(), true /* signature optional because we don't sign the dump */); else @@ -354,6 +387,8 @@ ConfigBase::ConfigBase(std::optional dump) { ConfigBase::~ConfigBase() { sodium_free(_keys); + if (_sign_sk) + sodium_free(_sign_sk); } int ConfigBase::key_count() const { @@ -455,6 +490,51 @@ void ConfigBase::load_key(ustring_view ed25519_secretkey) { add_key(ed25519_secretkey.substr(0, 32)); } +void ConfigBase::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 = static_cast(sodium_malloc(sizeof(Ed25519Secret))); + std::memcpy(_sign_sk->data(), secret.data(), secret.size()); + _sign_pk.emplace(); + crypto_sign_ed25519_sk_to_pk(_sign_pk->data(), _sign_sk->data()); + + _config->verifier = [this](ustring_view data, ustring_view sig) { + return 0 == crypto_sign_ed25519_verify_detached( + sig.data(), data.data(), data.size(), _sign_pk->data()); + }; + _config->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 ConfigBase::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); + + _config->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 ConfigBase::clear_sig_keys() { + _sign_pk.reset(); + if (_sign_sk) { + sodium_free(_sign_sk); + _sign_sk = nullptr; + } + _config->signer = nullptr; + _config->verifier = nullptr; +} + void set_error(config_object* conf, std::string e) { auto& error = unbox(conf).error; error = std::move(e); @@ -596,6 +676,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/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..6f90b83c --- /dev/null +++ b/src/config/groups/info.cpp @@ -0,0 +1,107 @@ +#include "session/config/groups/info.hpp" + +#include +#include + +#include + +#include "../internal.hpp" +//#include "session/config/groups/info.h" +#include "session/config/error.h" +#include "session/export.h" +#include "session/types.hpp" +#include "session/util.hpp" + +namespace session::config::groups { + +using namespace std::literals; +using session::ustring_view; + +Info::Info( + const std::vector& keys, + ustring_view ed25519_pubkey, + std::optional ed25519_secretkey, + std::optional dumped) : + ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey} { + for (const auto& k : keys) + add_key(k); +} + +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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dcc4dfa8..a7cdefb3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(testAll test_config_contacts.cpp test_config_convo_info_volatile.cpp test_encrypt.cpp + test_group_info.cpp test_xed25519.cpp ) diff --git a/tests/test_group_info.cpp b/tests/test_group_info.cpp new file mode 100644 index 00000000..db2d7ed8 --- /dev/null +++ b/tests/test_group_info.cpp @@ -0,0 +1,187 @@ +#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("Verify-only Group Info", "[config][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_keys; + enc_keys.push_back("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes); + // This Info object has only the public key, not the priv key, and so cannot modify things: + groups::Info ginfo{view_vec(enc_keys), to_usv(ed_pk), std::nullopt, std::nullopt}; + + 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{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + 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{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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); + }; + SECTION("read-only group info") { + test(ginfo); + } + SECTION("group writer 1") { + test(ginfo_rw); + } + SECTION("group writer 2") { + test(ginfo_rw2); + } + + + CHECK(ginfo.needs_dump()); + auto dump = ginfo.dump(); + groups::Info ginfo2{view_vec(enc_keys), to_usv(ed_pk), std::nullopt, dump}; + + 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()); +} diff --git a/tests/utils.hpp b/tests/utils.hpp index aff40513..3a73a139 100644 --- a/tests/utils.hpp +++ b/tests/utils.hpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -38,6 +39,10 @@ inline std::string_view to_sv(ustring_view x) { 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; @@ -74,3 +79,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; +} From 80f4d1428456cceefee71532c72ab87b8bc89ba3 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 3 Aug 2023 12:47:38 -0300 Subject: [PATCH 02/55] Add multi-key encryption tests Fixes a bug where construction-provided keys were loaded in reversed priority. --- src/config/groups/info.cpp | 2 +- tests/test_group_info.cpp | 71 +++++++++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index 6f90b83c..484a2e73 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -24,7 +24,7 @@ Info::Info( std::optional dumped) : ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey} { for (const auto& k : keys) - add_key(k); + add_key(k, false); } std::optional Info::get_name() const { diff --git a/tests/test_group_info.cpp b/tests/test_group_info.cpp index db2d7ed8..2b532c17 100644 --- a/tests/test_group_info.cpp +++ b/tests/test_group_info.cpp @@ -30,10 +30,17 @@ TEST_CASE("Verify-only Group Info", "[config][verify-only]") { CHECK(oxenc::to_hex(seed.begin(), seed.end()) == oxenc::to_hex(ed_sk.begin(), ed_sk.begin() + 32)); - std::vector enc_keys; - enc_keys.push_back("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes); + 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{view_vec(enc_keys), to_usv(ed_pk), std::nullopt, std::nullopt}; + groups::Info ginfo{view_vec(enc_keys1), to_usv(ed_pk), std::nullopt, std::nullopt}; REQUIRE_THROWS_WITH( ginfo.set_name("Super Group!"), "Unable to make changes to a read-only config object"); @@ -42,7 +49,7 @@ TEST_CASE("Verify-only Group Info", "[config][verify-only]") { CHECK(!ginfo.is_dirty()); // This one is good and has the right signature: - groups::Info ginfo_rw{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + groups::Info ginfo_rw{view_vec(enc_keys1), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; ginfo_rw.set_name("Super Group!!"); CHECK(ginfo_rw.is_dirty()); @@ -62,7 +69,7 @@ TEST_CASE("Verify-only Group Info", "[config][verify-only]") { CHECK(ginfo.merge(merge_configs) == 1); CHECK_FALSE(ginfo.needs_push()); - groups::Info ginfo_rw2{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + groups::Info ginfo_rw2{view_vec(enc_keys1), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; CHECK(ginfo_rw2.merge(merge_configs) == 1); CHECK_FALSE(ginfo.needs_push()); @@ -85,7 +92,7 @@ TEST_CASE("Verify-only Group Info", "[config][verify-only]") { ed_sk_bad1.data(), reinterpret_cast(seed_bad1.data())); - groups::Info ginfo_bad1{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + groups::Info ginfo_bad1{view_vec(enc_keys1), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; ginfo_bad1.merge(merge_configs); ginfo_bad1.set_sig_keys(to_usv(ed_sk_bad1)); ginfo_bad1.set_name("Bad name, BAD!"); @@ -158,20 +165,13 @@ TEST_CASE("Verify-only Group Info", "[config][verify-only]") { REQUIRE(et); CHECK(*et == 365 * 24h); }; - SECTION("read-only group info") { - test(ginfo); - } - SECTION("group writer 1") { - test(ginfo_rw); - } - SECTION("group writer 2") { - test(ginfo_rw2); - } - + test(ginfo); + test(ginfo_rw); + test(ginfo_rw2); CHECK(ginfo.needs_dump()); auto dump = ginfo.dump(); - groups::Info ginfo2{view_vec(enc_keys), to_usv(ed_pk), std::nullopt, dump}; + groups::Info ginfo2{view_vec(enc_keys1), to_usv(ed_pk), std::nullopt, dump}; CHECK(!ginfo.needs_dump()); CHECK(!ginfo2.needs_dump()); @@ -184,4 +184,41 @@ TEST_CASE("Verify-only Group Info", "[config][verify-only]") { CHECK(t4 == t5); CHECK(o4.empty()); CHECK(o5.empty()); + + // This account has a different primary decryption key + groups::Info ginfo_rw3{view_vec(enc_keys2), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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); } From 83c9dec70f8382a5bc780e74e11c172e415a4f30 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 4 Aug 2023 14:42:49 -0300 Subject: [PATCH 03/55] Remove duplicate implementation --- src/config/contacts.cpp | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/config/contacts.cpp b/src/config/contacts.cpp index 6e973504..85b011f1 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); } From 12d601b9db3aaf433569f3839bac1daae5a1fe43 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 4 Aug 2023 14:43:10 -0300 Subject: [PATCH 04/55] Add group info fields test --- tests/test_group_info.cpp | 103 +++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/tests/test_group_info.cpp b/tests/test_group_info.cpp index 2b532c17..c85315ea 100644 --- a/tests/test_group_info.cpp +++ b/tests/test_group_info.cpp @@ -17,7 +17,108 @@ static constexpr int64_t created_ts = 1680064059; using namespace session::config; -TEST_CASE("Verify-only Group Info", "[config][verify-only]") { +TEST_CASE("Group Info settings", "[config][groups]") { + + 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{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + enc_keys.insert(enc_keys.begin(), "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); + enc_keys.push_back("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"_hexbytes); + enc_keys.push_back("dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"_hexbytes); + groups::Info ginfo2{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + 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; From 6f1964611bd0b6a6bad7518f87cffa4f48794575 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 4 Aug 2023 16:43:48 -0300 Subject: [PATCH 05/55] Add group members config --- include/session/config/groups/members.hpp | 356 ++++++++++++++++++++++ include/session/config/namespaces.hpp | 1 + src/CMakeLists.txt | 1 + src/config/contacts.cpp | 3 + src/config/groups/members.cpp | 129 ++++++++ tests/CMakeLists.txt | 1 + tests/test_group_info.cpp | 2 +- tests/test_group_members.cpp | 240 +++++++++++++++ 8 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 include/session/config/groups/members.hpp create mode 100644 src/config/groups/members.cpp create mode 100644 tests/test_group_members.cpp diff --git a/include/session/config/groups/members.hpp b/include/session/config/groups/members.hpp new file mode 100644 index 00000000..92f6f9d8 --- /dev/null +++ b/include/session/config/groups/members.hpp @@ -0,0 +1,356 @@ +#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): +/// +/// 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: + member(const struct config_group_member& c); // From c struct + + /// API: groups/member::session_id + /// + /// The member's session ID, in hex. + std::string session_id; + + /// API: groups/member::name + /// + /// 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 + /// + /// 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 + /// + /// 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). + 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. + 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). + /// + /// 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). + /// + /// 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. + 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). + /// + /// 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). + /// + /// 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. + bool promoted() const { return admin || promotion_pending(); } + + /// API: groups/member::info + /// + /// converts the member info into a c struct + /// + /// Inputs: + /// - `c` -- Return Parameter that will be filled with data in contact_info + void into(config_group_member& c) 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 second argument. + /// + /// Inputs: + /// - `keys` -- contains the possible 32-byte en/decryption keys that may be used for incoming + /// messages. These are *not* Ed25519 secret keys, but rather symmetric encryption keys used + /// for encryption (generally generated using a cryptographically secure random generator). + /// The *first* key in this list will be used to encrypt outgoing config messages (and so, in + /// general, should be the most current key). There must always be at least one key present + /// (either provided at construction or via add_keys) before you can push a config. + /// Post-construction you can add or remove keys via add_key/remove_key/clear_keys from + /// ConfigBase. + /// - `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(const std::vector& keys, + 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); + /// c.name = "Session User 42"; + /// members.set(c); + /// ``` + /// + /// 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. + /// + /// 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); + + 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 c80957f9..7b1db95a 100644 --- a/include/session/config/namespaces.hpp +++ b/include/session/config/namespaces.hpp @@ -12,6 +12,7 @@ enum class Namespace : std::int16_t { // Groups namespaces (i.e. for config of the group itself, not one user's group settings) GroupInfo = 11, + GroupMembers = 12, }; } // namespace session::config diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1b5dcdeb..1e96f27d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -23,6 +23,7 @@ add_library(config config/encrypt.cpp config/error.c config/groups/info.cpp + config/groups/members.cpp config/internal.cpp config/user_groups.cpp config/user_profile.cpp diff --git a/src/config/contacts.cpp b/src/config/contacts.cpp index 85b011f1..2b9e5875 100644 --- a/src/config/contacts.cpp +++ b/src/config/contacts.cpp @@ -356,6 +356,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/groups/members.cpp b/src/config/groups/members.cpp new file mode 100644 index 00000000..2c37be88 --- /dev/null +++ b/src/config/groups/members.cpp @@ -0,0 +1,129 @@ +#include "session/config/groups/members.hpp" +#include + +#include "../internal.hpp" + +namespace session::config::groups { + +Members::Members( + const std::vector& keys, + ustring_view ed25519_pubkey, + std::optional ed25519_secretkey, + std::optional dumped) : + ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey} { + for (const auto& k : keys) + add_key(k, false); +} + +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)); + auto hex = 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; +} + + +member::member(std::string sid) : session_id{std::move(sid)} { + check_session_id(session_id); +} + +} // namespace session::config::groups diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a7cdefb3..1cba0814 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,7 @@ add_executable(testAll test_config_convo_info_volatile.cpp test_encrypt.cpp test_group_info.cpp + test_group_members.cpp test_xed25519.cpp ) diff --git a/tests/test_group_info.cpp b/tests/test_group_info.cpp index c85315ea..798aa2e7 100644 --- a/tests/test_group_info.cpp +++ b/tests/test_group_info.cpp @@ -17,7 +17,7 @@ static constexpr int64_t created_ts = 1680064059; using namespace session::config; -TEST_CASE("Group Info settings", "[config][groups]") { +TEST_CASE("Group Info settings", "[config][groups][info]") { const auto seed = "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; std::array ed_pk; diff --git a/tests/test_group_members.cpp b/tests/test_group_members.cpp new file mode 100644 index 00000000..974afb94 --- /dev/null +++ b/tests/test_group_members.cpp @@ -0,0 +1,240 @@ +#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; + +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{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + enc_keys.insert( + enc_keys.begin(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); + enc_keys.push_back("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"_hexbytes); + enc_keys.push_back("dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"_hexbytes); + groups::Members gmem2{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + + 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); + } +} From 8f142c0a35b1158cccf3afacf4219e69e4e75fbc Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 4 Aug 2023 19:18:01 -0300 Subject: [PATCH 06/55] Fix bad doc name --- include/session/config/groups/info.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/session/config/groups/info.hpp b/include/session/config/groups/info.hpp index 5276bdaf..5d417655 100644 --- a/include/session/config/groups/info.hpp +++ b/include/session/config/groups/info.hpp @@ -228,7 +228,7 @@ class Info final : public ConfigBase { /// (or negative) to disable the delete-attachment-before timestamp. void set_delete_attach_before(int64_t timestamp); - /// API: groups/Info::get_delete_before + /// API: groups/Info::get_delete_attach_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. From bf3df15913871fd369330951d85f267ee0f8efea Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 8 Aug 2023 13:52:35 -0300 Subject: [PATCH 07/55] More API doc fixes --- include/session/config/groups/info.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/session/config/groups/info.hpp b/include/session/config/groups/info.hpp index 5d417655..6d598f42 100644 --- a/include/session/config/groups/info.hpp +++ b/include/session/config/groups/info.hpp @@ -241,7 +241,7 @@ class Info final : public ConfigBase { /// - `int64_t` -- the unix timestamp for which all older messages shall be delete std::optional get_delete_attach_before() const; - /// API: groups/Info::destroy_group() + /// 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 @@ -254,7 +254,7 @@ class Info final : public ConfigBase { /// None: this call is destructive and permanent. Be careful! void destroy_group(); - /// API: groups/Info::is_destroyed() + /// API: groups/Info::is_destroyed /// /// Returns true if this group has been marked destroyed; the receiving client is expected to /// delete it. From 7e3001d90f2d720afbf7f1161928305704fe4891 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 18 Aug 2023 19:41:54 -0300 Subject: [PATCH 08/55] Group encryption keys Tests to follow. --- include/session/config.hpp | 47 +++ include/session/config/base.hpp | 190 +++++---- include/session/config/groups/info.hpp | 9 + include/session/config/groups/keys.hpp | 298 ++++++++++++++ include/session/config/groups/members.hpp | 31 +- include/session/config/namespaces.hpp | 1 + include/session/util.hpp | 225 +++++++++++ src/CMakeLists.txt | 2 + src/config.cpp | 117 +++--- src/config/base.cpp | 206 ++++++---- src/config/groups/info.cpp | 5 +- src/config/groups/keys.cpp | 451 ++++++++++++++++++++++ src/config/groups/members.cpp | 2 +- src/config/internal.cpp | 48 +++ src/config/internal.hpp | 21 + src/util.cpp | 16 + tests/test_group_info.cpp | 29 +- 17 files changed, 1453 insertions(+), 245 deletions(-) create mode 100644 include/session/config/groups/keys.hpp create mode 100644 src/config/groups/keys.cpp create mode 100644 src/util.cpp diff --git a/include/session/config.hpp b/include/session/config.hpp index f02fe961..0a27103f 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. @@ -342,6 +347,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). +/// - `signature_optional` -- true if the message is allowed to omit a signature; in such a case a +/// message will be accepted with no signature, or with a valid signature, but not with an invalid +/// signature. (Ignored if `verifier` is empty) +/// - `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. +/// +/// 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, + bool signature_optional, + std::optional>* verified_signature = nullptr); + } // namespace session::config namespace oxenc::detail { diff --git a/include/session/config/base.hpp b/include/session/config/base.hpp index 1c230b60..66c0e307 100644 --- a/include/session/config/base.hpp +++ b/include/session/config/base.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -44,9 +45,95 @@ 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. + /// + /// 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 @@ -64,18 +151,7 @@ class ConfigBase { // 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; - - // 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!) - using Ed25519PubKey = std::array; - using Ed25519Secret = std::array; - std::optional _sign_pk = std::nullopt; - Ed25519Secret* _sign_sk = nullptr; + 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 @@ -119,6 +195,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 @@ -702,7 +781,7 @@ 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. @@ -983,19 +1062,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 /// @@ -1008,10 +1092,26 @@ 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 /// @@ -1057,59 +1157,9 @@ 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()}; } - - /// API: base/ConfigBase::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/ConfigBase::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/ConfigBase::get_sig_pubkey - /// - /// Returns a const reference to the 32-byte Ed25519 signing pubkey, if set. - /// - /// 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/ConfigBase::clear_sig_keys - /// - /// Drops the signature pubkey and/or secret key, if the object has them. - /// - /// Inputs: none. - void clear_sig_keys(); }; // The C++ struct we hold opaquely inside the C internals struct. This is designed so that any diff --git a/include/session/config/groups/info.hpp b/include/session/config/groups/info.hpp index 6d598f42..c46bb83a 100644 --- a/include/session/config/groups/info.hpp +++ b/include/session/config/groups/info.hpp @@ -80,6 +80,15 @@ class Info final : public ConfigBase { /// - `const char*` - Will return "groups::Info" const char* encryption_domain() const override { return "groups::Info"; } + /// Returns the subaccount masking value. This is based on the group's seed and thus is only + /// obtainable by an admin account. + /// + /// Inputs: none + /// + /// Outputs: + /// - `ustring_view` - the 32-byte masking value. + std::array subaccount_mask() const; + /// API: groups/Info::get_name /// /// Returns the group name, or std::nullopt if there is no group name set. diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp new file mode 100644 index 00000000..dde05a43 --- /dev/null +++ b/include/session/config/groups/keys.hpp @@ -0,0 +1,298 @@ +#pragma once + +#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. +/// + -- set to 1 if this is a supplemental key message; omitted for a full key message. (It's +/// important that this key sort earlier than any fields that can differ between +/// supplemental/non-supplemental fields so we can identify the message type while parsing it). +/// 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. +/// ~ -- 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 `n` 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; + 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 !(*this == b); } + }; + + /// 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_; + + sodium_cleared> pending_key_; + sodium_vector pending_key_config_; + int64_t pending_gen_ = -1; + + 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(); + + 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; + + // 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 second 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. It is required. + /// - `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()`. + Keys(ustring_view user_ed25519_secretkey, + ustring_view group_ed25519_pubkey, + std::optional group_ed25519_secretkey, + std::optional dumped); + + /// 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. + /// + /// Outputs: + /// - `std::vector` - vector of encryption keys. + std::vector group_keys() const; + + /// 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 two 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. + /// + /// 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 `push()` if needed until it has been + /// confirmed or superceded). + ustring_view rekey(Info& info, Members& members); + + /// API: groups/Keys::pending_push + /// + /// 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 used to allow a rekey + member list update using + /// the new key in the same swarm upload sequence. + /// + /// 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. + /// + /// Inputs: + /// - `msg` - the full stored config message value + /// - `hash` - the storage message hash (used to track current config messages) + /// - `timestamp` - the timestamp (from the swarm) when this message was stored (used to track + /// when other keys expire). + /// - `members` - the given group::Members object's en/decryption key list will be updated to + /// match this object's key list. + /// - `info` - the given group::Info 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. + void load_key_message( + ustring_view data, + ustring_view msgid, + int64_t timestamp_ms, + Members& members, + Info& info); + + /// 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; +}; + +} // namespace session::config::groups diff --git a/include/session/config/groups/members.hpp b/include/session/config/groups/members.hpp index 92f6f9d8..7eefa6d9 100644 --- a/include/session/config/groups/members.hpp +++ b/include/session/config/groups/members.hpp @@ -189,13 +189,13 @@ class Members final : public ConfigBase { /// /// Inputs: /// - `keys` -- contains the possible 32-byte en/decryption keys that may be used for incoming - /// messages. These are *not* Ed25519 secret keys, but rather symmetric encryption keys used - /// for encryption (generally generated using a cryptographically secure random generator). - /// The *first* key in this list will be used to encrypt outgoing config messages (and so, in - /// general, should be the most current key). There must always be at least one key present - /// (either provided at construction or via add_keys) before you can push a config. - /// Post-construction you can add or remove keys via add_key/remove_key/clear_keys from - /// ConfigBase. + /// messages (both config messages and group messages). These are *not* Ed25519 secret keys, + /// but rather symmetric encryption keys used for encryption (generally generated using a + /// cryptographically secure random generator). The *first* key in this list will be used to + /// encrypt outgoing config messages (and so, in general, should be the most current key). + /// There must always be at least one key present (either provided at construction or via + /// add_keys) before you can push a config. Post-construction you can add or remove keys via + /// add_key/remove_key/clear_keys from ConfigBase. /// - `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. @@ -277,6 +277,23 @@ class Members final : public ConfigBase { /// /// 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 /// diff --git a/include/session/config/namespaces.hpp b/include/session/config/namespaces.hpp index 7b1db95a..1fc58c42 100644 --- a/include/session/config/namespaces.hpp +++ b/include/session/config/namespaces.hpp @@ -13,6 +13,7 @@ enum class Namespace : std::int16_t { // Groups namespaces (i.e. for config of the group itself, not one user's group settings) GroupInfo = 11, GroupMembers = 12, + GroupKeys = 13, }; } // namespace session::config diff --git a/include/session/util.hpp b/include/session/util.hpp index 6cfa77e2..cdb6fccb 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 { @@ -41,4 +48,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/src/CMakeLists.txt b/src/CMakeLists.txt index 1e96f27d..041afbc8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -24,10 +24,12 @@ add_library(config 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 83abde0a..d7062cb6 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, + bool signature_optional, + std::optional>* verified_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) { + if (sig.empty()) { + if (!signature_optional) + throw missing_signature{"Config signature is missing"}; + } else if (sig.size() != 64) + throw signature_error{"Config signature is invalid (not 64B)"}; + else if (!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; } @@ -568,38 +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 (sig.size() != 64) - throw signature_error{"Config signature is invalid (not 64B)"}; - else if (!verifier(to_verify, sig)) - throw signature_error{"Config signature failed verification"}; - else - std::memcpy(verified_signature_.emplace().data(), sig.data(), 64); - } + verify_config_sig(dict, serialized, verifier, signature_optional, &verified_signature_); } catch (const oxenc::bt_deserialize_invalid& err) { throw config_parse_error{"Failed to parse config file: "s + err.what()}; } @@ -689,7 +665,6 @@ ConfigMessage::ConfigMessage( return; } - unmerged_ = -1; // Clear any redundant messages. (we do it *here* rather than above because, in the diff --git a/src/config/base.cpp b/src/config/base.cpp index 5364b810..7ea0fbc9 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -21,8 +22,7 @@ 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"}; + 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)); @@ -60,7 +60,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(); @@ -96,9 +96,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; @@ -267,7 +266,7 @@ void compress_message(ustring& msg, int level) { } 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{ @@ -276,6 +275,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()); @@ -339,12 +339,19 @@ ConfigBase::ConfigBase( else _config = std::make_unique(); + 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) - assert(*ed25519_pubkey == ed25519_secretkey->substr(32)); + 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(); } } @@ -385,14 +392,8 @@ void ConfigBase::init_from_dump(std::string_view dump) { load_extra_data(std::move(extra)); } -ConfigBase::~ConfigBase() { - sodium_free(_keys); - if (_sign_sk) - sodium_free(_sign_sk); -} - int ConfigBase::key_count() const { - return _keys_size; + return _keys.size(); } bool ConfigBase::has_key(ustring_view key) const { @@ -400,86 +401,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) { @@ -490,49 +509,68 @@ void ConfigBase::load_key(ustring_view ed25519_secretkey) { add_key(ed25519_secretkey.substr(0, 32)); } -void ConfigBase::set_sig_keys(ustring_view secret) { +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 = static_cast(sodium_malloc(sizeof(Ed25519Secret))); - std::memcpy(_sign_sk->data(), secret.data(), secret.size()); + _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()); + crypto_sign_ed25519_sk_to_pk(_sign_pk->data(), _sign_sk.data()); - _config->verifier = [this](ustring_view data, ustring_view sig) { + 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()); - }; - _config->signer = [this](ustring_view 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())) + sig.data(), nullptr, data.data(), data.size(), _sign_sk.data())) throw std::runtime_error{"Internal error: config signing failed!"}; return sig; - }; + }); } -void ConfigBase::set_sig_pubkey(ustring_view pubkey) { +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); - _config->verifier = [this](ustring_view data, ustring_view sig) { + 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 ConfigBase::clear_sig_keys() { +void ConfigSig::clear_sig_keys() { _sign_pk.reset(); - if (_sign_sk) { - sodium_free(_sign_sk); - _sign_sk = nullptr; - } - _config->signer = nullptr; - _config->verifier = nullptr; + _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) { diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index 484a2e73..066c6225 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -6,7 +6,6 @@ #include #include "../internal.hpp" -//#include "session/config/groups/info.h" #include "session/config/error.h" #include "session/export.h" #include "session/types.hpp" @@ -27,6 +26,10 @@ Info::Info( add_key(k, false); } +std::array Info::subaccount_mask() const { + return seed_hash("SessionGroupSubaccountMask"); +} + std::optional Info::get_name() const { if (auto* s = data["n"].string(); s && !s->empty()) return *s; diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp new file mode 100644 index 00000000..88da86e8 --- /dev/null +++ b/src/config/groups/keys.cpp @@ -0,0 +1,451 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "../internal.hpp" + +namespace session::config::groups { + +Keys::Keys( + ustring_view user_ed25519_secretkey, + ustring_view group_ed25519_pubkey, + std::optional group_ed25519_secretkey, + std::optional dumped) { + + 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); +} + +std::vector Keys::group_keys() const { + std::vector ret; + ret.reserve(keys_.size() + !pending_key_config_.empty()); + + 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; +} + +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 (!_sign_sk || !_sign_pk) + 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)); + // d.append("+", 0); // Not supplemental, so leave off + + 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_xpk(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); + + return ustring_view{pending_key_config_.data(), pending_key_config_.size()}; +} + +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::load_key_message( + ustring_view data, ustring_view msgid, int64_t timestamp_ms, Members& members, Info& info) { + + 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()); + + bool supplemental = false; + if (d.skip_until("+")) { + auto supp = d.consume_integer(); + if (supp == 0 || supp == 1) + supplemental = static_cast(supp); + else + throw config_value_error{ + "Unexpected value " + std::to_string(supp) + " for '+' key (expected 0/1)"}; + } + + bool found_key = false; + sodium_cleared new_key{}; + new_key.timestamp = std::chrono::system_clock::from_time_t(timestamp_ms / 1000) + + 1ms * (timestamp_ms % 1000); + + 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 (!supplemental) { + 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 (_sign_sk) { + 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; + } + } + + sodium_cleared> member_dec_key; + if (!found_key) { + 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()); + } + + // 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 (!supplemental && member_key_count % MESSAGE_KEY_MULTIPLE != 0) + throw config_value_error{"Member key list has wrong size (missing junk key padding?)"}; + + verify_config_sig(d, data, verifier_, false); + + if (found_key) { + auto it = std::lower_bound(keys_.begin(), keys_.end(), new_key); + if (it != keys_.end() && new_key == *it) { + // We found a key we already had, so just ignore it. + found_key = false; + } else { + keys_.insert(it, new_key); + + remove_expired(); + } + } + + // If this is our pending config or this has a later generation than our pending config then + // drop our pending status. + if (!pending_key_config_.empty() && + (new_key.generation > pending_gen_ || new_key.key == pending_key_)) + pending_key_config_.clear(); + + auto new_key_list = group_keys(); + members.replace_keys(new_key_list, /*dirty=*/false); + info.replace_keys(new_key_list, /*dirty=*/false); +} + +void Keys::remove_expired() { + if (keys_.size() < 2) + return; + + 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 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); +} + +bool Keys::needs_rekey() const { + if (!_sign_sk || !_sign_pk || 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; +} + +} // namespace session::config::groups diff --git a/src/config/groups/members.cpp b/src/config/groups/members.cpp index 2c37be88..bfbdc1b3 100644 --- a/src/config/groups/members.cpp +++ b/src/config/groups/members.cpp @@ -1,4 +1,5 @@ #include "session/config/groups/members.hpp" + #include #include "../internal.hpp" @@ -121,7 +122,6 @@ bool Members::erase(std::string_view session_id) { return ret; } - member::member(std::string sid) : session_id{std::move(sid)} { check_session_id(session_id); } diff --git a/src/config/internal.cpp b/src/config/internal.cpp index 48ba1ec2..a6ac18bb 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -22,6 +23,13 @@ std::string session_id_to_bytes(std::string_view session_id) { return oxenc::from_hex(session_id); } +std::array session_id_xpk(std::string_view session_id) { + check_session_id(session_id); + std::array xpk; + oxenc::from_hex(session_id.begin(), session_id.end(), xpk.begin()); + return xpk; +} + 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 +133,44 @@ 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 session::config diff --git a/src/config/internal.hpp b/src/config/internal.hpp index 5bbab837..7b9d92e7 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -1,7 +1,10 @@ #pragma once +#include + #include #include +#include #include "session/config/base.hpp" #include "session/config/error.h" @@ -56,6 +59,10 @@ void check_session_id(std::string_view session_id); // Checks the session_id (throwing if invalid) then returns it as bytes std::string session_id_to_bytes(std::string_view session_id); +// Checks the session_id (throwing if invalid) then returns it as bytes, omitting the 05 prefix +// (which is the x25519 pubkey). +std::array session_id_xpk(std::string_view session_id); + // Validates an open group pubkey; we accept it in hex, base32z, or base64 (padded or unpadded). // Throws std::invalid_argument if invalid. void check_encoded_pubkey(std::string_view pk); @@ -113,4 +120,18 @@ 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); + } // namespace session::config diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 00000000..29cec1df --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,16 @@ +#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); +} +} // namespace session diff --git a/tests/test_group_info.cpp b/tests/test_group_info.cpp index 798aa2e7..b8013d0f 100644 --- a/tests/test_group_info.cpp +++ b/tests/test_group_info.cpp @@ -30,11 +30,14 @@ TEST_CASE("Group Info settings", "[config][groups][info]") { CHECK(oxenc::to_hex(seed.begin(), seed.end()) == oxenc::to_hex(ed_sk.begin(), ed_sk.begin() + 32)); - std::vector enc_keys{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes}; + std::vector enc_keys{ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes}; groups::Info ginfo1{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; - enc_keys.insert(enc_keys.begin(), "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); + enc_keys.insert( + enc_keys.begin(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); enc_keys.push_back("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"_hexbytes); enc_keys.push_back("dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"_hexbytes); groups::Info ginfo2{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; @@ -67,8 +70,8 @@ TEST_CASE("Group Info settings", "[config][groups][info]") { 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.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(); @@ -87,7 +90,9 @@ TEST_CASE("Group Info settings", "[config][groups][info]") { CHECK(ginfo1.merge(merge_configs) == 0); ginfo1.add_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"_hexbytes); - ginfo1.add_key("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"_hexbytes, /*prepend=*/ false); + ginfo1.add_key( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"_hexbytes, + /*prepend=*/false); CHECK(ginfo1.merge(merge_configs) == 1); @@ -96,11 +101,12 @@ TEST_CASE("Group Info settings", "[config][groups][info]") { 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_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.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"); @@ -110,11 +116,12 @@ TEST_CASE("Group Info settings", "[config][groups][info]") { 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_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.get_delete_before() == create_time + 50 * 86400); + CHECK(ginfo2.get_delete_attach_before() == create_time + 70 * 86400); CHECK(ginfo2.is_destroyed()); } From fdc664ef6ba17a7593c978e14d1d1cabada484f2 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 21 Aug 2023 14:05:25 -0300 Subject: [PATCH 09/55] Address ftrget review comments --- include/session/config/groups/info.hpp | 8 ++++---- include/session/config/groups/members.hpp | 10 +++++----- src/config/contacts.cpp | 1 - src/config/groups/members.cpp | 1 - 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/include/session/config/groups/info.hpp b/include/session/config/groups/info.hpp index c46bb83a..c278e00a 100644 --- a/include/session/config/groups/info.hpp +++ b/include/session/config/groups/info.hpp @@ -239,15 +239,15 @@ class Info final : public ConfigBase { /// API: groups/Info::get_delete_attach_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 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-before timestamp is set. + /// Returns std::nullopt if no delete-attachments-before timestamp is set. /// /// Inputs: none. /// /// Outputs: - /// - `int64_t` -- the unix timestamp for which all older messages shall be delete + /// - `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 diff --git a/include/session/config/groups/members.hpp b/include/session/config/groups/members.hpp index 7eefa6d9..55e8a3d3 100644 --- a/include/session/config/groups/members.hpp +++ b/include/session/config/groups/members.hpp @@ -150,11 +150,11 @@ struct member { /// API: groups/member::info /// - /// converts the member info into a c struct + /// Converts the member info into a C struct. /// /// Inputs: - /// - `c` -- Return Parameter that will be filled with data in contact_info - void into(config_group_member& c) const; + /// - `m` -- Reference to C struct to fill with group member info. + void into(config_group_member& m) const; /// API: groups/member::set_name /// @@ -265,8 +265,8 @@ class Members final : public ConfigBase { /// /// ```cpp /// auto m = members.get_or_construct(pubkey); - /// c.name = "Session User 42"; - /// members.set(c); + /// m.name = "Session User 42"; + /// members.set(m); /// ``` /// /// Inputs: diff --git a/src/config/contacts.cpp b/src/config/contacts.cpp index 2b9e5875..6c2ca1ce 100644 --- a/src/config/contacts.cpp +++ b/src/config/contacts.cpp @@ -338,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; } diff --git a/src/config/groups/members.cpp b/src/config/groups/members.cpp index bfbdc1b3..32980bac 100644 --- a/src/config/groups/members.cpp +++ b/src/config/groups/members.cpp @@ -80,7 +80,6 @@ void Members::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; } From 6e3cbf8c4498dc600c1145ceb987fc11244d2682 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 21 Aug 2023 15:01:51 -0300 Subject: [PATCH 10/55] Fix broken assert The first part of this assert shouldn't fail if we are starting from a fresh, blank config (in which case we don't actually serialize our own message anymore). --- src/config/base.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/base.cpp b/src/config/base.cpp index 7ea0fbc9..98ef19ab 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -221,7 +221,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()]; } From 0fcc07c197d5833c1f94f899c3fd9a6deba710eb Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 21 Aug 2023 17:15:14 -0300 Subject: [PATCH 11/55] Remove `signature_optional` parameter It doesn't really make sense to have this as we either want signatures (for shared messages) or don't (for personally encrypted messages). The only place we were passing it as `true` was in a place that also didn't pass a verifier or signer (during dumping), and so already wasn't adding/checking signatures. --- include/session/config.hpp | 16 ++-------------- src/config.cpp | 24 ++++++++---------------- src/config/base.cpp | 10 ++++------ src/config/groups/keys.cpp | 2 +- tests/test_configdata.cpp | 16 +--------------- 5 files changed, 16 insertions(+), 52 deletions(-) diff --git a/include/session/config.hpp b/include/session/config.hpp index 0a27103f..9eb7b8d5 100644 --- a/include/session/config.hpp +++ b/include/session/config.hpp @@ -127,8 +127,7 @@ class ConfigMessage { ustring_view serialized, verify_callable verifier = nullptr, sign_callable signer = nullptr, - int lag = DEFAULT_DIFF_LAGS, - bool signature_optional = false); + int lag = DEFAULT_DIFF_LAGS); /// Constructs a new ConfigMessage by loading and potentially merging multiple serialized /// ConfigMessages together, according to the config conflict resolution rules. The result @@ -152,10 +151,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 @@ -168,7 +163,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 @@ -288,7 +282,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 @@ -298,8 +291,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 @@ -372,9 +364,6 @@ class MutableConfigMessage : public ConfigMessage { /// `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). -/// - `signature_optional` -- true if the message is allowed to omit a signature; in such a case a -/// message will be accepted with no signature, or with a valid signature, but not with an invalid -/// signature. (Ignored if `verifier` is empty) /// - `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. @@ -386,7 +375,6 @@ void verify_config_sig( oxenc::bt_dict_consumer dict, ustring_view config_msg, const ConfigMessage::verify_callable& verifier, - bool signature_optional, std::optional>* verified_signature = nullptr); } // namespace session::config diff --git a/src/config.cpp b/src/config.cpp index d7062cb6..77b95e6e 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -433,7 +433,6 @@ void verify_config_sig( oxenc::bt_dict_consumer dict, ustring_view config_msg, const ConfigMessage::verify_callable& verifier, - bool signature_optional, std::optional>* verified_signature) { ustring_view to_verify, sig; dict.skip_until("~"); @@ -458,10 +457,9 @@ void verify_config_sig( 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 (sig.size() != 64) + if (sig.empty()) + throw missing_signature{"Config signature is missing"}; + else if (sig.size() != 64) throw signature_error{"Config signature is invalid (not 64B)"}; else if (!verifier(to_verify, sig)) throw signature_error{"Config signature failed verification"}; @@ -547,8 +545,7 @@ ConfigMessage::ConfigMessage( ustring_view serialized, verify_callable verifier_, sign_callable signer_, - int lag, - bool signature_optional) : + int lag) : verifier{std::move(verifier_)}, signer{std::move(signer_)}, lag{lag} { oxenc::bt_dict_consumer dict{from_unsigned_sv(serialized)}; @@ -575,7 +572,7 @@ ConfigMessage::ConfigMessage( load_unknowns(unknown_, dict, "=", "~"); - verify_config_sig(dict, serialized, verifier, signature_optional, &verified_signature_); + verify_config_sig(dict, serialized, verifier, &verified_signature_); } catch (const oxenc::bt_deserialize_invalid& err) { throw config_parse_error{"Failed to parse config file: "s + err.what()}; } @@ -586,7 +583,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} { @@ -594,7 +590,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) @@ -653,7 +649,7 @@ ConfigMessage::ConfigMessage( // 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 && !signature_optional) { + 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 @@ -716,14 +712,12 @@ 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(); @@ -733,14 +727,12 @@ MutableConfigMessage::MutableConfigMessage( ustring_view config, verify_callable verifier, sign_callable signer, - int lag, - bool signature_optional) : + 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() { diff --git a/src/config/base.cpp b/src/config/base.cpp index 98ef19ab..6b6d2e70 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -181,7 +181,6 @@ int ConfigBase::merge(const std::vector>& c _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 @@ -372,13 +371,12 @@ void ConfigBase::init_from_dump(std::string_view dump) { // could store a dump. _config = std::make_unique( to_unsigned_sv(d.consume_string_view()), - nullptr, // verifier, set later - nullptr, // signer, set later - config_lags(), - true /* signature optional because we don't sign the dump */); + 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); + to_unsigned_sv(d.consume_string_view()), nullptr, nullptr, config_lags()); if (d.skip_until("(")) { _curr_hash = d.consume_string(); diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 88da86e8..689db1a5 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -369,7 +369,7 @@ void Keys::load_key_message( if (!supplemental && member_key_count % MESSAGE_KEY_MULTIPLE != 0) throw config_value_error{"Member key list has wrong size (missing junk key padding?)"}; - verify_config_sig(d, data, verifier_, false); + verify_config_sig(d, data, verifier_); if (found_key) { auto it = std::lower_bound(keys_.begin(), keys_.end(), new_key); 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{ From c6fd4713aabfc3eb8df0b0ed48d51395de5b6593 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 21 Aug 2023 20:11:17 -0300 Subject: [PATCH 12/55] Move all key management into `Keys`; make Keys dumpable This makes Keys construction, loading, and rekeying take the Info and Members object to update their keys, thus removing the requirement for application code to worry about key management at all (they just feed the keys in, and they propagate to info/members). Also adds a state dump (similar to base config `dump()`) to Keys. --- include/session/config/groups/info.hpp | 19 ++-- include/session/config/groups/keys.hpp | 62 +++++++++-- include/session/config/groups/members.hpp | 16 +-- include/session/util.hpp | 8 ++ src/config/groups/info.cpp | 6 +- src/config/groups/keys.cpp | 119 +++++++++++++++++++++- src/config/groups/members.cpp | 6 +- src/util.cpp | 6 ++ tests/CMakeLists.txt | 1 + tests/test_group_info.cpp | 45 ++++++-- tests/test_group_keys.cpp | 75 ++++++++++++++ tests/test_group_members.cpp | 13 ++- 12 files changed, 318 insertions(+), 58 deletions(-) create mode 100644 tests/test_group_keys.cpp diff --git a/include/session/config/groups/info.hpp b/include/session/config/groups/info.hpp index c278e00a..6568a616 100644 --- a/include/session/config/groups/info.hpp +++ b/include/session/config/groups/info.hpp @@ -33,21 +33,15 @@ class Info final : public ConfigBase { /// API: groups/Info::Info /// - /// Constructs a group info config object from existing data (stored from `dump()`) and a list - /// of encryption keys for encrypting new and decrypting existing messages. + /// 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 second argument. + /// `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: - /// - `keys` -- contains the possible 32-byte en/decryption keys that may be used for incoming - /// messages. These are *not* Ed25519 secret keys, but rather symmetric encryption keys used - /// for encryption (generally generated using a cryptographically secure random generator). - /// The *first* key in this list will be used to encrypt outgoing config messages (and so, in - /// general, should be the most current key). There must always be at least one key present - /// (either provided at construction or via add_keys) before you can push a config. - /// Post-construction you can add or remove keys via add_key/remove_key/clear_keys from - /// ConfigBase. /// - `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. @@ -55,8 +49,7 @@ class Info final : public ConfigBase { /// 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(const std::vector& keys, - ustring_view ed25519_pubkey, + Info(ustring_view ed25519_pubkey, std::optional ed25519_secretkey, std::optional dumped); diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index dde05a43..b3f05179 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -63,13 +63,16 @@ class Keys final : public ConfigSig { struct key_info { std::array key; - std::chrono::system_clock::time_point timestamp; + 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 !(*this == b); } + 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 @@ -80,6 +83,8 @@ class Keys final : public ConfigSig { sodium_vector pending_key_config_; int64_t pending_gen_ = -1; + bool needs_dump_ = false; + ConfigMessage::verify_callable verifier_; ConfigMessage::sign_callable signer_; @@ -89,6 +94,9 @@ class Keys final : public ConfigSig { // 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); + 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 @@ -123,22 +131,29 @@ class Keys final : public ConfigSig { /// 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 second argument. + /// `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. It is required. + /// 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); + std::optional dumped, + Info& info, + Members& members); /// API: groups/Keys::storage_namespace /// @@ -167,6 +182,9 @@ class Keys final : public ConfigSig { /// 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. + /// /// Outputs: /// - `std::vector` - vector of encryption keys. std::vector group_keys() const; @@ -247,7 +265,8 @@ class Keys final : public ConfigSig { /// 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. + /// malformed or is not properly signed an exception will be raised (but the Keys object remains + /// usable). /// /// Inputs: /// - `msg` - the full stored config message value @@ -265,8 +284,8 @@ class Keys final : public ConfigSig { ustring_view data, ustring_view msgid, int64_t timestamp_ms, - Members& members, - Info& info); + Info& info, + Members& members); /// API: groups/Keys::needs_rekey /// @@ -293,6 +312,33 @@ class Keys final : public ConfigSig { /// 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(); }; } // namespace session::config::groups diff --git a/include/session/config/groups/members.hpp b/include/session/config/groups/members.hpp index 55e8a3d3..43f3ee0e 100644 --- a/include/session/config/groups/members.hpp +++ b/include/session/config/groups/members.hpp @@ -185,17 +185,12 @@ class Members final : public ConfigBase { /// 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 second argument. + /// `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: - /// - `keys` -- contains the possible 32-byte en/decryption keys that may be used for incoming - /// messages (both config messages and group messages). These are *not* Ed25519 secret keys, - /// but rather symmetric encryption keys used for encryption (generally generated using a - /// cryptographically secure random generator). The *first* key in this list will be used to - /// encrypt outgoing config messages (and so, in general, should be the most current key). - /// There must always be at least one key present (either provided at construction or via - /// add_keys) before you can push a config. Post-construction you can add or remove keys via - /// add_key/remove_key/clear_keys from ConfigBase. /// - `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. @@ -203,8 +198,7 @@ class Members final : public ConfigBase { /// 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(const std::vector& keys, - ustring_view ed25519_pubkey, + Members(ustring_view ed25519_pubkey, std::optional ed25519_secretkey, std::optional dumped); diff --git a/include/session/util.hpp b/include/session/util.hpp index cdb6fccb..322756c9 100644 --- a/include/session/util.hpp +++ b/include/session/util.hpp @@ -30,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) { diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index 066c6225..b9ecaa28 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -17,14 +17,10 @@ using namespace std::literals; using session::ustring_view; Info::Info( - const std::vector& keys, ustring_view ed25519_pubkey, std::optional ed25519_secretkey, std::optional dumped) : - ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey} { - for (const auto& k : keys) - add_key(k, false); -} + ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey} {} std::array Info::subaccount_mask() const { return seed_hash("SessionGroupSubaccountMask"); diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 689db1a5..6a78b6fd 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -15,11 +16,17 @@ 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) { + std::optional dumped, + Info& info, + Members& members) { if (sodium_init() == -1) throw std::runtime_error{"libsodium initialization failed!"}; @@ -34,6 +41,103 @@ Keys::Keys( init_sig_keys(group_ed25519_pubkey, group_ed25519_secretkey); user_ed25519_sk.load(user_ed25519_secretkey.data(), 64); + + if (dumped) { + load_dump(*dumped); + } else if (_sign_sk) { + rekey(info, members); + } +} + +bool Keys::needs_dump() const { + return needs_dump_; +} + +ustring Keys::dump() { + oxenc::bt_dict_producer d; + { + 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("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()); + } } std::vector Keys::group_keys() const { @@ -237,6 +341,8 @@ ustring_view Keys::rekey(Info& info, Members& members) { 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()}; } @@ -247,7 +353,7 @@ std::optional Keys::pending_config() const { } void Keys::load_key_message( - ustring_view data, ustring_view msgid, int64_t timestamp_ms, Members& members, Info& info) { + ustring_view data, ustring_view msgid, int64_t timestamp_ms, Info& info, Members& members) { oxenc::bt_dict_consumer d{from_unsigned_sv(data)}; @@ -272,8 +378,7 @@ void Keys::load_key_message( bool found_key = false; sodium_cleared new_key{}; - new_key.timestamp = std::chrono::system_clock::from_time_t(timestamp_ms / 1000) + - 1ms * (timestamp_ms % 1000); + 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"}; @@ -380,14 +485,18 @@ void Keys::load_key_message( keys_.insert(it, new_key); remove_expired(); + + needs_dump_ = true; } } // If this is our pending config or this has a later generation than our pending config then // drop our pending status. if (!pending_key_config_.empty() && - (new_key.generation > pending_gen_ || new_key.key == pending_key_)) + (new_key.generation > pending_gen_ || new_key.key == pending_key_)) { pending_key_config_.clear(); + needs_dump_ = true; + } auto new_key_list = group_keys(); members.replace_keys(new_key_list, /*dirty=*/false); diff --git a/src/config/groups/members.cpp b/src/config/groups/members.cpp index 32980bac..fffbcf99 100644 --- a/src/config/groups/members.cpp +++ b/src/config/groups/members.cpp @@ -7,14 +7,10 @@ namespace session::config::groups { Members::Members( - const std::vector& keys, ustring_view ed25519_pubkey, std::optional ed25519_secretkey, std::optional dumped) : - ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey} { - for (const auto& k : keys) - add_key(k, false); -} + 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); diff --git a/src/util.cpp b/src/util.cpp index 29cec1df..8a4d5b44 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -13,4 +13,10 @@ 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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1cba0814..dbf39f45 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,7 @@ 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 diff --git a/tests/test_group_info.cpp b/tests/test_group_info.cpp index b8013d0f..eac73373 100644 --- a/tests/test_group_info.cpp +++ b/tests/test_group_info.cpp @@ -33,14 +33,22 @@ TEST_CASE("Group Info settings", "[config][groups][info]") { std::vector enc_keys{ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes}; - groups::Info ginfo1{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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()); @@ -148,7 +156,10 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes); // This Info object has only the public key, not the priv key, and so cannot modify things: - groups::Info ginfo{view_vec(enc_keys1), to_usv(ed_pk), std::nullopt, std::nullopt}; + 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"); @@ -157,7 +168,10 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { CHECK(!ginfo.is_dirty()); // This one is good and has the right signature: - groups::Info ginfo_rw{view_vec(enc_keys1), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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()); @@ -177,7 +191,11 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { CHECK(ginfo.merge(merge_configs) == 1); CHECK_FALSE(ginfo.needs_push()); - groups::Info ginfo_rw2{view_vec(enc_keys1), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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()); @@ -200,7 +218,11 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { ed_sk_bad1.data(), reinterpret_cast(seed_bad1.data())); - groups::Info ginfo_bad1{view_vec(enc_keys1), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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!"); @@ -279,7 +301,10 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { CHECK(ginfo.needs_dump()); auto dump = ginfo.dump(); - groups::Info ginfo2{view_vec(enc_keys1), to_usv(ed_pk), std::nullopt, 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()); @@ -294,7 +319,11 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { CHECK(o5.empty()); // This account has a different primary decryption key - groups::Info ginfo_rw3{view_vec(enc_keys2), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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"); diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp new file mode 100644 index 00000000..2750e547 --- /dev/null +++ b/tests/test_group_keys.cpp @@ -0,0 +1,75 @@ +#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; + +TEST_CASE("Group Keys", "[config][groups][keys]") { + + const std::array seeds = { + "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes, + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"_hexbytes, + "000111222333444555666777888999aaabbbcccdddeeefff0123456789abcdef"_hexbytes, + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"_hexbytes, + "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes}; + std::array, seeds.size()> ed_pk; + std::array, seeds.size()> ed_sk; + for (size_t i = 0; i < seeds.size(); i++) { + crypto_sign_ed25519_seed_keypair( + ed_pk[i].data(), + ed_sk[i].data(), + reinterpret_cast(seeds[i].data())); + CHECK(oxenc::to_hex(seeds[i].begin(), seeds[i].end()) == + oxenc::to_hex(ed_sk[i].begin(), ed_sk[i].begin() + 32)); + } + + constexpr size_t ADMIN1 = 0, ADMIN2 = 1, MEMBER1 = 2, MEMBER2 = 3, GROUP = 4; + + REQUIRE(oxenc::to_hex(ed_pk[ADMIN1].begin(), ed_pk[ADMIN1].end()) == + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece"); + REQUIRE(oxenc::to_hex(ed_pk[ADMIN2].begin(), ed_pk[ADMIN2].end()) == + "3ccd241cffc9b3618044b97d036d8614593d8b017c340f1dee8773385517654b"); + REQUIRE(oxenc::to_hex(ed_pk[MEMBER1].begin(), ed_pk[MEMBER1].end()) == + "8b79719da06ee8a14823f0c8d740aabb134ab7cbc174b8c1a022a27c0964abfd"); + REQUIRE(oxenc::to_hex(ed_pk[MEMBER2].begin(), ed_pk[MEMBER2].end()) == + "d813a070116a8c74e6fcbb3f53d5698a14b6236fcca9bb3136acff749dacdcc4"); + REQUIRE(oxenc::to_hex(ed_pk[GROUP].begin(), ed_pk[GROUP].end()) == + "c50cb3ae956947a8de19135b5be2685ff348afc63fc34a837aca12bc5c1f5625"); + + std::array, seeds.size() - 1> info; + std::array, seeds.size() - 1> members; + std::array, seeds.size() - 1> keys; + for (size_t i = 0; i < GROUP; i++) { + info[i] = std::make_unique( + to_usv(ed_pk[GROUP]), + i <= ADMIN2 ? std::make_optional(to_usv(ed_sk[GROUP])) : std::nullopt, + std::nullopt); + members[i] = std::make_unique( + to_usv(ed_pk[GROUP]), + i <= ADMIN2 ? std::make_optional(to_usv(ed_sk[GROUP])) : std::nullopt, + std::nullopt); + keys[i] = std::make_unique( + to_usv(ed_sk[i]), + to_usv(ed_pk[GROUP]), + i <= ADMIN2 ? std::make_optional(to_usv(ed_sk[GROUP])) : std::nullopt, + std::nullopt, + *info[i], + *members[i] + ); + } +} diff --git a/tests/test_group_members.cpp b/tests/test_group_members.cpp index 974afb94..24afaf49 100644 --- a/tests/test_group_members.cpp +++ b/tests/test_group_members.cpp @@ -1,6 +1,5 @@ #include #include -#include #include #include @@ -43,14 +42,22 @@ TEST_CASE("Group Members", "[config][groups][members]") { std::vector enc_keys{ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"_hexbytes}; - groups::Members gmem1{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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{view_vec(enc_keys), to_usv(ed_pk), to_usv(ed_sk), std::nullopt}; + 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) { From dddc5b332cf0bb6d335c81b95582620b0d3e4de4 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 21 Aug 2023 21:42:31 -0300 Subject: [PATCH 13/55] Doc CI fix --- .drone.jsonnet | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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', ], From f929e79ae1b873347fd6e72dae48e66d264ca019 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 21 Aug 2023 23:08:01 -0300 Subject: [PATCH 14/55] Doc fixes --- docs/api/api-to-markdown.py | 12 +++++++-- docs/api/static/sidebar.md | 1 + external/oxen-encoding | 2 +- include/session/config/base.h | 2 ++ include/session/config/base.hpp | 2 ++ include/session/config/groups/info.hpp | 2 ++ include/session/config/groups/keys.hpp | 7 +++-- include/session/config/groups/members.hpp | 31 +++++++++++++++++++++++ 8 files changed, 54 insertions(+), 5 deletions(-) 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 3bca3ac2..fc85dfd3 160000 --- a/external/oxen-encoding +++ b/external/oxen-encoding @@ -1 +1 @@ -Subproject commit 3bca3ac22dac31258a4dd158e1e6568aa2315c75 +Subproject commit fc85dfd352e8474bc7195b0ba881838bd72ebea6 diff --git a/include/session/config/base.h b/include/session/config/base.h index 522445f1..27e108d5 100644 --- a/include/session/config/base.h +++ b/include/session/config/base.h @@ -486,6 +486,8 @@ LIBSESSION_EXPORT void config_set_sig_pubkey(config_object* conf, const unsigned /// 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); diff --git a/include/session/config/base.hpp b/include/session/config/base.hpp index 66c0e307..b4f9511b 100644 --- a/include/session/config/base.hpp +++ b/include/session/config/base.hpp @@ -119,6 +119,8 @@ class ConfigSig { /// /// 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; } diff --git a/include/session/config/groups/info.hpp b/include/session/config/groups/info.hpp index 6568a616..f02d4ef1 100644 --- a/include/session/config/groups/info.hpp +++ b/include/session/config/groups/info.hpp @@ -261,6 +261,8 @@ class Info final : public ConfigBase { /// 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; diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index b3f05179..71c35445 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -185,6 +185,8 @@ class Keys final : public ConfigSig { /// 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; @@ -336,8 +338,9 @@ class Keys final : public ConfigSig { /// /// 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. + /// 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(); }; diff --git a/include/session/config/groups/members.hpp b/include/session/config/groups/members.hpp index 43f3ee0e..7513ab72 100644 --- a/include/session/config/groups/members.hpp +++ b/include/session/config/groups/members.hpp @@ -45,17 +45,23 @@ struct member { /// 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. @@ -63,6 +69,8 @@ struct member { /// 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 @@ -83,12 +91,18 @@ struct member { /// 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 @@ -96,6 +110,8 @@ struct member { /// 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; } @@ -105,6 +121,8 @@ struct member { /// 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; } @@ -121,6 +139,10 @@ struct member { /// 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; } @@ -130,6 +152,8 @@ struct member { /// 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; } @@ -139,6 +163,8 @@ struct member { /// 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; } @@ -146,6 +172,11 @@ struct member { /// 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::info From cb40a14c9d9a20a1c5db7c0e1d6a8b0d1cb922cd Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 22 Aug 2023 13:21:07 -0300 Subject: [PATCH 15/55] Add dedicated namespace for messages; rearrange config namespace values Messages to a new group can't go into 0 (since that is publicly depositable), so reserve a namespace (11) for messages. --- include/session/config/namespaces.hpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/include/session/config/namespaces.hpp b/include/session/config/namespaces.hpp index 1fc58c42..f7d48469 100644 --- a/include/session/config/namespaces.hpp +++ b/include/session/config/namespaces.hpp @@ -10,10 +10,12 @@ enum class Namespace : std::int16_t { ConvoInfoVolatile = 4, UserGroups = 5, - // Groups namespaces (i.e. for config of the group itself, not one user's group settings) - GroupInfo = 11, - GroupMembers = 12, - GroupKeys = 13, + // 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 From 60cbeca4b42c9ecc1363585e2ae399b47962fd99 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 22 Aug 2023 16:29:15 -0300 Subject: [PATCH 16/55] Add group message encryption + compression Adds method for encrypting/decrypting a message. This supports both compressed+encrypted and just plain encrypted. Abstracts the zstd compression implementation from base.cpp into internal.cpp, and uses it inside the new group message encryption Compression is only used if beneficial (that is, only if compression actually reduces the message size). --- include/session/config/groups/keys.hpp | 62 ++++++++++++++++ src/config/base.cpp | 41 ++--------- src/config/groups/keys.cpp | 97 ++++++++++++++++++++++++++ src/config/internal.cpp | 53 ++++++++++++++ src/config/internal.hpp | 7 ++ 5 files changed, 226 insertions(+), 34 deletions(-) diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index 71c35445..bd271468 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -191,6 +191,20 @@ class Keys final : public ConfigSig { /// - `std::vector` - vector of encryption keys. std::vector group_keys() 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::rekey /// /// Generate a new encryption key for the group and returns an encrypted key message to be @@ -342,6 +356,54 @@ class Keys final : public ConfigSig { /// - 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 if there no encryption keys are available at all (which should not + /// occur in normal use). + /// + /// 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. + /// + /// 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/src/config/base.cpp b/src/config/base.cpp index 6b6d2e70..3fac5f09 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -5,12 +5,12 @@ #include #include #include -#include #include #include #include +#include "internal.hpp" #include "session/config/base.h" #include "session/config/encrypt.hpp" #include "session/export.h" @@ -133,33 +133,13 @@ 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') @@ -252,15 +232,8 @@ 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); } diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 6a78b6fd..c1fe603c 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -153,6 +153,14 @@ std::vector Keys::group_keys() const { return ret; } +ustring_view Keys::group_enc_key() const { + 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)) @@ -557,4 +565,93 @@ std::optional Keys::pending_key() const { 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 { + 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)) + plain = std::move(*decomp); + else + // Decompression failed + return std::nullopt; + } + + return std::move(plain); +} + } // namespace session::config::groups diff --git a/src/config/internal.cpp b/src/config/internal.cpp index a6ac18bb..a84d3689 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -173,4 +174,56 @@ void load_unknowns( } } +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) { + 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; + + 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 7b9d92e7..5be9a601 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -134,4 +134,11 @@ void load_unknowns( 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. +std::optional zstd_decompress(ustring_view data); + } // namespace session::config From 286243cc22f0901b75d5a06957bc4f494cce3836 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 22 Aug 2023 16:31:20 -0300 Subject: [PATCH 17/55] Revert me -- disable broken group keys test --- tests/test_group_keys.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index 2750e547..07897171 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -19,6 +19,7 @@ static constexpr int64_t created_ts = 1680064059; using namespace session::config; +/* TEST_CASE("Group Keys", "[config][groups][keys]") { const std::array seeds = { @@ -73,3 +74,4 @@ TEST_CASE("Group Keys", "[config][groups][keys]") { ); } } +*/ From 95aeea6cf4733f9e266ef09f426e05c533871652 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 22 Aug 2023 17:25:00 -0300 Subject: [PATCH 18/55] groups::Info C API --- include/session/config/groups/info.h | 282 ++++++++++----------------- src/config/groups/info.cpp | 261 ++++++++++++++++++++++++- 2 files changed, 365 insertions(+), 178 deletions(-) diff --git a/include/session/config/groups/info.h b/include/session/config/groups/info.h index d5326192..9fb4129b 100644 --- a/include/session/config/groups/info.h +++ b/include/session/config/groups/info.h @@ -8,31 +8,18 @@ extern "C" { #include "../profile_pic.h" #include "../util.h" -/// API: groups/group_info_init +/// 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`). /// -/// Declaration: -/// ```cpp -/// INT group_info_init( -/// [out] config_object** conf, -/// [in] const unsigned char** keys, -/// [in] size_t keylen, -/// [in] const unsigned char* dump, -/// [in] size_t dumplen, -/// [out] char* error -/// ); -/// ``` -/// /// Inputs: /// - `conf` -- [out] Pointer to the config object -/// - `keys` -- pointer to the beginning of an array of 32-byte encryption/decryption keys for this -/// group info. These should be specified in most-recent-to-least-recent order; the *first* key -/// will be the one used for encryption when pushing an update. -/// - `keylen` -- the number of the `keys` array +/// - `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. @@ -43,226 +30,171 @@ extern "C" { /// 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 contacts_init( +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: contacts/contacts_get -/// -/// Fills `contact` with the contact info given a session ID (specified as a null-terminated hex -/// string), if the contact exists, and returns true. If the contact does not exist then `contact` -/// is left unchanged and false is returned. +/// API: groups_info/groups_info_get_name /// -/// Declaration: -/// ```cpp -/// BOOL contacts_get( -/// [in] config_object* conf, -/// [out] contacts_contact* contact, -/// [in] const char* session_id -/// ); -/// ``` +/// 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 -/// - `contact` -- [out] the contact info data -/// - `session_id` -- [in] null terminated hex string -/// -/// Output: -/// - `bool` -- Returns true if contact exsts -LIBSESSION_EXPORT bool contacts_get( - config_object* conf, contacts_contact* contact, const char* session_id) - __attribute__((warn_unused_result)); +/// +/// 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: contacts/contacts_get_or_construct +/// API: groups_info/groups_info_set_name /// -/// Same as the above `contacts_get()` except that when the contact does not exist, this sets all -/// the contact fields to defaults and loads it with the given session_id. +/// 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). /// -/// 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. +/// Inputs: +/// - `conf` -- [in] Pointer to the config object +/// - `name` -- [in] Pointer to the name as a null-terminated C string /// -/// This is the method that should usually be used to create or update a contact, followed by -/// setting fields in the contact, and then giving it to contacts_set(). +/// 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 /// -/// Declaration: -/// ```cpp -/// BOOL contacts_get_or_construct( -/// [in] config_object* conf, -/// [out] contacts_contact* contact, -/// [in] const char* session_id -/// ); -/// ``` +/// 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 -/// - `contact` -- [out] the contact info data -/// - `session_id` -- [in] null terminated hex string -/// -/// Output: -/// - `bool` -- Returns true if contact exsts -LIBSESSION_EXPORT bool contacts_get_or_construct( - config_object* conf, contacts_contact* contact, const char* session_id) - __attribute__((warn_unused_result)); - -/// API: contacts/contacts_set /// -/// Adds or updates a contact from the given contact info struct. +/// 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 /// -/// Declaration: -/// ```cpp -/// VOID contacts_set( -/// [in, out] config_object* conf, -/// [in] const contacts_contact* contact -/// ); -/// ``` +/// Sets a user profile /// /// Inputs: -/// - `conf` -- [in, out] Pointer to the config object -/// - `contact` -- [in] Pointer containing the contact info data +/// - `conf` -- [in] Pointer to the config object +/// - `pic` -- [in] Pointer to the pic /// -/// Output: -/// - `void` -- Returns Nothing -LIBSESSION_EXPORT void contacts_set(config_object* conf, const contacts_contact* contact); - -// NB: wrappers for set_name, set_nickname, etc. C++ methods are deliberately omitted as they would -// save very little in actual calling code. The procedure for updating a single field without them -// is simple enough; for example to update `approved` and leave everything else unchanged: -// -// contacts_contact c; -// if (contacts_get_or_construct(conf, &c, some_session_id)) { -// const char* new_nickname = "Joe"; -// c.approved = new_nickname; -// contacts_set_or_create(conf, &c); -// } else { -// // some_session_id was invalid! -// } +/// 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: contacts/contacts_erase -/// -/// Erases a contact from the contact list. session_id is in hex. Returns true if the contact was -/// found and removed, false if the contact was not present. You must not call this during -/// iteration; see details below. +/// API: groups_info/groups_info_get_expiry_timer /// -/// Declaration: -/// ```cpp -/// BOOL contacts_erase( -/// [in, out] config_object* conf, -/// [in] const char* session_id -/// ); -/// ``` +/// Gets the group's message expiry timer (seconds). Returns 0 if not set. /// /// Inputs: -/// - `conf` -- [in, out] Pointer to the config object -/// - `session_id` -- [in] Text containing null terminated hex string +/// - `conf` -- [in] Pointer to the config object /// /// Outputs: -/// - `bool` -- True if erasing was successful -LIBSESSION_EXPORT bool contacts_erase(config_object* conf, const char* session_id); +/// - `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: contacts/contacts_size +/// 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. /// -/// Returns the number of contacts. +/// 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 /// -/// Declaration: -/// ```cpp -/// SIZE_T contacts_size( -/// [in] const config_object* conf -/// ); -/// ``` +/// Returns the timestamp (unix time, in seconds) when the group was created. Returns 0 if unset. /// /// Inputs: -/// - `conf` -- input - Pointer to the config object +/// - `conf` -- [in] Pointer to the config object /// /// Outputs: -/// - `size_t` -- number of contacts -LIBSESSION_EXPORT size_t contacts_size(const config_object* conf); +/// - `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); -typedef struct contacts_iterator { - void* _internals; -} contacts_iterator; +/// 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: contacts/contacts_iterator_new +/// API: groups_info/groups_info_get_delete_before /// -/// Starts a new iterator. +/// 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. /// -/// Functions for iterating through the entire contact list, in sorted order. Intended use is: +/// Inputs: +/// - `conf` -- [in] Pointer to the config object /// -/// contacts_contact c; -/// contacts_iterator *it = contacts_iterator_new(contacts); -/// for (; !contacts_iterator_done(it, &c); contacts_iterator_advance(it)) { -/// // c.session_id, c.nickname, etc. are loaded -/// } -/// contacts_iterator_free(it); +/// 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 /// -/// It is NOT permitted to add/remove/modify records while iterating. +/// Sets the delete-before time (unix timestamp, in seconds) before which messages should be delete. +/// Setting 0 clears the value. /// -/// Declaration: -/// ```cpp -/// CONTACTS_ITERATOR* contacts_iterator_new( -/// [in] const config_object* conf -/// ); -/// ``` +/// 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: -/// - `contacts_iterator*` -- pointer to the iterator -LIBSESSION_EXPORT contacts_iterator* contacts_iterator_new(const config_object* conf); +/// - `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: contacts/contacts_iterator_free +/// API: groups_info/groups_info_set_attach_delete_before /// -/// Frees an iterator once no longer needed. -/// -/// Declaration: -/// ```cpp -/// VOID contacts_iterator_free( -/// [in] contacts_iterator* it -/// ); -/// ``` +/// 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: -/// - `it` -- [in] Pointer to the contacts_iterator -LIBSESSION_EXPORT void contacts_iterator_free(contacts_iterator* it); +/// - `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: contacts/contacts_iterator_done +/// API: groups_info/groups_info_is_destroyed(const config_object* conf); /// -/// Returns true if iteration has reached the end. Otherwise `c` is populated and false is -/// returned. -/// -/// Declaration: -/// ```cpp -/// BOOL contacts_iterator_done( -/// [in] contacts_iterator* it, -/// [out] contacts_contact* c -/// ); -/// ``` +/// 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: -/// - `it` -- [in] Pointer to the contacts_iterator -/// - `c` -- [out] Pointer to the contact, will be populated if false +/// - `conf` -- [in] Pointer to the config object /// /// Outputs: -/// - `bool` -- True if iteration has reached the end -LIBSESSION_EXPORT bool contacts_iterator_done(contacts_iterator* it, contacts_contact* c); +/// - `true` if the group has been nuked, `false` otherwise. +LIBSESSION_EXPORT bool groups_info_is_destroyed(const config_object* conf); -/// API: contacts/contacts_iterator_advance +/// API: groups_info/groups_info_destroy_group(const config_object* conf); /// -/// Advances the iterator. -/// -/// Declaration: -/// ```cpp -/// VOID contacts_iterator_advance( -/// [in] contacts_iterator* it -/// ); -/// ``` +/// Nukes a group from orbit. This is permanent (i.e. there is no removing this setting once set). /// /// Inputs: -/// - `it` -- [in] Pointer to the contacts_iterator -LIBSESSION_EXPORT void contacts_iterator_advance(contacts_iterator* it); +/// - `conf` -- [in] Pointer to the config object +LIBSESSION_EXPORT void groups_info_destroy_group(config_object* conf); #ifdef __cplusplus } // extern "C" diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index b9ecaa28..f1a67ff2 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -7,14 +7,14 @@ #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" -namespace session::config::groups { - using namespace std::literals; -using session::ustring_view; + +namespace session::config::groups { Info::Info( ustring_view ed25519_pubkey, @@ -104,3 +104,258 @@ bool Info::is_destroyed() const { } } // 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_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, 32); + std::optional dump; + if (dump_bytes && dumplen) + dump.emplace(dump_bytes, dumplen); + + auto c_conf = std::make_unique(); + auto c = std::make_unique>(); + + try { + c->config = std::make_unique(ed25519_pubkey, ed25519_secretkey, dump); + } 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->internals = c.release(); + c_conf->last_error = nullptr; + *conf = c_conf.release(); + return SESSION_ERR_NONE; +} + +/// 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(); +} From a2dc2e9e147937d85d41e22cf5cdbd159f26229d Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 23 Aug 2023 10:55:55 -0300 Subject: [PATCH 19/55] Add Group Members C wrappers --- include/session/config/groups/members.h | 199 ++++++++++++++++++++++ include/session/config/groups/members.hpp | 16 +- src/config/groups/info.cpp | 37 +--- src/config/groups/members.cpp | 117 +++++++++++++ src/config/internal.hpp | 55 ++++-- 5 files changed, 375 insertions(+), 49 deletions(-) create mode 100644 include/session/config/groups/members.h diff --git a/include/session/config/groups/members.h b/include/session/config/groups/members.h new file mode 100644 index 00000000..3e03ffcb --- /dev/null +++ b/include/session/config/groups/members.h @@ -0,0 +1,199 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "../base.h" +#include "../util.h" +#include "../profile_pic.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 exsts +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 member exists, false if not (`member` is always filled +/// regardless). +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 contacts_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 index 7513ab72..34a06eb3 100644 --- a/include/session/config/groups/members.hpp +++ b/include/session/config/groups/members.hpp @@ -8,6 +8,8 @@ #include "../namespaces.hpp" #include "../profile_pic.hpp" +struct config_group_member; + namespace session::config::groups { using namespace std::literals; @@ -41,7 +43,7 @@ struct member { explicit member(std::string sid); // Internal ctor/method for C API implementations: - member(const struct config_group_member& c); // From c struct + explicit member(const config_group_member& c); // From c struct /// API: groups/member::session_id /// @@ -179,7 +181,7 @@ struct member { /// - `bool` -- true if the member is promoted (or promotion-in-progress) bool promoted() const { return admin || promotion_pending(); } - /// API: groups/member::info + /// API: groups/member::into /// /// Converts the member info into a C struct. /// @@ -326,6 +328,16 @@ class Members final : public ConfigBase { /// - 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 /// diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index f1a67ff2..2ba43e8f 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -110,41 +110,12 @@ using namespace session::config; LIBSESSION_C_API int groups_info_init( config_object** conf, - const unsigned char* ed25519_pubkey_bytes, - const unsigned char* ed25519_secretkey_bytes, - const unsigned char* dump_bytes, + const unsigned char* ed25519_pubkey, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, 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, 32); - std::optional dump; - if (dump_bytes && dumplen) - dump.emplace(dump_bytes, dumplen); - - auto c_conf = std::make_unique(); - auto c = std::make_unique>(); - - try { - c->config = std::make_unique(ed25519_pubkey, ed25519_secretkey, dump); - } 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->internals = c.release(); - c_conf->last_error = nullptr; - *conf = c_conf.release(); - return SESSION_ERR_NONE; + return c_group_wrapper_init(conf, ed25519_pubkey, ed25519_secretkey, dump, dumplen, error); } /// API: groups_info/groups_info_get_name diff --git a/src/config/groups/members.cpp b/src/config/groups/members.cpp index fffbcf99..c0938038 100644 --- a/src/config/groups/members.cpp +++ b/src/config/groups/members.cpp @@ -3,6 +3,7 @@ #include #include "../internal.hpp" +#include "session/config/groups/members.h" namespace session::config::groups { @@ -117,8 +118,124 @@ bool Members::erase(std::string_view session_id) { 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.hpp b/src/config/internal.hpp index 5be9a601..dab43707 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -12,23 +12,13 @@ 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(); @@ -45,6 +35,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, 32}; + 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, 32); + 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) From c454e35b04d55906a539c9c8b4e768e176e5fd96 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 23 Aug 2023 19:27:17 -0300 Subject: [PATCH 20/55] Add safety limit to decompressed group decryption size --- include/session/config/groups/keys.hpp | 14 ++++++++++++-- src/config/groups/keys.cpp | 11 +++++++---- src/config/internal.cpp | 5 ++++- src/config/internal.hpp | 5 +++-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index bd271468..31c290f0 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -122,6 +122,9 @@ class Keys final : public ConfigSig { /// 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; @@ -377,8 +380,12 @@ class Keys final : public ConfigSig { /// /// Future versions may change this to support other encryption algorithms. /// - /// This method will throw if there no encryption keys are available at all (which should not - /// occur in normal use). + /// 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. @@ -395,6 +402,9 @@ class Keys final : public ConfigSig { /// 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()`. diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index c1fe603c..f1b89253 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -1,3 +1,5 @@ +#include "session/config/groups/keys.hpp" + #include #include #include @@ -7,12 +9,11 @@ #include #include -#include -#include -#include #include #include "../internal.hpp" +#include "session/config/groups/info.hpp" +#include "session/config/groups/members.hpp" namespace session::config::groups { @@ -570,6 +571,8 @@ static constexpr size_t OVERHEAD = 1 // encryption type indicator 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); @@ -644,7 +647,7 @@ std::optional Keys::decrypt_message(ustring_view ciphertext) const { return std::nullopt; if (compressed) { - if (auto decomp = zstd_decompress(plain)) + if (auto decomp = zstd_decompress(plain, MAX_PLAINTEXT_MESSAGE_SIZE)) plain = std::move(*decomp); else // Decompression failed diff --git a/src/config/internal.cpp b/src/config/internal.cpp index a84d3689..4b75f35a 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -203,7 +203,7 @@ ustring zstd_compress(ustring_view data, int level, ustring_view prefix) { return compressed; } -std::optional zstd_decompress(ustring_view data) { +std::optional zstd_decompress(ustring_view data, size_t max_size) { zstd_decomp_ptr z_decompressor{ZSTD_createDStream()}; auto* zds = z_decompressor.get(); @@ -220,6 +220,9 @@ std::optional zstd_decompress(ustring_view data) { 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); diff --git a/src/config/internal.hpp b/src/config/internal.hpp index dab43707..f9f1f2f9 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -165,7 +165,8 @@ void load_unknowns( /// serious error. ustring zstd_compress(ustring_view data, int level = 1, ustring_view prefix = {}); -/// ZSTD-decompresses a value. Returns nullopt if decompression fails. -std::optional zstd_decompress(ustring_view data); +/// 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 From ae2f1ba9478d95e2d55362f2695bdae7c1be438a Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 23 Aug 2023 19:32:07 -0300 Subject: [PATCH 21/55] C wrapper API for group keys --- include/session/config/groups/info.h | 12 +- include/session/config/groups/keys.h | 246 ++++++++++++++++++++++++ include/session/config/groups/keys.hpp | 31 ++- include/session/config/groups/members.h | 22 +-- include/session/config/namespaces.hpp | 3 +- src/config.cpp | 10 +- src/config/base.cpp | 5 +- src/config/groups/info.cpp | 3 +- src/config/groups/keys.cpp | 194 +++++++++++++++++-- src/config/groups/members.cpp | 3 +- tests/test_group_info.cpp | 14 +- tests/test_group_members.cpp | 2 +- 12 files changed, 475 insertions(+), 70 deletions(-) create mode 100644 include/session/config/groups/keys.h diff --git a/include/session/config/groups/info.h b/include/session/config/groups/info.h index 9fb4129b..1efc4a75 100644 --- a/include/session/config/groups/info.h +++ b/include/session/config/groups/info.h @@ -134,8 +134,8 @@ 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 deleted all messages from the group -/// with timestamps earlier than this value, if set. +/// 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 @@ -146,8 +146,8 @@ LIBSESSION_EXPORT int64_t groups_info_get_delete_before(const config_object* con /// 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. +/// 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 @@ -168,8 +168,8 @@ LIBSESSION_EXPORT int64_t groups_info_get_attach_delete_before(const config_obje /// 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. +/// 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 diff --git a/include/session/config/groups/keys.h b/include/session/config/groups/keys.h new file mode 100644 index 00000000..a1e64775 --- /dev/null +++ b/include/session/config/groups/keys.h @@ -0,0 +1,246 @@ +#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_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 +/// - `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 new keys, if needed +/// - `members` -- [in] the members config object to update with new keys, if needed +/// +/// 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 unsigned char* data, + size_t datalen, + int64_t timestamp_ms, + config_object* info, + config_object* members) __attribute__((warn_unused_result)); + +/// 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_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 index 31c290f0..2acbbed7 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -63,7 +63,7 @@ class Keys final : public ConfigSig { struct key_info { std::array key; - std::chrono::system_clock::time_point timestamp; // millisecond precision + std::chrono::system_clock::time_point timestamp; // millisecond precision int64_t generation; auto cmpval() const { return std::tie(generation, timestamp, key); } @@ -215,10 +215,13 @@ class Keys final : public ConfigSig { /// 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 two situations: + /// 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. @@ -241,7 +244,7 @@ class Keys final : public ConfigSig { /// confirmed or superceded). ustring_view rekey(Info& info, Members& members); - /// API: groups/Keys::pending_push + /// 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 @@ -258,8 +261,8 @@ class Keys final : public ConfigSig { /// 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 used to allow a rekey + member list update using - /// the new key in the same swarm upload sequence. + /// 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. @@ -288,23 +291,17 @@ class Keys final : public ConfigSig { /// usable). /// /// Inputs: - /// - `msg` - the full stored config message value - /// - `hash` - the storage message hash (used to track current config messages) - /// - `timestamp` - the timestamp (from the swarm) when this message was stored (used to track - /// when other keys expire). - /// - `members` - the given group::Members object's en/decryption key list will be updated to - /// match this object's key list. + /// - `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. - void load_key_message( - ustring_view data, - ustring_view msgid, - int64_t timestamp_ms, - Info& info, - Members& members); + void load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, Members& members); /// API: groups/Keys::needs_rekey /// diff --git a/include/session/config/groups/members.h b/include/session/config/groups/members.h index 3e03ffcb..dcaabda4 100644 --- a/include/session/config/groups/members.h +++ b/include/session/config/groups/members.h @@ -5,13 +5,10 @@ extern "C" { #endif #include "../base.h" -#include "../util.h" #include "../profile_pic.h" +#include "../util.h" -enum groups_members_invite_status { - INVITE_SENT = 1, - INVITE_FAILED = 2 -}; +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. @@ -21,8 +18,8 @@ typedef struct config_group_member { 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 + 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; @@ -96,8 +93,6 @@ 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. @@ -105,9 +100,7 @@ LIBSESSION_EXPORT bool groups_members_get_or_construct( /// 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); - +LIBSESSION_EXPORT void groups_members_set(config_object* conf, const config_group_member* member); /// API: groups/groups_members_erase /// @@ -137,7 +130,6 @@ LIBSESSION_EXPORT bool groups_members_erase(config_object* conf, const char* ses /// - `size_t` -- number of contacts LIBSESSION_EXPORT size_t contacts_size(const config_object* conf); - typedef struct groups_members_iterator { void* _internals; } groups_members_iterator; @@ -183,7 +175,8 @@ LIBSESSION_EXPORT void groups_members_iterator_free(groups_members_iterator* it) /// /// Outputs: /// - `bool` -- True if iteration has reached the end -LIBSESSION_EXPORT bool groups_members_iterator_done(groups_members_iterator* it, config_group_member* m); +LIBSESSION_EXPORT bool groups_members_iterator_done( + groups_members_iterator* it, config_group_member* m); /// API: groups/groups_members_iterator_advance /// @@ -193,7 +186,6 @@ LIBSESSION_EXPORT bool groups_members_iterator_done(groups_members_iterator* it, /// - `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/namespaces.hpp b/include/session/config/namespaces.hpp index f7d48469..c5c29ec5 100644 --- a/include/session/config/namespaces.hpp +++ b/include/session/config/namespaces.hpp @@ -12,7 +12,8 @@ enum class Namespace : std::int16_t { // 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) + // Groups config namespaces (i.e. for shared config of the group itself, not one user's group + // settings) GroupKeys = 12, GroupInfo = 13, GroupMembers = 14, diff --git a/src/config.cpp b/src/config.cpp index 77b95e6e..f60d554f 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -542,10 +542,7 @@ ConfigMessage::ConfigMessage() { } ConfigMessage::ConfigMessage( - ustring_view serialized, - verify_callable verifier_, - sign_callable signer_, - int lag) : + ustring_view serialized, verify_callable verifier_, sign_callable signer_, int lag) : verifier{std::move(verifier_)}, signer{std::move(signer_)}, lag{lag} { oxenc::bt_dict_consumer dict{from_unsigned_sv(serialized)}; @@ -724,10 +721,7 @@ MutableConfigMessage::MutableConfigMessage( } MutableConfigMessage::MutableConfigMessage( - ustring_view config, - verify_callable verifier, - sign_callable signer, - int lag) : + ustring_view config, verify_callable verifier, sign_callable signer, int lag) : MutableConfigMessage{ std::vector{{config}}, std::move(verifier), diff --git a/src/config/base.cpp b/src/config/base.cpp index 3fac5f09..c3d03edd 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -133,8 +133,9 @@ int ConfigBase::merge(const std::vector>& c // 'z' prefix indicates zstd-compressed data: if (plain[0] == 'z') { - if (auto decompressed = zstd_decompress(ustring_view{plain.data() + 1, plain.size() - 1}); - decompressed && !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"); diff --git a/src/config/groups/info.cpp b/src/config/groups/info.cpp index 2ba43e8f..9c34a9d0 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -115,7 +115,8 @@ LIBSESSION_C_API int groups_info_init( const unsigned char* dump, size_t dumplen, char* error) { - return c_group_wrapper_init(conf, ed25519_pubkey, ed25519_secretkey, dump, dumplen, error); + return c_group_wrapper_init( + conf, ed25519_pubkey, ed25519_secretkey, dump, dumplen, error); } /// API: groups_info/groups_info_get_name diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index f1b89253..0965d333 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -13,6 +13,7 @@ #include "../internal.hpp" #include "session/config/groups/info.hpp" +#include "session/config/groups/keys.h" #include "session/config/groups/members.hpp" namespace session::config::groups { @@ -361,8 +362,7 @@ std::optional Keys::pending_config() const { return ustring_view{pending_key_config_.data(), pending_key_config_.size()}; } -void Keys::load_key_message( - ustring_view data, ustring_view msgid, int64_t timestamp_ms, Info& info, Members& members) { +void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, Members& members) { oxenc::bt_dict_consumer d{from_unsigned_sv(data)}; @@ -590,15 +590,15 @@ ustring Keys::encrypt_message(ustring_view plaintext, bool compress) const { 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())) + 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; @@ -658,3 +658,175 @@ std::optional Keys::decrypt_message(ustring_view ciphertext) const { } } // 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, dump, 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 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 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( + 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 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; +} diff --git a/src/config/groups/members.cpp b/src/config/groups/members.cpp index c0938038..9d330d7f 100644 --- a/src/config/groups/members.cpp +++ b/src/config/groups/members.cpp @@ -228,7 +228,8 @@ LIBSESSION_C_API void groups_members_iterator_free(groups_members_iterator* it) delete it; } -LIBSESSION_C_API bool groups_members_iterator_done(groups_members_iterator* it, config_group_member* c) { +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; diff --git a/tests/test_group_info.cpp b/tests/test_group_info.cpp index eac73373..ce21534a 100644 --- a/tests/test_group_info.cpp +++ b/tests/test_group_info.cpp @@ -47,7 +47,7 @@ TEST_CASE("Group Info settings", "[config][groups][info]") { 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. + for (const auto& k : enc_keys) // Just for testing, as above. ginfo2.add_key(k, false); ginfo1.set_name("GROUP Name"); @@ -158,7 +158,7 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { // 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. + for (const auto& k : enc_keys1) // Just for testing, as above. ginfo.add_key(k, false); REQUIRE_THROWS_WITH( @@ -170,7 +170,7 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { // 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. + for (const auto& k : enc_keys1) // Just for testing, as above. ginfo_rw.add_key(k, false); ginfo_rw.set_name("Super Group!!"); @@ -193,7 +193,7 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { 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. + for (const auto& k : enc_keys1) // Just for testing, as above. ginfo_rw2.add_key(k, false); CHECK(ginfo_rw2.merge(merge_configs) == 1); @@ -220,7 +220,7 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { 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. + for (const auto& k : enc_keys1) // Just for testing, as above. ginfo_bad1.add_key(k, false); ginfo_bad1.merge(merge_configs); @@ -303,7 +303,7 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { 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. + for (const auto& k : enc_keys1) // Just for testing, as above. ginfo2.add_key(k, false); CHECK(!ginfo.needs_dump()); @@ -321,7 +321,7 @@ TEST_CASE("Verify-only Group Info", "[config][groups][verify-only]") { // 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. + for (const auto& k : enc_keys2) // Just for testing, as above. ginfo_rw3.add_key(k, false); CHECK(ginfo_rw3.merge(merge_configs) == 1); diff --git a/tests/test_group_members.cpp b/tests/test_group_members.cpp index 24afaf49..e78d6723 100644 --- a/tests/test_group_members.cpp +++ b/tests/test_group_members.cpp @@ -56,7 +56,7 @@ TEST_CASE("Group Members", "[config][groups][members]") { 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. + for (const auto& k : enc_keys) // Just for testing, as above. gmem2.add_key(k, false); std::vector sids; From a44567e876f393b0645fa1264b355866ae44d51b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 24 Aug 2023 00:40:19 -0300 Subject: [PATCH 22/55] oxen-encoding update to fix llvm compile error --- external/oxen-encoding | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/oxen-encoding b/external/oxen-encoding index fc85dfd3..462be41b 160000 --- a/external/oxen-encoding +++ b/external/oxen-encoding @@ -1 +1 @@ -Subproject commit fc85dfd352e8474bc7195b0ba881838bd72ebea6 +Subproject commit 462be41bd481b331dabeb3c220b349ef35c89e56 From 995364577cad13db87214b78870a79964b0673dd Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 24 Aug 2023 15:06:46 -0300 Subject: [PATCH 23/55] Add new group storage to UserGroups config --- include/session/config/user_groups.h | 22 +++- include/session/config/user_groups.hpp | 155 ++++++++++++++++++----- src/config/internal.cpp | 10 +- src/config/internal.hpp | 7 +- src/config/user_groups.cpp | 164 +++++++++++++++++++++---- 5 files changed, 295 insertions(+), 63 deletions(-) diff --git a/include/session/config/user_groups.h b/include/session/config/user_groups.h index e7187a44..9410add1 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) @@ -40,6 +40,26 @@ typedef struct ugroups_legacy_group_info { 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 + + 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_sig; // Will be true if the `auth_sig` is populated + unsigned char auth_sig[64]; // If `have_auth_sig` is set then this is the authentication + // signature that can be used 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) +} 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) diff --git a/include/session/config/user_groups.hpp b/include/session/config/user_groups.hpp index 0809df50..b9684348 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,25 +21,23 @@ 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: +/// 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). /// -/// 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) +/// 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 /// @ - 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. /// j - joined at unix timestamp. Omitted if 0. /// /// o - dict of communities (AKA open groups); within this dict (which deliberately has the same @@ -51,14 +50,25 @@ 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). +/// @ - notification setting (same values as groups, 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. +/// + - the conversation priority, for pinning/hiding this community room. See above. /// j - joined at unix timestamp. Omitted if 0. /// -/// 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. +/// 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). Same as above. +/// ! - mute timestamp (see above). +/// + - the conversation priority, for pinned/hidden conversations. See above. +/// j - joined at unix timestamp. Omitted if 0. /// Common base type with fields shared by all the groups struct base_group_info { @@ -162,6 +172,32 @@ 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 signature; this is possessed by non-admins. (This value will be + /// dropped when serializing if secretkey is non-empty, and so does not need to be explicitly + /// cleared when being promoted to admin) + ustring auth_sig; + + /// 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 + + 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 +217,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 +245,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 +300,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 +374,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 +410,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); @@ -380,11 +457,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 +473,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 +488,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 +508,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 +543,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 @@ -482,11 +571,12 @@ class UserGroups : public ConfigBase { /// Returns an iterator that iterates only through one type of conversations. (The regular /// `.end()` iterator is valid for testing the end of these iterations). + subtype_iterator legacy_groups() const { return {data}; } subtype_iterator begin_communities() 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; @@ -494,13 +584,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 +618,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/src/config/internal.cpp b/src/config/internal.cpp index 4b75f35a..1b2035d2 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -11,16 +11,16 @@ 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, unsigned char prefix) { + if (!(session_id.size() == 66 && oxenc::is_hex(session_id) && + session_id[0] == ('0' + (prefix >> 4)) && session_id[1] == ('0' + (prefix & 0xf)))) throw std::invalid_argument{ "Invalid session ID: expected 66 hex digits starting with 05; 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, unsigned char prefix) { + check_session_id(session_id, prefix); return oxenc::from_hex(session_id); } diff --git a/src/config/internal.hpp b/src/config/internal.hpp index f9f1f2f9..d94faabc 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -80,11 +80,12 @@ 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); +// 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, unsigned char prefix = 0x05); // 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, unsigned char prefix = 0x05); // Checks the session_id (throwing if invalid) then returns it as bytes, omitting the 05 prefix // (which is the x25519 pubkey). diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index aa689653..bace4bf3 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -48,6 +49,10 @@ static void base_from(base_group_info& self, const T& c) { self.mute_until = c.mute_until; } +group_info::group_info(std::string sid) : id{std::move(sid)} { + check_session_id(id, 0x03); +} + legacy_group_info::legacy_group_info(std::string sid) : session_id{std::move(sid)} { check_session_id(session_id); } @@ -190,6 +195,38 @@ 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); + if (c.have_secretkey) + secretkey.assign(c.secretkey, 64); + if (c.have_auth_sig) + auth_sig.assign(c.auth_sig, 64); +} + +void group_info::into(ugroups_group_info& c) const { + assert(id.size() == 66); + base_into(*this, c); + copy_c_str(c.id, id); + if ((c.have_secretkey = secretkey.size() == 64)) + std::memcpy(c.secretkey, secretkey.data(), 64); + if ((c.have_auth_sig = auth_sig.size() == 64)) + std::memcpy(c.auth_sig, auth_sig.data(), 64); +} + +void group_info::load(const dict& info_dict) { + base_group_info::load(info_dict); + + if (auto seed = maybe_ustring(info_dict, "K"); seed && seed->size() == 32) { + std::array pk; + secretkey.resize(64); + crypto_sign_seed_keypair(pk.data(), 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() == 64) + auth_sig = std::move(*sig); +} + void community_info::load(const dict& info_dict) { base_group_info::load(info_dict); @@ -277,6 +314,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, 0x03); + + 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] @@ -316,6 +387,17 @@ 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 info = data["g"][session_id_to_bytes(g.id, 0x03)]; + set_base(g, info); + + if (g.secretkey.size() == 64) + info["K"] = ustring_view{g.secretkey.data(), 32}; + + else if (g.auth_sig.size() == 64) + info["s"] = g.auth_sig; +} + template static bool erase_impl(Field convo) { bool ret = convo.exists(); @@ -337,6 +419,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, 0x03)]); +} bool UserGroups::erase(const legacy_group_info& c) { return erase_impl(data["C"][session_id_to_bytes(c.session_id)]); } @@ -373,11 +458,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,38 +486,55 @@ 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 { From d3b902f657083da3f37315327e2722d4fbda411f Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 24 Aug 2023 15:10:54 -0300 Subject: [PATCH 24/55] Fix unclosed list in key config generation --- src/config/groups/keys.cpp | 105 +++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 52 deletions(-) diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 0965d333..bc76e731 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -269,59 +269,60 @@ ustring_view Keys::rekey(Info& info, Members& members) { 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_xpk(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); + { + auto member_keys = d.append_list("k"); + int member_count = 0; + for (const auto& m : members) { + auto m_xpk = session_id_xpk(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++; + } - 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()); + // 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()); + } } } From 455125717c6b3b92fa7c4c9c07f3af28060d8c84 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 25 Aug 2023 03:17:53 -0300 Subject: [PATCH 25/55] Fix broken x25519 extraction --- src/config/internal.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/internal.cpp b/src/config/internal.cpp index 1b2035d2..3b9ef85e 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -27,6 +27,7 @@ std::string session_id_to_bytes(std::string_view session_id, unsigned char prefi std::array session_id_xpk(std::string_view session_id) { check_session_id(session_id); std::array xpk; + session_id.remove_prefix(2); oxenc::from_hex(session_id.begin(), session_id.end(), xpk.begin()); return xpk; } From 8d9ce6e30153a785b13354c99a9a210d5e8fc1a7 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 25 Aug 2023 03:30:56 -0300 Subject: [PATCH 26/55] Don't truncate secretkey in C API --- src/config/internal.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/internal.hpp b/src/config/internal.hpp index d94faabc..e1a0dfc9 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -43,7 +43,7 @@ template size_t dumplen, char* error) { assert(ed25519_secretkey_bytes); - ustring_view ed25519_secretkey{ed25519_secretkey_bytes, 32}; + ustring_view ed25519_secretkey{ed25519_secretkey_bytes, 64}; std::optional dump; if (dumpstr && dumplen) dump.emplace(dumpstr, dumplen); @@ -64,7 +64,7 @@ template ustring_view ed25519_pubkey{ed25519_pubkey_bytes, 32}; std::optional ed25519_secretkey; if (ed25519_secretkey_bytes) - ed25519_secretkey.emplace(ed25519_secretkey_bytes, 32); + ed25519_secretkey.emplace(ed25519_secretkey_bytes, 64); std::optional dump; if (dump_bytes && dumplen) dump.emplace(dump_bytes, dumplen); From 744b25eae61dbdc60c472ec9b60854df73a045c6 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 25 Aug 2023 11:57:30 -0300 Subject: [PATCH 27/55] Fix broken logic in group_keys --- src/config/groups/keys.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index bc76e731..6d077506 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -146,7 +146,7 @@ std::vector Keys::group_keys() const { std::vector ret; ret.reserve(keys_.size() + !pending_key_config_.empty()); - if (pending_key_config_.empty()) + if (!pending_key_config_.empty()) ret.emplace_back(pending_key_.data(), 32); for (auto it = keys_.rbegin(); it != keys_.rend(); ++it) From 3c5f74b470487769b54f7eda66c348adc0bb2741 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 25 Aug 2023 19:03:36 -0300 Subject: [PATCH 28/55] Add supplemental key messages --- include/session/config/groups/keys.hpp | 61 ++++- src/config/groups/keys.cpp | 329 ++++++++++++++++++++----- 2 files changed, 321 insertions(+), 69 deletions(-) diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index 2acbbed7..f1cb0841 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -31,13 +31,25 @@ using namespace std::literals; /// /// Fields used (in ascii order): /// # -- 24-byte nonce used for all the encrypted values in this message; required. -/// + -- set to 1 if this is a supplemental key message; omitted for a full key message. (It's -/// important that this key sort earlier than any fields that can differ between -/// supplemental/non-supplemental fields so we can identify the message type while parsing it). +/// +/// 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) +/// +/// 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 @@ -45,7 +57,7 @@ using namespace std::literals; /// /// Some extra details: /// -/// - each copy of the encryption key uses xchacha20_poly1305 using the `n` nonce +/// - 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, @@ -97,6 +109,10 @@ class Keys final : public ConfigSig { // 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_`. Returns true if the key was inserted, false + // if it already existed. + bool insert_key(const key_info& key); + 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 @@ -208,6 +224,16 @@ class Keys final : public ConfigSig { /// - `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 @@ -244,6 +270,33 @@ class Keys final : public ConfigSig { /// confirmed or superceded). 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 an existing key to a new member so that that member can access existing keys 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 + /// + /// 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. + /// - `all` -- if true (the default) then generate a supplemental message for *all* current + /// keys; if false then only generate one for the most recent key. + /// + /// Outputs: + /// - `ustring` containing the message that should be pushed to the swarm containing encrypted + /// keys for the given user(s). + ustring key_supplement(std::vector sids, bool all = true) const; + ustring key_supplement(std::string sid, bool all = true) const { + return key_supplement(std::vector{{std::move(sid)}}, all); + } + /// API: groups/Keys::pending_config /// /// If a rekey has been performed but not yet confirmed then this will contain the config diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 6d077506..d9f2f0f6 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -46,7 +46,7 @@ Keys::Keys( if (dumped) { load_dump(*dumped); - } else if (_sign_sk) { + } else if (admin()) { rekey(info, members); } } @@ -179,7 +179,7 @@ static const ustring_view enc_key_member_hash_key = to_unsigned_sv("SessionGroup static const ustring_view junk_seed_hash_key = to_unsigned_sv("SessionGroupJunkMembers"sv); ustring_view Keys::rekey(Info& info, Members& members) { - if (!_sign_sk || !_sign_pk) + if (!admin()) throw std::logic_error{ "Unable to issue a new group encryption key without the main group keys"}; @@ -222,6 +222,7 @@ ustring_view Keys::rekey(Info& info, Members& members) { 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) @@ -357,12 +358,164 @@ ustring_view Keys::rekey(Info& info, Members& members) { return ustring_view{pending_key_config_.data(), pending_key_config_.size()}; } +ustring Keys::key_supplement(std::vector sids, bool all) 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_xpk(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"}; + } + + // 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())}; +} + 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()}; } +bool Keys::insert_key(const key_info& new_key) { + auto it = std::lower_bound(keys_.begin(), keys_.end(), new_key); + if (it != keys_.end() && new_key == *it) + // We found a key we already had, so just ignore it. + return false; + + if (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 false; + + keys_.insert(it, new_key); + remove_expired(); + needs_dump_ = true; + + return true; +} + void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, Members& members) { oxenc::bt_dict_consumer d{from_unsigned_sv(data)}; @@ -376,57 +529,11 @@ void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, throw config_value_error{"Key message has no nonce"}; auto nonce = to_unsigned_sv(d.consume_string_view()); - bool supplemental = false; - if (d.skip_until("+")) { - auto supp = d.consume_integer(); - if (supp == 0 || supp == 1) - supplemental = static_cast(supp); - else - throw config_value_error{ - "Unexpected value " + std::to_string(supp) + " for '+' key (expected 0/1)"}; - } - bool found_key = false; sodium_cleared new_key{}; - 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 (!supplemental) { - 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 (_sign_sk) { - 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; - } - } sodium_cleared> member_dec_key; - if (!found_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); @@ -449,6 +556,111 @@ void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, 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 (found_key || admin()) + continue; + + 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! + found_key = true; + + oxenc::bt_list_consumer key_infos{from_unsigned_sv(plaintext)}; + while (!key_infos.is_finished()) { + 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()); + + insert_key(new_key); + } + } + } + } + + return; + } + + 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")) @@ -478,28 +690,15 @@ void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, member_dec_key.data())) { // Decryption success, we found our key! found_key = true; + insert_key(new_key); } } - if (!supplemental && member_key_count % MESSAGE_KEY_MULTIPLE != 0) + if (member_key_count % MESSAGE_KEY_MULTIPLE != 0) throw config_value_error{"Member key list has wrong size (missing junk key padding?)"}; verify_config_sig(d, data, verifier_); - if (found_key) { - auto it = std::lower_bound(keys_.begin(), keys_.end(), new_key); - if (it != keys_.end() && new_key == *it) { - // We found a key we already had, so just ignore it. - found_key = false; - } else { - keys_.insert(it, new_key); - - remove_expired(); - - needs_dump_ = true; - } - } - // If this is our pending config or this has a later generation than our pending config then // drop our pending status. if (!pending_key_config_.empty() && @@ -551,7 +750,7 @@ void Keys::remove_expired() { } bool Keys::needs_rekey() const { - if (!_sign_sk || !_sign_pk || keys_.size() < 2) + if (!admin() || keys_.size() < 2) return false; // We rekey if the max generation value is being used across multiple keys (which indicates some From e83f479aaa43970cf154e3d1031af434b655a707 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 25 Aug 2023 19:29:26 -0300 Subject: [PATCH 29/55] fix admin key loading --- src/config/groups/keys.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index d9f2f0f6..e75bd48b 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -243,7 +243,6 @@ ustring_view Keys::rekey(Info& info, Members& members) { oxenc::bt_dict_producer d{}; d.append("#", from_unsigned_sv(nonce)); - // d.append("+", 0); // Not supplemental, so leave off static_assert(crypto_aead_xchacha20poly1305_ietf_KEYBYTES == 32); static_assert(crypto_aead_xchacha20poly1305_ietf_ABYTES == 16); @@ -659,6 +658,7 @@ void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, throw config_value_error{"Failed to decrypt admin key from key message"}; found_key = true; + insert_key(new_key); } // Even if we're already found a key we still parse these, so that admins and all users have the From cb89c0fd70825a8005b599565de400286a5cc3f5 Mon Sep 17 00:00:00 2001 From: dr7ana Date: Thu, 24 Aug 2023 13:03:06 -0700 Subject: [PATCH 30/55] off by 2 error (Jason) --- include/session/config/user_groups.h | 16 ++-- include/session/config/user_groups.hpp | 2 +- tests/CMakeLists.txt | 8 ++ tests/test_group_keys.cpp | 121 ++++++++++++++++++++----- 4 files changed, 113 insertions(+), 34 deletions(-) diff --git a/include/session/config/user_groups.h b/include/session/config/user_groups.h index 9410add1..2728b029 100644 --- a/include/session/config/user_groups.h +++ b/include/session/config/user_groups.h @@ -43,14 +43,14 @@ typedef struct 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 - - 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_sig; // Will be true if the `auth_sig` is populated - unsigned char auth_sig[64]; // If `have_auth_sig` is set then this is the authentication - // signature that can be used to access the swarm. + char id[67]; // in hex; 66 hex chars + null terminator + + 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_sig; // Will be true if the `auth_sig` is populated + unsigned char auth_sig[64]; // If `have_auth_sig` is set then this is the authentication + // signature that can be used to access the swarm. int priority; // pinned message priority; 0 = unpinned, negative = hidden, positive = pinned // (with higher meaning pinned higher). diff --git a/include/session/config/user_groups.hpp b/include/session/config/user_groups.hpp index b9684348..3549e55c 100644 --- a/include/session/config/user_groups.hpp +++ b/include/session/config/user_groups.hpp @@ -190,7 +190,7 @@ struct group_info : base_group_info { // 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 + void into(struct ugroups_group_info& c) const; // Into c struct private: friend class UserGroups; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dbf39f45..e73548bd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,6 +16,14 @@ add_executable(testAll test_xed25519.cpp ) +add_executable(testKeys + test_group_keys.cpp +) + +target_link_libraries(testKeys PRIVATE + config + Catch2::Catch2WithMain) + target_link_libraries(testAll PRIVATE config Catch2::Catch2WithMain) diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index 07897171..bda24ce4 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -19,28 +19,48 @@ static constexpr int64_t created_ts = 1680064059; using namespace session::config; -/* TEST_CASE("Group Keys", "[config][groups][keys]") { + const ustring group_seed = + "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes; + const std::array seeds = { - "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes, - "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"_hexbytes, - "000111222333444555666777888999aaabbbcccdddeeefff0123456789abcdef"_hexbytes, - "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"_hexbytes, - "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes}; + "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes, // admin1 + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"_hexbytes, // admin2 + "000111222333444555666777888999aaabbbcccdddeeefff0123456789abcdef"_hexbytes, // member1 + "00011122435111155566677788811263446552465222efff0123456789abcdef"_hexbytes, // member2 + "00011129824754185548239498168169316979583253efff0123456789abcdef"_hexbytes, // member3 + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"_hexbytes // member4 + }; + + size_t n_seeds = seeds.size(); + std::array, seeds.size()> ed_pk; std::array, seeds.size()> ed_sk; - for (size_t i = 0; i < seeds.size(); i++) { + + std::array group_pk; + std::array group_sk; + + for (size_t i = 0; i < n_seeds; i++) { crypto_sign_ed25519_seed_keypair( ed_pk[i].data(), ed_sk[i].data(), reinterpret_cast(seeds[i].data())); - CHECK(oxenc::to_hex(seeds[i].begin(), seeds[i].end()) == - oxenc::to_hex(ed_sk[i].begin(), ed_sk[i].begin() + 32)); + REQUIRE(oxenc::to_hex(seeds[i].begin(), seeds[i].end()) == + oxenc::to_hex(ed_sk[i].begin(), ed_sk[i].begin() + 32)); } - constexpr size_t ADMIN1 = 0, ADMIN2 = 1, MEMBER1 = 2, MEMBER2 = 3, GROUP = 4; + 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)); + + constexpr size_t ADMIN1 = 0, ADMIN2 = 1, MEMBER1 = 2, MEMBER2 = 3, MEMBER3 = 4, MEMBER4 = 5; + REQUIRE(oxenc::to_hex(group_pk.begin(), group_pk.end()) == + "c50cb3ae956947a8de19135b5be2685ff348afc63fc34a837aca12bc5c1f5625"); REQUIRE(oxenc::to_hex(ed_pk[ADMIN1].begin(), ed_pk[ADMIN1].end()) == "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece"); REQUIRE(oxenc::to_hex(ed_pk[ADMIN2].begin(), ed_pk[ADMIN2].end()) == @@ -48,30 +68,81 @@ TEST_CASE("Group Keys", "[config][groups][keys]") { REQUIRE(oxenc::to_hex(ed_pk[MEMBER1].begin(), ed_pk[MEMBER1].end()) == "8b79719da06ee8a14823f0c8d740aabb134ab7cbc174b8c1a022a27c0964abfd"); REQUIRE(oxenc::to_hex(ed_pk[MEMBER2].begin(), ed_pk[MEMBER2].end()) == + "a2b000e46c13859c0eecea72af9db9e06b22cad767ccf487b004b7592628a595"); + REQUIRE(oxenc::to_hex(ed_pk[MEMBER3].begin(), ed_pk[MEMBER3].end()) == + "dee285469b5ae983e03749aa41ff5b723f2bcad4f31d0de6515275f40e7b32cb"); + REQUIRE(oxenc::to_hex(ed_pk[MEMBER4].begin(), ed_pk[MEMBER4].end()) == "d813a070116a8c74e6fcbb3f53d5698a14b6236fcca9bb3136acff749dacdcc4"); - REQUIRE(oxenc::to_hex(ed_pk[GROUP].begin(), ed_pk[GROUP].end()) == - "c50cb3ae956947a8de19135b5be2685ff348afc63fc34a837aca12bc5c1f5625"); - std::array, seeds.size() - 1> info; - std::array, seeds.size() - 1> members; - std::array, seeds.size() - 1> keys; - for (size_t i = 0; i < GROUP; i++) { + std::array, seeds.size()> info; + std::array, seeds.size()> members; + std::array, seeds.size()> keys; + + for (size_t i = 0; i < n_seeds; i++) { info[i] = std::make_unique( - to_usv(ed_pk[GROUP]), - i <= ADMIN2 ? std::make_optional(to_usv(ed_sk[GROUP])) : std::nullopt, + to_usv(group_pk), + i <= ADMIN2 ? std::make_optional(to_usv(group_sk)) : std::nullopt, std::nullopt); members[i] = std::make_unique( - to_usv(ed_pk[GROUP]), - i <= ADMIN2 ? std::make_optional(to_usv(ed_sk[GROUP])) : std::nullopt, + to_usv(group_pk), + i <= ADMIN2 ? std::make_optional(to_usv(group_sk)) : std::nullopt, std::nullopt); keys[i] = std::make_unique( to_usv(ed_sk[i]), - to_usv(ed_pk[GROUP]), - i <= ADMIN2 ? std::make_optional(to_usv(ed_sk[GROUP])) : std::nullopt, + to_usv(group_pk), + i <= ADMIN2 ? std::make_optional(to_usv(group_sk)) : std::nullopt, std::nullopt, *info[i], - *members[i] - ); + *members[i]); + } + + std::vector sids; + + for (int i = 0; i < n_seeds; ++i) { + std::array sid; + memcpy(&sid[1], &ed_pk[i], 32); + sid[0] = 0x05; + sids.push_back(oxenc::to_hex(sid.begin(), sid.end())); } + + for (const auto& m : members) + REQUIRE(m->size() == 0); + + std::vector> info_configs; + std::vector> mem_configs; + + SECTION("Add members and re-key") { + for (int i = MEMBER1; i < MEMBER4; ++i) { + auto m = members[ADMIN1]->get_or_construct(sids[i]); + m.admin = false; + members[ADMIN1]->set(m); + } + + CHECK(members[ADMIN1]->needs_push()); + + // get new configs + auto new_keys_config = keys[ADMIN1]->rekey(*info[ADMIN1], *members[ADMIN1]); + auto [iseq, new_info_config, iobs] = info[ADMIN1]->push(); + info[ADMIN1]->confirm_pushed(iseq, "fakehash1"); + auto [mseq, new_mem_config, mobs] = members[ADMIN1]->push(); + members[ADMIN1]->confirm_pushed(mseq, "fakehash1"); + + info_configs.emplace_back("fakehash1", new_info_config); + mem_configs.emplace_back("fakehash1", new_mem_config); + + for (int i = MEMBER1; i < MEMBER4; ++i) { + auto n = info[i]->merge(info_configs); + auto m = members[i]->merge(mem_configs); + } + } + + // SECTION("Remove member 4 and re-key") { + // REQUIRE(members[ADMIN1]->erase(sids[MEMBER4])); + + // // get new configs + // auto new_keys_config = keys[ADMIN1]->rekey(*info[ADMIN1], *members[ADMIN1]); + // auto [iseq, new_info_config, iobs] = info[ADMIN1]->push(); + // auto [mseq, new_members_config, mobs] = members[ADMIN1]->push(); + + // } } -*/ From b4cf7e2a9994965453e227064064dc3f0d53624f Mon Sep 17 00:00:00 2001 From: dr7ana Date: Fri, 25 Aug 2023 07:49:58 -0700 Subject: [PATCH 31/55] Config keys unit tests - new group keys unit tests runs through relevant functionalities - add/remove users, change group info, add admin, verify encryption compression --- tests/test_group_keys.cpp | 327 +++++++++++++++++++++++++++----------- tests/utils.hpp | 4 + 2 files changed, 241 insertions(+), 90 deletions(-) diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index bda24ce4..cf27b330 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -21,35 +21,69 @@ using namespace session::config; TEST_CASE("Group Keys", "[config][groups][keys]") { + struct pseudo_client { + const bool is_admin; + + const ustring seed; + std::string session_id; + + std::array public_key; + std::array secret_key; + + std::unique_ptr keys; + std::unique_ptr info; + std::unique_ptr members; + + pseudo_client(ustring s, bool a, unsigned char* gpk, std::optional gsk) : + seed{s}, is_admin{a} { + crypto_sign_ed25519_seed_keypair( + public_key.data(), + secret_key.data(), + reinterpret_cast(seed.data())); + + REQUIRE(oxenc::to_hex(seed.begin(), seed.end()) == + oxenc::to_hex(secret_key.begin(), secret_key.begin() + 32)); + + std::array sid; + int rc = crypto_sign_ed25519_pk_to_curve25519(&sid[1], public_key.data()); + REQUIRE(rc == 0); + sid[0] = 0x05; + session_id = oxenc::to_hex(sid.begin(), sid.end()); + + info = std::make_unique( + ustring_view{gpk, 32}, + is_admin ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt); + members = std::make_unique( + ustring_view{gpk, 32}, + is_admin ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt); + keys = std::make_unique( + to_usv(secret_key), + ustring_view{gpk, 32}, + is_admin ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt, + *info, + *members); + } + }; + const ustring group_seed = "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes; - - const std::array seeds = { - "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes, // admin1 - "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"_hexbytes, // admin2 + 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 }; - size_t n_seeds = seeds.size(); - - std::array, seeds.size()> ed_pk; - std::array, seeds.size()> ed_sk; - std::array group_pk; std::array group_sk; - for (size_t i = 0; i < n_seeds; i++) { - crypto_sign_ed25519_seed_keypair( - ed_pk[i].data(), - ed_sk[i].data(), - reinterpret_cast(seeds[i].data())); - REQUIRE(oxenc::to_hex(seeds[i].begin(), seeds[i].end()) == - oxenc::to_hex(ed_sk[i].begin(), ed_sk[i].begin() + 32)); - } - crypto_sign_ed25519_seed_keypair( group_pk.data(), group_sk.data(), @@ -57,92 +91,205 @@ TEST_CASE("Group Keys", "[config][groups][keys]") { REQUIRE(oxenc::to_hex(group_seed.begin(), group_seed.end()) == oxenc::to_hex(group_sk.begin(), group_sk.begin() + 32)); - constexpr size_t ADMIN1 = 0, ADMIN2 = 1, MEMBER1 = 2, MEMBER2 = 3, MEMBER3 = 4, MEMBER4 = 5; - - REQUIRE(oxenc::to_hex(group_pk.begin(), group_pk.end()) == - "c50cb3ae956947a8de19135b5be2685ff348afc63fc34a837aca12bc5c1f5625"); - REQUIRE(oxenc::to_hex(ed_pk[ADMIN1].begin(), ed_pk[ADMIN1].end()) == - "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece"); - REQUIRE(oxenc::to_hex(ed_pk[ADMIN2].begin(), ed_pk[ADMIN2].end()) == - "3ccd241cffc9b3618044b97d036d8614593d8b017c340f1dee8773385517654b"); - REQUIRE(oxenc::to_hex(ed_pk[MEMBER1].begin(), ed_pk[MEMBER1].end()) == - "8b79719da06ee8a14823f0c8d740aabb134ab7cbc174b8c1a022a27c0964abfd"); - REQUIRE(oxenc::to_hex(ed_pk[MEMBER2].begin(), ed_pk[MEMBER2].end()) == - "a2b000e46c13859c0eecea72af9db9e06b22cad767ccf487b004b7592628a595"); - REQUIRE(oxenc::to_hex(ed_pk[MEMBER3].begin(), ed_pk[MEMBER3].end()) == - "dee285469b5ae983e03749aa41ff5b723f2bcad4f31d0de6515275f40e7b32cb"); - REQUIRE(oxenc::to_hex(ed_pk[MEMBER4].begin(), ed_pk[MEMBER4].end()) == - "d813a070116a8c74e6fcbb3f53d5698a14b6236fcca9bb3136acff749dacdcc4"); - - std::array, seeds.size()> info; - std::array, seeds.size()> members; - std::array, seeds.size()> keys; - - for (size_t i = 0; i < n_seeds; i++) { - info[i] = std::make_unique( - to_usv(group_pk), - i <= ADMIN2 ? std::make_optional(to_usv(group_sk)) : std::nullopt, - std::nullopt); - members[i] = std::make_unique( - to_usv(group_pk), - i <= ADMIN2 ? std::make_optional(to_usv(group_sk)) : std::nullopt, - std::nullopt); - keys[i] = std::make_unique( - to_usv(ed_sk[i]), - to_usv(group_pk), - i <= ADMIN2 ? std::make_optional(to_usv(group_sk)) : std::nullopt, - std::nullopt, - *info[i], - *members[i]); - } + std::vector admins; + std::vector members; - std::vector sids; + // 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 < n_seeds; ++i) { - std::array sid; - memcpy(&sid[1], &ed_pk[i], 32); - sid[0] = 0x05; - sids.push_back(oxenc::to_hex(sid.begin(), sid.end())); - } + 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->size() == 0); + REQUIRE(m.members->size() == 0); std::vector> info_configs; std::vector> mem_configs; - SECTION("Add members and re-key") { - for (int i = MEMBER1; i < MEMBER4; ++i) { - auto m = members[ADMIN1]->get_or_construct(sids[i]); - m.admin = false; - members[ADMIN1]->set(m); - } + // add admin account, re-key, distribute + auto& admin1 = admins[0]; - CHECK(members[ADMIN1]->needs_push()); + auto m = admin1.members->get_or_construct(admin1.session_id); + m.admin = true; + m.name = "Admin1"; + admin1.members->set(m); - // get new configs - auto new_keys_config = keys[ADMIN1]->rekey(*info[ADMIN1], *members[ADMIN1]); - auto [iseq, new_info_config, iobs] = info[ADMIN1]->push(); - info[ADMIN1]->confirm_pushed(iseq, "fakehash1"); - auto [mseq, new_mem_config, mobs] = members[ADMIN1]->push(); - members[ADMIN1]->confirm_pushed(mseq, "fakehash1"); + CHECK(admin1.members->needs_push()); - info_configs.emplace_back("fakehash1", new_info_config); - mem_configs.emplace_back("fakehash1", new_mem_config); + auto new_keys_config1 = admin1.keys->rekey(*admin1.info, *admin1.members); + CHECK(not new_keys_config1.empty()); - for (int i = MEMBER1; i < MEMBER4; ++i) { - auto n = info[i]->merge(info_configs); - auto m = members[i]->merge(mem_configs); - } + 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(new_keys_config1, get_timestamp(), *a.info, *a.members); + CHECK(a.info->merge(info_configs) == 1); + CHECK(a.members->merge(mem_configs) == 1); + CHECK(a.members->size() == 1); } - // SECTION("Remove member 4 and re-key") { - // REQUIRE(members[ADMIN1]->erase(sids[MEMBER4])); + /* 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(new_keys_config1, get_timestamp(), *m.info, *m.members); + CHECK_THROWS(m.info->merge(info_configs)); + CHECK_THROWS(m.members->merge(mem_configs)); + CHECK(m.members->size() == 0); + } + + 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(new_keys_config2, get_timestamp(), *a.info, *a.members); + CHECK(a.info->merge(info_configs) == 1); + CHECK(a.members->merge(mem_configs) == 1); + CHECK(a.members->size() == 5); + } + + for (auto& m : members) { + m.keys->load_key_message(new_keys_config2, get_timestamp(), *m.info, *m.members); + CHECK(m.info->merge(info_configs) == 1); + CHECK(m.members->merge(mem_configs) == 1); + CHECK(m.members->size() == 5); + } + + 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(new_keys_config3, get_timestamp(), *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); + } + + for (auto& m : members) { + m.keys->load_key_message(new_keys_config3, get_timestamp(), *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); + } + + info_configs.clear(); + mem_configs.clear(); + + // remove members, re-key, distribute + CHECK(admin1.members->erase(members[3].session_id)); + CHECK(admin1.members->erase(members[2].session_id)); + + CHECK(admin1.members->needs_push()); + + auto new_keys_config4 = admin1.keys->rekey(*admin1.info, *admin1.members); + CHECK(not new_keys_config4.empty()); + + 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) { + a.keys->load_key_message(new_keys_config4, get_timestamp(), *a.info, *a.members); + CHECK(a.info->merge(info_configs) == 1); + CHECK(a.members->merge(mem_configs) == 1); + CHECK(a.members->size() == 3); + } + + for (int i = 0; i < 2; ++i) { + auto& m = members[i]; + m.keys->load_key_message(new_keys_config2, get_timestamp(), *m.info, *m.members); + CHECK(m.info->merge(info_configs) == 1); + CHECK(m.members->merge(mem_configs) == 1); + CHECK(m.members->size() == 3); + } + + for (int i = 2; i < 4; ++i) { + auto& m = members[i]; + m.keys->load_key_message(new_keys_config2, get_timestamp(), *m.info, *m.members); + CHECK(m.info->merge(info_configs) == 0); + CHECK(m.members->merge(mem_configs) == 0); + CHECK(m.members->size() == 5); + } + + 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; - // // get new configs - // auto new_keys_config = keys[ADMIN1]->rekey(*info[ADMIN1], *members[ADMIN1]); - // auto [iseq, new_info_config, iobs] = info[ADMIN1]->push(); - // auto [mseq, new_members_config, mobs] = members[ADMIN1]->push(); + auto compressed = admin1.keys->encrypt_message(to_usv(msg), true); + auto uncompressed = admin1.keys->encrypt_message(to_usv(msg), false); - // } + CHECK(compressed.size() < msg.size()); + CHECK(compressed.size() < uncompressed.size()); } diff --git a/tests/utils.hpp b/tests/utils.hpp index 3a73a139..f842a076 100644 --- a/tests/utils.hpp +++ b/tests/utils.hpp @@ -33,6 +33,10 @@ inline constexpr auto operator""_kiB(unsigned long long kiB) { return kiB * 1024; } +inline int64_t get_timestamp() { + return std::chrono::steady_clock::now().time_since_epoch().count(); +} + inline std::string_view to_sv(ustring_view x) { return {reinterpret_cast(x.data()), x.size()}; } From 9f447b6c32811a8af76a4c8d97997387f259b3bf Mon Sep 17 00:00:00 2001 From: dr7ana Date: Fri, 25 Aug 2023 15:28:40 -0700 Subject: [PATCH 32/55] review --- tests/test_group_keys.cpp | 131 +++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index cf27b330..4870fa79 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -6,8 +6,11 @@ #include #include #include +#include #include +#include #include +#include #include #include "utils.hpp" @@ -19,7 +22,7 @@ static constexpr int64_t created_ts = 1680064059; using namespace session::config; -TEST_CASE("Group Keys", "[config][groups][keys]") { +TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { struct pseudo_client { const bool is_admin; @@ -132,8 +135,9 @@ TEST_CASE("Group Keys", "[config][groups][keys]") { CHECK(admin1.members->needs_push()); - auto new_keys_config1 = admin1.keys->rekey(*admin1.info, *admin1.members); - CHECK(not new_keys_config1.empty()); + 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"); @@ -293,3 +297,124 @@ TEST_CASE("Group Keys", "[config][groups][keys]") { CHECK(compressed.size() < msg.size()); CHECK(compressed.size() < uncompressed.size()); } + +TEST_CASE("Group Keys - C++ API", "[config][groups][keys][c]") +{ + struct pseudo_client { + const bool is_admin; + + const ustring seed; + std::string session_id; + + std::array public_key; + std::array secret_key; + + config_group_keys* keys; + config_object* info; + config_object* members; + + pseudo_client(ustring s, bool a, unsigned char* gpk, std::optional gsk) : + seed{s}, is_admin{a} { + crypto_sign_ed25519_seed_keypair( + public_key.data(), + secret_key.data(), + reinterpret_cast(seed.data())); + + REQUIRE(oxenc::to_hex(seed.begin(), seed.end()) == + oxenc::to_hex(secret_key.begin(), secret_key.begin() + 32)); + + std::array sid; + int rc = crypto_sign_ed25519_pk_to_curve25519(&sid[1], public_key.data()); + REQUIRE(rc == 0); + sid[0] = 0x05; + session_id = oxenc::to_hex(sid.begin(), sid.end()); + + 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)); + + std::vector admins; + std::vector 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(contacts_size(a.members) == 0); + // for (const auto& m : members) + // REQUIRE(contacts_size(m.members) == 0); +} From d1218641b78dac3a3ab0b3b35b8e8d8d778c0638 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 25 Aug 2023 19:59:11 -0300 Subject: [PATCH 33/55] Fix crash when keys_ is empty --- src/config/groups/keys.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index e75bd48b..37877ae2 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -502,7 +502,7 @@ bool Keys::insert_key(const key_info& new_key) { // We found a key we already had, so just ignore it. return false; - if (it == keys_.begin() && new_key.generation < keys_.front().generation && + 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. From c431f125d9a55490bade5b473257fcc29952ef99 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 25 Aug 2023 20:25:51 -0300 Subject: [PATCH 34/55] Fix crash in C API keys init when no dump given --- src/config/groups/keys.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 37877ae2..2bd4b2fd 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -908,7 +908,7 @@ LIBSESSION_C_API int groups_keys_init( auto c_conf = std::make_unique(); try { - c_conf->internals = new groups::Keys{user_sk, group_pk, group_sk, dump, info, members}; + 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(); From 883710340a14b74ffbac482b8b4b41aadcad446e Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 25 Aug 2023 22:33:43 -0300 Subject: [PATCH 35/55] Supplemental config fixes and tests --- include/session/config/groups/keys.hpp | 20 +- src/config/groups/keys.cpp | 183 +++++++------ tests/test_group_keys.cpp | 355 +++++++++++++++---------- 3 files changed, 325 insertions(+), 233 deletions(-) diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index f1cb0841..e8ca92d3 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -273,8 +273,8 @@ class Keys final : public ConfigSig { /// API: groups/Keys::key_supplement /// /// Generates a supplemental key message for one or more session IDs. This is used to - /// distribute an existing key to a new member so that that member can access existing keys and - /// messages. Only admins can call this. + /// 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 @@ -282,19 +282,19 @@ class Keys final : public ConfigSig { /// - 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. - /// - `all` -- if true (the default) then generate a supplemental message for *all* current - /// keys; if false then only generate one for the most recent key. /// /// Outputs: /// - `ustring` containing the message that should be pushed to the swarm containing encrypted /// keys for the given user(s). - ustring key_supplement(std::vector sids, bool all = true) const; - ustring key_supplement(std::string sid, bool all = true) const { - return key_supplement(std::vector{{std::move(sid)}}, all); + ustring key_supplement(std::vector sids) const; + ustring key_supplement(std::string sid) const { + return key_supplement(std::vector{{std::move(sid)}}); } /// API: groups/Keys::pending_config @@ -354,7 +354,11 @@ class Keys final : public ConfigSig { /// /// Outputs: /// - throws `std::runtime_error` (typically a subclass thereof) on failure to parse. - void load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, Members& members); + /// - 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(ustring_view data, int64_t timestamp_ms, Info& info, Members& members); /// API: groups/Keys::needs_rekey /// diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 2bd4b2fd..c17062ab 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -16,6 +17,8 @@ #include "session/config/groups/keys.h" #include "session/config/groups/members.hpp" +using namespace std::literals; + namespace session::config::groups { static auto sys_time_from_ms(int64_t milliseconds_since_epoch) { @@ -156,6 +159,8 @@ std::vector Keys::group_keys() const { } 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!"}; @@ -357,7 +362,7 @@ ustring_view Keys::rekey(Info& info, Members& members) { return ustring_view{pending_key_config_.data(), pending_key_config_.size()}; } -ustring Keys::key_supplement(std::vector sids, bool all) const { +ustring Keys::key_supplement(std::vector sids) const { if (!admin()) throw std::logic_error{ "Unable to issue supplemental group encryption keys without the main group keys"}; @@ -497,10 +502,18 @@ std::optional Keys::pending_config() const { } bool Keys::insert_key(const 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) + return false; + auto it = std::lower_bound(keys_.begin(), keys_.end(), new_key); - if (it != keys_.end() && new_key == *it) - // We found a key we already had, so just ignore it. - return false; if (keys_.size() >= 2 && it == keys_.begin() && new_key.generation < keys_.front().generation && keys_.front().timestamp + KEY_EXPIRY < keys_.back().timestamp) @@ -515,7 +528,7 @@ bool Keys::insert_key(const key_info& new_key) { return true; } -void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, Members& members) { +bool Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, Members& members) { oxenc::bt_dict_consumer d{from_unsigned_sv(data)}; @@ -528,8 +541,7 @@ void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, throw config_value_error{"Key message has no nonce"}; auto nonce = to_unsigned_sv(d.consume_string_view()); - bool found_key = false; - sodium_cleared new_key{}; + sodium_vector new_keys; sodium_cleared> member_dec_key; if (!admin()) { @@ -578,8 +590,8 @@ void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, "Supplemental key message has invalid key info size at index " + std::to_string(member_key_count)}; - if (found_key || admin()) - continue; + 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); @@ -595,10 +607,10 @@ void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, nonce.data(), member_dec_key.data())) { // Decryption success, we found our key list! - found_key = true; 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{ @@ -616,100 +628,109 @@ void Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, throw config_value_error{ "Invalid supplemental key message: no `t` timestamp"}; new_key.timestamp = sys_time_from_ms(keyinf.consume_integer()); - - insert_key(new_key); } } } } + } else { + // Full message (i.e. not supplemental) - return; - } - - 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"}; + bool found_key = false; + auto& new_key = new_keys.emplace_back(); + new_key.timestamp = sys_time_from_ms(timestamp_ms); - 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("G")) + throw config_value_error{"Key message missing required generation (G) field"}; - if (!d.skip_until("K")) - throw config_value_error{"Non-supplemental key message is missing required admin key (K)"}; + new_key.generation = d.consume_integer(); + if (new_key.generation < 0) + throw config_value_error{"Key message contains invalid negative generation"}; - 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 (!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"}; - if (admin()) { - auto k = seed_hash(enc_key_admin_hash_key); + found_key = true; + } - 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"}; + // 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(); - found_key = true; - insert_key(new_key); - } + 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; + } + } - // 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(); + if (member_key_count % MESSAGE_KEY_MULTIPLE != 0) + throw config_value_error{"Member key list has wrong size (missing junk key padding?)"}; - 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; - insert_key(new_key); - } + if (!found_key) + new_keys.pop_back(); } - if (member_key_count % MESSAGE_KEY_MULTIPLE != 0) - throw config_value_error{"Member key list has wrong size (missing junk key padding?)"}; - 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 (!pending_key_config_.empty() && - (new_key.generation > pending_gen_ || new_key.key == pending_key_)) { + 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; } - auto new_key_list = group_keys(); - members.replace_keys(new_key_list, /*dirty=*/false); - info.replace_keys(new_key_list, /*dirty=*/false); + if (!new_keys.empty()) { + for (auto& k : new_keys) + insert_key(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; + } + return false; } void Keys::remove_expired() { diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index 4870fa79..633d0eff 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -1,16 +1,18 @@ #include #include +#include +#include +#include #include +#include #include #include #include +#include #include -#include #include -#include #include -#include #include #include "utils.hpp" @@ -22,53 +24,62 @@ static constexpr int64_t created_ts = 1680064059; using namespace session::config; -TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { - - struct pseudo_client { - const bool is_admin; - - const ustring seed; - std::string session_id; - - std::array public_key; - std::array secret_key; +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; +} - std::unique_ptr keys; - std::unique_ptr info; - std::unique_ptr members; +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; +} - pseudo_client(ustring s, bool a, unsigned char* gpk, std::optional gsk) : - seed{s}, is_admin{a} { - crypto_sign_ed25519_seed_keypair( - public_key.data(), - secret_key.data(), - reinterpret_cast(seed.data())); +// 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); } +}; - REQUIRE(oxenc::to_hex(seed.begin(), seed.end()) == - oxenc::to_hex(secret_key.begin(), secret_key.begin() + 32)); +TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { - std::array sid; - int rc = crypto_sign_ed25519_pk_to_curve25519(&sid[1], public_key.data()); - REQUIRE(rc == 0); - sid[0] = 0x05; - session_id = oxenc::to_hex(sid.begin(), sid.end()); - - info = std::make_unique( - ustring_view{gpk, 32}, - is_admin ? std::make_optional({*gsk, 64}) : std::nullopt, - std::nullopt); - members = std::make_unique( - ustring_view{gpk, 32}, - is_admin ? std::make_optional({*gsk, 64}) : std::nullopt, - std::nullopt); - keys = std::make_unique( - to_usv(secret_key), - ustring_view{gpk, 32}, - is_admin ? std::make_optional({*gsk, 64}) : std::nullopt, - std::nullopt, - *info, - *members); - } + 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 a, + const unsigned char* gpk, + std::optional gsk) : + secret_key{sk_from_seed(seed)}, + info{ustring_view{gpk, 32}, + a ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt}, + members{ustring_view{gpk, 32}, + a ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt}, + keys{to_usv(secret_key), + ustring_view{gpk, 32}, + a ? std::make_optional({*gsk, 64}) : std::nullopt, + std::nullopt, + info, + members} {} }; const ustring group_seed = @@ -81,7 +92,9 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { "000111222333444555666777888999aaabbbcccdddeeefff0123456789abcdef"_hexbytes, // member1 "00011122435111155566677788811263446552465222efff0123456789abcdef"_hexbytes, // member2 "00011129824754185548239498168169316979583253efff0123456789abcdef"_hexbytes, // member3 - "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"_hexbytes // member4 + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"_hexbytes, // member4 + "3333333333333333333333333333333333333333333333333333333333333333"_hexbytes, // member3b + "4444444444444444444444444444444444444444444444444444444444444444"_hexbytes, // member4b }; std::array group_pk; @@ -94,8 +107,11 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { REQUIRE(oxenc::to_hex(group_seed.begin(), group_seed.end()) == oxenc::to_hex(group_sk.begin(), group_sk.begin() + 32)); - std::vector admins; - std::vector members; + // 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()); @@ -118,9 +134,9 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { "050a41669a06c098f22633aee2eba03764ef6813bd4f770a3a2b9033b868ca470d"); for (const auto& a : admins) - REQUIRE(a.members->size() == 0); + REQUIRE(a.members.size() == 0); for (const auto& m : members) - REQUIRE(m.members->size() == 0); + REQUIRE(m.members.size() == 0); std::vector> info_configs; std::vector> mem_configs; @@ -128,23 +144,23 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { // add admin account, re-key, distribute auto& admin1 = admins[0]; - auto m = admin1.members->get_or_construct(admin1.session_id); + auto m = admin1.members.get_or_construct(admin1.session_id); m.admin = true; m.name = "Admin1"; - admin1.members->set(m); + admin1.members.set(m); - CHECK(admin1.members->needs_push()); + CHECK(admin1.members.needs_push()); - auto maybe_key_config = admin1.keys->pending_config(); + 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"); + 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"); + 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 @@ -152,20 +168,20 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { message, which they can decrypt with the group secret key. */ for (auto& a : admins) { - a.keys->load_key_message(new_keys_config1, get_timestamp(), *a.info, *a.members); - CHECK(a.info->merge(info_configs) == 1); - CHECK(a.members->merge(mem_configs) == 1); - CHECK(a.members->size() == 1); + a.keys.load_key_message(new_keys_config1, get_timestamp(), a.info, a.members); + CHECK(a.info.merge(info_configs) == 1); + CHECK(a.members.merge(mem_configs) == 1); + CHECK(a.members.size() == 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) { - m.keys->load_key_message(new_keys_config1, get_timestamp(), *m.info, *m.members); - CHECK_THROWS(m.info->merge(info_configs)); - CHECK_THROWS(m.members->merge(mem_configs)); - CHECK(m.members->size() == 0); + m.keys.load_key_message(new_keys_config1, get_timestamp(), m.info, m.members); + CHECK_THROWS(m.info.merge(info_configs)); + CHECK_THROWS(m.members.merge(mem_configs)); + CHECK(m.members.size() == 0); } info_configs.clear(); @@ -173,114 +189,124 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { // 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); + 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); + admin1.members.set(m); } - CHECK(admin1.members->needs_push()); + CHECK(admin1.members.needs_push()); - auto new_keys_config2 = admin1.keys->rekey(*admin1.info, *admin1.members); + 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"); + 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"); + 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(new_keys_config2, get_timestamp(), *a.info, *a.members); - CHECK(a.info->merge(info_configs) == 1); - CHECK(a.members->merge(mem_configs) == 1); - CHECK(a.members->size() == 5); + a.keys.load_key_message(new_keys_config2, get_timestamp(), a.info, a.members); + CHECK(a.info.merge(info_configs) == 1); + CHECK(a.members.merge(mem_configs) == 1); + CHECK(a.members.size() == 5); } for (auto& m : members) { - m.keys->load_key_message(new_keys_config2, get_timestamp(), *m.info, *m.members); - CHECK(m.info->merge(info_configs) == 1); - CHECK(m.members->merge(mem_configs) == 1); - CHECK(m.members->size() == 5); + m.keys.load_key_message(new_keys_config2, get_timestamp(), m.info, m.members); + CHECK(m.info.merge(info_configs) == 1); + CHECK(m.members.merge(mem_configs) == 1); + CHECK(m.members.size() == 5); } info_configs.clear(); mem_configs.clear(); // change group info, re-key, distribute - admin1.info->set_name("tomatosauce"s); + admin1.info.set_name("tomatosauce"s); - CHECK(admin1.info->needs_push()); + CHECK(admin1.info.needs_push()); - auto new_keys_config3 = admin1.keys->rekey(*admin1.info, *admin1.members); + 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"); + 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"); + 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(new_keys_config3, get_timestamp(), *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); + a.keys.load_key_message(new_keys_config3, get_timestamp(), 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); } for (auto& m : members) { - m.keys->load_key_message(new_keys_config3, get_timestamp(), *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); + m.keys.load_key_message(new_keys_config3, get_timestamp(), 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); } info_configs.clear(); mem_configs.clear(); // remove members, re-key, distribute - CHECK(admin1.members->erase(members[3].session_id)); - CHECK(admin1.members->erase(members[2].session_id)); + 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()); + CHECK(admin1.members.needs_push()); - auto new_keys_config4 = admin1.keys->rekey(*admin1.info, *admin1.members); + 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()); - auto [iseq4, new_info_config4, iobs4] = admin1.info->push(); - admin1.info->confirm_pushed(iseq4, "fakehash4"); + 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"); + 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) { - a.keys->load_key_message(new_keys_config4, get_timestamp(), *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.load_key_message(new_keys_config4, get_timestamp(), a.info, a.members)); + CHECK(a.info.merge(info_configs) == 1); + CHECK(a.members.merge(mem_configs) == 1); + CHECK(a.members.size() == 3); } - for (int i = 0; i < 2; ++i) { + for (int i = 0; i < members.size(); i++) { auto& m = members[i]; - m.keys->load_key_message(new_keys_config2, get_timestamp(), *m.info, *m.members); - CHECK(m.info->merge(info_configs) == 1); - CHECK(m.members->merge(mem_configs) == 1); - CHECK(m.members->size() == 3); + bool found_key = + m.keys.load_key_message(new_keys_config2, get_timestamp(), m.info, m.members); + + 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); + } } - for (int i = 2; i < 4; ++i) { - auto& m = members[i]; - m.keys->load_key_message(new_keys_config2, get_timestamp(), *m.info, *m.members); - 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(); @@ -291,15 +317,69 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { for (int i = 0; i < 5; ++i) msg += msg; - auto compressed = admin1.keys->encrypt_message(to_usv(msg), true); - auto uncompressed = admin1.keys->encrypt_message(to_usv(msg), false); + 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(); + 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 (size_t i = 0; i < members.size(); i++) { + DYNAMIC_SECTION("supp key load " << i) { + auto& m = members[i]; + bool found_key = m.keys.load_key_message(supp, get_timestamp(), m.info, m.members); + + if (i < 1) { + // This supp key wasn't for us + CHECK_FALSE(found_key); + 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.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); + } + } } -TEST_CASE("Group Keys - C++ API", "[config][groups][keys][c]") -{ +TEST_CASE("Group Keys - C++ API", "[config][groups][keys][c]") { struct pseudo_client { const bool is_admin; @@ -326,25 +406,13 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][c]") std::array sid; int rc = crypto_sign_ed25519_pk_to_curve25519(&sid[1], public_key.data()); REQUIRE(rc == 0); - sid[0] = 0x05; - session_id = oxenc::to_hex(sid.begin(), sid.end()); + session_id += "\x05"; + oxenc::to_hex(sid.begin(), sid.end(), std::back_inserter(session_id)); - int rv = groups_members_init( - &members, - gpk, - is_admin ? *gsk : NULL, - NULL, - 0, - NULL); + 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); + rv = groups_info_init(&info, gpk, is_admin ? *gsk : NULL, NULL, 0, NULL); REQUIRE(rv == 0); rv = groups_keys_init( @@ -360,8 +428,7 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][c]") REQUIRE(rv == 0); } - ~pseudo_client() - { + ~pseudo_client() { config_free(info); config_free(members); } From 517a61a455d31cd9363198d1b3d02f20093a5811 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 28 Aug 2023 15:53:58 -0300 Subject: [PATCH 36/55] Updates for user groups -> groups - add test - add missing `erase_group` to erase by id - fix bug where `K` wasn't set for non-admins (it should be set but empty in such a case so that we always have a key). - make `check_session_id` take a string view prefix instead of a char to make it a little easier, and to include it in the error message. --- include/session/config/user_groups.hpp | 15 ++++- src/config/internal.cpp | 12 ++-- src/config/internal.hpp | 4 +- src/config/user_groups.cpp | 19 ++++-- tests/test_config_user_groups.cpp | 90 ++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 16 deletions(-) diff --git a/include/session/config/user_groups.hpp b/include/session/config/user_groups.hpp index 3549e55c..3ca6cb8a 100644 --- a/include/session/config/user_groups.hpp +++ b/include/session/config/user_groups.hpp @@ -28,7 +28,8 @@ namespace session::config { /// 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 +/// s - authentication signature; this is used by non-admins to authenticate. Omitted when K is +/// non-empty. /// @ - 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 @@ -443,6 +444,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 diff --git a/src/config/internal.cpp b/src/config/internal.cpp index 3b9ef85e..979137da 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -11,15 +11,15 @@ namespace session::config { -void check_session_id(std::string_view session_id, unsigned char prefix) { - if (!(session_id.size() == 66 && oxenc::is_hex(session_id) && - session_id[0] == ('0' + (prefix >> 4)) && session_id[1] == ('0' + (prefix & 0xf)))) +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, unsigned char prefix) { +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); } diff --git a/src/config/internal.hpp b/src/config/internal.hpp index e1a0dfc9..7a749bec 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -82,10 +82,10 @@ void copy_c_str(char (&dest)[N], std::string_view src) { // 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, unsigned char prefix = 0x05); +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, unsigned char prefix = 0x05); +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 prefix // (which is the x25519 pubkey). diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index bace4bf3..7c6f7126 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -50,7 +50,7 @@ static void base_from(base_group_info& self, const T& c) { } group_info::group_info(std::string sid) : id{std::move(sid)} { - check_session_id(id, 0x03); + check_session_id(id, "03"); } legacy_group_info::legacy_group_info(std::string sid) : session_id{std::move(sid)} { @@ -315,7 +315,7 @@ legacy_group_info UserGroups::get_or_construct_legacy_group(std::string_view pub } std::optional UserGroups::get_group(std::string_view pubkey_hex) const { - std::string pubkey = session_id_to_bytes(pubkey_hex, 0x03); + std::string pubkey = session_id_to_bytes(pubkey_hex, "03"); auto* info_dict = data["g"][pubkey].dict(); if (!info_dict) @@ -388,14 +388,16 @@ void UserGroups::set(const legacy_group_info& g) { } void UserGroups::set(const group_info& g) { - auto info = data["g"][session_id_to_bytes(g.id, 0x03)]; + auto info = data["g"][session_id_to_bytes(g.id, "03")]; set_base(g, info); if (g.secretkey.size() == 64) info["K"] = ustring_view{g.secretkey.data(), 32}; - - else if (g.auth_sig.size() == 64) - info["s"] = g.auth_sig; + else { + info["K"] = ustring_view{}; + if (g.auth_sig.size() == 64) + info["s"] = g.auth_sig; + } } template @@ -420,7 +422,7 @@ 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, 0x03)]); + 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)]); @@ -435,6 +437,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; diff --git a/tests/test_config_user_groups.cpp b/tests/test_config_user_groups.cpp index cccf18bf..4fe91cac 100644 --- a/tests/test_config_user_groups.cpp +++ b/tests/test_config_user_groups.cpp @@ -408,6 +408,96 @@ TEST_CASE("User Groups", "[config][groups]") { } } +TEST_CASE("User Groups (new)", "[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.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); + + 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); + + c2->priority = 123; + c2->joined_at = 1234567890; + c2->notifications = session::config::notify_mode::mentions_only; + c2->mute_until = 456789012; + + g2.set(*c2); + + 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->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); + + groups.erase(*c3); + + auto gg = groups.get_or_construct_group("030303030303030303030303030303030303030303030303030303030303030303"); + groups.set(gg); + CHECK(groups.erase_group("030303030303030303030303030303030303030303030303030303030303030303")); + CHECK_FALSE(groups.erase_group("030303030303030303030303030303030303030303030303030303030303030303")); +} + TEST_CASE("User Groups members C API", "[config][groups][c]") { const auto seed = "0123456789abcdef0123456789abcdef00000000000000000000000000000000"_hexbytes; From 390faa8a27c6a7c60086f850c7094314baa7fa0c Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 28 Aug 2023 23:08:15 -0300 Subject: [PATCH 37/55] Remove temporary testKeys binary It was temporary for simpler keys testing. --- tests/CMakeLists.txt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e73548bd..dbf39f45 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,14 +16,6 @@ add_executable(testAll test_xed25519.cpp ) -add_executable(testKeys - test_group_keys.cpp -) - -target_link_libraries(testKeys PRIVATE - config - Catch2::Catch2WithMain) - target_link_libraries(testAll PRIVATE config Catch2::Catch2WithMain) From 8cb26be50d29c9ef4962b5e6cbaaec208c9d2632 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 30 Aug 2023 16:31:03 -0300 Subject: [PATCH 38/55] Swarm subaccount authentication This adds methods to `Keys` that generates subaccount tokens and signatures as needed to do storage server subaccount authentication (which currently requires testnet as the subaccount code is not yet active on mainnet), along with test code to test it. Also adds a tests/swarm-auth-test binary that spits out storage requests for store/retrieve testing. --- include/session/config/groups/info.hpp | 12 +- include/session/config/groups/keys.hpp | 143 ++++++++++++ include/session/config/user_groups.h | 7 +- include/session/config/user_groups.hpp | 11 +- include/session/xed25519.hpp | 14 ++ src/config/groups/info.cpp | 7 +- src/config/groups/keys.cpp | 306 ++++++++++++++++++++++++- src/config/internal.cpp | 10 +- src/config/internal.hpp | 6 +- src/config/user_groups.cpp | 16 +- src/xed25519.cpp | 13 -- tests/CMakeLists.txt | 3 + tests/swarm-auth-test.cpp | 162 +++++++++++++ tests/test_config_user_groups.cpp | 8 +- tests/test_config_userprofile.cpp | 8 + tests/test_group_keys.cpp | 163 ++++++++++--- tests/utils.hpp | 11 +- 17 files changed, 807 insertions(+), 93 deletions(-) create mode 100644 tests/swarm-auth-test.cpp diff --git a/include/session/config/groups/info.hpp b/include/session/config/groups/info.hpp index f02d4ef1..9ebdfd7d 100644 --- a/include/session/config/groups/info.hpp +++ b/include/session/config/groups/info.hpp @@ -73,14 +73,16 @@ class Info final : public ConfigBase { /// - `const char*` - Will return "groups::Info" const char* encryption_domain() const override { return "groups::Info"; } - /// Returns the subaccount masking value. This is based on the group's seed and thus is only - /// obtainable by an admin account. + /// API: groups/Info::id /// - /// Inputs: none + /// 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: - /// - `ustring_view` - the 32-byte masking value. - std::array subaccount_mask() const; + /// - `std::string` containing the hex group id/pubkey + const std::string id; /// API: groups/Info::get_name /// diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index e8ca92d3..718a6422 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -113,6 +113,11 @@ class Keys final : public ConfigSig { // if it already existed. bool insert_key(const 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 @@ -297,6 +302,144 @@ class Keys final : public ConfigSig { 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 hex/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_auth_key`). + /// + /// 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, the + /// returned struct values will be hex-encoded (subaccount token) or base64-encoded + /// (signatures) suitable for direct passing as JSON values to the storage server. + /// + /// 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 diff --git a/include/session/config/user_groups.h b/include/session/config/user_groups.h index 2728b029..2e9be8ce 100644 --- a/include/session/config/user_groups.h +++ b/include/session/config/user_groups.h @@ -48,9 +48,10 @@ typedef struct ugroups_group_info { 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_sig; // Will be true if the `auth_sig` is populated - unsigned char auth_sig[64]; // If `have_auth_sig` is set then this is the authentication - // signature that can be used to access the swarm. + 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). diff --git a/include/session/config/user_groups.hpp b/include/session/config/user_groups.hpp index 3ca6cb8a..16ba58d9 100644 --- a/include/session/config/user_groups.hpp +++ b/include/session/config/user_groups.hpp @@ -181,10 +181,13 @@ struct group_info : base_group_info { /// Group secret key (64 bytes); this is only possessed by admins. ustring secretkey; - /// Group authentication signature; this is possessed by non-admins. (This value will be - /// dropped when serializing if secretkey is non-empty, and so does not need to be explicitly - /// cleared when being promoted to admin) - ustring auth_sig; + /// 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); 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/config/groups/info.cpp b/src/config/groups/info.cpp index 9c34a9d0..be29e581 100644 --- a/src/config/groups/info.cpp +++ b/src/config/groups/info.cpp @@ -20,11 +20,8 @@ Info::Info( ustring_view ed25519_pubkey, std::optional ed25519_secretkey, std::optional dumped) : - ConfigBase{dumped, ed25519_pubkey, ed25519_secretkey} {} - -std::array Info::subaccount_mask() const { - return seed_hash("SessionGroupSubaccountMask"); -} + 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()) diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index c17062ab..0e6a81ae 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -1,10 +1,13 @@ #include "session/config/groups/keys.hpp" +#include +#include #include #include #include #include #include +#include #include #include #include @@ -16,6 +19,7 @@ #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; @@ -278,7 +282,7 @@ ustring_view Keys::rekey(Info& info, Members& members) { auto member_keys = d.append_list("k"); int member_count = 0; for (const auto& m : members) { - auto m_xpk = session_id_xpk(m.session_id); + 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? @@ -442,7 +446,7 @@ ustring Keys::key_supplement(std::vector sids) const { size_t member_count = 0; for (auto& sid : sids) { - auto m_xpk = session_id_xpk(sid); + auto m_xpk = session_id_pk(sid); // Calculate the encryption key: H(aB || A || B) std::array member_k; @@ -495,6 +499,304 @@ ustring Keys::key_supplement(std::vector sids) const { 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_hex(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; diff --git a/src/config/internal.cpp b/src/config/internal.cpp index 979137da..c5b4421c 100644 --- a/src/config/internal.cpp +++ b/src/config/internal.cpp @@ -24,12 +24,12 @@ std::string session_id_to_bytes(std::string_view session_id, std::string_view pr return oxenc::from_hex(session_id); } -std::array session_id_xpk(std::string_view session_id) { - check_session_id(session_id); - std::array xpk; +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(), xpk.begin()); - return xpk; + oxenc::from_hex(session_id.begin(), session_id.end(), pk.begin()); + return pk; } void check_encoded_pubkey(std::string_view pk) { diff --git a/src/config/internal.hpp b/src/config/internal.hpp index 7a749bec..ca37f3a0 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -87,9 +87,9 @@ 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_view prefix = "05"); -// Checks the session_id (throwing if invalid) then returns it as bytes, omitting the 05 prefix -// (which is the x25519 pubkey). -std::array session_id_xpk(std::string_view session_id); +// 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. diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index 7c6f7126..3a79972c 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -199,8 +199,8 @@ group_info::group_info(const ugroups_group_info& c) : id{c.id, 66} { base_from(*this, c); if (c.have_secretkey) secretkey.assign(c.secretkey, 64); - if (c.have_auth_sig) - auth_sig.assign(c.auth_sig, 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 { @@ -209,8 +209,8 @@ void group_info::into(ugroups_group_info& c) const { copy_c_str(c.id, id); if ((c.have_secretkey = secretkey.size() == 64)) std::memcpy(c.secretkey, secretkey.data(), 64); - if ((c.have_auth_sig = auth_sig.size() == 64)) - std::memcpy(c.auth_sig, auth_sig.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) { @@ -223,8 +223,8 @@ void group_info::load(const dict& info_dict) { if (id != oxenc::to_hex(pk.begin(), pk.end())) secretkey.clear(); } - if (auto sig = maybe_ustring(info_dict, "s"); sig && sig->size() == 64) - auth_sig = std::move(*sig); + if (auto sig = maybe_ustring(info_dict, "s"); sig && sig->size() == 100) + auth_data = std::move(*sig); } void community_info::load(const dict& info_dict) { @@ -395,8 +395,8 @@ void UserGroups::set(const group_info& g) { info["K"] = ustring_view{g.secretkey.data(), 32}; else { info["K"] = ustring_view{}; - if (g.auth_sig.size() == 64) - info["s"] = g.auth_sig; + if (g.auth_data.size() == 100) + info["s"] = g.auth_data; } } 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 dbf39f45..cce4b155 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,3 +21,6 @@ target_link_libraries(testAll PRIVATE Catch2::Catch2WithMain) add_custom_target(check COMMAND testAll) + +add_executable(swarm-auth-test 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_user_groups.cpp b/tests/test_config_user_groups.cpp index 4fe91cac..054fa334 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; @@ -492,10 +493,13 @@ TEST_CASE("User Groups (new)", "[config][groups][new]") { groups.erase(*c3); - auto gg = groups.get_or_construct_group("030303030303030303030303030303030303030303030303030303030303030303"); + auto gg = groups.get_or_construct_group( + "030303030303030303030303030303030303030303030303030303030303030303"); groups.set(gg); CHECK(groups.erase_group("030303030303030303030303030303030303030303030303030303030303030303")); - CHECK_FALSE(groups.erase_group("030303030303030303030303030303030303030303030303030303030303030303")); + CHECK_FALSE( + groups.erase_group("03030303030303030303030303030303030303030303030303030303030303030" + "3")); } TEST_CASE("User Groups members C API", "[config][groups][c]") { 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_group_keys.cpp b/tests/test_group_keys.cpp index 633d0eff..b52d1084 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -7,12 +8,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include "utils.hpp" @@ -51,36 +54,36 @@ struct hacky_list : std::list { T& operator[](size_t n) { return *std::next(std::begin(*this), n); } }; -TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { +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} {} +}; - 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 a, - const unsigned char* gpk, - std::optional gsk) : - secret_key{sk_from_seed(seed)}, - info{ustring_view{gpk, 32}, - a ? std::make_optional({*gsk, 64}) : std::nullopt, - std::nullopt}, - members{ustring_view{gpk, 32}, - a ? std::make_optional({*gsk, 64}) : std::nullopt, - std::nullopt}, - keys{to_usv(secret_key), - ustring_view{gpk, 32}, - a ? std::make_optional({*gsk, 64}) : std::nullopt, - std::nullopt, - info, - members} {} - }; +TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { const ustring group_seed = "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes; @@ -100,10 +103,7 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { std::array group_pk; std::array group_sk; - crypto_sign_ed25519_seed_keypair( - group_pk.data(), - group_sk.data(), - reinterpret_cast(group_seed.data())); + 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)); @@ -379,7 +379,7 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { } } -TEST_CASE("Group Keys - C++ API", "[config][groups][keys][c]") { +TEST_CASE("Group Keys - C API", "[config][groups][keys][c]") { struct pseudo_client { const bool is_admin; @@ -485,3 +485,98 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][c]") { // for (const auto& m : members) // REQUIRE(contacts_size(m.members) == 0); } + +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 == + "0303000085af311da72570859cae7e84d6a135e716a8c0b7f7f4d554b8da4778a636e839"); + 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_hex(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/utils.hpp b/tests/utils.hpp index f842a076..7d45d3d4 100644 --- a/tests/utils.hpp +++ b/tests/utils.hpp @@ -3,11 +3,12 @@ #include #include -#include +#include #include #include #include #include +#include #include "session/config/base.h" @@ -66,14 +67,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()}; From 18d3df2d4376d820f6725177a3bba351cb052de0 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 30 Aug 2023 19:58:46 -0300 Subject: [PATCH 39/55] Don't build swarm-auth-test by default It adds a nlohmann::json dependency, and isn't really part of the normal test suite. --- include/session/config/groups/keys.h | 35 ++++++++++++++++++++++++++++ tests/CMakeLists.txt | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/include/session/config/groups/keys.h b/include/session/config/groups/keys.h index a1e64775..4f4ff496 100644 --- a/include/session/config/groups/keys.h +++ b/include/session/config/groups/keys.h @@ -190,6 +190,41 @@ LIBSESSION_EXPORT bool groups_keys_needs_dump(const config_group_keys* conf) LIBSESSION_EXPORT void groups_keys_dump( config_group_keys* conf, unsigned char** out, size_t* outlen); +/// API: grous/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_encrypt_message /// /// Encrypts a message using the most recent group encryption key of this object. The message will diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cce4b155..3bf47602 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,5 +22,5 @@ target_link_libraries(testAll PRIVATE add_custom_target(check COMMAND testAll) -add_executable(swarm-auth-test swarm-auth-test.cpp) +add_executable(swarm-auth-test EXCLUDE_FROM_ALL swarm-auth-test.cpp) target_link_libraries(swarm-auth-test PRIVATE config) From 224dda91e1f0a4430e7d0f4bcc09dd82a2fbf4d9 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 30 Aug 2023 19:59:30 -0300 Subject: [PATCH 40/55] Format fix --- src/config/internal.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/internal.hpp b/src/config/internal.hpp index ca37f3a0..e78ba640 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -89,7 +89,8 @@ std::string session_id_to_bytes(std::string_view session_id, std::string_view pr // 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"); +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. From 9b0cdcdc0cfc788b4e46c1b337ceddfbc1deee0f Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 30 Aug 2023 21:15:53 -0300 Subject: [PATCH 41/55] C API updates, and related tweaks - Adds C wrappers for all the swarm authentication methods - Adds C wrapper for querying if the keys object has admin permission - Adds C wrapper for key_supplement to generate supplemental key messages - Changes the swarm `subaccount` value to be base64 instead of hex (since it isn't really a pubkey) - Makes the vector passed into key_supplement const& --- include/session/config/groups/keys.h | 219 ++++++++++++++++++++++++- include/session/config/groups/keys.hpp | 12 +- src/config/groups/keys.cpp | 156 +++++++++++++++++- tests/test_group_keys.cpp | 5 +- 4 files changed, 380 insertions(+), 12 deletions(-) diff --git a/include/session/config/groups/keys.h b/include/session/config/groups/keys.h index 4f4ff496..b6e29a41 100644 --- a/include/session/config/groups/keys.h +++ b/include/session/config/groups/keys.h @@ -69,6 +69,18 @@ LIBSESSION_EXPORT int groups_keys_init( size_t dumplen, char* error) __attribute__((warn_unused_result)); +/// 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 @@ -190,7 +202,7 @@ LIBSESSION_EXPORT bool groups_keys_needs_dump(const config_group_keys* conf) LIBSESSION_EXPORT void groups_keys_dump( config_group_keys* conf, unsigned char** out, size_t* outlen); -/// API: grous/groups_keys_key_supplement +/// 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 @@ -225,6 +237,211 @@ LIBSESSION_EXPORT bool groups_keys_key_supplement( 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 diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index 718a6422..26719cf6 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -297,7 +297,7 @@ class Keys final : public ConfigSig { /// Outputs: /// - `ustring` containing the message that should be pushed to the swarm containing encrypted /// keys for the given user(s). - ustring key_supplement(std::vector sids) const; + ustring key_supplement(const std::vector& sids) const; ustring key_supplement(std::string sid) const { return key_supplement(std::vector{{std::move(sid)}}); } @@ -373,7 +373,7 @@ class Keys final : public ConfigSig { /// 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 hex/base64 + /// 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 @@ -397,7 +397,7 @@ class Keys final : public ConfigSig { /// /// 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_auth_key`). + /// `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). @@ -408,9 +408,9 @@ class Keys final : public ConfigSig { /// 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, the - /// returned struct values will be hex-encoded (subaccount token) or base64-encoded - /// (signatures) suitable for direct passing as JSON values to the storage server. + /// - `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 diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 0e6a81ae..a3dcd2e6 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -366,7 +366,7 @@ ustring_view Keys::rekey(Info& info, Members& members) { return ustring_view{pending_key_config_.data(), pending_key_config_.size()}; } -ustring Keys::key_supplement(std::vector sids) const { +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"}; @@ -731,7 +731,7 @@ Keys::swarm_auth Keys::swarm_subaccount_sign( // 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_hex(token); + token = oxenc::to_base64(token); sub_sig = oxenc::to_base64(sub_sig); sig = oxenc::to_base64(sig); } @@ -1247,6 +1247,10 @@ LIBSESSION_C_API int groups_keys_init( return SESSION_ERR_NONE; } +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, @@ -1353,3 +1357,151 @@ LIBSESSION_C_API bool groups_keys_decrypt_message( *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/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index b52d1084..cce81204 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -541,8 +541,7 @@ TEST_CASE("Group Keys - swarm authentication", "[config][groups][keys][swarm]") auto to_sign = to_usv("retrieve9991693340111000"); auto subauth_b64 = member.keys.swarm_subaccount_sign(to_sign, auth_data); - CHECK(subauth_b64.subaccount == - "0303000085af311da72570859cae7e84d6a135e716a8c0b7f7f4d554b8da4778a636e839"); + CHECK(subauth_b64.subaccount == "AwMAAIWvMR2nJXCFnK5+hNahNecWqMC39/TVVLjaR3imNug5"); CHECK(subauth_b64.subaccount_sig == "6brvv/" "2jfciBAJeRKMGSepNJLullyrVVHijyVDE+8GC5Oc89UNxjNrq1kVV1P+pkUIRDOew24gSLFgLZfdl+BQ=="); @@ -551,7 +550,7 @@ TEST_CASE("Group Keys - swarm authentication", "[config][groups][keys][swarm]") "6kVd0yONnpz5U5zePMYptqW3iYQ0TYf2rEzv3qqkPhS5p67M5GAccHoBHGDQ=="); auto subauth = member.keys.swarm_subaccount_sign(to_sign, auth_data, true); - CHECK(oxenc::to_hex(subauth.subaccount) == subauth_b64.subaccount); + 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); From e30122b524907a2ad724e3fc236770714dc164c7 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 31 Aug 2023 15:39:32 -0300 Subject: [PATCH 42/55] Add missing user_groups C API for new groups --- include/session/config/user_groups.h | 135 +++++++++++++++++++++++-- include/session/config/user_groups.hpp | 27 ++++- src/config/user_groups.cpp | 52 ++++++++++ 3 files changed, 203 insertions(+), 11 deletions(-) diff --git a/include/session/config/user_groups.h b/include/session/config/user_groups.h index 2e9be8ce..c58f5584 100644 --- a/include/session/config/user_groups.h +++ b/include/session/config/user_groups.h @@ -108,12 +108,46 @@ 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 @@ -277,6 +311,16 @@ 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 @@ -343,6 +387,29 @@ 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 @@ -561,7 +628,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 @@ -577,9 +644,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 @@ -605,12 +690,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); @@ -678,6 +766,22 @@ 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. @@ -740,7 +844,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: @@ -748,7 +852,22 @@ 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. @@ -762,7 +881,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 16ba58d9..4a3d7d7c 100644 --- a/include/session/config/user_groups.hpp +++ b/include/session/config/user_groups.hpp @@ -585,10 +585,31 @@ 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). - subtype_iterator legacy_groups() const { return {data}; } + /// 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; diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index 3a79972c..e5c827b1 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -613,6 +613,34 @@ 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) { @@ -657,6 +685,10 @@ 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}); @@ -674,6 +706,14 @@ 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); @@ -772,6 +812,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(); } @@ -784,6 +827,10 @@ 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()}}; @@ -818,6 +865,11 @@ 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); From 2ad96d55f04d5a615cdbc7b9a409aec5f4415027 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 31 Aug 2023 16:02:20 -0300 Subject: [PATCH 43/55] Keys.size(); updates to Keys C API - Add keys.size() return the number of keys in the object. - Add C API for retrieving keys & key size - Fix copy-and-paste error in groups_members_size C API name --- include/session/config/groups/keys.h | 39 +++++++++++++++++++++++++ include/session/config/groups/keys.hpp | 11 +++++++ include/session/config/groups/members.h | 2 +- src/config/groups/keys.cpp | 17 ++++++++++- tests/test_group_keys.cpp | 2 ++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/include/session/config/groups/keys.h b/include/session/config/groups/keys.h index b6e29a41..6d7933cc 100644 --- a/include/session/config/groups/keys.h +++ b/include/session/config/groups/keys.h @@ -69,6 +69,45 @@ LIBSESSION_EXPORT int groups_keys_init( 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 diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index 26719cf6..faddd57a 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -215,6 +215,17 @@ class Keys final : public ConfigSig { /// - `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 diff --git a/include/session/config/groups/members.h b/include/session/config/groups/members.h index dcaabda4..d65b3245 100644 --- a/include/session/config/groups/members.h +++ b/include/session/config/groups/members.h @@ -128,7 +128,7 @@ LIBSESSION_EXPORT bool groups_members_erase(config_object* conf, const char* ses /// /// Outputs: /// - `size_t` -- number of contacts -LIBSESSION_EXPORT size_t contacts_size(const config_object* conf); +LIBSESSION_EXPORT size_t groups_members_size(const config_object* conf); typedef struct groups_members_iterator { void* _internals; diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index a3dcd2e6..ba064792 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -149,9 +149,13 @@ void Keys::load_dump(ustring_view dump) { } } +size_t Keys::size() const { + return keys_.size() + !pending_key_config_.empty(); +} + std::vector Keys::group_keys() const { std::vector ret; - ret.reserve(keys_.size() + !pending_key_config_.empty()); + ret.reserve(size()); if (!pending_key_config_.empty()) ret.emplace_back(pending_key_.data(), 32); @@ -1247,6 +1251,17 @@ LIBSESSION_C_API int groups_keys_init( return SESSION_ERR_NONE; } +LIBSESSION_C_API size_t group_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(); } diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index cce81204..be4fd860 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -362,11 +362,13 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { 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); } From 8a9d8accfa27e1ab843427f7f2ad9f12bf0e8d05 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 31 Aug 2023 16:24:20 -0300 Subject: [PATCH 44/55] Fix doc typos/mistakes --- include/session/config/groups/members.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/include/session/config/groups/members.h b/include/session/config/groups/members.h index d65b3245..fa9ad181 100644 --- a/include/session/config/groups/members.h +++ b/include/session/config/groups/members.h @@ -65,7 +65,7 @@ LIBSESSION_EXPORT int groups_members_init( /// - `session_id` -- [in] null terminated hex string /// /// Output: -/// - `bool` -- Returns true if member exsts +/// - `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)); @@ -87,8 +87,8 @@ LIBSESSION_EXPORT bool groups_members_get( /// - `session_id` -- [in] null terminated hex string /// /// Output: -/// - `bool` -- Returns true if the member exists, false if not (`member` is always filled -/// regardless). +/// - `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)); From 24ed15885efe1598cef2574cbc0495ff95b8f80d Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 31 Aug 2023 17:16:07 -0300 Subject: [PATCH 45/55] Add groups to convo info volatile --- include/session/config/convo_info_volatile.h | 198 ++++++++++++++++-- .../session/config/convo_info_volatile.hpp | 102 ++++++++- src/config/convo_info_volatile.cpp | 68 +++++- tests/test_config_convo_info_volatile.cpp | 25 ++- 4 files changed, 364 insertions(+), 29 deletions(-) diff --git a/include/session/config/convo_info_volatile.h b/include/session/config/convo_info_volatile.h index fc94ab1a..cd3f6f43 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,67 @@ 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 +300,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 +332,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 +376,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 +408,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 +459,29 @@ 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 +559,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 +605,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 +686,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 +808,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 +824,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 +854,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/src/config/convo_info_volatile.cpp b/src/config/convo_info_volatile.cpp index 27d40b19..d4eac2c1 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,29 @@ 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 +174,28 @@ 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 +280,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 +313,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 +329,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 +358,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 +371,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 +384,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/tests/test_config_convo_info_volatile.cpp b/tests/test_config_convo_info_volatile.cpp index 0edc9720..a7d6095b 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,18 @@ 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 +126,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 +163,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 +194,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(); From 4d0c6e412e248547f66a1392a5987d67e46be5ea Mon Sep 17 00:00:00 2001 From: dr7ana Date: Thu, 31 Aug 2023 13:39:17 -0700 Subject: [PATCH 46/55] Completed group keys C api unit test --- include/session/config/convo_info_volatile.h | 5 +- include/session/config/user_groups.h | 25 +-- src/config/convo_info_volatile.cpp | 8 +- src/config/user_groups.cpp | 22 +-- tests/test_config_convo_info_volatile.cpp | 1 - tests/test_group_keys.cpp | 195 ++++++++++++++----- 6 files changed, 167 insertions(+), 89 deletions(-) diff --git a/include/session/config/convo_info_volatile.h b/include/session/config/convo_info_volatile.h index cd3f6f43..6ddba3eb 100644 --- a/include/session/config/convo_info_volatile.h +++ b/include/session/config/convo_info_volatile.h @@ -280,7 +280,6 @@ 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 @@ -478,9 +477,7 @@ LIBSESSION_EXPORT bool convo_info_volatile_erase_community( /// /// 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); - +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 /// diff --git a/include/session/config/user_groups.h b/include/session/config/user_groups.h index c58f5584..65529fe0 100644 --- a/include/session/config/user_groups.h +++ b/include/session/config/user_groups.h @@ -121,9 +121,7 @@ LIBSESSION_EXPORT int user_groups_init( /// 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); + config_object* conf, ugroups_group_info* group, const char* group_id); /// API: user_groups/user_groups_get_or_construct_group /// @@ -138,9 +136,7 @@ LIBSESSION_EXPORT bool user_groups_get_group( /// 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); + config_object* conf, ugroups_group_info* group, const char* group_id); /// API: user_groups/user_groups_get_community /// @@ -318,8 +314,7 @@ LIBSESSION_EXPORT void user_groups_set_community( /// 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); +LIBSESSION_EXPORT void user_groups_set_group(config_object* conf, const ugroups_group_info* group); /// API: user_groups/user_groups_set_legacy_group /// @@ -407,8 +402,7 @@ LIBSESSION_EXPORT bool user_groups_erase_community( /// /// Outputs: /// - `bool` -- Returns True if conversation was found and removed -LIBSESSION_EXPORT bool user_groups_erase_group( - config_object* conf, const char* group_id); +LIBSESSION_EXPORT bool user_groups_erase_group(config_object* conf, const char* group_id); /// API: user_groups/user_groups_erase_legacy_group /// @@ -778,9 +772,7 @@ LIBSESSION_EXPORT user_groups_iterator* user_groups_iterator_new_legacy_groups( /// /// Outputs: /// - `user_groups_iterator*` -- The Iterator -LIBSESSION_EXPORT user_groups_iterator* user_groups_iterator_new_groups( - const config_object* conf); - +LIBSESSION_EXPORT user_groups_iterator* user_groups_iterator_new_groups(const config_object* conf); /// API: user_groups/user_groups_iterator_free /// @@ -862,10 +854,9 @@ LIBSESSION_EXPORT bool user_groups_it_is_community( /// - `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); - +/// - `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 /// diff --git a/src/config/convo_info_volatile.cpp b/src/config/convo_info_volatile.cpp index d4eac2c1..3ce3dae1 100644 --- a/src/config/convo_info_volatile.cpp +++ b/src/config/convo_info_volatile.cpp @@ -69,7 +69,6 @@ namespace convo { c.unread = unread; } - legacy_group::legacy_group(std::string&& cgid) : id{std::move(cgid)} { check_session_id(id); } @@ -174,8 +173,7 @@ convo::community ConvoInfoVolatile::get_or_construct_community( return result; } -std::optional ConvoInfoVolatile::get_group( - std::string_view pubkey_hex) const { +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(); @@ -187,15 +185,13 @@ std::optional ConvoInfoVolatile::get_group( return result; } -convo::group ConvoInfoVolatile::get_or_construct_group( - std::string_view pubkey_hex) const { +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); diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index e5c827b1..c84a4893 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -614,7 +614,7 @@ LIBSESSION_C_API bool user_groups_get_or_construct_community( } } LIBSESSION_C_API bool user_groups_get_group( - config_object* conf, ugroups_group_info* group, const char* group_id){ + 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)) { @@ -627,14 +627,10 @@ LIBSESSION_C_API bool user_groups_get_group( return false; } LIBSESSION_C_API bool user_groups_get_or_construct_group( - config_object* conf, - ugroups_group_info* group, - const char* group_id) { + 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); + unbox(conf)->get_or_construct_group(group_id).into(*group); return true; } catch (const std::exception& e) { set_error(conf, e.what()); @@ -685,8 +681,7 @@ 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) { +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( @@ -706,8 +701,7 @@ 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) { +LIBSESSION_C_API bool user_groups_erase_group(config_object* conf, const char* group_id) { try { return unbox(conf)->erase_group(group_id); } catch (...) { @@ -827,8 +821,7 @@ 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) { +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( @@ -865,8 +858,7 @@ 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) { +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); } diff --git a/tests/test_config_convo_info_volatile.cpp b/tests/test_config_convo_info_volatile.cpp index a7d6095b..9e4ddf22 100644 --- a/tests/test_config_convo_info_volatile.cpp +++ b/tests/test_config_convo_info_volatile.cpp @@ -93,7 +93,6 @@ TEST_CASE("Conversations", "[config][conversations]") { g.unread = true; convos.set(g); - auto [seqno, to_push, obs] = convos.push(); CHECK(seqno == 1); diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index be4fd860..d5a867c9 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -383,34 +384,20 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { TEST_CASE("Group Keys - C API", "[config][groups][keys][c]") { struct pseudo_client { - const bool is_admin; - - const ustring seed; - std::string session_id; - - std::array public_key; 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 s, bool a, unsigned char* gpk, std::optional gsk) : - seed{s}, is_admin{a} { - crypto_sign_ed25519_seed_keypair( - public_key.data(), - secret_key.data(), - reinterpret_cast(seed.data())); - - REQUIRE(oxenc::to_hex(seed.begin(), seed.end()) == - oxenc::to_hex(secret_key.begin(), secret_key.begin() + 32)); - - std::array sid; - int rc = crypto_sign_ed25519_pk_to_curve25519(&sid[1], public_key.data()); - REQUIRE(rc == 0); - session_id += "\x05"; - oxenc::to_hex(sid.begin(), sid.end(), std::back_inserter(session_id)); - + 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); @@ -459,33 +446,149 @@ TEST_CASE("Group Keys - C API", "[config][groups][keys][c]") { REQUIRE(oxenc::to_hex(group_seed.begin(), group_seed.end()) == oxenc::to_hex(group_sk.begin(), group_sk.begin() + 32)); - std::vector admins; - std::vector members; + 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(contacts_size(a.members) == 0); - // for (const auto& m : members) - // REQUIRE(contacts_size(m.members) == 0); + 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, new_keys_config_1, key_len1, get_timestamp(), 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, new_keys_config_1, key_len1, get_timestamp(), 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, new_keys_config_2, key_len2, get_timestamp(), 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]") { From 3ed91d53a56333a7cf8a991e50b3c623a4182fdd Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 31 Aug 2023 18:25:38 -0300 Subject: [PATCH 47/55] Fix propagation of secret key values Also adds tests to verify that auth_data and secretkey data propagate as expected. Many thanks to Harris for identifying and tracking this down. --- src/config/user_groups.cpp | 15 +++++++++++---- tests/test_config_user_groups.cpp | 28 +++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index c84a4893..b0157136 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -217,9 +217,10 @@ void group_info::load(const dict& info_dict) { base_group_info::load(info_dict); if (auto seed = maybe_ustring(info_dict, "K"); seed && seed->size() == 32) { - std::array pk; + std::array pk; + pk[0] = 0x03; secretkey.resize(64); - crypto_sign_seed_keypair(pk.data(), secretkey.data(), seed->data()); + crypto_sign_seed_keypair(pk.data() + 1, secretkey.data(), seed->data()); if (id != oxenc::to_hex(pk.begin(), pk.end())) secretkey.clear(); } @@ -388,10 +389,16 @@ void UserGroups::set(const legacy_group_info& g) { } void UserGroups::set(const group_info& g) { - auto info = data["g"][session_id_to_bytes(g.id, "03")]; + auto pk_bytes = session_id_to_bytes(g.id, "03"); + auto info = data["g"][pk_bytes]; set_base(g, info); - if (g.secretkey.size() == 64) + 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{}; diff --git a/tests/test_config_user_groups.cpp b/tests/test_config_user_groups.cpp index 054fa334..b04e329e 100644 --- a/tests/test_config_user_groups.cpp +++ b/tests/test_config_user_groups.cpp @@ -409,7 +409,7 @@ TEST_CASE("User Groups", "[config][groups]") { } } -TEST_CASE("User Groups (new)", "[config][groups][new]") { +TEST_CASE("User Groups -- (non-legacy) groups", "[config][groups][new]") { const auto seed = "0123456789abcdef0123456789abcdef00000000000000000000000000000000"_hexbytes; std::array ed_pk, curve_pk; @@ -442,12 +442,20 @@ TEST_CASE("User Groups (new)", "[config][groups][new]") { 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()); @@ -476,6 +484,14 @@ TEST_CASE("User Groups (new)", "[config][groups][new]") { 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"); @@ -485,6 +501,11 @@ TEST_CASE("User Groups (new)", "[config][groups][new]") { 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); @@ -493,6 +514,11 @@ TEST_CASE("User Groups (new)", "[config][groups][new]") { 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())); + auto gg = groups.get_or_construct_group( "030303030303030303030303030303030303030303030303030303030303030303"); groups.set(gg); From 5854c4f168f45599d49ba65457cad74890db6b87 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 31 Aug 2023 20:59:07 -0300 Subject: [PATCH 48/55] Fix name typo in groups_keys_size --- src/config/groups/keys.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index ba064792..cd6b365b 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -1251,7 +1251,7 @@ LIBSESSION_C_API int groups_keys_init( return SESSION_ERR_NONE; } -LIBSESSION_C_API size_t group_keys_size(const config_group_keys* conf) { +LIBSESSION_C_API size_t groups_keys_size(const config_group_keys* conf) { return unbox(conf).size(); } From 194f972d161a57dae07430f92eac44e95c208c84 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Fri, 1 Sep 2023 00:16:41 -0300 Subject: [PATCH 49/55] Fix user_groups iterators with new closed groups --- src/config/user_groups.cpp | 8 +++++--- tests/test_config_user_groups.cpp | 22 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index b0157136..e8e6cbe8 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -550,15 +550,17 @@ void UserGroups::iterator::_load_val() { } 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); diff --git a/tests/test_config_user_groups.cpp b/tests/test_config_user_groups.cpp index b04e329e..5e759e01 100644 --- a/tests/test_config_user_groups.cpp +++ b/tests/test_config_user_groups.cpp @@ -198,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; @@ -226,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); @@ -260,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", }); @@ -311,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)); @@ -348,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"; @@ -361,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"); @@ -394,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", From 364f8d32fc42396355ca43cdb61b0a4a4deea0e8 Mon Sep 17 00:00:00 2001 From: dr7ana Date: Fri, 1 Sep 2023 05:27:03 -0700 Subject: [PATCH 50/55] formatter --- src/config/user_groups.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index e8e6cbe8..957afc10 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -550,7 +550,8 @@ void UserGroups::iterator::_load_val() { } bool UserGroups::iterator::operator==(const iterator& other) const { - return _it_group == other._it_group && _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 { From 8ed090e669577131405f2f136bd859bcfb6cba32 Mon Sep 17 00:00:00 2001 From: dr7ana Date: Fri, 1 Sep 2023 06:09:05 -0700 Subject: [PATCH 51/55] C method to return groups keys - Added config_groups_keys to config/base.h - Looped in test call into config user groups C api unit test --- include/session/config/base.h | 13 +++++++++++++ src/config/base.cpp | 23 +++++++++++++++++++++++ tests/test_config_user_groups.cpp | 4 ++++ 3 files changed, 40 insertions(+) diff --git a/include/session/config/base.h b/include/session/config/base.h index 27e108d5..ee4404a8 100644 --- a/include/session/config/base.h +++ b/include/session/config/base.h @@ -281,6 +281,19 @@ 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_groups_keys +/// +/// Obtains the current group decryption keys. +/// +/// The returned pointer belongs to the caller and must be freed via `free()` when done with it. +/// +/// Inputs: +/// - `conf` -- [in] Pointer to the config_object object +/// +/// Outputs: +/// - `config_string_list*` -- pointer to the list of keys, pointer belongs to the caller +LIBSESSION_EXPORT config_string_list* config_groups_keys(const config_object* conf); + /// 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). diff --git a/src/config/base.cpp b/src/config/base.cpp index c3d03edd..cdad946e 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -661,6 +661,29 @@ LIBSESSION_EXPORT config_string_list* config_current_hashes(const config_object* return ret; } +LIBSESSION_EXPORT config_string_list* config_groups_keys(const config_object* conf) { + auto keys = unbox(conf)->get_keys(); + size_t sz = sizeof(config_string_list) + keys.size() * sizeof(char*); + for (auto& k : keys) + sz += k.size() + 1; + void* buf = std::malloc(sz); + auto* ret = static_cast(buf); + ret->len = keys.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, keys[i].data(), keys[i].size() + 1); + next_str += keys[i].size() + 1; + } + + return ret; +} + LIBSESSION_EXPORT void config_add_key(config_object* conf, const unsigned char* key) { unbox(conf)->add_key({key, 32}); } diff --git a/tests/test_config_user_groups.cpp b/tests/test_config_user_groups.cpp index 5e759e01..e389df53 100644 --- a/tests/test_config_user_groups.cpp +++ b/tests/test_config_user_groups.cpp @@ -674,6 +674,10 @@ TEST_CASE("User Groups members C API", "[config][groups][c]") { CHECK(hashes->value[0] == "fakehash1"sv); free(hashes); + config_string_list* keys = config_groups_keys(conf); + REQUIRE(keys); + REQUIRE(keys->len == 1); + session::config::UserGroups c2{ustring_view{seed}, std::nullopt}; std::vector> to_merge; From bb7a2cfa1bbbfe94db7a69ffc4875cdf48330432 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Sat, 2 Sep 2023 12:00:54 -0300 Subject: [PATCH 52/55] Add current hash tracking Adds current hash tracking to groups::Keys; this adds a `current_hashes` method to group::Keys that works similarly to the base config current_hashes (though returns a set instead of vector). (The C version is `groups_keys_current_hashes`) Testing this also triggered a bug in that we weren't probably re-loading the verified-signature state on a dump->load cycle, which caused an assertion failure on merge (because the current state couldn't be successfully serialized-then-deserialized), also fixed here. Also renames the recently added C config_group_keys to config_get_keys to better reflect its purpose. --- external/oxen-encoding | 2 +- include/session/config.hpp | 20 ++- include/session/config/base.h | 16 +- include/session/config/base.hpp | 4 + include/session/config/groups/keys.h | 20 ++- include/session/config/groups/keys.hpp | 34 +++- src/config.cpp | 22 ++- src/config/base.cpp | 88 ++++------ src/config/groups/keys.cpp | 155 +++++++++++++----- src/config/internal.hpp | 46 ++++++ tests/test_config_user_groups.cpp | 5 +- tests/test_group_keys.cpp | 216 ++++++++++++++++++++----- tests/utils.hpp | 6 +- 13 files changed, 469 insertions(+), 165 deletions(-) diff --git a/external/oxen-encoding b/external/oxen-encoding index 462be41b..867d0797 160000 --- a/external/oxen-encoding +++ b/external/oxen-encoding @@ -1 +1 @@ -Subproject commit 462be41bd481b331dabeb3c220b349ef35c89e56 +Subproject commit 867d0797a08361eee613b91060a2ef447d2f9f4d diff --git a/include/session/config.hpp b/include/session/config.hpp index 9eb7b8d5..170019c0 100644 --- a/include/session/config.hpp +++ b/include/session/config.hpp @@ -127,7 +127,8 @@ class ConfigMessage { ustring_view serialized, verify_callable verifier = nullptr, sign_callable signer = nullptr, - int lag = DEFAULT_DIFF_LAGS); + int lag = DEFAULT_DIFF_LAGS, + 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 @@ -210,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_.has_value(); } + /// 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. @@ -367,6 +371,9 @@ class MutableConfigMessage : public ConfigMessage { /// - `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 @@ -375,7 +382,8 @@ void verify_config_sig( oxenc::bt_dict_consumer dict, ustring_view config_msg, const ConfigMessage::verify_callable& verifier, - std::optional>* verified_signature = nullptr); + std::optional>* verified_signature = nullptr, + bool trust_signature = false); } // namespace session::config diff --git a/include/session/config/base.h b/include/session/config/base.h index ee4404a8..a14ba528 100644 --- a/include/session/config/base.h +++ b/include/session/config/base.h @@ -281,18 +281,26 @@ 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_groups_keys +/// API: base/config_get_keys /// /// Obtains the current group decryption keys. /// -/// The returned pointer belongs to the caller and must be freed via `free()` when done with it. +/// 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: -/// - `config_string_list*` -- pointer to the list of keys, pointer belongs to the caller -LIBSESSION_EXPORT config_string_list* config_groups_keys(const config_object* conf); +/// - `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). diff --git a/include/session/config/base.hpp b/include/session/config/base.hpp index b4f9511b..73516a4d 100644 --- a/include/session/config/base.hpp +++ b/include/session/config/base.hpp @@ -1120,6 +1120,10 @@ class ConfigBase : public ConfigSig { /// 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: diff --git a/include/session/config/groups/keys.h b/include/session/config/groups/keys.h index 6d7933cc..25592c26 100644 --- a/include/session/config/groups/keys.h +++ b/include/session/config/groups/keys.h @@ -179,23 +179,39 @@ LIBSESSION_EXPORT bool groups_keys_pending_config( /// /// 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 new keys, if needed -/// - `members` -- [in] the members config object to update with new keys, if needed +/// - `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 diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index faddd57a..cab57089 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -2,6 +2,7 @@ #include #include +#include #include "../../config.hpp" #include "../base.hpp" @@ -47,6 +48,8 @@ using namespace std::literals; /// - 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: /// @@ -91,6 +94,9 @@ class Keys final : public ConfigSig { /// 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; @@ -109,9 +115,8 @@ class Keys final : public ConfigSig { // 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_`. Returns true if the key was inserted, false - // if it already existed. - bool insert_key(const key_info& key); + // 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. @@ -283,7 +288,8 @@ class Keys final : public ConfigSig { /// 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 `push()` if needed until it has been - /// confirmed or superceded). + /// 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 @@ -498,6 +504,7 @@ class Keys final : public ConfigSig { /// 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). @@ -512,7 +519,24 @@ class Keys final : public ConfigSig { /// 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(ustring_view data, int64_t timestamp_ms, Info& info, Members& members); + 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 /// diff --git a/src/config.cpp b/src/config.cpp index f60d554f..99957bad 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -433,7 +433,8 @@ void verify_config_sig( oxenc::bt_dict_consumer dict, ustring_view config_msg, const ConfigMessage::verify_callable& verifier, - std::optional>* verified_signature) { + std::optional>* verified_signature, + bool trust_signature) { ustring_view to_verify, sig; dict.skip_until("~"); if (!dict.is_finished() && dict.key() == "~") { @@ -456,12 +457,13 @@ void verify_config_sig( if (!dict.is_finished()) throw config_parse_error{"Invalid config: dict has invalid key(s) after \"~\""}; - if (verifier) { - if (sig.empty()) - throw missing_signature{"Config signature is missing"}; - else if (sig.size() != 64) + 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(to_verify, sig)) + else if (verifier && !verifier(to_verify, sig)) throw signature_error{"Config signature failed verification"}; else if (verified_signature) { if (!*verified_signature) @@ -542,7 +544,11 @@ ConfigMessage::ConfigMessage() { } ConfigMessage::ConfigMessage( - ustring_view serialized, verify_callable verifier_, sign_callable signer_, int lag) : + ustring_view serialized, + verify_callable verifier_, + sign_callable signer_, + int lag, + bool trust_signature) : verifier{std::move(verifier_)}, signer{std::move(signer_)}, lag{lag} { oxenc::bt_dict_consumer dict{from_unsigned_sv(serialized)}; @@ -569,7 +575,7 @@ ConfigMessage::ConfigMessage( load_unknowns(unknown_, dict, "=", "~"); - verify_config_sig(dict, serialized, verifier, &verified_signature_); + 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()}; } diff --git a/src/config/base.cpp b/src/config/base.cpp index cdad946e..d22c3b14 100644 --- a/src/config/base.cpp +++ b/src/config/base.cpp @@ -1,5 +1,7 @@ #include "session/config/base.hpp" +#include +#include #include #include #include @@ -281,23 +283,20 @@ ustring ConfigBase::dump() { auto data_sv = from_unsigned_sv(data); oxenc::bt_list 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()); - 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)}, - }; 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( @@ -337,6 +336,7 @@ void ConfigBase::init_from_dump(std::string_view dump) { 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 @@ -344,13 +344,17 @@ void ConfigBase::init_from_dump(std::string_view 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()), + 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()); + data, + nullptr, + nullptr, + config_lags(), + /*trust_signature=*/true); if (d.skip_until("(")) { _curr_hash = d.consume_string(); @@ -639,49 +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; -} - -LIBSESSION_EXPORT config_string_list* config_groups_keys(const config_object* conf) { - auto keys = unbox(conf)->get_keys(); - size_t sz = sizeof(config_string_list) + keys.size() * sizeof(char*); - for (auto& k : keys) - sz += k.size() + 1; - void* buf = std::malloc(sz); - auto* ret = static_cast(buf); - ret->len = keys.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, keys[i].data(), keys[i].size() + 1); - next_str += keys[i].size() + 1; - } - - return ret; + return buf; } LIBSESSION_EXPORT void config_add_key(config_object* conf, const unsigned char* key) { diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index cd6b365b..66776473 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -14,6 +14,7 @@ #include #include +#include #include "../internal.hpp" #include "session/config/groups/info.hpp" @@ -64,6 +65,16 @@ bool Keys::needs_dump() const { 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_) { @@ -94,6 +105,18 @@ ustring Keys::dump() { 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()) { @@ -488,6 +511,8 @@ ustring Keys::key_supplement(const std::vector& sids) const { "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()); @@ -807,7 +832,7 @@ std::optional Keys::pending_config() const { return ustring_view{pending_key_config_.data(), pending_key_config_.size()}; } -bool Keys::insert_key(const key_info& new_key) { +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). @@ -816,8 +841,10 @@ bool Keys::insert_key(const key_info& new_key) { return a.generation < b.generation; }); for (auto it = gen_begin; it != gen_end; ++it) - if (it->key == new_key.key) - return false; + 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); @@ -825,16 +852,20 @@ bool Keys::insert_key(const key_info& new_key) { 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 false; + return; - keys_.insert(it, new_key); + active_msgs_[new_key.generation].emplace(msg_hash); + keys_.insert(it, std::move(new_key)); remove_expired(); needs_dump_ = true; - - return true; } -bool Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, Members& members) { +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)}; @@ -848,6 +879,8 @@ bool Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, 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()) { @@ -938,8 +971,13 @@ bool Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, } } } - } else { - // Full message (i.e. not supplemental) + + 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(); @@ -1013,8 +1051,10 @@ bool Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, 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) + if (!found_key) { + max_gen = new_key.generation; new_keys.pop_back(); + } } verify_config_sig(d, data, verifier_); @@ -1029,51 +1069,74 @@ bool Keys::load_key_message(ustring_view data, int64_t timestamp_ms, Info& info, if (!new_keys.empty()) { for (auto& k : new_keys) - insert_key(k); + 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; } -void Keys::remove_expired() { - if (keys_.size() < 2) - return; - - auto lapsed_end = keys_.begin(); +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; +} - 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 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; +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; + } - // 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); } - 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 { @@ -1299,6 +1362,7 @@ LIBSESSION_C_API bool groups_keys_pending_config( 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, @@ -1307,6 +1371,7 @@ LIBSESSION_C_API bool groups_keys_load_message( assert(data && info && members); try { unbox(conf).load_key_message( + msg_hash, ustring_view{data, datalen}, timestamp_ms, *unbox(info), @@ -1318,6 +1383,10 @@ LIBSESSION_C_API bool groups_keys_load_message( 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(); } diff --git a/src/config/internal.hpp b/src/config/internal.hpp index e78ba640..8872ea4c 100644 --- a/src/config/internal.hpp +++ b/src/config/internal.hpp @@ -5,7 +5,9 @@ #include #include #include +#include +#include "session/config/base.h" #include "session/config/base.hpp" #include "session/config/error.h" #include "session/types.hpp" @@ -80,6 +82,50 @@ void copy_c_str(char (&dest)[N], std::string_view src) { dest[src.size()] = 0; } +// 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"); diff --git a/tests/test_config_user_groups.cpp b/tests/test_config_user_groups.cpp index e389df53..050f91da 100644 --- a/tests/test_config_user_groups.cpp +++ b/tests/test_config_user_groups.cpp @@ -674,9 +674,10 @@ TEST_CASE("User Groups members C API", "[config][groups][c]") { CHECK(hashes->value[0] == "fakehash1"sv); free(hashes); - config_string_list* keys = config_groups_keys(conf); + size_t key_len; + unsigned char* keys = config_get_keys(conf, &key_len); REQUIRE(keys); - REQUIRE(keys->len == 1); + REQUIRE(key_len == 1); session::config::UserGroups c2{ustring_view{seed}, std::nullopt}; diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index d5a867c9..48e30240 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -68,18 +67,21 @@ struct pseudo_client { ustring_view seed, bool admin, const unsigned char* gpk, - std::optional gsk) : + 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, - std::nullopt}, + info_dump}, members{ustring_view{gpk, 32}, admin ? std::make_optional({*gsk, 64}) : std::nullopt, - std::nullopt}, + members_dump}, keys{to_usv(secret_key), ustring_view{gpk, 32}, admin ? std::make_optional({*gsk, 64}) : std::nullopt, - std::nullopt, + keys_dump, info, members} {} }; @@ -169,20 +171,24 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { message, which they can decrypt with the group secret key. */ for (auto& a : admins) { - a.keys.load_key_message(new_keys_config1, get_timestamp(), a.info, a.members); + 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(new_keys_config1, get_timestamp(), m.info, 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(); @@ -210,17 +216,21 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { mem_configs.emplace_back("fakehash2", new_mem_config2); for (auto& a : admins) { - a.keys.load_key_message(new_keys_config2, get_timestamp(), a.info, a.members); + 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(new_keys_config2, get_timestamp(), m.info, 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(); @@ -243,17 +253,22 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { mem_configs.emplace_back("fakehash3", new_mem_config3); for (auto& a : admins) { - a.keys.load_key_message(new_keys_config3, get_timestamp(), a.info, a.members); + 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(new_keys_config3, get_timestamp(), m.info, 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(); @@ -282,17 +297,22 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { mem_configs.emplace_back("fakehash4", new_mem_config4); for (auto& a : admins) { - CHECK(a.keys.load_key_message(new_keys_config4, get_timestamp(), a.info, a.members)); + 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(new_keys_config2, get_timestamp(), m.info, m.members); + 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); @@ -330,6 +350,7 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { 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()); @@ -355,30 +376,131 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { 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++) { - DYNAMIC_SECTION("supp key load " << i) { - auto& m = members[i]; - bool found_key = m.keys.load_key_message(supp, get_timestamp(), 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); - } + auto& m = members[i]; + bool found_key = + m.keys.load_key_message("keyhash5", supp, get_timestamp_ms(), m.info, m.members); - 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 < 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}}); } } @@ -514,7 +636,13 @@ TEST_CASE("Group Keys - C API", "[config][groups][keys][c]") { */ for (auto& a : admins) { REQUIRE(groups_keys_load_message( - a.keys, new_keys_config_1, key_len1, get_timestamp(), a.info, a.members)); + 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"); @@ -531,7 +659,13 @@ TEST_CASE("Group Keys - C API", "[config][groups][keys][c]") { // this will return true if the message was parsed successfully, NOT if the keys were // decrypted REQUIRE(groups_keys_load_message( - m.keys, new_keys_config_1, key_len1, get_timestamp(), m.info, m.members)); + 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)); @@ -577,7 +711,13 @@ TEST_CASE("Group Keys - C API", "[config][groups][keys][c]") { for (auto& a : admins) { REQUIRE(groups_keys_load_message( - a.keys, new_keys_config_2, key_len2, get_timestamp(), a.info, a.members)); + 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"); diff --git a/tests/utils.hpp b/tests/utils.hpp index 7d45d3d4..76b145c1 100644 --- a/tests/utils.hpp +++ b/tests/utils.hpp @@ -34,8 +34,10 @@ inline constexpr auto operator""_kiB(unsigned long long kiB) { return kiB * 1024; } -inline int64_t get_timestamp() { - return std::chrono::steady_clock::now().time_since_epoch().count(); +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) { From c272e06f53b08d7b6eb92d39a7ac83c44c62eb41 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 5 Sep 2023 14:23:09 -0300 Subject: [PATCH 53/55] Doc fix: `push()` should be `pending_config()` --- include/session/config/groups/keys.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index cab57089..4424bde4 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -287,8 +287,8 @@ class Keys final : public ConfigSig { /// /// 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 `push()` if needed until it has been - /// confirmed or superceded). This data must be consumed or copied from the returned + /// 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); From 2adb20c0a7edcf77883ab6e8a4872381192f241b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 6 Sep 2023 19:04:01 -0300 Subject: [PATCH 54/55] Add invited and name to groups; add kicked methods This adds an `.invited` flag for all group types (legacy, new, and communities) that can be used to track a invited-but-not-yet-joined room by session clients. Also adds `.name` to new groups data so that the name from an invitation can be stored before accepting (after accepting the name will come from the shared group config message). Add `setKicked()` and `kicked()` methods which clear both auth_data and secretkey and report whether both are empty, respectively. (For the C API these are `ugroups_group_set_kicked` and `ugroups_group_is_kicked` and take a pointer to the `ugroups_group_info`). --- include/session/config/user_groups.h | 30 +++++++++++++ include/session/config/user_groups.hpp | 61 ++++++++++++++++---------- src/config/user_groups.cpp | 55 +++++++++++++++++++---- tests/test_config_user_groups.cpp | 16 +++++++ 4 files changed, 131 insertions(+), 31 deletions(-) diff --git a/include/session/config/user_groups.h b/include/session/config/user_groups.h index 65529fe0..b17bf2f7 100644 --- a/include/session/config/user_groups.h +++ b/include/session/config/user_groups.h @@ -35,6 +35,8 @@ 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. @@ -45,6 +47,9 @@ typedef struct ugroups_legacy_group_info { 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) @@ -59,6 +64,9 @@ typedef struct ugroups_group_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_group_info; typedef struct ugroups_community_info { @@ -75,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 @@ -426,6 +437,25 @@ LIBSESSION_EXPORT bool user_groups_erase_group(config_object* conf, const char* /// - `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 diff --git a/include/session/config/user_groups.hpp b/include/session/config/user_groups.hpp index 4a3d7d7c..5187af1a 100644 --- a/include/session/config/user_groups.hpp +++ b/include/session/config/user_groups.hpp @@ -21,15 +21,8 @@ namespace session::config { /// keys used in this config, either currently or in the past (so that we don't reuse): /// -/// 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). +/// *Within* the group dicts (i.e. not at the top level), we use these common values: /// -/// 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. /// @ - 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 @@ -39,7 +32,25 @@ namespace session::config { /// 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,45 +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 (same values as groups, above). -/// ! - mute timestamp (see above). -/// + - the conversation priority, for pinning/hiding this community room. See above. -/// j - joined at unix timestamp. Omitted if 0. +/// @, !, +, i, j - see common values, above. /// /// 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. +/// 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) -/// @ - notification setting (int). Same as above. -/// ! - mute timestamp (see above). -/// + - the conversation priority, for pinned/hidden conversations. See above. -/// j - joined at unix timestamp. Omitted if 0. +/// @, !, +, 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. @@ -196,6 +205,14 @@ struct group_info : base_group_info { 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; diff --git a/src/config/user_groups.cpp b/src/config/user_groups.cpp index 957afc10..4119d322 100644 --- a/src/config/user_groups.cpp +++ b/src/config/user_groups.cpp @@ -39,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 @@ -47,6 +48,7 @@ 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)} { @@ -71,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) { @@ -134,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"); @@ -197,6 +204,10 @@ bool legacy_group_info::erase(const std::string& 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) @@ -207,6 +218,7 @@ 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)) @@ -216,6 +228,11 @@ void group_info::into(ugroups_group_info& c) const { 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; @@ -228,11 +245,20 @@ void group_info::load(const dict& info_dict) { 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) : @@ -361,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, @@ -393,6 +418,9 @@ void UserGroups::set(const group_info& g) { 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} == @@ -726,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; diff --git a/tests/test_config_user_groups.cpp b/tests/test_config_user_groups.cpp index 050f91da..5e433baa 100644 --- a/tests/test_config_user_groups.cpp +++ b/tests/test_config_user_groups.cpp @@ -490,11 +490,15 @@ TEST_CASE("User Groups -- (non-legacy) groups", "[config][groups][new]") { 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); @@ -525,6 +529,8 @@ TEST_CASE("User Groups -- (non-legacy) groups", "[config][groups][new]") { 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); @@ -532,6 +538,16 @@ TEST_CASE("User Groups -- (non-legacy) groups", "[config][groups][new]") { 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"); From f61bc4acd6d670452bf350e10d4e3fb0f4563909 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 6 Sep 2023 19:46:48 -0300 Subject: [PATCH 55/55] Fix info/members key lists when loading from dump The key lists weren't being set properly when loading a Keys object from a dump; these sets the key lists properly. --- src/config/groups/keys.cpp | 3 +++ tests/test_group_keys.cpp | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 66776473..1f99909a 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -54,6 +54,9 @@ Keys::Keys( 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); } diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index 48e30240..111307f9 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -502,6 +502,22 @@ TEST_CASE("Group Keys - C++ API", "[config][groups][keys][cpp]") { 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]") {