feat: add support for blinded25 receipt of messages for later impl

pull/2822/head
Audric Ackermann 2 years ago
parent 69638f72fa
commit 77acd1396b

@ -6,7 +6,7 @@ import { MessageRenderingProps } from '../../../../models/messageType';
import { findCachedBlindedMatchOrLookItUp } from '../../../../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import { findCachedBlindedMatchOrLookItUp } from '../../../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { getConversationController } from '../../../../session/conversations'; import { getConversationController } from '../../../../session/conversations';
import { getSodiumRenderer } from '../../../../session/crypto'; import { getSodiumRenderer } from '../../../../session/crypto';
import { PubKey } from '../../../../session/types'; import { KeyPrefixType, PubKey } from '../../../../session/types';
import { openConversationWithMessages } from '../../../../state/ducks/conversations'; import { openConversationWithMessages } from '../../../../state/ducks/conversations';
import { updateUserDetailsModal } from '../../../../state/ducks/modalDialog'; import { updateUserDetailsModal } from '../../../../state/ducks/modalDialog';
import { import {
@ -61,7 +61,7 @@ export const MessageAvatar = (props: Props) => {
const userName = authorName || authorProfileName || sender; const userName = authorName || authorProfileName || sender;
const onMessageAvatarClick = useCallback(async () => { const onMessageAvatarClick = useCallback(async () => {
if (isPublic && !PubKey.hasBlindedPrefix(sender)) { if (isPublic && !PubKey.isBlinded(sender)) {
// public chat but session id not blinded. disable showing user details if we do not have an active convo with that user. // public chat but session id not blinded. disable showing user details if we do not have an active convo with that user.
// an unactive convo with that user means that we never chatted with that id directyly, but only through a sogs // an unactive convo with that user means that we never chatted with that id directyly, but only through a sogs
const convoWithSender = getConversationController().get(sender); const convoWithSender = getConversationController().get(sender);
@ -84,6 +84,11 @@ export const MessageAvatar = (props: Props) => {
} }
if (isPublic && selectedConvoKey) { if (isPublic && selectedConvoKey) {
if (sender.startsWith(KeyPrefixType.blinded25)) {
window.log.info('onMessageAvatarClick: blinded25 convo click are disabled currently...');
return;
}
const convoOpen = getConversationController().get(selectedConvoKey); const convoOpen = getConversationController().get(selectedConvoKey);
const room = OpenGroupData.getV2OpenGroupRoom(convoOpen.id); const room = OpenGroupData.getV2OpenGroupRoom(convoOpen.id);
let privateConvoToOpen = sender; let privateConvoToOpen = sender;

@ -357,7 +357,7 @@ export const CopyMenuItem = (): JSX.Element | null => {
export const MarkAllReadMenuItem = (): JSX.Element | null => { export const MarkAllReadMenuItem = (): JSX.Element | null => {
const convoId = useConvoIdFromContext(); const convoId = useConvoIdFromContext();
const isIncomingRequest = useIsIncomingRequest(convoId); const isIncomingRequest = useIsIncomingRequest(convoId);
if (!isIncomingRequest && !PubKey.hasBlindedPrefix(convoId)) { if (!isIncomingRequest && !PubKey.isBlinded(convoId)) {
return ( return (
<Item onClick={() => markAllReadByConvoId(convoId)}>{window.i18n('markAllAsRead')}</Item> <Item onClick={() => markAllReadByConvoId(convoId)}>{window.i18n('markAllAsRead')}</Item>
); );
@ -379,7 +379,7 @@ export const BlockMenuItem = (): JSX.Element | null => {
const isPrivate = useIsPrivate(convoId); const isPrivate = useIsPrivate(convoId);
const isIncomingRequest = useIsIncomingRequest(convoId); const isIncomingRequest = useIsIncomingRequest(convoId);
if (!isMe && isPrivate && !isIncomingRequest && !PubKey.hasBlindedPrefix(convoId)) { if (!isMe && isPrivate && !isIncomingRequest && !PubKey.isBlinded(convoId)) {
const blockTitle = isBlocked ? window.i18n('unblock') : window.i18n('block'); const blockTitle = isBlocked ? window.i18n('unblock') : window.i18n('block');
const blockHandler = isBlocked const blockHandler = isBlocked
? () => unblockConvoById(convoId) ? () => unblockConvoById(convoId)

@ -101,7 +101,7 @@ export function useIsBlinded(convoId?: string) {
if (!convoId) { if (!convoId) {
return false; return false;
} }
return Boolean(PubKey.hasBlindedPrefix(convoId)); return Boolean(PubKey.isBlinded(convoId));
} }
export function useHasNickname(convoId?: string) { export function useHasNickname(convoId?: string) {

@ -532,8 +532,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
); );
const hasIncomingMessages = incomingMessageCount > 0; const hasIncomingMessages = incomingMessageCount > 0;
if (this.id.startsWith('15')) { if (PubKey.isBlinded(this.id)) {
window.log.info('Sending a blinded message to this user: ', this.id); window.log.info('Sending a blinded message react to this user: ', this.id);
await this.sendBlindedMessageRequest(chatMessageParams); await this.sendBlindedMessageRequest(chatMessageParams);
return; return;
} }
@ -1674,7 +1674,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
); );
const hasIncomingMessages = incomingMessageCount > 0; const hasIncomingMessages = incomingMessageCount > 0;
if (this.id.startsWith('15')) { if (PubKey.isBlinded(this.id)) {
window.log.info('Sending a blinded message to this user: ', this.id); window.log.info('Sending a blinded message to this user: ', this.id);
await this.sendBlindedMessageRequest(chatMessageParams); await this.sendBlindedMessageRequest(chatMessageParams);
return; return;
@ -1778,7 +1778,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const ourSignKeyBytes = await UserUtils.getUserED25519KeyPairBytes(); const ourSignKeyBytes = await UserUtils.getUserED25519KeyPairBytes();
const groupUrl = this.getSogsOriginMessage(); const groupUrl = this.getSogsOriginMessage();
if (!PubKey.hasBlindedPrefix(this.id)) { if (!PubKey.isBlinded(this.id)) {
window?.log?.warn('sendBlindedMessageRequest - convo is not a blinded one'); window?.log?.warn('sendBlindedMessageRequest - convo is not a blinded one');
return; return;
} }
@ -1826,7 +1826,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
} }
this.set({ active_at: Date.now(), isApproved: true }); this.set({ active_at: Date.now(), isApproved: true });
// TODO we need to add support for sending blinded25 message request in addition to the legacy blinded15
await getMessageQueue().sendToOpenGroupV2BlindedRequest({ await getMessageQueue().sendToOpenGroupV2BlindedRequest({
encryptedContent: encryptedMsg, encryptedContent: encryptedMsg,
roomInfos: roomInfo, roomInfos: roomInfo,

@ -1279,7 +1279,7 @@ export function findAndFormatContact(pubkey: string): FindAndFormatContactType {
if ( if (
pubkey === UserUtils.getOurPubKeyStrFromCache() || pubkey === UserUtils.getOurPubKeyStrFromCache() ||
(pubkey && pubkey.startsWith('15') && isUsAnySogsFromCache(pubkey)) (pubkey && PubKey.isBlinded(pubkey) && isUsAnySogsFromCache(pubkey))
) { ) {
profileName = window.i18n('you'); profileName = window.i18n('you');
isMe = true; isMe = true;

@ -1676,7 +1676,7 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite
// this filter is based on the `isContactToStoreInWrapper` function. Note, blocked contacts won't be added to the wrapper at first, but will on the first start // this filter is based on the `isContactToStoreInWrapper` function. Note, blocked contacts won't be added to the wrapper at first, but will on the first start
const contactsToWriteInWrapper = db const contactsToWriteInWrapper = db
.prepare( .prepare(
`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE type = 'private' AND active_at > 0 AND priority <> ${CONVERSATION_PRIORITIES.hidden} AND (didApproveMe OR isApproved) AND id <> '$us' AND id NOT LIKE '15%' ;` `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE type = 'private' AND active_at > 0 AND priority <> ${CONVERSATION_PRIORITIES.hidden} AND (didApproveMe OR isApproved) AND id <> '$us' AND id NOT LIKE '15%' AND id NOT LIKE '25%' ;`
) )
.all({ .all({
us: publicKeyHex, us: publicKeyHex,

@ -145,8 +145,8 @@ export function tryMatchBlindWithStandardKey(
throw new Error('standardKey must be a standard key (starting with 05)'); throw new Error('standardKey must be a standard key (starting with 05)');
} }
if (!blindedSessionId.startsWith(KeyPrefixType.blinded)) { if (!PubKey.isBlinded(blindedSessionId)) {
throw new Error('blindedKey must be a blinded key (starting with 15)'); throw new Error('blindedKey must be a blinded key (starting with 15 or 25)');
} }
// We don't want to stop iterating even if an error happens while looking for a blind/standard match. // We don't want to stop iterating even if an error happens while looking for a blind/standard match.
@ -204,7 +204,7 @@ function findNotCachedBlindingMatch(
// we iterate only over the convos private, approved, and which have an unblinded id. // we iterate only over the convos private, approved, and which have an unblinded id.
const foundConvoMatchingBlindedPubkey = getConversationController() const foundConvoMatchingBlindedPubkey = getConversationController()
.getConversations() .getConversations()
.filter(m => m.isPrivate() && m.isApproved() && !PubKey.hasBlindedPrefix(m.id)) .filter(m => m.isPrivate() && m.isApproved() && !PubKey.isBlinded(m.id))
.find(m => { .find(m => {
return tryMatchBlindWithStandardKey(m.id, blindedId, serverPublicKey, sodium); return tryMatchBlindWithStandardKey(m.id, blindedId, serverPublicKey, sodium);
}); });
@ -214,13 +214,13 @@ function findNotCachedBlindingMatch(
/** /**
* This function returns true if the given blindedId matches our own blinded id on any pysogs. * This function returns true if the given blindedId matches our own blinded id on any pysogs.
* If the given pubkey is not blinded, it returns true if it is our naked SessionID. * If the given pubkey is not blinded, it returns true if it is our naked SessionID.
* It can be used to replace mentions with the @You syntax and for the quotes too * It can be used to replace mentions with the @You syntax and for the quotes too
*/ */
export function isUsAnySogsFromCache(blindedOrNakedId: string): boolean { export function isUsAnySogsFromCache(blindedOrNakedId: string): boolean {
const usUnblinded = UserUtils.getOurPubKeyStrFromCache(); const usUnblinded = UserUtils.getOurPubKeyStrFromCache();
if (!PubKey.hasBlindedPrefix(blindedOrNakedId)) { if (!PubKey.isBlinded(blindedOrNakedId)) {
return blindedOrNakedId === usUnblinded; return blindedOrNakedId === usUnblinded;
} }
const found = assertLoaded().find( const found = assertLoaded().find(
@ -268,7 +268,7 @@ function findNotCachedBlindedConvoFromUnblindedKey(
serverPublicKey: string, serverPublicKey: string,
sodium: LibSodiumWrappers sodium: LibSodiumWrappers
): Array<ConversationModel> { ): Array<ConversationModel> {
if (PubKey.hasBlindedPrefix(unblindedID)) { if (PubKey.isBlinded(unblindedID)) {
throw new Error( throw new Error(
'findNotCachedBlindedConvoFromUnblindedKey unblindedID is supposed to be unblinded!' 'findNotCachedBlindedConvoFromUnblindedKey unblindedID is supposed to be unblinded!'
); );
@ -279,7 +279,7 @@ function findNotCachedBlindedConvoFromUnblindedKey(
const foundConvosForThisServerPk = const foundConvosForThisServerPk =
getConversationController() getConversationController()
.getConversations() .getConversations()
.filter(m => m.isPrivate() && PubKey.hasBlindedPrefix(m.id) && m.isActive()) .filter(m => m.isPrivate() && PubKey.isBlinded(m.id) && m.isActive())
.filter(m => { .filter(m => {
return tryMatchBlindWithStandardKey(unblindedID, m.id, serverPublicKey, sodium); return tryMatchBlindWithStandardKey(unblindedID, m.id, serverPublicKey, sodium);
}) || []; }) || [];
@ -302,7 +302,7 @@ export async function findCachedBlindedMatchOrLookItUp(
serverPubKey: string, serverPubKey: string,
sodium: LibSodiumWrappers sodium: LibSodiumWrappers
): Promise<string | undefined> { ): Promise<string | undefined> {
if (!PubKey.hasBlindedPrefix(blindedId)) { if (!PubKey.isBlinded(blindedId)) {
return blindedId; return blindedId;
} }
const found = getCachedNakedKeyFromBlinded(blindedId, serverPubKey); const found = getCachedNakedKeyFromBlinded(blindedId, serverPubKey);
@ -333,7 +333,7 @@ export function findCachedBlindedIdFromUnblinded(
unblindedId: string, unblindedId: string,
serverPubKey: string serverPubKey: string
): string | undefined { ): string | undefined {
if (PubKey.hasBlindedPrefix(unblindedId)) { if (PubKey.isBlinded(unblindedId)) {
throw new Error('findCachedBlindedIdFromUnblinded needs an unblindedID'); throw new Error('findCachedBlindedIdFromUnblinded needs an unblindedID');
} }
const found = assertLoaded().find( const found = assertLoaded().find(
@ -351,7 +351,7 @@ export async function findCachedOurBlindedPubkeyOrLookItUp(
): Promise<string> { ): Promise<string> {
const ourNakedSessionID = UserUtils.getOurPubKeyStrFromCache(); const ourNakedSessionID = UserUtils.getOurPubKeyStrFromCache();
if (PubKey.hasBlindedPrefix(ourNakedSessionID)) { if (PubKey.isBlinded(ourNakedSessionID)) {
throw new Error('findCachedBlindedIdFromUnblindedOrLookItUp needs a unblindedID'); throw new Error('findCachedBlindedIdFromUnblindedOrLookItUp needs a unblindedID');
} }
let found = findCachedBlindedIdFromUnblinded(ourNakedSessionID, serverPubKey); let found = findCachedBlindedIdFromUnblinded(ourNakedSessionID, serverPubKey);
@ -402,7 +402,7 @@ export function findCachedBlindedMatchOrLookupOnAllServers(
unblindedId: string, unblindedId: string,
sodium: LibSodiumWrappers sodium: LibSodiumWrappers
): Array<ConversationModel> { ): Array<ConversationModel> {
if (PubKey.hasBlindedPrefix(unblindedId)) { if (PubKey.isBlinded(unblindedId)) {
throw new Error('findCachedBlindedMatchOrLookupOnAllServers needs an unblindedId'); throw new Error('findCachedBlindedMatchOrLookupOnAllServers needs an unblindedId');
} }

@ -63,7 +63,8 @@ async function getOpenGroupHeaders(data: {
const blindingValues = getBlindingValues(serverPK, signingKeys, sodium); const blindingValues = getBlindingValues(serverPK, signingKeys, sodium);
ka = blindingValues.secretKey; ka = blindingValues.secretKey;
kA = blindingValues.publicKey; kA = blindingValues.publicKey;
pubkey = `${KeyPrefixType.blinded}${toHex(kA)}`; // TODO we will need to add the support of blinded25 here
pubkey = `${KeyPrefixType.blinded15}${toHex(kA)}`;
} else { } else {
pubkey = `${KeyPrefixType.unblinded}${toHex(signingKeys.pubKeyBytes)}`; pubkey = `${KeyPrefixType.unblinded}${toHex(signingKeys.pubKeyBytes)}`;
} }
@ -151,7 +152,8 @@ const getBlindedPubKey = (
sodium: LibSodiumWrappers sodium: LibSodiumWrappers
): string => { ): string => {
const blindedPubKeyBytes = getBlindingValues(serverPK, signingKeys, sodium); const blindedPubKeyBytes = getBlindingValues(serverPK, signingKeys, sodium);
return `${KeyPrefixType.blinded}${to_hex(blindedPubKeyBytes.publicKey)}`; // TODO we will need to add the support of blinded25 here
return `${KeyPrefixType.blinded15}${to_hex(blindedPubKeyBytes.publicKey)}`;
}; };
const getBlindingValues = ( const getBlindingValues = (

@ -187,7 +187,7 @@ export class ConversationController {
'getConversationController().deleteBlindedContact() needs complete initial fetch' 'getConversationController().deleteBlindedContact() needs complete initial fetch'
); );
} }
if (!PubKey.hasBlindedPrefix(blindedId)) { if (!PubKey.isBlinded(blindedId)) {
throw new Error('deleteBlindedContact allow accepts blinded id'); throw new Error('deleteBlindedContact allow accepts blinded id');
} }
window.log.info(`deleteBlindedContact with ${blindedId}`); window.log.info(`deleteBlindedContact with ${blindedId}`);

@ -139,7 +139,8 @@ export class MessageQueue {
recipientBlindedId: string; recipientBlindedId: string;
}) { }) {
try { try {
if (!PubKey.hasBlindedPrefix(recipientBlindedId)) { // TODO we will need to add the support for blinded25 messages requests
if (!PubKey.isBlinded(recipientBlindedId)) {
throw new Error('sendToOpenGroupV2BlindedRequest needs a blindedId'); throw new Error('sendToOpenGroupV2BlindedRequest needs a blindedId');
} }
const { serverTimestamp, serverId } = await MessageSender.sendToOpenGroupV2BlindedRequest( const { serverTimestamp, serverId } = await MessageSender.sendToOpenGroupV2BlindedRequest(

@ -10,9 +10,15 @@ export enum KeyPrefixType {
*/ */
standard = '05', standard = '05',
/** /**
* used for participants in open groups * used for participants in open groups (legacy blinding logic)
*/ */
blinded = '15', blinded15 = '15',
/**
* used for participants in open groups (new blinding logic)
*/
blinded25 = '25',
/** /**
* used for participants in open groups * used for participants in open groups
*/ */
@ -32,7 +38,7 @@ export class PubKey {
public static readonly PREFIX_GROUP_TEXTSECURE = '__textsecure_group__!'; public static readonly PREFIX_GROUP_TEXTSECURE = '__textsecure_group__!';
// prettier-ignore // prettier-ignore
private static readonly regex: RegExp = new RegExp( private static readonly regex: RegExp = new RegExp(
`^(${PubKey.PREFIX_GROUP_TEXTSECURE})?(${KeyPrefixType.standard}|${KeyPrefixType.blinded}|${KeyPrefixType.unblinded}|${KeyPrefixType.groupV3})?(${PubKey.HEX}{64}|${PubKey.HEX}{32})$` `^(${PubKey.PREFIX_GROUP_TEXTSECURE})?(${KeyPrefixType.standard}|${KeyPrefixType.blinded15}|${KeyPrefixType.blinded25}|${KeyPrefixType.unblinded}|${KeyPrefixType.groupV3})?(${PubKey.HEX}{64}|${PubKey.HEX}{32})$`
); );
/** /**
* If you want to update this regex. Be sure that those are matches ; * If you want to update this regex. Be sure that those are matches ;
@ -146,18 +152,19 @@ export class PubKey {
/** /**
* @param keyWithOrWithoutPrefix Key with or without prefix * @param keyWithOrWithoutPrefix Key with or without prefix
* @returns If key is the correct length and has a supported prefix 05 or 15 * @returns If key is the correct length and has a supported prefix 05, 15, 25
*/ */
public static isValidPrefixAndLength(keyWithOrWithoutPrefix: string): boolean { public static isValidPrefixAndLength(keyWithOrWithoutPrefix: string): boolean {
return ( return (
keyWithOrWithoutPrefix.length === 66 && keyWithOrWithoutPrefix.length === 66 &&
(keyWithOrWithoutPrefix.startsWith(KeyPrefixType.blinded) || (keyWithOrWithoutPrefix.startsWith(KeyPrefixType.blinded15) ||
keyWithOrWithoutPrefix.startsWith(KeyPrefixType.blinded25) ||
keyWithOrWithoutPrefix.startsWith(KeyPrefixType.standard)) keyWithOrWithoutPrefix.startsWith(KeyPrefixType.standard))
); );
} }
/** /**
* This removes the 05 or 15 prefix from a Pubkey which have it and have a length of 66 * This removes the 05, 15 or 25 prefix from a Pubkey which have it and have a length of 66
* @param keyWithOrWithoutPrefix the key with or without the prefix * @param keyWithOrWithoutPrefix the key with or without the prefix
*/ */
public static removePrefixIfNeeded(keyWithOrWithoutPrefix: string): string { public static removePrefixIfNeeded(keyWithOrWithoutPrefix: string): string {
@ -230,8 +237,8 @@ export class PubKey {
return fromHexToArray(PubKey.removePrefixIfNeeded(this.key)); return fromHexToArray(PubKey.removePrefixIfNeeded(this.key));
} }
public static hasBlindedPrefix(key: string) { public static isBlinded(key: string) {
return key.startsWith(KeyPrefixType.blinded); return key.startsWith(KeyPrefixType.blinded15) || key.startsWith(KeyPrefixType.blinded25);
} }
public static isClosedGroupV3(key: string) { public static isClosedGroupV3(key: string) {

@ -29,9 +29,7 @@ const mappedContactWrapperValues = new Map<string, ContactInfo>();
* We want to sync the message request status so we need to allow a contact even if it's not approved, did not approve us and is not blocked. * We want to sync the message request status so we need to allow a contact even if it's not approved, did not approve us and is not blocked.
*/ */
function isContactToStoreInWrapper(convo: ConversationModel): boolean { function isContactToStoreInWrapper(convo: ConversationModel): boolean {
return ( return !convo.isMe() && convo.isPrivate() && convo.isActive() && !PubKey.isBlinded(convo.id);
!convo.isMe() && convo.isPrivate() && convo.isActive() && !PubKey.hasBlindedPrefix(convo.id)
);
} }
/** /**

@ -204,7 +204,7 @@ const getValidContacts = (convos: Array<ConversationModel>) => {
c.getRealSessionUsername() && c.getRealSessionUsername() &&
c.isPrivate() && c.isPrivate() &&
c.isApproved() && c.isApproved() &&
!PubKey.hasBlindedPrefix(c.get('id')) !PubKey.isBlinded(c.get('id'))
); );
const contacts = contactsModels.map(c => { const contacts = contactsModels.map(c => {

@ -785,7 +785,7 @@ export const getMessageQuoteProps = createSelector(
const isFromMe = isUsAnySogsFromCache(author) || false; const isFromMe = isUsAnySogsFromCache(author) || false;
// NOTE the quote lookup map always stores our messages using the unblinded pubkey // NOTE the quote lookup map always stores our messages using the unblinded pubkey
if (isFromMe && PubKey.hasBlindedPrefix(author)) { if (isFromMe && PubKey.isBlinded(author)) {
author = UserUtils.getOurPubKeyStrFromCache(); author = UserUtils.getOurPubKeyStrFromCache();
} }

@ -418,7 +418,7 @@ describe('knownBlindedKeys', () => {
it('throws if blindedSessionId is not standard', () => { it('throws if blindedSessionId is not standard', () => {
expect(() => { expect(() => {
tryMatchBlindWithStandardKey(realSessionId, realSessionId, serverPublicKey, sodium); tryMatchBlindWithStandardKey(realSessionId, realSessionId, serverPublicKey, sodium);
}).to.throw('blindedKey must be a blinded key (starting with 15)'); }).to.throw('blindedKey must be a blinded key (starting with 15 or 25)');
}); });
it('returns true if those keys are not matching blind & naked', () => { it('returns true if those keys are not matching blind & naked', () => {

@ -141,7 +141,8 @@ async function verifySignature(
} }
const messageData = fromBase64ToUint8Array(messageBase64); const messageData = fromBase64ToUint8Array(messageBase64);
const signature = fromBase64ToUint8Array(signatureBase64); const signature = fromBase64ToUint8Array(signatureBase64);
const isBlindedSender = senderPubKey.startsWith('15'); // blinded15 or blinded25 are the same for the verifySignature logic
const isBlindedSender = senderPubKey.startsWith('15') || senderPubKey.startsWith('25');
const pubkeyWithoutPrefix = senderPubKey.slice(2); const pubkeyWithoutPrefix = senderPubKey.slice(2);
const pubkeyBytes = fromHexToArray(pubkeyWithoutPrefix); const pubkeyBytes = fromHexToArray(pubkeyWithoutPrefix);

Loading…
Cancel
Save