diff --git a/include/session/config/groups/keys.h b/include/session/config/groups/keys.h index 7e309726..9dab2ac7 100644 --- a/include/session/config/groups/keys.h +++ b/include/session/config/groups/keys.h @@ -120,6 +120,29 @@ LIBSESSION_EXPORT const unsigned char* groups_keys_get_key(const config_group_ke /// - `true` if we have admin keys, `false` otherwise. LIBSESSION_EXPORT bool groups_keys_is_admin(const config_group_keys* conf); +/// API: groups/groups_keys_load_admin_key +/// +/// Loads the admin keys, effectively upgrading this keys object from a member to an admin. +/// +/// This does nothing if the keys object already has admin keys. +/// +/// Inputs: +/// - `conf` -- the groups keys config object +/// - `secret` -- pointer to the 32-byte group seed. (This a 64-byte libsodium "secret key" begins +/// with the seed, this can also be a given a pointer to such a value). +/// - `group_info_conf` -- the group info config instance (the key will be added) +/// - `group_members_conf` -- the group members config instance (the key will be added) +/// +/// Outputs: +/// - `true` if the object has been upgraded to admin status, or was already admin status; `false` +/// if the given seed value does not match the group's public key. If this returns `true` then +/// after the call a call to `groups_keys_is_admin` would also return `true`. +LIBSESSION_EXPORT bool groups_keys_load_admin_key( + config_group_keys* conf, + const unsigned char* secret, + config_object* group_info_conf, + config_object* group_members_conf); + /// API: groups/groups_keys_rekey /// /// Generates a new encryption key for the group and returns an encrypted key message to be pushed diff --git a/include/session/config/groups/keys.hpp b/include/session/config/groups/keys.hpp index 0428873e..f7dd59ff 100644 --- a/include/session/config/groups/keys.hpp +++ b/include/session/config/groups/keys.hpp @@ -258,6 +258,29 @@ class Keys final : public ConfigSig { /// - `true` if this object knows the group's master key bool admin() const { return _sign_sk && _sign_pk; } + /// API: groups/Keys::load_admin_key + /// + /// Loads the group secret key into the Keys object (as well as passing it along to the Info and + /// Members objects). + /// + /// The primary use of this is when accepting a promotion-to-admin: the Keys object would be + /// constructed as a regular member (without the admin key) then this method "upgrades" the + /// object with the group signing key. + /// + /// This will do nothing if the secret key is already known; it will throw if + /// the given secret key does not yield the group's public key. The given key can be either the + /// 32 byte seed, or the libsodium 64 byte "secret key" (which is just the seed and cached + /// public key stuck together). + /// + /// Inputs: + /// - `secret` -- the group's 64-byte secret key or 32-byte seed + /// - `info` and `members` -- will be loaded with the group keys if the key is loaded + /// successfully. + /// + /// Outputs: nothing. After a successful call, `admin()` will return true. Throws if the given + /// secret key does not match the group's pubkey. + void load_admin_key(ustring_view secret, Info& info, Members& members); + /// API: groups/Keys::rekey /// /// Generate a new encryption key for the group and returns an encrypted key message to be diff --git a/src/config/groups/keys.cpp b/src/config/groups/keys.cpp index 64f343d0..5bb1e1e5 100644 --- a/src/config/groups/keys.cpp +++ b/src/config/groups/keys.cpp @@ -209,6 +209,30 @@ ustring_view Keys::group_enc_key() const { return {key.data(), key.size()}; } +void Keys::load_admin_key(ustring_view seed, Info& info, Members& members) { + if (admin()) + return; + + if (seed.size() == 64) + seed.remove_suffix(32); + else if (seed.size() != 32) + throw std::invalid_argument{ + "Failed to load admin key: invalid secret key (expected 32 or 64 bytes)"}; + + std::array pk; + sodium_cleared> sk; + crypto_sign_ed25519_seed_keypair(pk.data(), sk.data(), seed.data()); + + if (_sign_pk.has_value() && *_sign_pk != pk) + throw std::runtime_error{ + "Failed to load admin key: given secret key does not match group pubkey"}; + + auto seckey = to_sv(sk); + set_sig_keys(seckey); + info.set_sig_keys(seckey); + members.set_sig_keys(seckey); +} + 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)) @@ -1424,6 +1448,23 @@ LIBSESSION_C_API bool groups_keys_is_admin(const config_group_keys* conf) { return unbox(conf).admin(); } +LIBSESSION_C_API bool groups_keys_load_admin_key( + config_group_keys* conf, + const unsigned char* secret, + config_object* info, + config_object* members) { + try { + unbox(conf).load_admin_key( + ustring_view{secret, 32}, + *unbox(info), + *unbox(members)); + } catch (const std::exception& e) { + set_error(conf, e.what()); + return false; + } + return true; +} + LIBSESSION_C_API bool groups_keys_rekey( config_group_keys* conf, config_object* info, diff --git a/tests/test_group_keys.cpp b/tests/test_group_keys.cpp index 7ffccc56..475ba217 100644 --- a/tests/test_group_keys.cpp +++ b/tests/test_group_keys.cpp @@ -887,3 +887,93 @@ TEST_CASE("Group Keys - swarm authentication", "[config][groups][keys][swarm]") } } } + +TEST_CASE("Group Keys promotion", "[config][groups][keys][promotion]") { + + const ustring group_seed = + "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210"_hexbytes; + const ustring admin1_seed = + "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210"_hexbytes; + const ustring member1_seed = + "000111222333444555666777888999aaabbbcccdddeeefff0123456789abcdef"_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)); + + pseudo_client admin{admin1_seed, true, group_pk.data(), group_sk.data()}; + pseudo_client member{member1_seed, false, group_pk.data(), std::nullopt}; + + std::vector> configs; + { + auto m = admin.members.get_or_construct(admin.session_id); + m.admin = true; + m.name = "Lrrr"; + admin.members.set(m); + } + { + auto m = admin.members.get_or_construct(member.session_id); + m.admin = false; + m.name = "Nibbler"; + admin.members.set(m); + } + admin.info.set_name("Omicron Persei 8"); + auto [mseq, mdata, mobs] = admin.members.push(); + admin.members.confirm_pushed(mseq, "mpush1"); + auto [iseq, idata, iobs] = admin.info.push(); + admin.info.confirm_pushed(mseq, "ipush1"); + + REQUIRE(admin.keys.pending_config()); + member.keys.load_key_message( + "keyhash1", + *admin.keys.pending_config(), + get_timestamp_ms(), + member.info, + member.members); + admin.keys.load_key_message( + "keyhash1", + *admin.keys.pending_config(), + get_timestamp_ms(), + member.info, + member.members); + + member.keys.load_key_message( + "keyhash2", + admin.keys.key_supplement(member.session_id), + get_timestamp_ms(), + member.info, + member.members); + + configs.emplace_back("mpush1", mdata); + CHECK(member.members.merge(configs) == std::vector{{"mpush1"s}}); + + configs.clear(); + configs.emplace_back("ipush1", idata); + CHECK(member.info.merge(configs) == std::vector{{"ipush1"s}}); + + REQUIRE(admin.keys.admin()); + REQUIRE_FALSE(member.keys.admin()); + REQUIRE(member.info.is_readonly()); + REQUIRE(member.members.is_readonly()); + + member.keys.load_admin_key(to_usv(group_sk), member.info, member.members); + + CHECK(member.keys.admin()); + CHECK_FALSE(member.members.is_readonly()); + CHECK_FALSE(member.info.is_readonly()); + + member.info.set_name("new name"s); + + CHECK(member.info.needs_push()); + auto [iseq2, idata2, iobs2] = member.info.push(); + + configs.clear(); + configs.emplace_back("ihash2", idata2); + + CHECK(admin.info.merge(configs) == std::vector{{"ihash2"s}}); + + CHECK(admin.info.get_name() == "new name"); +}