Subscribe to group config messages (#969)

pull/1710/head
SessionHero01 1 month ago committed by GitHub
parent ad7792f659
commit 01b1d26f76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -235,14 +235,14 @@ class ConfigUploader @Inject constructor(
auth,
snode,
push,
Namespace.CLOSED_GROUP_MEMBERS()
Namespace.GROUP_MEMBERS()
)
}
}
val infoConfigHashTask = infoPush?.let { push ->
async {
push to pushConfig(auth, snode, push, Namespace.CLOSED_GROUP_INFO())
push to pushConfig(auth, snode, push, Namespace.GROUP_INFO())
}
}
@ -252,7 +252,7 @@ class ConfigUploader @Inject constructor(
snode = snode,
publicKey = auth.accountId.hexString,
request = SnodeAPI.buildAuthenticatedStoreBatchInfo(
Namespace.ENCRYPTION_KEYS(),
Namespace.GROUP_KEYS(),
SnodeMessage(
auth.accountId.hexString,
Base64.encodeBytes(push),

@ -254,7 +254,7 @@ class GroupManagerV2Impl @Inject constructor(
val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString })
batchRequests.add(
SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = Namespace.ENCRYPTION_KEYS(),
namespace = Namespace.GROUP_KEYS(),
message = SnodeMessage(
recipient = group.hexString,
data = Base64.encodeBytes(memberKey),

@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import network.loki.messenger.libsession_util.util.Sodium
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
@ -30,7 +29,6 @@ import org.session.libsession.utilities.getGroup
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.exceptions.NonRetryableException
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.session.libsignal.utilities.Snode
@ -48,6 +46,7 @@ class GroupPoller(
private val lokiApiDatabase: LokiAPIDatabaseProtocol,
private val clock: SnodeClock,
private val appVisibilityManager: AppVisibilityManager,
private val groupRevokedMessageHandler: GroupRevokedMessageHandler,
) {
companion object {
private const val POLL_INTERVAL = 3_000L
@ -239,7 +238,7 @@ class GroupPoller(
val lastHash = lokiApiDatabase.getLastMessageHashValue(
snode,
groupId.hexString,
Namespace.CLOSED_GROUP_MESSAGES()
Namespace.GROUP_MESSAGES()
).orEmpty()
Log.d(TAG, "Retrieving group message since lastHash = $lastHash")
@ -250,7 +249,7 @@ class GroupPoller(
request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
lastHash = lastHash,
auth = groupAuth,
namespace = Namespace.CLOSED_GROUP_MESSAGES(),
namespace = Namespace.GROUP_MESSAGES(),
maxSize = null,
),
responseType = Map::class.java
@ -258,9 +257,9 @@ class GroupPoller(
}
val groupConfigRetrieval = listOf(
Namespace.ENCRYPTION_KEYS(),
Namespace.CLOSED_GROUP_INFO(),
Namespace.CLOSED_GROUP_MEMBERS()
Namespace.GROUP_KEYS(),
Namespace.GROUP_INFO(),
Namespace.GROUP_MEMBERS()
).map { ns ->
async {
SnodeAPI.sendBatchRequest(
@ -288,9 +287,9 @@ class GroupPoller(
val result = runCatching {
val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.map { it.await() }
handleGroupConfigMessages(keysMessage, infoMessage, membersMessage)
saveLastMessageHash(snode, keysMessage, Namespace.ENCRYPTION_KEYS())
saveLastMessageHash(snode, infoMessage, Namespace.CLOSED_GROUP_INFO())
saveLastMessageHash(snode, membersMessage, Namespace.CLOSED_GROUP_MEMBERS())
saveLastMessageHash(snode, keysMessage, Namespace.GROUP_KEYS())
saveLastMessageHash(snode, infoMessage, Namespace.GROUP_INFO())
saveLastMessageHash(snode, membersMessage, Namespace.GROUP_MEMBERS())
groupExpired = configFactoryProtocol.withGroupConfigs(groupId) {
it.groupKeys.size() == 0
@ -370,40 +369,7 @@ class GroupPoller(
}
private suspend fun handleRevoked(messages: List<RetrieveMessageResponse.Message>) {
messages.forEach { msg ->
val decoded = configFactoryProtocol.decryptForUser(
msg.data,
Sodium.KICKED_DOMAIN,
groupId,
)
if (decoded != null) {
// The message should be in the format of "<sessionIdPubKeyBinary><messageGenerationASCII>",
// where the pub key is 32 bytes, so we need to have at least 33 bytes of data
if (decoded.size < 33) {
Log.w(TAG, "Received an invalid kicked message, expecting at least 33 bytes, got ${decoded.size}")
return@forEach
}
val sessionId = AccountId(IdPrefix.STANDARD, decoded.copyOfRange(0, 32))
val messageGeneration = decoded.copyOfRange(32, decoded.size).decodeToString().toIntOrNull()
if (messageGeneration == null) {
Log.w(TAG, "Received an invalid kicked message: missing message generation")
return@forEach
}
val currentKeysGeneration = configFactoryProtocol.withGroupConfigs(groupId) {
it.groupKeys.currentGeneration()
}
val isForMe = sessionId.hexString == storage.getUserPublicKey()
Log.d(TAG, "Received kicked message, for us? ${isForMe}, message key generation = $messageGeneration, our key generation = $currentKeysGeneration")
if (isForMe && messageGeneration >= currentKeysGeneration) {
groupManagerV2.handleKicked(groupId)
}
}
}
groupRevokedMessageHandler.handleRevokeMessage(groupId, messages.map { it.data })
}
private fun handleGroupConfigMessages(
@ -437,7 +403,7 @@ class GroupPoller(
snode = snode,
publicKey = groupId.hexString,
decrypt = it.groupKeys::decrypt,
namespace = Namespace.CLOSED_GROUP_MESSAGES(),
namespace = Namespace.GROUP_MESSAGES(),
)
}

@ -61,6 +61,7 @@ class GroupPollerManager @Inject constructor(
preferences: TextSecurePreferences,
appVisibilityManager: AppVisibilityManager,
connectivity: InternetConnectivity,
groupRevokedMessageHandler: GroupRevokedMessageHandler,
) {
@Suppress("OPT_IN_USAGE")
private val groupPollers: StateFlow<Map<AccountId, GroupPollerHandle>> =
@ -117,6 +118,7 @@ class GroupPollerManager @Inject constructor(
lokiApiDatabase = lokiApiDatabase,
clock = clock,
appVisibilityManager = appVisibilityManager,
groupRevokedMessageHandler = groupRevokedMessageHandler,
),
scope = scope
)

@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.groups
import network.loki.messenger.libsession_util.util.Sodium
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import javax.inject.Inject
import javax.inject.Provider
class GroupRevokedMessageHandler @Inject constructor(
private val configFactoryProtocol: ConfigFactoryProtocol,
private val storage: StorageProtocol,
private val groupManagerV2: Provider<GroupManagerV2>,
) {
suspend fun handleRevokeMessage(
groupId: AccountId,
rawMessages: List<ByteArray>,
) {
rawMessages.forEach { data ->
val decoded = configFactoryProtocol.decryptForUser(
data,
Sodium.KICKED_DOMAIN,
groupId,
)
if (decoded != null) {
// The message should be in the format of "<sessionIdPubKeyBinary><messageGenerationASCII>",
// where the pub key is 32 bytes, so we need to have at least 33 bytes of data
if (decoded.size < 33) {
Log.w(TAG, "Received an invalid kicked message, expecting at least 33 bytes, got ${decoded.size}")
return@forEach
}
val sessionId = AccountId(IdPrefix.STANDARD, decoded.copyOfRange(0, 32)) // copyOfRange: [start,end)
val messageGeneration = decoded.copyOfRange(32, decoded.size).decodeToString().toIntOrNull()
if (messageGeneration == null) {
Log.w(TAG, "Received an invalid kicked message: missing message generation")
return@forEach
}
val currentKeysGeneration = configFactoryProtocol.withGroupConfigs(groupId) {
it.groupKeys.currentGeneration()
}
val isForMe = sessionId.hexString == storage.getUserPublicKey()
Log.d(TAG, "Received kicked message, for us? ${isForMe}, message key generation = $messageGeneration, our key generation = $currentKeysGeneration")
if (isForMe && messageGeneration >= currentKeysGeneration) {
groupManagerV2.get().handleKicked(groupId)
}
}
}
}
companion object {
private const val TAG = "GroupRevokedMessageHandler"
}
}

@ -138,7 +138,7 @@ class RemoveGroupMemberHandler @Inject constructor(
// Call No 3. Conditionally send the `GroupUpdateDeleteMemberContent`
if (pendingRemovals.any { (member, status) -> member.shouldRemoveMessages(status) }) {
calls += SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = Namespace.CLOSED_GROUP_MESSAGES(),
namespace = Namespace.GROUP_MESSAGES(),
message = buildDeleteGroupMemberContentMessage(
adminKey = adminKey,
groupAccountId = groupAccountId.hexString,

@ -10,6 +10,8 @@ import androidx.core.content.ContextCompat.getString
import com.goterl.lazysodium.interfaces.AEAD
import com.goterl.lazysodium.utils.Key
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import network.loki.messenger.R
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
@ -20,6 +22,7 @@ import org.session.libsession.messaging.sending_receiving.notifications.PushNoti
import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsession.utilities.ConfigMessage
import org.session.libsession.utilities.bencode.Bencode
import org.session.libsession.utilities.bencode.BencodeList
import org.session.libsession.utilities.bencode.BencodeString
@ -30,13 +33,15 @@ import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.groups.GroupRevokedMessageHandler
import javax.inject.Inject
private const val TAG = "PushHandler"
class PushReceiver @Inject constructor(
@ApplicationContext private val context: Context,
private val configFactory: ConfigFactory
private val configFactory: ConfigFactory,
private val groupRevokedMessageHandler: GroupRevokedMessageHandler,
) {
private val json = Json { ignoreUnknownKeys = true }
@ -56,7 +61,7 @@ class PushReceiver @Inject constructor(
addMessageReceiveJob(PushData(data = data, metadata = null))
}
private fun addMessageReceiveJob(pushData: PushData?){
private fun addMessageReceiveJob(pushData: PushData?) {
// send a generic notification if we have no data
if (pushData?.data == null) {
sendGenericNotification()
@ -64,24 +69,63 @@ class PushReceiver @Inject constructor(
}
try {
val namespace = pushData.metadata?.namespace
val params = when {
pushData.metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> {
namespace == Namespace.GROUP_MESSAGES() ||
namespace == Namespace.REVOKED_GROUP_MESSAGES() ||
namespace == Namespace.GROUP_INFO() ||
namespace == Namespace.GROUP_MEMBERS() ||
namespace == Namespace.GROUP_KEYS() -> {
val groupId = AccountId(requireNotNull(pushData.metadata.account) {
"Received a closed group message push notification without an account ID"
})
val envelop = checkNotNull(tryDecryptGroupMessage(groupId, pushData.data)) {
"Unable to decrypt closed group message"
if (namespace == Namespace.GROUP_MESSAGES()) {
val envelope = checkNotNull(tryDecryptGroupEnvelope(groupId, pushData.data)) {
"Unable to decrypt closed group message"
}
MessageReceiveParameters(
data = envelope.toByteArray(),
serverHash = pushData.metadata.msg_hash,
closedGroup = Destination.ClosedGroup(groupId.hexString)
)
} else if (namespace == Namespace.REVOKED_GROUP_MESSAGES()) {
GlobalScope.launch {
groupRevokedMessageHandler.handleRevokeMessage(groupId, listOf(pushData.data))
}
null
} else {
val hash = requireNotNull(pushData.metadata.msg_hash) {
"Received a closed group config push notification without a message hash"
}
// If we receive group config messages from notification, try to merge
// them directly
val configMessage = listOf(
ConfigMessage(
hash = hash,
data = pushData.data,
timestamp = pushData.metadata.timestampSeconds
)
)
configFactory.mergeGroupConfigMessages(
groupId = groupId,
keys = configMessage.takeIf { namespace == Namespace.GROUP_KEYS() }
.orEmpty(),
members = configMessage.takeIf { namespace == Namespace.GROUP_MEMBERS() }
.orEmpty(),
info = configMessage.takeIf { namespace == Namespace.GROUP_INFO() }
.orEmpty(),
)
null
}
MessageReceiveParameters(
data = envelop.toByteArray(),
serverHash = pushData.metadata.msg_hash,
closedGroup = Destination.ClosedGroup(groupId.hexString)
)
}
pushData.metadata?.namespace == 0 || pushData.metadata == null -> {
namespace == Namespace.DEFAULT() || pushData.metadata == null -> {
val envelopeAsData = MessageWrapper.unwrap(pushData.data).toByteArray()
MessageReceiveParameters(
data = envelopeAsData,
@ -90,25 +134,30 @@ class PushReceiver @Inject constructor(
}
else -> {
Log.w(TAG, "Received a push notification with an unknown namespace: ${pushData.metadata.namespace}")
Log.w(TAG, "Received a push notification with an unknown namespace: $namespace")
return
}
}
JobQueue.shared.add(BatchMessageReceiveJob(listOf(params), null))
if (params != null) {
JobQueue.shared.add(BatchMessageReceiveJob(listOf(params), null))
}
} catch (e: Exception) {
Log.d(TAG, "Failed to unwrap data for message due to error.", e)
}
}
private fun tryDecryptGroupMessage(groupId: AccountId, data: ByteArray): Envelope? {
val (envelopBytes, sender) = checkNotNull(configFactory.withGroupConfigs(groupId) { it.groupKeys.decrypt(data) }) {
private fun tryDecryptGroupEnvelope(groupId: AccountId, data: ByteArray): Envelope? {
val (envelopBytes, sender) = checkNotNull(configFactory.withGroupConfigs(groupId) {
it.groupKeys.decrypt(
data
)
}) {
"Failed to decrypt group message"
}
Log.d(TAG, "Successfully decrypted group message from ${sender.hexString}")
Log.d(TAG, "Successfully decrypted group message from $sender")
return Envelope.parseFrom(envelopBytes)
.toBuilder()
.setSource(sender.hexString)

@ -73,7 +73,7 @@ constructor(
getGroupSubscriptions(
token = token
) + mapOf(
SubscriptionKey(userAuth.accountId, token) to Subscription(userAuth, 0)
SubscriptionKey(userAuth.accountId, token) to Subscription(userAuth, listOf(Namespace.DEFAULT()))
)
}
.scan<Map<SubscriptionKey, Subscription>, Pair<Map<SubscriptionKey, Subscription>, Map<SubscriptionKey, Subscription>>?>(
@ -103,7 +103,7 @@ constructor(
pushRegistry.register(
token = key.token,
swarmAuth = subscription.auth,
namespaces = listOf(subscription.namespace)
namespaces = subscription.namespaces.toList()
)
} catch (e: Exception) {
Log.e(TAG, "Failed to register for push notification", e)
@ -135,6 +135,16 @@ constructor(
): Map<SubscriptionKey, Subscription> {
return buildMap {
val groups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() }
.filter { it.shouldPoll }
val namespaces = listOf(
Namespace.GROUP_MESSAGES(),
Namespace.GROUP_INFO(),
Namespace.GROUP_MEMBERS(),
Namespace.GROUP_KEYS(),
Namespace.REVOKED_GROUP_MESSAGES(),
)
for (group in groups) {
val adminKey = group.adminKey
if (adminKey != null && adminKey.isNotEmpty()) {
@ -142,7 +152,7 @@ constructor(
SubscriptionKey(group.groupAccountId, token),
Subscription(
auth = OwnedSwarmAuth.ofClosedGroup(group.groupAccountId, adminKey),
namespace = Namespace.GROUPS()
namespaces = namespaces
)
)
continue
@ -154,7 +164,7 @@ constructor(
?.let {
Subscription(
auth = it,
namespace = Namespace.GROUPS()
namespaces = namespaces
)
}
@ -167,5 +177,5 @@ constructor(
}
private data class SubscriptionKey(val accountId: AccountId, val token: String)
private data class Subscription(val auth: SwarmAuth, val namespace: Int)
private data class Subscription(val auth: SwarmAuth, val namespaces: List<Int>)
}

@ -46,13 +46,14 @@ class PushRegistryV2 @Inject constructor(
val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s
val publicKey = swarmAuth.accountId.hexString
val sortedNamespace = namespaces.sorted()
val signed = swarmAuth.sign(
"MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray()
"MONITOR${publicKey}${timestamp}1${sortedNamespace.joinToString(separator = ",")}".encodeToByteArray()
)
val requestParameters = SubscriptionRequest(
pubkey = publicKey,
session_ed25519 = swarmAuth.ed25519PublicKeyHex,
namespaces = namespaces,
namespaces = sortedNamespace,
data = true, // only permit data subscription for now (?)
service = device.service,
sig_ts = timestamp,

@ -361,31 +361,31 @@ Java_org_session_libsignal_utilities_Namespace_CONVO_1INFO_1VOLATILE(JNIEnv *env
extern "C"
JNIEXPORT jint JNICALL
Java_org_session_libsignal_utilities_Namespace_GROUPS(JNIEnv *env, jobject thiz) {
Java_org_session_libsignal_utilities_Namespace_USER_1GROUPS(JNIEnv *env, jobject thiz) {
return (int) session::config::Namespace::UserGroups;
}
extern "C"
JNIEXPORT jint JNICALL
Java_org_session_libsignal_utilities_Namespace_CLOSED_1GROUP_1INFO(JNIEnv *env, jobject thiz) {
Java_org_session_libsignal_utilities_Namespace_GROUP_1INFO(JNIEnv *env, jobject thiz) {
return (int) session::config::Namespace::GroupInfo;
}
extern "C"
JNIEXPORT jint JNICALL
Java_org_session_libsignal_utilities_Namespace_CLOSED_1GROUP_1MEMBERS(JNIEnv *env, jobject thiz) {
Java_org_session_libsignal_utilities_Namespace_GROUP_1MEMBERS(JNIEnv *env, jobject thiz) {
return (int) session::config::Namespace::GroupMembers;
}
extern "C"
JNIEXPORT jint JNICALL
Java_org_session_libsignal_utilities_Namespace_ENCRYPTION_1KEYS(JNIEnv *env, jobject thiz) {
Java_org_session_libsignal_utilities_Namespace_GROUP_1KEYS(JNIEnv *env, jobject thiz) {
return (int) session::config::Namespace::GroupKeys;
}
extern "C"
JNIEXPORT jint JNICALL
Java_org_session_libsignal_utilities_Namespace_CLOSED_1GROUP_1MESSAGES(JNIEnv *env, jobject thiz) {
Java_org_session_libsignal_utilities_Namespace_GROUP_1MESSAGES(JNIEnv *env, jobject thiz) {
return (int) session::config::Namespace::GroupMessages;
}

@ -298,7 +298,7 @@ class UserGroupsConfig private constructor(pointer: Long): ConfigBase(pointer),
)
)
override fun namespace() = Namespace.GROUPS()
override fun namespace() = Namespace.USER_GROUPS()
external override fun getCommunityInfo(baseUrl: String, room: String): GroupInfo.CommunityGroupInfo?
external override fun getLegacyGroupInfo(accountId: String): GroupInfo.LegacyGroupInfo?
@ -359,7 +359,7 @@ class GroupInfoConfig private constructor(pointer: Long): ConfigBase(pointer), M
): Long
}
override fun namespace() = Namespace.CLOSED_GROUP_INFO()
override fun namespace() = Namespace.GROUP_INFO()
external override fun id(): AccountId
external override fun destroyGroup()
@ -425,7 +425,7 @@ class GroupMembersConfig private constructor(pointer: Long): ConfigBase(pointer)
constructor(groupPubKey: ByteArray, groupAdminKey: ByteArray?, initialDump: ByteArray?)
: this(newInstance(groupPubKey, groupAdminKey, initialDump))
override fun namespace() = Namespace.CLOSED_GROUP_MEMBERS()
override fun namespace() = Namespace.GROUP_MEMBERS()
external override fun all(): Stack<GroupMember>
external override fun erase(pubKeyHex: String): Boolean
@ -503,7 +503,7 @@ class GroupKeysConfig private constructor(
members
)
override fun namespace() = Namespace.ENCRYPTION_KEYS()
override fun namespace() = Namespace.GROUP_KEYS()
external override fun groupKeys(): Stack<ByteArray>
external override fun needsDump(): Boolean

@ -246,7 +246,7 @@ object MessageSender {
Namespace.UNAUTHENTICATED_CLOSED_GROUP(),
Namespace.DEFAULT
())
destination is Destination.ClosedGroup -> listOf(Namespace.CLOSED_GROUP_MESSAGES())
destination is Destination.ClosedGroup -> listOf(Namespace.GROUP_MESSAGES())
else -> listOf(Namespace.DEFAULT())
}

@ -96,6 +96,9 @@ data class PushNotificationMetadata(
@SerialName("n")
val namespace: Int,
@SerialName("t")
val timestampSeconds: Long,
/** The length of the message data. This is always included, even if the message content
* itself was too large to fit into the push notification. */
@SerialName("l")

@ -108,7 +108,7 @@ enum class UserConfigType(val namespace: Int) {
CONTACTS(Namespace.CONTACTS()),
USER_PROFILE(Namespace.USER_PROFILE()),
CONVO_INFO_VOLATILE(Namespace.CONVO_INFO_VOLATILE()),
USER_GROUPS(Namespace.GROUPS()),
USER_GROUPS(Namespace.USER_GROUPS()),
}
/**

@ -2,15 +2,21 @@ package org.session.libsignal.utilities
object Namespace {
fun ALL() = "all"
// Namespaces used for legacy group
fun UNAUTHENTICATED_CLOSED_GROUP() = -10
// Namespaces used for user's own swarm
external fun DEFAULT(): Int
external fun USER_PROFILE(): Int
external fun CONTACTS(): Int
external fun CONVO_INFO_VOLATILE(): Int
external fun GROUPS(): Int
external fun CLOSED_GROUP_INFO(): Int
external fun CLOSED_GROUP_MEMBERS(): Int
external fun ENCRYPTION_KEYS(): Int
external fun CLOSED_GROUP_MESSAGES(): Int
external fun USER_GROUPS(): Int
// Namesapced used for groupv2
external fun GROUP_INFO(): Int
external fun GROUP_MEMBERS(): Int
external fun GROUP_KEYS(): Int
external fun GROUP_MESSAGES(): Int
external fun REVOKED_GROUP_MESSAGES(): Int
}
Loading…
Cancel
Save