Page MenuHome GnuPG

No OneTemporary

diff --git a/src/keycache.cpp b/src/keycache.cpp
index 58e7c67..2331c89 100644
--- a/src/keycache.cpp
+++ b/src/keycache.cpp
@@ -1,942 +1,943 @@
/* @file keycache.cpp
* @brief Internal keycache
*
* Copyright (C) 2018 Intevation GmbH
*
* This file is part of GpgOL.
*
* GpgOL is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* GpgOL is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, see <http://www.gnu.org/licenses/>.
*/
#include "keycache.h"
#include "common.h"
#include "cpphelp.h"
#include "mail.h"
#include <gpg-error.h>
#include <gpgme++/context.h>
#include <gpgme++/key.h>
#include <windows.h>
#include <set>
#include <unordered_map>
#include <sstream>
GPGRT_LOCK_DEFINE (keycache_lock);
GPGRT_LOCK_DEFINE (fpr_map_lock);
GPGRT_LOCK_DEFINE (update_lock);
static KeyCache* singleton = nullptr;
/** At some point we need to set a limit. There
seems to be no limit on how many recipients a mail
can have in outlook.
We would run out of resources or block.
50 Threads already seems a bit excessive but
it should really cover most legit use cases.
*/
#define MAX_LOCATOR_THREADS 50
static int s_thread_cnt;
namespace
{
class LocateArgs
{
public:
LocateArgs (const std::string& mbox, Mail *mail = nullptr):
m_mbox (mbox),
m_mail (mail)
{
s_thread_cnt++;
Mail::lockDelete ();
if (Mail::isValidPtr (m_mail))
{
m_mail->incrementLocateCount ();
}
Mail::unlockDelete ();
};
~LocateArgs()
{
s_thread_cnt--;
Mail::lockDelete ();
if (Mail::isValidPtr (m_mail))
{
m_mail->decrementLocateCount ();
}
Mail::unlockDelete ();
}
std::string m_mbox;
Mail *m_mail;
};
} // namespace
typedef std::pair<std::string, GpgME::Protocol> update_arg_t;
static DWORD WINAPI
do_update (LPVOID arg)
{
auto args = std::unique_ptr<update_arg_t> ((update_arg_t*) arg);
log_mime_parser ("%s:%s updating: \"%s\" with protocol %s",
SRCNAME, __func__, args->first.c_str (),
to_cstr (args->second));
auto ctx = std::unique_ptr<GpgME::Context> (GpgME::Context::createForProtocol
(args->second));
if (!ctx)
{
TRACEPOINT;
KeyCache::instance ()->onUpdateJobDone (args->first.c_str(),
GpgME::Key ());
return 0;
}
ctx->setKeyListMode (GpgME::KeyListMode::Local |
GpgME::KeyListMode::Signatures |
GpgME::KeyListMode::Validate |
GpgME::KeyListMode::WithTofu);
GpgME::Error err;
const auto newKey = ctx->key (args->first.c_str (), err, false);
TRACEPOINT;
if (newKey.isNull())
{
log_debug ("%s:%s Failed to find key for %s",
SRCNAME, __func__, args->first.c_str ());
}
if (err)
{
log_debug ("%s:%s Failed to find key for %s err: ",
SRCNAME, __func__, err.asString ());
}
KeyCache::instance ()->onUpdateJobDone (args->first.c_str(),
newKey);
log_debug ("%s:%s Update job done",
SRCNAME, __func__);
return 0;
}
class KeyCache::Private
{
public:
Private()
{
}
void setPgpKey(const std::string &mbox, const GpgME::Key &key)
{
gpgrt_lock_lock (&keycache_lock);
auto it = m_pgp_key_map.find (mbox);
if (it == m_pgp_key_map.end ())
{
m_pgp_key_map.insert (std::pair<std::string, GpgME::Key> (mbox, key));
}
else
{
it->second = key;
}
insertOrUpdateInFprMap (key);
gpgrt_lock_unlock (&keycache_lock);
}
void setSmimeKey(const std::string &mbox, const GpgME::Key &key)
{
gpgrt_lock_lock (&keycache_lock);
auto it = m_smime_key_map.find (mbox);
if (it == m_smime_key_map.end ())
{
m_smime_key_map.insert (std::pair<std::string, GpgME::Key> (mbox, key));
}
else
{
it->second = key;
}
insertOrUpdateInFprMap (key);
gpgrt_lock_unlock (&keycache_lock);
}
void setPgpKeySecret(const std::string &mbox, const GpgME::Key &key)
{
gpgrt_lock_lock (&keycache_lock);
auto it = m_pgp_skey_map.find (mbox);
if (it == m_pgp_skey_map.end ())
{
m_pgp_skey_map.insert (std::pair<std::string, GpgME::Key> (mbox, key));
}
else
{
it->second = key;
}
insertOrUpdateInFprMap (key);
gpgrt_lock_unlock (&keycache_lock);
}
void setSmimeKeySecret(const std::string &mbox, const GpgME::Key &key)
{
gpgrt_lock_lock (&keycache_lock);
auto it = m_smime_skey_map.find (mbox);
if (it == m_smime_skey_map.end ())
{
m_smime_skey_map.insert (std::pair<std::string, GpgME::Key> (mbox, key));
}
else
{
it->second = key;
}
insertOrUpdateInFprMap (key);
gpgrt_lock_unlock (&keycache_lock);
}
GpgME::Key getKey (const char *addr, GpgME::Protocol proto)
{
if (!addr)
{
return GpgME::Key();
}
auto mbox = GpgME::UserID::addrSpecFromString (addr);
if (proto == GpgME::OpenPGP)
{
gpgrt_lock_lock (&keycache_lock);
const auto it = m_pgp_key_map.find (mbox);
if (it == m_pgp_key_map.end ())
{
gpgrt_lock_unlock (&keycache_lock);
return GpgME::Key();
}
const auto ret = it->second;
gpgrt_lock_unlock (&keycache_lock);
return ret;
}
gpgrt_lock_lock (&keycache_lock);
const auto it = m_smime_key_map.find (mbox);
if (it == m_smime_key_map.end ())
{
gpgrt_lock_unlock (&keycache_lock);
return GpgME::Key();
}
const auto ret = it->second;
gpgrt_lock_unlock (&keycache_lock);
return ret;
}
GpgME::Key getSKey (const char *addr, GpgME::Protocol proto)
{
if (!addr)
{
return GpgME::Key();
}
auto mbox = GpgME::UserID::addrSpecFromString (addr);
if (proto == GpgME::OpenPGP)
{
gpgrt_lock_lock (&keycache_lock);
const auto it = m_pgp_skey_map.find (mbox);
if (it == m_pgp_skey_map.end ())
{
gpgrt_lock_unlock (&keycache_lock);
return GpgME::Key();
}
const auto ret = it->second;
gpgrt_lock_unlock (&keycache_lock);
return ret;
}
gpgrt_lock_lock (&keycache_lock);
const auto it = m_smime_skey_map.find (mbox);
if (it == m_smime_skey_map.end ())
{
gpgrt_lock_unlock (&keycache_lock);
return GpgME::Key();
}
const auto ret = it->second;
gpgrt_lock_unlock (&keycache_lock);
return ret;
}
GpgME::Key getSigningKey (const char *addr, GpgME::Protocol proto)
{
const auto key = getSKey (addr, proto);
if (key.isNull())
{
log_mime_parser ("%s:%s: secret key for %s is null",
SRCNAME, __func__, addr);
return key;
}
if (!key.canReallySign())
{
log_mime_parser ("%s:%s: Discarding key for %s because it can't sign",
SRCNAME, __func__, addr);
return GpgME::Key();
}
if (!key.hasSecret())
{
log_mime_parser ("%s:%s: Discarding key for %s because it has no secret",
SRCNAME, __func__, addr);
return GpgME::Key();
}
if (in_de_vs_mode () && !key.isDeVs())
{
log_mime_parser ("%s:%s: signing key for %s is not deVS",
SRCNAME, __func__, addr);
return GpgME::Key();
}
return key;
}
std::vector<GpgME::Key> getEncryptionKeys (const std::vector<std::string>
&recipients,
GpgME::Protocol proto)
{
std::vector<GpgME::Key> ret;
if (recipients.empty ())
{
TRACEPOINT;
return ret;
}
for (const auto &recip: recipients)
{
const auto key = getKey (recip.c_str (), proto);
if (key.isNull())
{
log_mime_parser ("%s:%s: No key for %s. no internal encryption",
SRCNAME, __func__, recip.c_str ());
return std::vector<GpgME::Key>();
}
if (!key.canEncrypt() || key.isRevoked() ||
key.isExpired() || key.isDisabled() || key.isInvalid())
{
log_mime_parser ("%s:%s: Invalid key for %s. no internal encryption",
SRCNAME, __func__, recip.c_str ());
return std::vector<GpgME::Key>();
}
if (in_de_vs_mode () && !key.isDeVs ())
{
log_mime_parser ("%s:%s: key for %s is not deVS",
SRCNAME, __func__, recip.c_str ());
return std::vector<GpgME::Key>();
}
bool validEnough = false;
/* Here we do the check if the key is valid for this recipient */
const auto addrSpec = GpgME::UserID::addrSpecFromString (recip.c_str ());
for (const auto &uid: key.userIDs ())
{
if (addrSpec != uid.addrSpec())
{
// Ignore unmatching addr specs
continue;
}
if (uid.validity() >= GpgME::UserID::Marginal ||
uid.origin() == GpgME::Key::OriginWKD)
{
validEnough = true;
break;
}
}
if (!validEnough)
{
log_mime_parser ("%s:%s: UID for %s does not have at least marginal trust",
SRCNAME, __func__, recip.c_str ());
return std::vector<GpgME::Key>();
}
// Accepting key
ret.push_back (key);
}
return ret;
}
void insertOrUpdateInFprMap (const GpgME::Key &key)
{
if (key.isNull() || !key.primaryFingerprint())
{
TRACEPOINT;
return;
}
gpgrt_lock_lock (&fpr_map_lock);
/* First ensure that we have the subkeys mapped to the primary
fpr */
const char *primaryFpr = key.primaryFingerprint ();
for (const auto &sub: key.subkeys())
{
const char *subFpr = sub.fingerprint();
auto it = m_sub_fpr_map.find (subFpr);
if (it == m_sub_fpr_map.end ())
{
m_sub_fpr_map.insert (std::make_pair(
std::string (subFpr),
std::string (primaryFpr)));
}
}
auto it = m_fpr_map.find (primaryFpr);
log_mime_parser ("%s:%s \"%s\" updated.",
SRCNAME, __func__, primaryFpr);
if (it == m_fpr_map.end ())
{
m_fpr_map.insert (std::make_pair (primaryFpr, key));
gpgrt_lock_unlock (&fpr_map_lock);
return;
}
if (it->second.hasSecret () && !key.hasSecret())
{
log_debug ("%s:%s Lost secret info on update. Merging.",
SRCNAME, __func__);
auto merged = key;
merged.mergeWith (it->second);
it->second = merged;
}
else
{
it->second = key;
}
gpgrt_lock_unlock (&fpr_map_lock);
return;
}
GpgME::Key getFromMap (const char *fpr) const
{
if (!fpr)
{
TRACEPOINT;
return GpgME::Key();
}
gpgrt_lock_lock (&fpr_map_lock);
std::string primaryFpr;
const auto it = m_sub_fpr_map.find (fpr);
if (it != m_sub_fpr_map.end ())
{
log_debug ("%s:%s using \"%s\" for \"%s\"",
SRCNAME, __func__, it->second.c_str(), fpr);
primaryFpr = it->second;
}
else
{
primaryFpr = fpr;
}
const auto keyIt = m_fpr_map.find (primaryFpr);
if (keyIt != m_fpr_map.end ())
{
+ const auto ret = keyIt->second;
gpgrt_lock_unlock (&fpr_map_lock);
- return keyIt->second;
+ return ret;
}
gpgrt_lock_unlock (&fpr_map_lock);
return GpgME::Key();
}
GpgME::Key getByFpr (const char *fpr, bool block) const
{
if (!fpr)
{
TRACEPOINT;
return GpgME::Key ();
}
TRACEPOINT;
const auto ret = getFromMap (fpr);
if (ret.isNull())
{
// If the key was not found we need to check if there is
// an update running.
if (block)
{
const std::string sFpr (fpr);
int i = 0;
gpgrt_lock_lock (&update_lock);
while (m_update_jobs.find(sFpr) != m_update_jobs.end ())
{
i++;
if (i % 100 == 0)
{
log_debug ("%s:%s Waiting on update for \"%s\"",
SRCNAME, __func__, fpr);
}
gpgrt_lock_unlock (&update_lock);
Sleep (10);
gpgrt_lock_lock (&update_lock);
if (i == 3000)
{
/* Just to be on the save side */
log_error ("%s:%s Waiting on update for \"%s\" "
"failed! Bug!",
SRCNAME, __func__, fpr);
break;
}
}
gpgrt_lock_unlock (&update_lock);
TRACEPOINT;
const auto ret2 = getFromMap (fpr);
if (ret2.isNull ())
{
log_debug ("%s:%s Cache miss after blocking check %s.",
SRCNAME, __func__, fpr);
}
else
{
log_debug ("%s:%s Cache hit after wait for %s.",
SRCNAME, __func__, fpr);
return ret2;
}
}
log_debug ("%s:%s Cache miss for %s.",
SRCNAME, __func__, fpr);
return GpgME::Key();
}
log_debug ("%s:%s Cache hit for %s.",
SRCNAME, __func__, fpr);
return ret;
}
void update (const char *fpr, GpgME::Protocol proto)
{
if (!fpr)
{
return;
}
const std::string sFpr (fpr);
gpgrt_lock_lock (&update_lock);
if (m_update_jobs.find(sFpr) != m_update_jobs.end ())
{
log_debug ("%s:%s Update for \"%s\" already in progress.",
SRCNAME, __func__, fpr);
gpgrt_lock_unlock (&update_lock);
}
m_update_jobs.insert (sFpr);
gpgrt_lock_unlock (&update_lock);
update_arg_t * args = new update_arg_t;
args->first = sFpr;
args->second = proto;
CloseHandle (CreateThread (NULL, 0, do_update,
(LPVOID) args, 0,
NULL));
}
void onUpdateJobDone (const char *fpr, const GpgME::Key &key)
{
if (!fpr)
{
return;
}
TRACEPOINT;
insertOrUpdateInFprMap (key);
gpgrt_lock_lock (&update_lock);
const auto it = m_update_jobs.find(fpr);
if (it == m_update_jobs.end())
{
log_error ("%s:%s Update for \"%s\" already finished.",
SRCNAME, __func__, fpr);
gpgrt_lock_unlock (&update_lock);
return;
}
m_update_jobs.erase (it);
gpgrt_lock_unlock (&update_lock);
TRACEPOINT;
return;
}
std::unordered_map<std::string, GpgME::Key> m_pgp_key_map;
std::unordered_map<std::string, GpgME::Key> m_smime_key_map;
std::unordered_map<std::string, GpgME::Key> m_pgp_skey_map;
std::unordered_map<std::string, GpgME::Key> m_smime_skey_map;
std::unordered_map<std::string, GpgME::Key> m_fpr_map;
std::unordered_map<std::string, std::string> m_sub_fpr_map;
std::set<std::string> m_update_jobs;
};
KeyCache::KeyCache():
d(new Private)
{
}
KeyCache *
KeyCache::instance ()
{
if (!singleton)
{
singleton = new KeyCache();
}
return singleton;
}
GpgME::Key
KeyCache::getSigningKey (const char *addr, GpgME::Protocol proto) const
{
return d->getSigningKey (addr, proto);
}
std::vector<GpgME::Key>
KeyCache::getEncryptionKeys (const std::vector<std::string> &recipients, GpgME::Protocol proto) const
{
return d->getEncryptionKeys (recipients, proto);
}
static DWORD WINAPI
do_locate (LPVOID arg)
{
if (!arg)
{
return 0;
}
auto args = std::unique_ptr<LocateArgs> ((LocateArgs *) arg);
const auto addr = args->m_mbox;
log_mime_parser ("%s:%s searching key for addr: \"%s\"",
SRCNAME, __func__, addr.c_str());
const auto k = GpgME::Key::locate (addr.c_str());
if (!k.isNull ())
{
log_mime_parser ("%s:%s found key for addr: \"%s\":%s",
SRCNAME, __func__, addr.c_str(),
k.primaryFingerprint());
KeyCache::instance ()->setPgpKey (addr, k);
}
if (opt.enable_smime)
{
auto ctx = std::unique_ptr<GpgME::Context> (
GpgME::Context::createForProtocol (GpgME::CMS));
if (!ctx)
{
TRACEPOINT;
return 0;
}
// We need to validate here to fetch CRL's
ctx->setKeyListMode (GpgME::KeyListMode::Local |
GpgME::KeyListMode::Validate |
GpgME::KeyListMode::Signatures);
GpgME::Error e = ctx->startKeyListing (addr.c_str());
if (e)
{
TRACEPOINT;
return 0;
}
std::vector<GpgME::Key> keys;
GpgME::Error err;
do {
keys.push_back(ctx->nextKey(err));
} while (!err);
keys.pop_back();
GpgME::Key candidate;
for (const auto &key: keys)
{
if (key.isRevoked() || key.isExpired() ||
key.isDisabled() || key.isInvalid())
{
log_mime_parser ("%s:%s: Skipping invalid S/MIME key",
SRCNAME, __func__);
continue;
}
if (candidate.isNull() || !candidate.numUserIDs())
{
if (key.numUserIDs() &&
candidate.userID(0).validity() <= key.userID(0).validity())
{
candidate = key;
}
}
}
if (!candidate.isNull())
{
log_mime_parser ("%s:%s found SMIME key for addr: \"%s\":%s",
SRCNAME, __func__, addr.c_str(),
candidate.primaryFingerprint());
KeyCache::instance()->setSmimeKey (addr, candidate);
}
}
log_debug ("%s:%s locator thread done",
SRCNAME, __func__);
return 0;
}
static void
locate_secret (const char *addr, GpgME::Protocol proto)
{
auto ctx = std::unique_ptr<GpgME::Context> (
GpgME::Context::createForProtocol (proto));
if (!ctx)
{
TRACEPOINT;
return;
}
if (!addr)
{
TRACEPOINT;
return;
}
const auto mbox = GpgME::UserID::addrSpecFromString (addr);
if (mbox.empty())
{
log_debug ("%s:%s: Empty mbox for addr %s",
SRCNAME, __func__, addr);
return;
}
// We need to validate here to fetch CRL's
ctx->setKeyListMode (GpgME::KeyListMode::Local |
GpgME::KeyListMode::Validate);
GpgME::Error e = ctx->startKeyListing (mbox.c_str(), true);
if (e)
{
TRACEPOINT;
return;
}
std::vector<GpgME::Key> keys;
GpgME::Error err;
do
{
const auto key = ctx->nextKey(err);
if (key.isNull())
{
continue;
}
if (key.isRevoked() || key.isExpired() ||
key.isDisabled() || key.isInvalid())
{
if ((opt.enable_debug & DBG_MIME_PARSER))
{
std::stringstream ss;
ss << key;
log_mime_parser ("%s:%s: Skipping invalid secret key %s",
SRCNAME, __func__, ss.str().c_str());
}
continue;
}
if (proto == GpgME::OpenPGP)
{
log_mime_parser ("%s:%s found pgp skey for addr: \"%s\":%s",
SRCNAME, __func__, mbox.c_str(),
key.primaryFingerprint());
KeyCache::instance()->setPgpKeySecret (mbox, key);
return;
}
if (proto == GpgME::CMS)
{
log_mime_parser ("%s:%s found cms skey for addr: \"%s\":%s",
SRCNAME, __func__, mbox.c_str (),
key.primaryFingerprint());
KeyCache::instance()->setSmimeKeySecret (mbox, key);
return;
}
} while (!err);
return;
}
static DWORD WINAPI
do_locate_secret (LPVOID arg)
{
auto args = std::unique_ptr<LocateArgs> ((LocateArgs *) arg);
log_mime_parser ("%s:%s searching secret key for addr: \"%s\"",
SRCNAME, __func__, args->m_mbox.c_str ());
locate_secret (args->m_mbox.c_str(), GpgME::OpenPGP);
if (opt.enable_smime)
{
locate_secret (args->m_mbox.c_str(), GpgME::CMS);
}
log_debug ("%s:%s locator sthread thread done",
SRCNAME, __func__);
return 0;
}
void
KeyCache::startLocate (const std::vector<std::string> &addrs, Mail *mail) const
{
for (const auto &addr: addrs)
{
startLocate (addr.c_str(), mail);
}
}
void
KeyCache::startLocate (const char *addr, Mail *mail) const
{
if (!addr)
{
TRACEPOINT;
return;
}
std::string recp = GpgME::UserID::addrSpecFromString (addr);
if (recp.empty ())
{
return;
}
gpgrt_lock_lock (&keycache_lock);
if (d->m_pgp_key_map.find (recp) == d->m_pgp_key_map.end ())
{
// It's enough to look at the PGP Key map. We marked
// searched keys there.
d->m_pgp_key_map.insert (std::pair<std::string, GpgME::Key> (recp, GpgME::Key()));
log_debug ("%s:%s Creating a locator thread",
SRCNAME, __func__);
const auto args = new LocateArgs(recp, mail);
HANDLE thread = CreateThread (NULL, 0, do_locate,
args, 0,
NULL);
CloseHandle (thread);
}
gpgrt_lock_unlock (&keycache_lock);
}
void
KeyCache::startLocateSecret (const char *addr, Mail *mail) const
{
if (!addr)
{
TRACEPOINT;
return;
}
std::string recp = GpgME::UserID::addrSpecFromString (addr);
if (recp.empty ())
{
return;
}
gpgrt_lock_lock (&keycache_lock);
if (d->m_pgp_skey_map.find (recp) == d->m_pgp_skey_map.end ())
{
// It's enough to look at the PGP Key map. We marked
// searched keys there.
d->m_pgp_skey_map.insert (std::pair<std::string, GpgME::Key> (recp, GpgME::Key()));
log_debug ("%s:%s Creating a locator thread",
SRCNAME, __func__);
const auto args = new LocateArgs(recp, mail);
HANDLE thread = CreateThread (NULL, 0, do_locate_secret,
(LPVOID) args, 0,
NULL);
CloseHandle (thread);
}
gpgrt_lock_unlock (&keycache_lock);
}
void
KeyCache::setSmimeKey(const std::string &mbox, const GpgME::Key &key)
{
d->setSmimeKey(mbox, key);
}
void
KeyCache::setPgpKey(const std::string &mbox, const GpgME::Key &key)
{
d->setPgpKey(mbox, key);
}
void
KeyCache::setSmimeKeySecret(const std::string &mbox, const GpgME::Key &key)
{
d->setSmimeKeySecret(mbox, key);
}
void
KeyCache::setPgpKeySecret(const std::string &mbox, const GpgME::Key &key)
{
d->setPgpKeySecret(mbox, key);
}
bool
KeyCache::isMailResolvable(Mail *mail)
{
/* Get the data from the mail. */
const auto sender = mail->getSender ();
auto recps = mail->getCachedRecipients ();
if (sender.empty() || recps.empty())
{
log_debug ("%s:%s: Mail has no sender or no recipients.",
SRCNAME, __func__);
return false;
}
std::vector<GpgME::Key> encKeys = getEncryptionKeys (recps,
GpgME::OpenPGP);
if (!encKeys.empty())
{
return true;
}
if (!opt.enable_smime)
{
return false;
}
/* Check S/MIME instead here we need to include the sender
as we can't just generate a key. */
recps.push_back (sender);
GpgME::Key sigKey= getSigningKey (sender.c_str(), GpgME::CMS);
encKeys = getEncryptionKeys (recps, GpgME::CMS);
return !encKeys.empty() && !sigKey.isNull();
}
void
KeyCache::update (const char *fpr, GpgME::Protocol proto)
{
d->update (fpr, proto);
}
GpgME::Key
KeyCache::getByFpr (const char *fpr, bool block) const
{
return d->getByFpr (fpr, block);
}
void
KeyCache::onUpdateJobDone (const char *fpr, const GpgME::Key &key)
{
return d->onUpdateJobDone (fpr, key);
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Dec 23, 5:08 PM (10 h, 18 m)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
91/92/881370ceacc9c3de828132e562cf

Event Timeline