diff --git a/assignment-client/src/avatars/AvatarMixer.cpp b/assignment-client/src/avatars/AvatarMixer.cpp index 9816cebf43..3e93981ed3 100644 --- a/assignment-client/src/avatars/AvatarMixer.cpp +++ b/assignment-client/src/avatars/AvatarMixer.cpp @@ -82,6 +82,7 @@ AvatarMixer::AvatarMixer(ReceivedMessage& message) : packetReceiver.registerListener(PacketType::BulkAvatarTraitsAck, this, "queueIncomingPacket"); packetReceiver.registerListenerForTypes({ PacketType::OctreeStats, PacketType::EntityData, PacketType::EntityErase }, this, "handleOctreePacket"); + packetReceiver.registerListener(PacketType::ChallengeOwnership, this, "handleChallengeOwnership"); packetReceiver.registerListenerForTypes({ PacketType::ReplicatedAvatarIdentity, @@ -367,10 +368,13 @@ void AvatarMixer::manageIdentityData(const SharedNodePointer& node) { return; } - bool sendIdentity = false; - if (nodeData && nodeData->getAvatarSessionDisplayNameMustChange()) { - AvatarData& avatar = nodeData->getAvatar(); - const QString& existingBaseDisplayName = nodeData->getAvatar().getSessionDisplayName(); + MixerAvatar& avatar = nodeData->getAvatar(); + bool sendIdentity = avatar.needsIdentityUpdate(); + if (sendIdentity) { + nodeData->flagIdentityChange(); + } + if (nodeData->getAvatarSessionDisplayNameMustChange()) { + const QString& existingBaseDisplayName = avatar.getSessionDisplayName(); if (!existingBaseDisplayName.isEmpty()) { SessionDisplayName existingDisplayName { existingBaseDisplayName }; @@ -414,10 +418,11 @@ void AvatarMixer::manageIdentityData(const SharedNodePointer& node) { sendIdentityPacket(nodeData, node); // Tell node whose name changed about its new session display name or avatar. // since this packet includes a change to either the skeleton model URL or the display name // it needs a new sequence number - nodeData->getAvatar().pushIdentitySequenceNumber(); + avatar.pushIdentitySequenceNumber(); // tell node whose name changed about its new session display name or avatar. sendIdentityPacket(nodeData, node); + avatar.setNeedsIdentityUpdate(false); } } @@ -1123,6 +1128,16 @@ void AvatarMixer::entityChange() { _dirtyHeroStatus = true; } +void AvatarMixer::handleChallengeOwnership(QSharedPointer message, SharedNodePointer senderNode) { + if (senderNode->getType() == NodeType::Agent && senderNode->getLinkedData()) { + auto clientData = static_cast(senderNode->getLinkedData()); + auto avatar = clientData->getAvatarSharedPointer(); + if (avatar) { + avatar->handleChallengeResponse(message.data()); + } + } +} + void AvatarMixer::aboutToFinish() { DependencyManager::destroy(); DependencyManager::destroy(); diff --git a/assignment-client/src/avatars/AvatarMixer.h b/assignment-client/src/avatars/AvatarMixer.h index 10dff5e8a4..93dc755f51 100644 --- a/assignment-client/src/avatars/AvatarMixer.h +++ b/assignment-client/src/avatars/AvatarMixer.h @@ -65,6 +65,7 @@ private slots: void domainSettingsRequestComplete(); void handlePacketVersionMismatch(PacketType type, const HifiSockAddr& senderSockAddr, const QUuid& senderUUID); void handleOctreePacket(QSharedPointer message, SharedNodePointer senderNode); + void handleChallengeOwnership(QSharedPointer message, SharedNodePointer senderNode); void start(); private: diff --git a/assignment-client/src/avatars/AvatarMixerClientData.cpp b/assignment-client/src/avatars/AvatarMixerClientData.cpp index 4be9f4f46f..cdd639ed80 100644 --- a/assignment-client/src/avatars/AvatarMixerClientData.cpp +++ b/assignment-client/src/avatars/AvatarMixerClientData.cpp @@ -81,6 +81,10 @@ int AvatarMixerClientData::processPackets(const SlaveSharedData& slaveSharedData } assert(_packetQueue.empty()); + if (_avatar) { + _avatar->processCertifyEvents(); + } + return packetsProcessed; } @@ -200,6 +204,7 @@ void AvatarMixerClientData::processSetTraitsMessage(ReceivedMessage& message, if (traitType == AvatarTraits::SkeletonModelURL) { // special handling for skeleton model URL, since we need to make sure it is in the whitelist checkSkeletonURLAgainstWhitelist(slaveSharedData, sendingNode, packetTraitVersion); + _avatar->fetchAvatarFST(); } anyTraitsChanged = true; diff --git a/assignment-client/src/avatars/AvatarMixerSlave.cpp b/assignment-client/src/avatars/AvatarMixerSlave.cpp index 32c944f5b8..64f4aa6821 100644 --- a/assignment-client/src/avatars/AvatarMixerSlave.cpp +++ b/assignment-client/src/avatars/AvatarMixerSlave.cpp @@ -157,6 +157,11 @@ qint64 AvatarMixerSlave::addChangedTraitsToBulkPacket(AvatarMixerClientData* lis ++simpleReceivedIt; } + if (bytesWritten > 0 && sendingAvatar->isCertifyFailed()) { + // Resend identity packet if certification failed: + sendingAvatar->setNeedsIdentityUpdate(); + } + // enumerate the received instanced trait versions auto instancedReceivedIt = lastReceivedVersions.instancedCBegin(); while (instancedReceivedIt != lastReceivedVersions.instancedCEnd()) { diff --git a/assignment-client/src/avatars/MixerAvatar.cpp b/assignment-client/src/avatars/MixerAvatar.cpp new file mode 100644 index 0000000000..8f5c60a7d9 --- /dev/null +++ b/assignment-client/src/avatars/MixerAvatar.cpp @@ -0,0 +1,345 @@ +// +// MixerAvatar.cpp +// assignment-client/src/avatars +// +// Created by Simon Walton April 2019 +// Copyright 2019 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include "MixerAvatar.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include "ClientTraitsHandler.h" +#include "AvatarLogging.h" + +void MixerAvatar::fetchAvatarFST() { + _verifyState = nonCertified; + + _pendingEvent = false; + + QUrl avatarURL = getSkeletonModelURL(); + if (avatarURL.isEmpty() || avatarURL.isLocalFile() || avatarURL.scheme() == "qrc") { + // Not network FST. + return; + } + _certificateIdFromURL.clear(); + _certificateIdFromFST.clear(); + _marketplaceIdFromURL.clear(); + _marketplaceIdFromFST.clear(); + auto resourceManager = DependencyManager::get(); + + // Match UUID + (optionally) URL cert + static const QRegularExpression marketIdRegex{ + "^https://.*?highfidelity\\.com/api/.*?/commerce/entity_edition/([-0-9a-z]{36})(.*?certificate_id=([\\w/+%]+)|.*).*$" + }; + auto marketIdMatch = marketIdRegex.match(avatarURL.toDisplayString()); + if (marketIdMatch.hasMatch()) { + QMutexLocker certifyLocker(&_avatarCertifyLock); + _marketplaceIdFromURL = marketIdMatch.captured(1); + if (marketIdMatch.lastCapturedIndex() == 3) { + _certificateIdFromURL = QUrl::fromPercentEncoding(marketIdMatch.captured(3).toUtf8()); + } + } + + ResourceRequest* fstRequest = resourceManager->createResourceRequest(this, avatarURL); + if (fstRequest) { + QMutexLocker certifyLocker(&_avatarCertifyLock); + + _avatarRequest = fstRequest; + _verifyState = requestingFST; + connect(fstRequest, &ResourceRequest::finished, this, &MixerAvatar::fstRequestComplete); + fstRequest->send(); + } else { + qCDebug(avatars) << "Couldn't create FST request for" << avatarURL; + _verifyState = error; + } + _needsIdentityUpdate = true; +} + +void MixerAvatar::fstRequestComplete() { + ResourceRequest* fstRequest = static_cast(QObject::sender()); + QMutexLocker certifyLocker(&_avatarCertifyLock); + if (fstRequest == _avatarRequest) { + auto result = fstRequest->getResult(); + if (result != ResourceRequest::Success) { + _verifyState = error; + qCDebug(avatars) << "FST request for" << fstRequest->getUrl() << "failed:" << result; + } else { + _avatarFSTContents = fstRequest->getData(); + _verifyState = receivedFST; + _pendingEvent = true; + } + _avatarRequest->deleteLater(); + _avatarRequest = nullptr; + } else { + qCDebug(avatars) << "Incorrect request for" << getDisplayName(); + } +} + +bool MixerAvatar::generateFSTHash() { + if (_avatarFSTContents.length() == 0) { + return false; + } + QByteArray hashJson = canonicalJson(_avatarFSTContents); + QCryptographicHash fstHash(QCryptographicHash::Sha256); + fstHash.addData(hashJson); + _certificateHash = fstHash.result(); + return true; +} + +bool MixerAvatar::validateFSTHash(const QString& publicKey) { + // Guess we should refactor this stuff into a Authorization namespace ... + return EntityItemProperties::verifySignature(publicKey, _certificateHash, + QByteArray::fromBase64(_certificateIdFromFST.toUtf8())); +} + +QByteArray MixerAvatar::canonicalJson(const QString fstFile) { + QStringList fstLines = fstFile.split("\n", QString::SkipEmptyParts); + static const QString fstKeywordsReg { + "(marketplaceID|itemDescription|itemCategories|itemArtist|itemLicenseUrl|limitedRun|itemName|" + "filename|texdir|script|editionNumber|certificateID)" + }; + QRegularExpression fstLineRegExp { QString("^\\s*") + fstKeywordsReg + "\\s*=\\s*(\\S.*)$" }; + QStringListIterator fstLineIter(fstLines); + + QJsonObject certifiedItems; + QStringList scripts; + while (fstLineIter.hasNext()) { + auto line = fstLineIter.next(); + auto lineMatch = fstLineRegExp.match(line); + if (lineMatch.hasMatch()) { + QString key = lineMatch.captured(1); + if (key == "certificateID") { + _certificateIdFromFST = lineMatch.captured(2); + } else if (key == "itemDescription") { + // Item description can be multiline - intermediate lines end in + QString itemDesc = lineMatch.captured(2); + while (itemDesc.endsWith('\r') && fstLineIter.hasNext()) { + itemDesc += '\n' + fstLineIter.next(); + } + certifiedItems[key] = QJsonValue(itemDesc); + } else if (key == "limitedRun" || key == "editionNumber") { + double value = lineMatch.captured(2).toDouble(); + if (value != 0.0) { + certifiedItems[key] = QJsonValue(value); + } + } else if (key == "script") { + scripts.append(lineMatch.captured(2).trimmed()); + } else { + certifiedItems[key] = QJsonValue(lineMatch.captured(2)); + if (key == "marketplaceID") { + _marketplaceIdFromFST = lineMatch.captured(2); + } + } + } + } + if (!scripts.empty()) { + scripts.sort(); + certifiedItems["script"] = QJsonArray::fromStringList(scripts); + } + + QJsonDocument jsonDocCertifiedItems(certifiedItems); + //Example working form: + //return R"({"editionNumber":34,"filename":"http://mpassets.highfidelity.com/7f142fde-541a-4902-b33a-25fa89dfba21-v1/Bridger/Hifi_Toon_Male_3.fbx","itemArtist":"EgyMax", + //"itemCategories":"Avatars","itemDescription":"This is my first avatar. I hope you like it. More will come","itemName":"Bridger","limitedRun":-1, + //"marketplaceID":"7f142fde-541a-4902-b33a-25fa89dfba21","texdir":"http://mpassets.highfidelity.com/7f142fde-541a-4902-b33a-25fa89dfba21-v1/Bridger/textures"})"; + return jsonDocCertifiedItems.toJson(QJsonDocument::Compact); +} + +void MixerAvatar::ownerRequestComplete() { + QMutexLocker certifyLocker(&_avatarCertifyLock); + QNetworkReply* networkReply = static_cast(QObject::sender()); + + if (networkReply->error() == QNetworkReply::NoError) { + _dynamicMarketResponse = networkReply->readAll(); + _verifyState = ownerResponse; + _pendingEvent = true; + } else { + auto jsonData = QJsonDocument::fromJson(networkReply->readAll())["data"]; + if (!jsonData.isUndefined() && !jsonData.toObject()["message"].isUndefined()) { + qCDebug(avatars) << "Owner lookup failed for" << getDisplayName() << ":" + << jsonData.toObject()["message"].toString(); + _verifyState = error; + _pendingEvent = false; + } + } + networkReply->deleteLater(); +} + +void MixerAvatar::processCertifyEvents() { + if (!_pendingEvent) { + return; + } + + QMutexLocker certifyLocker(&_avatarCertifyLock); + switch (_verifyState) { + + case receivedFST: + { + generateFSTHash(); + if (_certificateIdFromFST.length() != 0) { + QString& marketplacePublicKey = EntityItem::_marketplacePublicKey; + bool staticVerification = validateFSTHash(marketplacePublicKey); + _verifyState = staticVerification ? staticValidation : verificationFailed; + + if (_verifyState == staticValidation) { + static const QString POP_MARKETPLACE_API { "/api/v1/commerce/proof_of_purchase_status/transfer" }; + auto& networkAccessManager = NetworkAccessManager::getInstance(); + QNetworkRequest networkRequest; + networkRequest.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); + networkRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + QUrl requestURL = NetworkingConstants::METAVERSE_SERVER_URL(); + requestURL.setPath(POP_MARKETPLACE_API); + networkRequest.setUrl(requestURL); + + QJsonObject request; + request["certificate_id"] = _certificateIdFromFST; + _verifyState = requestingOwner; + QNetworkReply* networkReply = networkAccessManager.put(networkRequest, QJsonDocument(request).toJson()); + connect(networkReply, &QNetworkReply::finished, this, &MixerAvatar::ownerRequestComplete); + } else { + _needsIdentityUpdate = true; + _pendingEvent = false; + qCDebug(avatars) << "Avatar" << getDisplayName() << "FAILED static certification"; + } + } else { // FST doesn't have a certificate, so noncertified rather than failed: + _pendingEvent = false; + _verifyState = nonCertified; + } + break; + } + + case ownerResponse: + { + QJsonDocument responseJson = QJsonDocument::fromJson(_dynamicMarketResponse.toUtf8()); + QString ownerPublicKey; + bool ownerValid = false; + if (responseJson["status"].toString() == "success") { + QJsonValue jsonData = responseJson["data"]; + if (jsonData.isObject()) { + auto ownerJson = jsonData["transfer_recipient_key"]; + if (ownerJson.isString()) { + ownerPublicKey = ownerJson.toString(); + } + auto transferStatusJson = jsonData["transfer_status"]; + if (transferStatusJson.isArray() && transferStatusJson.toArray()[0].toString() == "confirmed") { + ownerValid = true; + } + } + if (ownerValid && !ownerPublicKey.isEmpty()) { + if (ownerPublicKey.startsWith("-----BEGIN ")){ + _ownerPublicKey = ownerPublicKey; + } else { + _ownerPublicKey = "-----BEGIN PUBLIC KEY-----\n" + + ownerPublicKey + + "\n-----END PUBLIC KEY-----\n"; + } + sendOwnerChallenge(); + _verifyState = challengeClient; + } else { + _verifyState = error; + } + } else { + qCDebug(avatars) << "Get owner status failed for " << getDisplayName() << _marketplaceIdFromURL << + "message:" << responseJson["message"].toString(); + _verifyState = error; + } + _pendingEvent = false; + break; + } + + case challengeResponse: + { + if (_challengeResponse.length() < 8) { + _verifyState = error; + _pendingEvent = false; + break; + } + + int avatarIDLength; + int signedNonceLength; + { + QDataStream responseStream(_challengeResponse); + responseStream.setByteOrder(QDataStream::LittleEndian); + responseStream >> avatarIDLength >> signedNonceLength; + } + QByteArray avatarID(_challengeResponse.data() + 2 * sizeof(int), avatarIDLength); + QByteArray signedNonce(_challengeResponse.data() + 2 * sizeof(int) + avatarIDLength, signedNonceLength); + + bool challengeResult = EntityItemProperties::verifySignature(_ownerPublicKey, _challengeNonceHash, + QByteArray::fromBase64(signedNonce)); + _verifyState = challengeResult ? verificationSucceeded : verificationFailed; + _needsIdentityUpdate = true; + if (_verifyState == verificationFailed) { + qCDebug(avatars) << "Dynamic verification FAILED for " << getDisplayName() << getSessionUUID(); + } else { + qCDebug(avatars) << "Dynamic verification SUCCEEDED for " << getDisplayName() << getSessionUUID(); + } + _pendingEvent = false; + break; + } + + case requestingOwner: + { // Qt networking done on this thread: + QCoreApplication::processEvents(); + break; + } + + default: + qCDebug(avatars) << "Unexpected verify state" << _verifyState; + break; + + } // close switch +} + +void MixerAvatar::sendOwnerChallenge() { + auto nodeList = DependencyManager::get(); + QByteArray avatarID = ("{" + _marketplaceIdFromFST + "}").toUtf8(); + QByteArray nonce = QUuid::createUuid().toByteArray(); + + auto challengeOwnershipPacket = NLPacket::create(PacketType::ChallengeOwnership, + 2 * sizeof(int) + nonce.length() + avatarID.length(), true); + challengeOwnershipPacket->writePrimitive(avatarID.length()); + challengeOwnershipPacket->writePrimitive(nonce.length()); + challengeOwnershipPacket->write(avatarID); + challengeOwnershipPacket->write(nonce); + + nodeList->sendPacket(std::move(challengeOwnershipPacket), *(nodeList->nodeWithUUID(getSessionUUID())) ); + QCryptographicHash nonceHash(QCryptographicHash::Sha256); + nonceHash.addData(nonce); + _challengeNonceHash = nonceHash.result(); + + static constexpr int CHALLENGE_TIMEOUT_MS = 10 * 1000; // 10 s + _challengeTimeout.setInterval(CHALLENGE_TIMEOUT_MS); + _challengeTimeout.connect(&_challengeTimeout, &QTimer::timeout, [this]() { + _verifyState = verificationFailed; + _needsIdentityUpdate = true; + }); +} + +void MixerAvatar::handleChallengeResponse(ReceivedMessage* response) { + QByteArray avatarID; + QByteArray encryptedNonce; + QMutexLocker certifyLocker(&_avatarCertifyLock); + if (_verifyState == challengeClient) { + _challengeTimeout.stop(); + _challengeResponse = response->readAll(); + _verifyState = challengeResponse; + _pendingEvent = true; + } +} diff --git a/assignment-client/src/avatars/MixerAvatar.h b/assignment-client/src/avatars/MixerAvatar.h index 01e5e91b44..bafc398a02 100644 --- a/assignment-client/src/avatars/MixerAvatar.h +++ b/assignment-client/src/avatars/MixerAvatar.h @@ -17,14 +17,55 @@ #include +class ResourceRequest; + class MixerAvatar : public AvatarData { public: bool getNeedsHeroCheck() const { return _needsHeroCheck; } - void setNeedsHeroCheck(bool needsHeroCheck = true) - { _needsHeroCheck = needsHeroCheck; } + void setNeedsHeroCheck(bool needsHeroCheck = true) { _needsHeroCheck = needsHeroCheck; } + + void fetchAvatarFST(); + virtual bool isCertifyFailed() const override { return _verifyState == verificationFailed; } + bool needsIdentityUpdate() const { return _needsIdentityUpdate; } + void setNeedsIdentityUpdate(bool value = true) { _needsIdentityUpdate = value; } + + void processCertifyEvents(); + void handleChallengeResponse(ReceivedMessage* response); private: bool _needsHeroCheck { false }; + + // Avatar certification/verification: + enum VerifyState { nonCertified, requestingFST, receivedFST, staticValidation, requestingOwner, ownerResponse, + challengeClient, challengeResponse, verified, verificationFailed, verificationSucceeded, error }; + Q_ENUM(VerifyState); + VerifyState _verifyState { nonCertified }; + std::atomic _pendingEvent { false }; + QMutex _avatarCertifyLock; + ResourceRequest* _avatarRequest { nullptr }; + QString _marketplaceIdFromURL; + QString _marketplaceIdFromFST; + QByteArray _avatarFSTContents; + QByteArray _certificateHash; + QString _certificateIdFromURL; + QString _certificateIdFromFST; + QString _dynamicMarketResponse; + QString _ownerPublicKey; + QByteArray _challengeNonceHash; + QByteArray _challengeResponse; + QTimer _challengeTimeout; + bool _needsIdentityUpdate { false }; + + bool generateFSTHash(); + bool validateFSTHash(const QString& publicKey); + QByteArray canonicalJson(const QString fstFile); + void sendOwnerChallenge(); + + static const QString VERIFY_FAIL_MODEL; + +private slots: + void fstRequestComplete(); + void ownerRequestComplete(); }; using MixerAvatarSharedPointer = std::shared_ptr; diff --git a/interface/resources/images/AvatarTheftBanner.png b/interface/resources/images/AvatarTheftBanner.png new file mode 100644 index 0000000000..3dc76999e0 Binary files /dev/null and b/interface/resources/images/AvatarTheftBanner.png differ diff --git a/interface/resources/meshes/mannequin/man_stolen.fbx b/interface/resources/meshes/mannequin/man_stolen.fbx new file mode 100644 index 0000000000..739c0efe91 Binary files /dev/null and b/interface/resources/meshes/mannequin/man_stolen.fbx differ diff --git a/interface/resources/meshes/verifyFailed.fst b/interface/resources/meshes/verifyFailed.fst new file mode 100644 index 0000000000..97941bf836 --- /dev/null +++ b/interface/resources/meshes/verifyFailed.fst @@ -0,0 +1,86 @@ +name = mannequin2 +type = body+head +scale = 1 +filename = mannequin/man_stolen.fbx +texdir = textures +joint = jointEyeLeft = LeftEye +joint = jointRightHand = RightHand +joint = jointHead = Head +joint = jointEyeRight = RightEye +joint = jointNeck = Neck +joint = jointLeftHand = LeftHand +joint = jointLean = Spine +joint = jointRoot = Hips +freeJoint = LeftArm +freeJoint = LeftForeArm +freeJoint = RightArm +freeJoint = RightForeArm +jointIndex = RightHandPinky2 = 19 +jointIndex = LeftHandPinky3 = 44 +jointIndex = RightToeBase = 9 +jointIndex = LeftHandRing4 = 49 +jointIndex = LeftHandPinky1 = 42 +jointIndex = LeftHandRing1 = 46 +jointIndex = LeftLeg = 2 +jointIndex = RightHandIndex4 = 29 +jointIndex = LeftHandRing3 = 48 +jointIndex = RightShoulder = 14 +jointIndex = RightArm = 15 +jointIndex = Neck = 62 +jointIndex = RightHandMiddle2 = 35 +jointIndex = HeadTop_End = 66 +jointIndex = LeftHandRing2 = 47 +jointIndex = RightHandThumb1 = 30 +jointIndex = RightHandRing3 = 24 +jointIndex = LeftHandIndex3 = 52 +jointIndex = LeftForeArm = 40 +jointIndex = face = 68 +jointIndex = LeftToe_End = 5 +jointIndex = RightHandThumb3 = 32 +jointIndex = RightEye = 65 +jointIndex = Spine = 11 +jointIndex = LeftEye = 64 +jointIndex = LeftToeBase = 4 +jointIndex = LeftHandIndex4 = 53 +jointIndex = RightHandPinky4 = 21 +jointIndex = RightHandMiddle1 = 34 +jointIndex = Spine1 = 12 +jointIndex = LeftHandIndex2 = 51 +jointIndex = RightToe_End = 10 +jointIndex = RightHand = 17 +jointIndex = LeftUpLeg = 1 +jointIndex = RightHandRing1 = 22 +jointIndex = RightUpLeg = 6 +jointIndex = RightHandMiddle4 = 37 +jointIndex = Head = 63 +jointIndex = RightHandMiddle3 = 36 +jointIndex = RightHandIndex1 = 26 +jointIndex = LeftHandMiddle4 = 61 +jointIndex = LeftHandPinky4 = 45 +jointIndex = Hips = 0 +jointIndex = body = 67 +jointIndex = RightHandThumb2 = 31 +jointIndex = LeftHandThumb2 = 55 +jointIndex = RightHandThumb4 = 33 +jointIndex = RightHandPinky3 = 20 +jointIndex = LeftHandPinky2 = 43 +jointIndex = LeftShoulder = 38 +jointIndex = RightHandIndex3 = 28 +jointIndex = LeftHandThumb4 = 57 +jointIndex = RightLeg = 7 +jointIndex = RightHandIndex2 = 27 +jointIndex = LeftHandMiddle3 = 60 +jointIndex = RightHandRing4 = 25 +jointIndex = LeftHandThumb1 = 54 +jointIndex = LeftArm = 39 +jointIndex = LeftHandThumb3 = 56 +jointIndex = LeftHandMiddle1 = 58 +jointIndex = RightHandPinky1 = 18 +jointIndex = Spine2 = 13 +jointIndex = RightHandRing2 = 23 +jointIndex = RightForeArm = 16 +jointIndex = LeftHandIndex1 = 50 +jointIndex = RightFoot = 8 +jointIndex = LeftHandMiddle2 = 59 +jointIndex = LeftHand = 41 +jointIndex = LeftFoot = 3 diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index 4fce236e31..46b7017180 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -3390,6 +3390,7 @@ void Application::setupQmlSurface(QQmlContext* surfaceContext, bool setAdditiona surfaceContext->setContextProperty("KeyboardScriptingInterface", DependencyManager::get().data()); if (setAdditionalContextProperties) { + qDebug() << "setting additional context properties!"; auto tabletScriptingInterface = DependencyManager::get(); auto flags = tabletScriptingInterface->getFlags(); diff --git a/interface/src/avatar/AvatarManager.cpp b/interface/src/avatar/AvatarManager.cpp index 2025d3cabc..00e743312f 100755 --- a/interface/src/avatar/AvatarManager.cpp +++ b/interface/src/avatar/AvatarManager.cpp @@ -46,6 +46,7 @@ #include "MyAvatar.h" #include "DebugDraw.h" #include "SceneScriptingInterface.h" +#include "ui/AvatarCertifyBanner.h" // 50 times per second - target is 45hz, but this helps account for any small deviations // in the update loop - this also results in ~30hz when in desktop mode which is essentially @@ -178,6 +179,13 @@ void AvatarManager::updateMyAvatar(float deltaTime) { _lastSendAvatarDataTime = now; _myAvatarSendRate.increment(); } + + static AvatarCertifyBanner theftBanner; + if (_myAvatar->isCertifyFailed()) { + theftBanner.show(_myAvatar->getSessionUUID()); + } else { + theftBanner.clear(); + } } diff --git a/interface/src/ui/AvatarCertifyBanner.cpp b/interface/src/ui/AvatarCertifyBanner.cpp new file mode 100644 index 0000000000..3ffae43c5b --- /dev/null +++ b/interface/src/ui/AvatarCertifyBanner.cpp @@ -0,0 +1,76 @@ +// +// AvatarCertifyBanner.h +// interface/src/ui +// +// Created by Simon Walton, April 2019 +// Copyright 2019 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#include "AvatarCertifyBanner.h" + +#include + +#include "ui/TabletScriptingInterface.h" +#include "EntityTreeRenderer.h" + +namespace { + const QUrl AVATAR_THEFT_BANNER_IMAGE = PathUtils::resourcesUrl("images/AvatarTheftBanner.png"); + const QString AVATAR_THEFT_BANNER_SCRIPT { "/system/clickToAvatarApp.js" }; +} + +AvatarCertifyBanner::AvatarCertifyBanner(QQuickItem* parent) { +} + +void AvatarCertifyBanner::show(const QUuid& avatarID) { + if (!_active) { + auto entityTreeRenderer = DependencyManager::get(); + EntityTreePointer entityTree = entityTreeRenderer->getTree(); + if (!entityTree) { + return; + } + const bool tabletShown = DependencyManager::get()->property("tabletShown").toBool(); + const auto& position = tabletShown ? glm::vec3(0.0f, 0.0f, -1.8f) : glm::vec3(0.0f, 0.0f, -0.7f); + const float scaleFactor = tabletShown ? 2.6f : 1.0f; + + EntityItemProperties entityProperties; + entityProperties.setType(EntityTypes::Image); + entityProperties.setEntityHostType(entity::HostType::LOCAL); + entityProperties.setImageURL(AVATAR_THEFT_BANNER_IMAGE.toString()); + entityProperties.setName("hifi-avatar-notification-banner"); + entityProperties.setParentID(avatarID); + entityProperties.setParentJointIndex(CAMERA_MATRIX_INDEX); + entityProperties.setLocalPosition(position); + entityProperties.setDimensions(glm::vec3(1.0f, 1.0f, 0.3f) * scaleFactor); + entityProperties.setRenderLayer(tabletShown ? RenderLayer::WORLD : RenderLayer::FRONT); + entityProperties.getGrab().setGrabbable(false); + QString scriptPath = QUrl(PathUtils::defaultScriptsLocation("")).toString() + AVATAR_THEFT_BANNER_SCRIPT; + entityProperties.setScript(scriptPath); + entityProperties.setVisible(true); + + entityTree->withWriteLock([&] { + auto entityTreeItem = entityTree->addEntity(_bannerID, entityProperties); + entityTreeItem->setLocalPosition(position); + }); + + _active = true; + } +} + +void AvatarCertifyBanner::clear() { + if (_active) { + auto entityTreeRenderer = DependencyManager::get(); + EntityTreePointer entityTree = entityTreeRenderer->getTree(); + if (!entityTree) { + return; + } + + entityTree->withWriteLock([&] { + entityTree->deleteEntity(_bannerID); + }); + + _active = false; + } +} diff --git a/interface/src/ui/AvatarCertifyBanner.h b/interface/src/ui/AvatarCertifyBanner.h new file mode 100644 index 0000000000..c9bb23cb96 --- /dev/null +++ b/interface/src/ui/AvatarCertifyBanner.h @@ -0,0 +1,34 @@ +// +// AvatarCertifyBanner.h +// interface/src/ui +// +// Created by Simon Walton, April 2019 +// Copyright 2019 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +#ifndef hifi_AvatarCertifyBanner_h +#define hifi_AvatarCertifyBanner_h + +#include +#include "OffscreenQmlElement.h" +#include "EntityItemID.h" + +class EntityItemID; + +class AvatarCertifyBanner : QObject { + Q_OBJECT + HIFI_QML_DECL +public: + AvatarCertifyBanner(QQuickItem* parent = nullptr); + void show(const QUuid& avatarID); + void clear(); + +private: + const EntityItemID _bannerID { QUuid::createUuid() }; + bool _active { false }; +}; + +#endif // hifi_AvatarCertifyBanner_h diff --git a/libraries/avatars/src/AvatarData.cpp b/libraries/avatars/src/AvatarData.cpp index aea214efd7..942b13c237 100755 --- a/libraries/avatars/src/AvatarData.cpp +++ b/libraries/avatars/src/AvatarData.cpp @@ -1955,8 +1955,7 @@ void AvatarData::processAvatarIdentity(QDataStream& packetStream, bool& identity >> identity.attachmentData >> identity.displayName >> identity.sessionDisplayName - >> identity.isReplicated - >> identity.lookAtSnappingEnabled + >> identity.identityFlags ; if (incomingSequenceNumber > _identitySequenceNumber) { @@ -1971,8 +1970,22 @@ void AvatarData::processAvatarIdentity(QDataStream& packetStream, bool& identity } maybeUpdateSessionDisplayNameFromTransport(identity.sessionDisplayName); - if (identity.isReplicated != _isReplicated) { - _isReplicated = identity.isReplicated; + bool flagValue; + flagValue = identity.identityFlags.testFlag(AvatarDataPacket::IdentityFlag::isReplicated); + if ( flagValue != _isReplicated) { + _isReplicated = flagValue; + identityChanged = true; + } + + flagValue = identity.identityFlags.testFlag(AvatarDataPacket::IdentityFlag::lookAtSnapping); + if ( flagValue != _lookAtSnappingEnabled) { + setProperty("lookAtSnappingEnabled", flagValue); + identityChanged = true; + } + + flagValue = identity.identityFlags.testFlag(AvatarDataPacket::IdentityFlag::verificationFailed); + if (flagValue != _verificationFailed) { + _verificationFailed = flagValue; identityChanged = true; } @@ -1981,11 +1994,6 @@ void AvatarData::processAvatarIdentity(QDataStream& packetStream, bool& identity identityChanged = true; } - if (identity.lookAtSnappingEnabled != _lookAtSnappingEnabled) { - setProperty("lookAtSnappingEnabled", identity.lookAtSnappingEnabled); - identityChanged = true; - } - #ifdef WANT_DEBUG qCDebug(avatars) << __FUNCTION__ << "identity.uuid:" << identity.uuid @@ -2195,17 +2203,27 @@ void AvatarData::prepareResetTraitInstances() { QByteArray AvatarData::identityByteArray(bool setIsReplicated) const { QByteArray identityData; QDataStream identityStream(&identityData, QIODevice::Append); + using namespace AvatarDataPacket; // when mixers send identity packets to agents, they simply forward along the last incoming sequence number they received // whereas agents send a fresh outgoing sequence number when identity data has changed + IdentityFlags identityFlags = IdentityFlag::none; + if (_isReplicated || setIsReplicated) { + identityFlags.setFlag(IdentityFlag::isReplicated); + } + if (_lookAtSnappingEnabled) { + identityFlags.setFlag(IdentityFlag::lookAtSnapping); + } + if (isCertifyFailed()) { + identityFlags.setFlag(IdentityFlag::verificationFailed); + } identityStream << getSessionUUID() << (udt::SequenceNumber::Type) _identitySequenceNumber << _attachmentData << _displayName << getSessionDisplayNameForTransport() // depends on _sessionDisplayName - << (_isReplicated || setIsReplicated) - << _lookAtSnappingEnabled; + << identityFlags; return identityData; } diff --git a/libraries/avatars/src/AvatarData.h b/libraries/avatars/src/AvatarData.h index 76fa9e0a34..9219c2c03f 100755 --- a/libraries/avatars/src/AvatarData.h +++ b/libraries/avatars/src/AvatarData.h @@ -378,6 +378,10 @@ namespace AvatarDataPacket { static const size_t MIN_BULK_PACKET_SIZE = NUM_BYTES_RFC4122_UUID + HEADER_SIZE; + // AvatarIdentity packet: + enum class IdentityFlag: quint32 {none, isReplicated = 0x1, lookAtSnapping = 0x2, verificationFailed = 0x4}; + Q_DECLARE_FLAGS(IdentityFlags, IdentityFlag) + struct SendStatus { HasFlags itemFlags { 0 }; bool sendUUID { false }; @@ -1182,6 +1186,7 @@ public: QString sessionDisplayName; bool isReplicated; bool lookAtSnappingEnabled; + AvatarDataPacket::IdentityFlags identityFlags; }; // identityChanged returns true if identity has changed, false otherwise. @@ -1213,6 +1218,7 @@ public: _sessionDisplayName = sessionDisplayName; markIdentityDataChanged(); } + virtual bool isCertifyFailed() const { return _verificationFailed; } /**jsdoc * Gets information about the models currently attached to your avatar. @@ -1694,6 +1700,7 @@ protected: QString _displayName; QString _sessionDisplayName { }; bool _lookAtSnappingEnabled { true }; + bool _verificationFailed { false }; quint64 _errorLogExpiry; ///< time in future when to log an error diff --git a/libraries/avatars/src/AvatarHashMap.cpp b/libraries/avatars/src/AvatarHashMap.cpp index 29a40c5b6b..cb14d7ef41 100644 --- a/libraries/avatars/src/AvatarHashMap.cpp +++ b/libraries/avatars/src/AvatarHashMap.cpp @@ -23,6 +23,8 @@ #include "Profile.h" +static const QString VERIFY_FAIL_MODEL { "/meshes/verifyFailed.fst" }; + void AvatarReplicas::addReplica(const QUuid& parentID, AvatarSharedPointer replica) { if (parentID == QUuid()) { return; @@ -324,6 +326,10 @@ void AvatarHashMap::processAvatarIdentityPacket(QSharedPointer bool displayNameChanged = false; // In this case, the "sendingNode" is the Avatar Mixer. avatar->processAvatarIdentity(avatarIdentityStream, identityChanged, displayNameChanged); + if (avatar->isCertifyFailed() && identityUUID != EMPTY) { + qCDebug(avatars) << "Avatar" << avatar->getSessionDisplayName() << "marked as VERIFY-FAILED"; + avatar->setSkeletonModelURL(PathUtils::resourcesUrl(VERIFY_FAIL_MODEL)); + } _replicas.processAvatarIdentity(identityUUID, message->getMessage(), identityChanged, displayNameChanged); } } diff --git a/libraries/networking/src/udt/PacketHeaders.cpp b/libraries/networking/src/udt/PacketHeaders.cpp index 861774b145..f8574b3b94 100644 --- a/libraries/networking/src/udt/PacketHeaders.cpp +++ b/libraries/networking/src/udt/PacketHeaders.cpp @@ -38,10 +38,10 @@ PacketVersion versionForPacketType(PacketType packetType) { return static_cast(EntityQueryPacketVersion::ConicalFrustums); case PacketType::AvatarIdentity: case PacketType::AvatarData: - return static_cast(AvatarMixerPacketVersion::HandControllerSection); + return static_cast(AvatarMixerPacketVersion::SendVerificationFailed); case PacketType::BulkAvatarData: case PacketType::KillAvatar: - return static_cast(AvatarMixerPacketVersion::HandControllerSection); + return static_cast(AvatarMixerPacketVersion::SendVerificationFailed); case PacketType::MessagesData: return static_cast(MessageDataVersion::TextOrBinaryData); // ICE packets diff --git a/libraries/networking/src/udt/PacketHeaders.h b/libraries/networking/src/udt/PacketHeaders.h index 274c34a268..bf6024f96f 100644 --- a/libraries/networking/src/udt/PacketHeaders.h +++ b/libraries/networking/src/udt/PacketHeaders.h @@ -333,6 +333,7 @@ enum class AvatarMixerPacketVersion : PacketVersion { SendMaxTranslationDimension, FBXJointOrderChange, HandControllerSection, + SendVerificationFailed }; enum class DomainConnectRequestVersion : PacketVersion { diff --git a/scripts/system/clickToAvatarApp.js b/scripts/system/clickToAvatarApp.js new file mode 100644 index 0000000000..8024f595b5 --- /dev/null +++ b/scripts/system/clickToAvatarApp.js @@ -0,0 +1,7 @@ +(function () { + var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system"); + this.clickDownOnEntity = function (entityID, mouseEvent) { + tablet.loadQMLSource("hifi/AvatarApp.qml"); + }; +} +);