diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /services/crypto | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'services/crypto')
18 files changed, 3010 insertions, 0 deletions
diff --git a/services/crypto/component/IdentityCryptoService.cpp b/services/crypto/component/IdentityCryptoService.cpp new file mode 100644 index 0000000000..f2c59eb9d5 --- /dev/null +++ b/services/crypto/component/IdentityCryptoService.cpp @@ -0,0 +1,461 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsIIdentityCryptoService.h" +#include "nsServiceManagerUtils.h" +#include "nsIThread.h" +#include "nsThreadUtils.h" +#include "nsCOMPtr.h" +#include "nsProxyRelease.h" +#include "nsString.h" +#include "mozilla/ArrayUtils.h" // ArrayLength +#include "mozilla/Base64.h" +#include "mozilla/Components.h" +#include "ScopedNSSTypes.h" +#include "NSSErrorsService.h" + +#include "nss.h" +#include "pk11pub.h" +#include "secmod.h" +#include "secerr.h" +#include "keyhi.h" +#include "cryptohi.h" + +#include <limits.h> + +using namespace mozilla; + +namespace { + +void HexEncode(const SECItem* it, nsACString& result) { + static const char digits[] = "0123456789ABCDEF"; + result.SetLength(it->len * 2); + char* p = result.BeginWriting(); + for (unsigned int i = 0; i < it->len; ++i) { + *p++ = digits[it->data[i] >> 4]; + *p++ = digits[it->data[i] & 0x0f]; + } +} + +#define DSA_KEY_TYPE_STRING ("DS160"_ns) +#define RSA_KEY_TYPE_STRING ("RS256"_ns) + +class KeyPair : public nsIIdentityKeyPair { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIIDENTITYKEYPAIR + + KeyPair(SECKEYPrivateKey* aPrivateKey, SECKEYPublicKey* aPublicKey, + nsIEventTarget* aOperationThread); + + private: + virtual ~KeyPair() { + if (mPrivateKey) { + SECKEY_DestroyPrivateKey(mPrivateKey); + } + if (mPublicKey) { + SECKEY_DestroyPublicKey(mPublicKey); + } + } + + SECKEYPrivateKey* mPrivateKey; + SECKEYPublicKey* mPublicKey; + nsCOMPtr<nsIEventTarget> mThread; + + KeyPair(const KeyPair&) = delete; + void operator=(const KeyPair&) = delete; +}; + +NS_IMPL_ISUPPORTS(KeyPair, nsIIdentityKeyPair) + +class KeyGenRunnable : public Runnable { + public: + NS_DECL_NSIRUNNABLE + + KeyGenRunnable(KeyType keyType, nsIIdentityKeyGenCallback* aCallback, + nsIEventTarget* aOperationThread); + + private: + const KeyType mKeyType; // in + nsMainThreadPtrHandle<nsIIdentityKeyGenCallback> mCallback; // in + nsresult mRv; // out + nsCOMPtr<nsIIdentityKeyPair> mKeyPair; // out + nsCOMPtr<nsIEventTarget> mThread; + + KeyGenRunnable(const KeyGenRunnable&) = delete; + void operator=(const KeyGenRunnable&) = delete; +}; + +class SignRunnable : public Runnable { + public: + NS_DECL_NSIRUNNABLE + + SignRunnable(const nsACString& textToSign, SECKEYPrivateKey* privateKey, + nsIIdentitySignCallback* aCallback); + + private: + ~SignRunnable() override { + if (mPrivateKey) { + SECKEY_DestroyPrivateKey(mPrivateKey); + } + } + + const nsCString mTextToSign; // in + SECKEYPrivateKey* mPrivateKey; // in + nsMainThreadPtrHandle<nsIIdentitySignCallback> mCallback; // in + nsresult mRv; // out + nsCString mSignature; // out + + SignRunnable(const SignRunnable&) = delete; + void operator=(const SignRunnable&) = delete; +}; + +class IdentityCryptoService final : public nsIIdentityCryptoService { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIIDENTITYCRYPTOSERVICE + + IdentityCryptoService() = default; + nsresult Init() { + nsresult rv; + nsCOMPtr<nsISupports> dummyUsedToEnsureNSSIsInitialized = + do_GetService("@mozilla.org/psm;1", &rv); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIThread> thread; + rv = NS_NewNamedThread("IdentityCrypto", getter_AddRefs(thread)); + NS_ENSURE_SUCCESS(rv, rv); + + mThread = std::move(thread); + + return NS_OK; + } + + private: + ~IdentityCryptoService() = default; + IdentityCryptoService(const KeyPair&) = delete; + void operator=(const IdentityCryptoService&) = delete; + + nsCOMPtr<nsIEventTarget> mThread; +}; + +NS_IMPL_ISUPPORTS(IdentityCryptoService, nsIIdentityCryptoService) + +NS_IMETHODIMP +IdentityCryptoService::GenerateKeyPair(const nsACString& keyTypeString, + nsIIdentityKeyGenCallback* callback) { + KeyType keyType; + if (keyTypeString.Equals(RSA_KEY_TYPE_STRING)) { + keyType = rsaKey; + } else if (keyTypeString.Equals(DSA_KEY_TYPE_STRING)) { + keyType = dsaKey; + } else { + return NS_ERROR_UNEXPECTED; + } + + nsCOMPtr<nsIRunnable> r = new KeyGenRunnable(keyType, callback, mThread); + nsresult rv = mThread->Dispatch(r.forget(), NS_DISPATCH_NORMAL); + NS_ENSURE_SUCCESS(rv, rv); + + return NS_OK; +} + +NS_IMETHODIMP +IdentityCryptoService::Base64UrlEncode(const nsACString& utf8Input, + nsACString& result) { + return Base64URLEncode( + utf8Input.Length(), + reinterpret_cast<const uint8_t*>(utf8Input.BeginReading()), + Base64URLEncodePaddingPolicy::Include, result); +} + +KeyPair::KeyPair(SECKEYPrivateKey* privateKey, SECKEYPublicKey* publicKey, + nsIEventTarget* operationThread) + : mPrivateKey(privateKey), mPublicKey(publicKey), mThread(operationThread) { + MOZ_ASSERT(!NS_IsMainThread()); +} + +NS_IMETHODIMP +KeyPair::GetHexRSAPublicKeyExponent(nsACString& result) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == rsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.rsa.publicExponent, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexRSAPublicKeyModulus(nsACString& result) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == rsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.rsa.modulus, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexDSAPrime(nsACString& result) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == dsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.dsa.params.prime, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexDSASubPrime(nsACString& result) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == dsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.dsa.params.subPrime, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexDSAGenerator(nsACString& result) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == dsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.dsa.params.base, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetHexDSAPublicValue(nsACString& result) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + NS_ENSURE_TRUE(mPublicKey->keyType == dsaKey, NS_ERROR_NOT_AVAILABLE); + HexEncode(&mPublicKey->u.dsa.publicValue, result); + return NS_OK; +} + +NS_IMETHODIMP +KeyPair::GetKeyType(nsACString& result) { + MOZ_ASSERT(NS_IsMainThread()); + NS_ENSURE_TRUE(mPublicKey, NS_ERROR_NOT_AVAILABLE); + + switch (mPublicKey->keyType) { + case rsaKey: + result = RSA_KEY_TYPE_STRING; + return NS_OK; + case dsaKey: + result = DSA_KEY_TYPE_STRING; + return NS_OK; + default: + return NS_ERROR_UNEXPECTED; + } +} + +NS_IMETHODIMP +KeyPair::Sign(const nsACString& textToSign, nsIIdentitySignCallback* callback) { + MOZ_ASSERT(NS_IsMainThread()); + nsCOMPtr<nsIRunnable> r = new SignRunnable(textToSign, mPrivateKey, callback); + + return mThread->Dispatch(r, NS_DISPATCH_NORMAL); +} + +KeyGenRunnable::KeyGenRunnable(KeyType keyType, + nsIIdentityKeyGenCallback* callback, + nsIEventTarget* operationThread) + : mozilla::Runnable("KeyGenRunnable"), + mKeyType(keyType), + mCallback(new nsMainThreadPtrHolder<nsIIdentityKeyGenCallback>( + "KeyGenRunnable::mCallback", callback)), + mRv(NS_ERROR_NOT_INITIALIZED), + mThread(operationThread) {} + +[[nodiscard]] nsresult GenerateKeyPair(PK11SlotInfo* slot, + SECKEYPrivateKey** privateKey, + SECKEYPublicKey** publicKey, + CK_MECHANISM_TYPE mechanism, + void* params) { + *publicKey = nullptr; + *privateKey = PK11_GenerateKeyPair( + slot, mechanism, params, publicKey, PR_FALSE /*isPerm*/, + PR_TRUE /*isSensitive*/, nullptr /*&pwdata*/); + if (!*privateKey) { + MOZ_ASSERT(!*publicKey); + return mozilla::psm::GetXPCOMFromNSSError(PR_GetError()); + } + if (!*publicKey) { + SECKEY_DestroyPrivateKey(*privateKey); + *privateKey = nullptr; + MOZ_CRASH("PK11_GnerateKeyPair returned private key without public key"); + } + + return NS_OK; +} + +[[nodiscard]] nsresult GenerateRSAKeyPair(PK11SlotInfo* slot, + SECKEYPrivateKey** privateKey, + SECKEYPublicKey** publicKey) { + MOZ_ASSERT(!NS_IsMainThread()); + + PK11RSAGenParams rsaParams; + rsaParams.keySizeInBits = 2048; + rsaParams.pe = 0x10001; + return GenerateKeyPair(slot, privateKey, publicKey, CKM_RSA_PKCS_KEY_PAIR_GEN, + &rsaParams); +} + +[[nodiscard]] nsresult GenerateDSAKeyPair(PK11SlotInfo* slot, + SECKEYPrivateKey** privateKey, + SECKEYPublicKey** publicKey) { + MOZ_ASSERT(!NS_IsMainThread()); + + // XXX: These could probably be static const arrays, but this way we avoid + // compiler warnings and also we avoid having to worry much about whether the + // functions that take these inputs will (unexpectedly) modify them. + + // Using NIST parameters. Some other BrowserID components require that these + // exact parameters are used. + uint8_t P[] = { + 0xFF, 0x60, 0x04, 0x83, 0xDB, 0x6A, 0xBF, 0xC5, 0xB4, 0x5E, 0xAB, 0x78, + 0x59, 0x4B, 0x35, 0x33, 0xD5, 0x50, 0xD9, 0xF1, 0xBF, 0x2A, 0x99, 0x2A, + 0x7A, 0x8D, 0xAA, 0x6D, 0xC3, 0x4F, 0x80, 0x45, 0xAD, 0x4E, 0x6E, 0x0C, + 0x42, 0x9D, 0x33, 0x4E, 0xEE, 0xAA, 0xEF, 0xD7, 0xE2, 0x3D, 0x48, 0x10, + 0xBE, 0x00, 0xE4, 0xCC, 0x14, 0x92, 0xCB, 0xA3, 0x25, 0xBA, 0x81, 0xFF, + 0x2D, 0x5A, 0x5B, 0x30, 0x5A, 0x8D, 0x17, 0xEB, 0x3B, 0xF4, 0xA0, 0x6A, + 0x34, 0x9D, 0x39, 0x2E, 0x00, 0xD3, 0x29, 0x74, 0x4A, 0x51, 0x79, 0x38, + 0x03, 0x44, 0xE8, 0x2A, 0x18, 0xC4, 0x79, 0x33, 0x43, 0x8F, 0x89, 0x1E, + 0x22, 0xAE, 0xEF, 0x81, 0x2D, 0x69, 0xC8, 0xF7, 0x5E, 0x32, 0x6C, 0xB7, + 0x0E, 0xA0, 0x00, 0xC3, 0xF7, 0x76, 0xDF, 0xDB, 0xD6, 0x04, 0x63, 0x8C, + 0x2E, 0xF7, 0x17, 0xFC, 0x26, 0xD0, 0x2E, 0x17}; + + uint8_t Q[] = {0xE2, 0x1E, 0x04, 0xF9, 0x11, 0xD1, 0xED, 0x79, 0x91, 0x00, + 0x8E, 0xCA, 0xAB, 0x3B, 0xF7, 0x75, 0x98, 0x43, 0x09, 0xC3}; + + uint8_t G[] = { + 0xC5, 0x2A, 0x4A, 0x0F, 0xF3, 0xB7, 0xE6, 0x1F, 0xDF, 0x18, 0x67, 0xCE, + 0x84, 0x13, 0x83, 0x69, 0xA6, 0x15, 0x4F, 0x4A, 0xFA, 0x92, 0x96, 0x6E, + 0x3C, 0x82, 0x7E, 0x25, 0xCF, 0xA6, 0xCF, 0x50, 0x8B, 0x90, 0xE5, 0xDE, + 0x41, 0x9E, 0x13, 0x37, 0xE0, 0x7A, 0x2E, 0x9E, 0x2A, 0x3C, 0xD5, 0xDE, + 0xA7, 0x04, 0xD1, 0x75, 0xF8, 0xEB, 0xF6, 0xAF, 0x39, 0x7D, 0x69, 0xE1, + 0x10, 0xB9, 0x6A, 0xFB, 0x17, 0xC7, 0xA0, 0x32, 0x59, 0x32, 0x9E, 0x48, + 0x29, 0xB0, 0xD0, 0x3B, 0xBC, 0x78, 0x96, 0xB1, 0x5B, 0x4A, 0xDE, 0x53, + 0xE1, 0x30, 0x85, 0x8C, 0xC3, 0x4D, 0x96, 0x26, 0x9A, 0xA8, 0x90, 0x41, + 0xF4, 0x09, 0x13, 0x6C, 0x72, 0x42, 0xA3, 0x88, 0x95, 0xC9, 0xD5, 0xBC, + 0xCA, 0xD4, 0xF3, 0x89, 0xAF, 0x1D, 0x7A, 0x4B, 0xD1, 0x39, 0x8B, 0xD0, + 0x72, 0xDF, 0xFA, 0x89, 0x62, 0x33, 0x39, 0x7A}; + + static_assert(MOZ_ARRAY_LENGTH(P) == 1024 / CHAR_BIT, "bad DSA P"); + static_assert(MOZ_ARRAY_LENGTH(Q) == 160 / CHAR_BIT, "bad DSA Q"); + static_assert(MOZ_ARRAY_LENGTH(G) == 1024 / CHAR_BIT, "bad DSA G"); + + PQGParams pqgParams = { + nullptr /*arena*/, + {siBuffer, P, static_cast<unsigned int>(mozilla::ArrayLength(P))}, + {siBuffer, Q, static_cast<unsigned int>(mozilla::ArrayLength(Q))}, + {siBuffer, G, static_cast<unsigned int>(mozilla::ArrayLength(G))}}; + + return GenerateKeyPair(slot, privateKey, publicKey, CKM_DSA_KEY_PAIR_GEN, + &pqgParams); +} + +NS_IMETHODIMP +KeyGenRunnable::Run() { + if (!NS_IsMainThread()) { + // We always want to use the internal slot for BrowserID; in particular, + // we want to avoid smartcard slots. + PK11SlotInfo* slot = PK11_GetInternalSlot(); + if (!slot) { + mRv = NS_ERROR_UNEXPECTED; + } else { + SECKEYPrivateKey* privk = nullptr; + SECKEYPublicKey* pubk = nullptr; + + switch (mKeyType) { + case rsaKey: + mRv = GenerateRSAKeyPair(slot, &privk, &pubk); + break; + case dsaKey: + mRv = GenerateDSAKeyPair(slot, &privk, &pubk); + break; + default: + MOZ_CRASH("unknown key type"); + } + + PK11_FreeSlot(slot); + + if (NS_SUCCEEDED(mRv)) { + MOZ_ASSERT(privk); + MOZ_ASSERT(pubk); + // mKeyPair will take over ownership of privk and pubk + mKeyPair = new KeyPair(privk, pubk, mThread); + } + } + + NS_DispatchToMainThread(this); + } else { + // Back on Main Thread + (void)mCallback->GenerateKeyPairFinished(mRv, mKeyPair); + } + return NS_OK; +} + +SignRunnable::SignRunnable(const nsACString& aText, + SECKEYPrivateKey* privateKey, + nsIIdentitySignCallback* aCallback) + : mozilla::Runnable("SignRunnable"), + mTextToSign(aText), + mPrivateKey(SECKEY_CopyPrivateKey(privateKey)), + mCallback(new nsMainThreadPtrHolder<nsIIdentitySignCallback>( + "SignRunnable::mCallback", aCallback)), + mRv(NS_ERROR_NOT_INITIALIZED) {} + +NS_IMETHODIMP +SignRunnable::Run() { + if (!NS_IsMainThread()) { + // We need the output in PKCS#11 format, not DER encoding, so we must use + // PK11_HashBuf and PK11_Sign instead of SEC_SignData. + + SECItem sig = {siBuffer, nullptr, 0}; + int sigLength = PK11_SignatureLen(mPrivateKey); + if (sigLength <= 0) { + mRv = mozilla::psm::GetXPCOMFromNSSError(PR_GetError()); + } else if (!SECITEM_AllocItem(nullptr, &sig, sigLength)) { + mRv = mozilla::psm::GetXPCOMFromNSSError(PR_GetError()); + } else { + uint8_t hash[32]; // big enough for SHA-1 or SHA-256 + SECOidTag hashAlg = + mPrivateKey->keyType == dsaKey ? SEC_OID_SHA1 : SEC_OID_SHA256; + SECItem hashItem = {siBuffer, hash, hashAlg == SEC_OID_SHA1 ? 20u : 32u}; + + mRv = MapSECStatus( + PK11_HashBuf(hashAlg, hash, + const_cast<uint8_t*>( + reinterpret_cast<const uint8_t*>(mTextToSign.get())), + mTextToSign.Length())); + if (NS_SUCCEEDED(mRv)) { + mRv = MapSECStatus(PK11_Sign(mPrivateKey, &sig, &hashItem)); + } + if (NS_SUCCEEDED(mRv)) { + mRv = + Base64URLEncode(sig.len, sig.data, + Base64URLEncodePaddingPolicy::Include, mSignature); + } + SECITEM_FreeItem(&sig, false); + } + + NS_DispatchToMainThread(this); + } else { + // Back on Main Thread + (void)mCallback->SignFinished(mRv, mSignature); + } + + return NS_OK; +} +} // unnamed namespace + +// XPCOM module registration + +NS_IMPL_COMPONENT_FACTORY(nsIIdentityCryptoService) { + auto inst = MakeRefPtr<IdentityCryptoService>(); + if (NS_SUCCEEDED(inst->Init())) { + return inst.forget().downcast<nsIIdentityCryptoService>(); + } + return nullptr; +} diff --git a/services/crypto/component/components.conf b/services/crypto/component/components.conf new file mode 100644 index 0000000000..49a4b74d7a --- /dev/null +++ b/services/crypto/component/components.conf @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Classes = [ + { + 'cid': '{bea13a3a-44e8-4d7f-a0a2-2c67f84e3a97}', + 'contract_ids': ['@mozilla.org/identity/crypto-service;1'], + 'type': 'nsIIdentityCryptoService', + }, +] diff --git a/services/crypto/component/moz.build b/services/crypto/component/moz.build new file mode 100644 index 0000000000..41b47f25c6 --- /dev/null +++ b/services/crypto/component/moz.build @@ -0,0 +1,21 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XPIDL_SOURCES += [ + "nsIIdentityCryptoService.idl", +] + +XPIDL_MODULE = "services-crypto-component" + +SOURCES += [ + "IdentityCryptoService.cpp", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +FINAL_LIBRARY = "xul" diff --git a/services/crypto/component/nsIIdentityCryptoService.idl b/services/crypto/component/nsIIdentityCryptoService.idl new file mode 100644 index 0000000000..90149e2e82 --- /dev/null +++ b/services/crypto/component/nsIIdentityCryptoService.idl @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +interface nsIURI; +interface nsIIdentityKeyGenCallback; +interface nsIIdentitySignCallback; + +/* Naming and calling conventions: + * + * A"hex" prefix means "hex-encoded string representation of a byte sequence" + * e.g. "ae34bcdf123" + * + * A "base64url" prefix means "base-64-URL-encoded string repressentation of a + * byte sequence. + * e.g. "eyJhbGciOiJSUzI1NiJ9" + * http://en.wikipedia.org/wiki/Base64#Variants_summary_table + * we use the padded approach to base64-url-encoding + * + * Callbacks take an "in nsresult rv" argument that indicates whether the async + * operation succeeded. On success, rv will be a success code + * (NS_SUCCEEDED(rv) / Components.isSuccessCode(rv)) and the remaining + * arguments are as defined in the documentation for the callback. When the + * operation fails, rv will be a failure code (NS_FAILED(rv) / + * !Components.isSuccessCode(rv)) and the values of the remaining arguments will + * be unspecified. + * + * Key Types: + * + * "RS256": RSA + SHA-256. + * + * "DS160": DSA with SHA-1. A 1024-bit prime and a 160-bit subprime with SHA-1. + * + * we use these abbreviated algorithm names as per the JWA spec + * http://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-02 + */ + +// "@mozilla.org/identity/crypto-service;1" +[scriptable, builtinclass, uuid(f087e6bc-dd33-4f6c-a106-dd786e052ee9)] +interface nsIIdentityCryptoService : nsISupports +{ + void generateKeyPair(in AUTF8String algorithm, + in nsIIdentityKeyGenCallback callback); + + ACString base64UrlEncode(in AUTF8String toEncode); +}; + +/** + * This interface provides a keypair and signing interface for Identity functionality + */ +[scriptable, uuid(73962dc7-8ee7-4346-a12b-b039e1d9b54d)] +interface nsIIdentityKeyPair : nsISupports +{ + readonly attribute AUTF8String keyType; + + // RSA properties, only accessible when keyType == "RS256" + + readonly attribute AUTF8String hexRSAPublicKeyExponent; + readonly attribute AUTF8String hexRSAPublicKeyModulus; + + // DSA properties, only accessible when keyType == "DS128" + readonly attribute AUTF8String hexDSAPrime; // p + readonly attribute AUTF8String hexDSASubPrime; // q + readonly attribute AUTF8String hexDSAGenerator; // g + readonly attribute AUTF8String hexDSAPublicValue; // y + + void sign(in AUTF8String aText, + in nsIIdentitySignCallback callback); + + // XXX implement verification bug 769856 + // AUTF8String verify(in AUTF8String aSignature, in AUTF8String encodedPublicKey); + +}; + +/** + * This interface provides a JavaScript callback object used to collect the + * nsIIdentityServeKeyPair when the keygen operation is complete + * + * though there is discussion as to whether we need the nsresult, + * we keep it so we can track deeper crypto errors. + */ +[scriptable, function, uuid(90f24ca2-2b05-4ca9-8aec-89d38e2f905a)] +interface nsIIdentityKeyGenCallback : nsISupports +{ + void generateKeyPairFinished(in nsresult rv, + in nsIIdentityKeyPair keyPair); +}; + +/** + * This interface provides a JavaScript callback object used to collect the + * AUTF8String signature + */ +[scriptable, function, uuid(2d3e5036-374b-4b47-a430-1196b67b890f)] +interface nsIIdentitySignCallback : nsISupports +{ + /** On success, base64urlSignature is the base-64-URL-encoded signature + * + * For RS256 signatures, XXX bug 769858 + * + * For DSA128 signatures, the signature is the r value concatenated with the + * s value, each component padded with leading zeroes as necessary. + */ + void signFinished(in nsresult rv, in ACString base64urlSignature); +}; diff --git a/services/crypto/cryptoComponents.manifest b/services/crypto/cryptoComponents.manifest new file mode 100644 index 0000000000..f9f47bb42a --- /dev/null +++ b/services/crypto/cryptoComponents.manifest @@ -0,0 +1 @@ +resource services-crypto resource://gre/modules/services-crypto/ diff --git a/services/crypto/modules/WeaveCrypto.js b/services/crypto/modules/WeaveCrypto.js new file mode 100644 index 0000000000..fb9bfd5553 --- /dev/null +++ b/services/crypto/modules/WeaveCrypto.js @@ -0,0 +1,245 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var EXPORTED_SYMBOLS = ["WeaveCrypto"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]); + +const CRYPT_ALGO = "AES-CBC"; +const CRYPT_ALGO_LENGTH = 256; +const CRYPT_ALGO_USAGES = ["encrypt", "decrypt"]; +const AES_CBC_IV_SIZE = 16; +const OPERATIONS = { ENCRYPT: 0, DECRYPT: 1 }; +const UTF_LABEL = "utf-8"; + +const KEY_DERIVATION_ALGO = "PBKDF2"; +const KEY_DERIVATION_HASHING_ALGO = "SHA-1"; +const KEY_DERIVATION_ITERATIONS = 4096; // PKCS#5 recommends at least 1000. +const DERIVED_KEY_ALGO = CRYPT_ALGO; + +function WeaveCrypto() { + this.init(); +} + +WeaveCrypto.prototype = { + prefBranch: null, + debug: true, // services.sync.log.cryptoDebug + + observer: { + _self: null, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + observe(subject, topic, data) { + let self = this._self; + self.log("Observed " + topic + " topic."); + if (topic == "nsPref:changed") { + self.debug = self.prefBranch.getBoolPref("cryptoDebug"); + } + }, + }, + + init() { + // Preferences. Add observer so we get notified of changes. + this.prefBranch = Services.prefs.getBranch("services.sync.log."); + this.prefBranch.addObserver("cryptoDebug", this.observer); + this.observer._self = this; + this.debug = this.prefBranch.getBoolPref("cryptoDebug", false); + XPCOMUtils.defineLazyGetter( + this, + "encoder", + () => new TextEncoder(UTF_LABEL) + ); + XPCOMUtils.defineLazyGetter( + this, + "decoder", + () => new TextDecoder(UTF_LABEL, { fatal: true }) + ); + }, + + log(message) { + if (!this.debug) { + return; + } + dump("WeaveCrypto: " + message + "\n"); + Services.console.logStringMessage("WeaveCrypto: " + message); + }, + + // /!\ Only use this for tests! /!\ + _getCrypto() { + return crypto; + }, + + async encrypt(clearTextUCS2, symmetricKey, iv) { + this.log("encrypt() called"); + let clearTextBuffer = this.encoder.encode(clearTextUCS2).buffer; + let encrypted = await this._commonCrypt( + clearTextBuffer, + symmetricKey, + iv, + OPERATIONS.ENCRYPT + ); + return this.encodeBase64(encrypted); + }, + + async decrypt(cipherText, symmetricKey, iv) { + this.log("decrypt() called"); + if (cipherText.length) { + cipherText = atob(cipherText); + } + let cipherTextBuffer = this.byteCompressInts(cipherText); + let decrypted = await this._commonCrypt( + cipherTextBuffer, + symmetricKey, + iv, + OPERATIONS.DECRYPT + ); + return this.decoder.decode(decrypted); + }, + + /** + * _commonCrypt + * + * @args + * data: data to encrypt/decrypt (ArrayBuffer) + * symKeyStr: symmetric key (Base64 String) + * ivStr: initialization vector (Base64 String) + * operation: operation to apply (either OPERATIONS.ENCRYPT or OPERATIONS.DECRYPT) + * @returns + * the encrypted/decrypted data (ArrayBuffer) + */ + async _commonCrypt(data, symKeyStr, ivStr, operation) { + this.log("_commonCrypt() called"); + ivStr = atob(ivStr); + + if (operation !== OPERATIONS.ENCRYPT && operation !== OPERATIONS.DECRYPT) { + throw new Error("Unsupported operation in _commonCrypt."); + } + // We never want an IV longer than the block size, which is 16 bytes + // for AES, neither do we want one smaller; throw in both cases. + if (ivStr.length !== AES_CBC_IV_SIZE) { + throw new Error(`Invalid IV size; must be ${AES_CBC_IV_SIZE} bytes.`); + } + + let iv = this.byteCompressInts(ivStr); + let symKey = await this.importSymKey(symKeyStr, operation); + let cryptMethod = (operation === OPERATIONS.ENCRYPT + ? crypto.subtle.encrypt + : crypto.subtle.decrypt + ).bind(crypto.subtle); + let algo = { name: CRYPT_ALGO, iv }; + + let keyBytes = await cryptMethod.call(crypto.subtle, algo, symKey, data); + return new Uint8Array(keyBytes); + }, + + async generateRandomKey() { + this.log("generateRandomKey() called"); + let algo = { + name: CRYPT_ALGO, + length: CRYPT_ALGO_LENGTH, + }; + let key = await crypto.subtle.generateKey(algo, true, CRYPT_ALGO_USAGES); + let keyBytes = await crypto.subtle.exportKey("raw", key); + return this.encodeBase64(new Uint8Array(keyBytes)); + }, + + generateRandomIV() { + return this.generateRandomBytes(AES_CBC_IV_SIZE); + }, + + generateRandomBytes(byteCount) { + this.log("generateRandomBytes() called"); + + let randBytes = new Uint8Array(byteCount); + crypto.getRandomValues(randBytes); + + return this.encodeBase64(randBytes); + }, + + // + // SymKey CryptoKey memoization. + // + + // Memoize the import of symmetric keys. We do this by using the base64 + // string itself as a key. + _encryptionSymKeyMemo: {}, + _decryptionSymKeyMemo: {}, + async importSymKey(encodedKeyString, operation) { + let memo; + + // We use two separate memos for thoroughness: operation is an input to + // key import. + switch (operation) { + case OPERATIONS.ENCRYPT: + memo = this._encryptionSymKeyMemo; + break; + case OPERATIONS.DECRYPT: + memo = this._decryptionSymKeyMemo; + break; + default: + throw new Error("Unsupported operation in importSymKey."); + } + + if (encodedKeyString in memo) { + return memo[encodedKeyString]; + } + + let symmetricKeyBuffer = this.makeUint8Array(encodedKeyString, true); + let algo = { name: CRYPT_ALGO }; + let usages = [operation === OPERATIONS.ENCRYPT ? "encrypt" : "decrypt"]; + let symKey = await crypto.subtle.importKey( + "raw", + symmetricKeyBuffer, + algo, + false, + usages + ); + memo[encodedKeyString] = symKey; + return symKey; + }, + + // + // Utility functions + // + + /** + * Returns an Uint8Array filled with a JS string, + * which means we only keep utf-16 characters from 0x00 to 0xFF. + */ + byteCompressInts(str) { + let arrayBuffer = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + arrayBuffer[i] = str.charCodeAt(i) & 0xff; + } + return arrayBuffer; + }, + + expandData(data) { + let expanded = ""; + for (let i = 0; i < data.length; i++) { + expanded += String.fromCharCode(data[i]); + } + return expanded; + }, + + encodeBase64(data) { + return btoa(this.expandData(data)); + }, + + makeUint8Array(input, isEncoded) { + if (isEncoded) { + input = atob(input); + } + return this.byteCompressInts(input); + }, +}; diff --git a/services/crypto/modules/jwcrypto.jsm b/services/crypto/modules/jwcrypto.jsm new file mode 100644 index 0000000000..61f7d136e3 --- /dev/null +++ b/services/crypto/modules/jwcrypto.jsm @@ -0,0 +1,377 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); + +XPCOMUtils.defineLazyServiceGetter( + this, + "IdentityCryptoService", + "@mozilla.org/identity/crypto-service;1", + "nsIIdentityCryptoService" +); +XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]); + +const EXPORTED_SYMBOLS = ["jwcrypto"]; + +const PREF_LOG_LEVEL = "services.crypto.jwcrypto.log.level"; +XPCOMUtils.defineLazyGetter(this, "log", function() { + const log = Log.repository.getLogger("Services.Crypto.jwcrypto"); + // Default log level is "Error", but consumers can change this with the pref + // "services.crypto.jwcrypto.log.level". + log.level = Log.Level.Error; + const appender = new Log.DumpAppender(); + log.addAppender(appender); + try { + const level = + Services.prefs.getPrefType(PREF_LOG_LEVEL) == + Ci.nsIPrefBranch.PREF_STRING && + Services.prefs.getCharPref(PREF_LOG_LEVEL); + log.level = Log.Level[level] || Log.Level.Error; + } catch (e) { + log.error(e); + } + + return log; +}); + +const ASSERTION_DEFAULT_DURATION_MS = 1000 * 60 * 2; // 2 minutes default assertion lifetime +const ECDH_PARAMS = { + name: "ECDH", + namedCurve: "P-256", +}; +const AES_PARAMS = { + name: "AES-GCM", + length: 256, +}; +const AES_TAG_LEN = 128; +const AES_GCM_IV_SIZE = 12; +const UTF8_ENCODER = new TextEncoder(); +const UTF8_DECODER = new TextDecoder(); + +class JWCrypto { + /** + * Encrypts the given data into a JWE using AES-256-GCM content encryption. + * + * This function implements a very small subset of the JWE encryption standard + * from https://tools.ietf.org/html/rfc7516. The only supported content encryption + * algorithm is enc="A256GCM" [1] and the only supported key encryption algorithm + * is alg="ECDH-ES" [2]. + * + * @param {Object} key Peer Public JWK. + * @param {ArrayBuffer} data + * + * [1] https://tools.ietf.org/html/rfc7518#section-5.3 + * [2] https://tools.ietf.org/html/rfc7518#section-4.6 + * + * @returns {Promise<String>} + */ + async generateJWE(key, data) { + // Generate an ephemeral key to use just for this encryption. + // The public component gets embedded in the JWE header. + const epk = await crypto.subtle.generateKey(ECDH_PARAMS, true, [ + "deriveKey", + ]); + const ownPublicJWK = await crypto.subtle.exportKey("jwk", epk.publicKey); + // Remove properties added by our WebCrypto implementation but that aren't typically + // used with JWE in the wild. This saves space in the resulting JWE, and makes it easier + // to re-import the resulting JWK. + delete ownPublicJWK.key_ops; + delete ownPublicJWK.ext; + let header = { alg: "ECDH-ES", enc: "A256GCM", epk: ownPublicJWK }; + // Import the peer's public key. + const peerPublicKey = await crypto.subtle.importKey( + "jwk", + key, + ECDH_PARAMS, + false, + ["deriveKey"] + ); + if (key.hasOwnProperty("kid")) { + header.kid = key.kid; + } + // Do ECDH agreement to get the content encryption key. + const contentKey = await deriveECDHSharedAESKey( + epk.privateKey, + peerPublicKey, + ["encrypt"] + ); + // Encrypt with AES-GCM using the generated key. + // Note that the IV is generated randomly, which *in general* is not safe to do with AES-GCM because + // it's too short to guarantee uniqueness. But we know that the AES-GCM key itself is unique and will + // only be used for this single encryption, making a random IV safe to use for this particular use-case. + let iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_SIZE)); + // Yes, additionalData is the byte representation of the base64 representation of the stringified header. + const additionalData = UTF8_ENCODER.encode( + ChromeUtils.base64URLEncode(UTF8_ENCODER.encode(JSON.stringify(header)), { + pad: false, + }) + ); + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + additionalData, + tagLength: AES_TAG_LEN, + }, + contentKey, + data + ); + // JWE needs the authentication tag as a separate string. + const tagIdx = encrypted.byteLength - ((AES_TAG_LEN + 7) >> 3); + let ciphertext = encrypted.slice(0, tagIdx); + let tag = encrypted.slice(tagIdx); + // JWE serialization in compact format. + header = UTF8_ENCODER.encode(JSON.stringify(header)); + header = ChromeUtils.base64URLEncode(header, { pad: false }); + tag = ChromeUtils.base64URLEncode(tag, { pad: false }); + ciphertext = ChromeUtils.base64URLEncode(ciphertext, { pad: false }); + iv = ChromeUtils.base64URLEncode(iv, { pad: false }); + return `${header}..${iv}.${ciphertext}.${tag}`; // No CEK + } + + /** + * Decrypts the given JWE using AES-256-GCM content encryption into a byte array. + * This function does the opposite of `JWCrypto.generateJWE`. + * The only supported content encryption algorithm is enc="A256GCM" [1] + * and the only supported key encryption algorithm is alg="ECDH-ES" [2]. + * + * @param {"ECDH-ES"} algorithm + * @param {CryptoKey} key Local private key + * + * [1] https://tools.ietf.org/html/rfc7518#section-5.3 + * [2] https://tools.ietf.org/html/rfc7518#section-4.6 + * + * @returns {Promise<Uint8Array>} + */ + async decryptJWE(jwe, key) { + let [header, cek, iv, ciphertext, authTag] = jwe.split("."); + const additionalData = UTF8_ENCODER.encode(header); + header = JSON.parse( + UTF8_DECODER.decode( + ChromeUtils.base64URLDecode(header, { padding: "reject" }) + ) + ); + if ( + cek.length > 0 || + header.enc !== "A256GCM" || + header.alg !== "ECDH-ES" + ) { + throw new Error("Unknown algorithm."); + } + if ("apu" in header || "apv" in header) { + throw new Error("apu and apv header values are not supported."); + } + const peerPublicKey = await crypto.subtle.importKey( + "jwk", + header.epk, + ECDH_PARAMS, + false, + ["deriveKey"] + ); + // Do ECDH agreement to get the content encryption key. + const contentKey = await deriveECDHSharedAESKey(key, peerPublicKey, [ + "decrypt", + ]); + iv = ChromeUtils.base64URLDecode(iv, { padding: "reject" }); + ciphertext = new Uint8Array( + ChromeUtils.base64URLDecode(ciphertext, { padding: "reject" }) + ); + authTag = new Uint8Array( + ChromeUtils.base64URLDecode(authTag, { padding: "reject" }) + ); + const bundle = new Uint8Array([...ciphertext, ...authTag]); + + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + tagLength: AES_TAG_LEN, + additionalData, + }, + contentKey, + bundle + ); + return new Uint8Array(decrypted); + } + + generateKeyPair(aAlgorithmName, aCallback) { + log.debug("generating"); + log.debug("Generate key pair; alg = " + aAlgorithmName); + + IdentityCryptoService.generateKeyPair(aAlgorithmName, (rv, aKeyPair) => { + if (!Components.isSuccessCode(rv)) { + return aCallback("key generation failed"); + } + + let publicKey; + + switch (aKeyPair.keyType) { + case "RS256": + publicKey = { + algorithm: "RS", + exponent: aKeyPair.hexRSAPublicKeyExponent, + modulus: aKeyPair.hexRSAPublicKeyModulus, + }; + break; + + case "DS160": + publicKey = { + algorithm: "DS", + y: aKeyPair.hexDSAPublicValue, + p: aKeyPair.hexDSAPrime, + q: aKeyPair.hexDSASubPrime, + g: aKeyPair.hexDSAGenerator, + }; + break; + + default: + return aCallback("unknown key type"); + } + + const keyWrapper = { + serializedPublicKey: JSON.stringify(publicKey), + _kp: aKeyPair, + }; + + return aCallback(null, keyWrapper); + }); + } + + /** + * Generate an assertion and return it through the provided callback. + * + * @param aCert + * Identity certificate + * + * @param aKeyPair + * KeyPair object + * + * @param aAudience + * Audience of the assertion + * + * @param aOptions (optional) + * Can include: + * { + * localtimeOffsetMsec: <clock offset in milliseconds>, + * now: <current date in milliseconds> + * duration: <validity duration for this assertion in milliseconds> + * } + * + * localtimeOffsetMsec is the number of milliseconds that need to be + * added to the local clock time to make it concur with the server. + * For example, if the local clock is two minutes fast, the offset in + * milliseconds would be -120000. + * + * @param aCallback + * Function to invoke with resulting assertion. Assertion + * will be string or null on failure. + */ + generateAssertion(aCert, aKeyPair, aAudience, aOptions, aCallback) { + if (typeof aOptions == "function") { + aCallback = aOptions; + aOptions = {}; + } + + // for now, we hack the algorithm name + // XXX bug 769851 + const header = { alg: "DS128" }; + const headerBytes = IdentityCryptoService.base64UrlEncode( + JSON.stringify(header) + ); + + function getExpiration( + duration = ASSERTION_DEFAULT_DURATION_MS, + localtimeOffsetMsec = 0, + now = Date.now() + ) { + return now + localtimeOffsetMsec + duration; + } + + const payload = { + exp: getExpiration( + aOptions.duration, + aOptions.localtimeOffsetMsec, + aOptions.now + ), + aud: aAudience, + }; + const payloadBytes = IdentityCryptoService.base64UrlEncode( + JSON.stringify(payload) + ); + + log.debug("payload", { payload, payloadBytes }); + const message = headerBytes + "." + payloadBytes; + aKeyPair._kp.sign(message, (rv, signature) => { + if (!Components.isSuccessCode(rv)) { + log.error("signer.sign failed"); + aCallback("Sign failed"); + return; + } + log.debug("signer.sign: success"); + const signedAssertion = message + "." + signature; + aCallback(null, aCert + "~" + signedAssertion); + }); + } +} + +/** + * Do an ECDH agreement between a public and private key, + * returning the derived encryption key as specced by + * JWA RFC. + * The raw ECDH secret is derived into a key using + * Concat KDF, as defined in Section 5.8.1 of [NIST.800-56A]. + * @param {CryptoKey} privateKey + * @param {CryptoKey} publicKey + * @param {String[]} keyUsages See `SubtleCrypto.deriveKey` 5th paramater documentation. + * @returns {Promise<CryptoKey>} + */ +async function deriveECDHSharedAESKey(privateKey, publicKey, keyUsages) { + const params = { ...ECDH_PARAMS, ...{ public: publicKey } }; + const sharedKey = await crypto.subtle.deriveKey( + params, + privateKey, + AES_PARAMS, + true, + keyUsages + ); + // This is the NIST Concat KDF specialized to a specific set of parameters, + // which basically turn it into a single application of SHA256. + // The details are from the JWA RFC. + let sharedKeyBytes = await crypto.subtle.exportKey("raw", sharedKey); + sharedKeyBytes = new Uint8Array(sharedKeyBytes); + const info = [ + "\x00\x00\x00\x07A256GCM", // 7-byte algorithm identifier + "\x00\x00\x00\x00", // empty PartyUInfo + "\x00\x00\x00\x00", // empty PartyVInfo + "\x00\x00\x01\x00", // keylen == 256 + ].join(""); + const pkcs = `\x00\x00\x00\x01${String.fromCharCode.apply( + null, + sharedKeyBytes + )}${info}`; + const pkcsBuf = Uint8Array.from( + Array.prototype.map.call(pkcs, c => c.charCodeAt(0)) + ); + const derivedKeyBytes = await crypto.subtle.digest( + { + name: "SHA-256", + }, + pkcsBuf + ); + return crypto.subtle.importKey( + "raw", + derivedKeyBytes, + AES_PARAMS, + false, + keyUsages + ); +} + +const jwcrypto = new JWCrypto(); diff --git a/services/crypto/modules/utils.js b/services/crypto/modules/utils.js new file mode 100644 index 0000000000..3f37260f13 --- /dev/null +++ b/services/crypto/modules/utils.js @@ -0,0 +1,587 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var EXPORTED_SYMBOLS = ["CryptoUtils"]; + +const { Observers } = ChromeUtils.import( + "resource://services-common/observers.js" +); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]); + +XPCOMUtils.defineLazyGetter(this, "textEncoder", function() { + return new TextEncoder(); +}); + +/** + * A number of `Legacy` suffixed functions are exposed by CryptoUtils. + * They work with octet strings, which were used before Javascript + * got ArrayBuffer and friends. + */ +var CryptoUtils = { + xor(a, b) { + let bytes = []; + + if (a.length != b.length) { + throw new Error( + "can't xor unequal length strings: " + a.length + " vs " + b.length + ); + } + + for (let i = 0; i < a.length; i++) { + bytes[i] = a.charCodeAt(i) ^ b.charCodeAt(i); + } + + return String.fromCharCode.apply(String, bytes); + }, + + /** + * Generate a string of random bytes. + * @returns {String} Octet string + */ + generateRandomBytesLegacy(length) { + let bytes = CryptoUtils.generateRandomBytes(length); + return CommonUtils.arrayBufferToByteString(bytes); + }, + + generateRandomBytes(length) { + return crypto.getRandomValues(new Uint8Array(length)); + }, + + /** + * UTF8-encode a message and hash it with the given hasher. Returns a + * string containing bytes. The hasher is reset if it's an HMAC hasher. + */ + digestUTF8(message, hasher) { + let data = this._utf8Converter.convertToByteArray(message, {}); + hasher.update(data, data.length); + let result = hasher.finish(false); + if (hasher instanceof Ci.nsICryptoHMAC) { + hasher.reset(); + } + return result; + }, + + /** + * Treat the given message as a bytes string (if necessary) and hash it with + * the given hasher. Returns a string containing bytes. + * The hasher is reset if it's an HMAC hasher. + */ + digestBytes(bytes, hasher) { + if (typeof bytes == "string" || bytes instanceof String) { + bytes = CommonUtils.byteStringToArrayBuffer(bytes); + } + return CryptoUtils.digestBytesArray(bytes, hasher); + }, + + digestBytesArray(bytes, hasher) { + hasher.update(bytes, bytes.length); + let result = hasher.finish(false); + if (hasher instanceof Ci.nsICryptoHMAC) { + hasher.reset(); + } + return result; + }, + + /** + * Encode the message into UTF-8 and feed the resulting bytes into the + * given hasher. Does not return a hash. This can be called multiple times + * with a single hasher, but eventually you must extract the result + * yourself. + */ + updateUTF8(message, hasher) { + let bytes = this._utf8Converter.convertToByteArray(message, {}); + hasher.update(bytes, bytes.length); + }, + + sha256(message) { + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + return CommonUtils.bytesAsHex(CryptoUtils.digestUTF8(message, hasher)); + }, + + sha256Base64(message) { + let data = this._utf8Converter.convertToByteArray(message, {}); + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + hasher.update(data, data.length); + return hasher.finish(true); + }, + + /** + * Produce an HMAC key object from a key string. + */ + makeHMACKey: function makeHMACKey(str) { + return Svc.KeyFactory.keyFromString(Ci.nsIKeyObject.HMAC, str); + }, + + /** + * Produce an HMAC hasher and initialize it with the given HMAC key. + */ + makeHMACHasher: function makeHMACHasher(type, key) { + let hasher = Cc["@mozilla.org/security/hmac;1"].createInstance( + Ci.nsICryptoHMAC + ); + hasher.init(type, key); + return hasher; + }, + + /** + * @param {string} alg Hash algorithm (common values are SHA-1 or SHA-256) + * @param {string} key Key as an octet string. + * @param {string} data Data as an octet string. + */ + async hmacLegacy(alg, key, data) { + if (!key || !key.length) { + key = "\0"; + } + data = CommonUtils.byteStringToArrayBuffer(data); + key = CommonUtils.byteStringToArrayBuffer(key); + const result = await CryptoUtils.hmac(alg, key, data); + return CommonUtils.arrayBufferToByteString(result); + }, + + /** + * @param {string} ikm IKM as an octet string. + * @param {string} salt Salt as an Hex string. + * @param {string} info Info as a regular string. + * @param {Number} len Desired output length in bytes. + */ + async hkdfLegacy(ikm, xts, info, len) { + ikm = CommonUtils.byteStringToArrayBuffer(ikm); + xts = CommonUtils.byteStringToArrayBuffer(xts); + info = textEncoder.encode(info); + const okm = await CryptoUtils.hkdf(ikm, xts, info, len); + return CommonUtils.arrayBufferToByteString(okm); + }, + + /** + * @param {String} alg Hash algorithm (common values are SHA-1 or SHA-256) + * @param {ArrayBuffer} key + * @param {ArrayBuffer} data + * @param {Number} len Desired output length in bytes. + * @returns {Uint8Array} + */ + async hmac(alg, key, data) { + const hmacKey = await crypto.subtle.importKey( + "raw", + key, + { name: "HMAC", hash: alg }, + false, + ["sign"] + ); + const result = await crypto.subtle.sign("HMAC", hmacKey, data); + return new Uint8Array(result); + }, + + /** + * @param {ArrayBuffer} ikm + * @param {ArrayBuffer} salt + * @param {ArrayBuffer} info + * @param {Number} len Desired output length in bytes. + * @returns {Uint8Array} + */ + async hkdf(ikm, salt, info, len) { + const key = await crypto.subtle.importKey( + "raw", + ikm, + { name: "HKDF" }, + false, + ["deriveBits"] + ); + const okm = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt, + info, + }, + key, + len * 8 + ); + return new Uint8Array(okm); + }, + + /** + * PBKDF2 password stretching with SHA-256 hmac. + * + * @param {string} passphrase Passphrase as an octet string. + * @param {string} salt Salt as an octet string. + * @param {string} iterations Number of iterations, a positive integer. + * @param {string} len Desired output length in bytes. + */ + async pbkdf2Generate(passphrase, salt, iterations, len) { + passphrase = CommonUtils.byteStringToArrayBuffer(passphrase); + salt = CommonUtils.byteStringToArrayBuffer(salt); + const key = await crypto.subtle.importKey( + "raw", + passphrase, + { name: "PBKDF2" }, + false, + ["deriveBits"] + ); + const output = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + hash: "SHA-256", + salt, + iterations, + }, + key, + len * 8 + ); + return CommonUtils.arrayBufferToByteString(new Uint8Array(output)); + }, + + /** + * Compute the HTTP MAC SHA-1 for an HTTP request. + * + * @param identifier + * (string) MAC Key Identifier. + * @param key + * (string) MAC Key. + * @param method + * (string) HTTP request method. + * @param URI + * (nsIURI) HTTP request URI. + * @param extra + * (object) Optional extra parameters. Valid keys are: + * nonce_bytes - How many bytes the nonce should be. This defaults + * to 8. Note that this many bytes are Base64 encoded, so the + * string length of the nonce will be longer than this value. + * ts - Timestamp to use. Should only be defined for testing. + * nonce - String nonce. Should only be defined for testing as this + * function will generate a cryptographically secure random one + * if not defined. + * ext - Extra string to be included in MAC. Per the HTTP MAC spec, + * the format is undefined and thus application specific. + * @returns + * (object) Contains results of operation and input arguments (for + * symmetry). The object has the following keys: + * + * identifier - (string) MAC Key Identifier (from arguments). + * key - (string) MAC Key (from arguments). + * method - (string) HTTP request method (from arguments). + * hostname - (string) HTTP hostname used (derived from arguments). + * port - (string) HTTP port number used (derived from arguments). + * mac - (string) Raw HMAC digest bytes. + * getHeader - (function) Call to obtain the string Authorization + * header value for this invocation. + * nonce - (string) Nonce value used. + * ts - (number) Integer seconds since Unix epoch that was used. + */ + async computeHTTPMACSHA1(identifier, key, method, uri, extra) { + let ts = extra && extra.ts ? extra.ts : Math.floor(Date.now() / 1000); + let nonce_bytes = extra && extra.nonce_bytes > 0 ? extra.nonce_bytes : 8; + + // We are allowed to use more than the Base64 alphabet if we want. + let nonce = + extra && extra.nonce + ? extra.nonce + : btoa(CryptoUtils.generateRandomBytesLegacy(nonce_bytes)); + + let host = uri.asciiHost; + let port; + let usedMethod = method.toUpperCase(); + + if (uri.port != -1) { + port = uri.port; + } else if (uri.scheme == "http") { + port = "80"; + } else if (uri.scheme == "https") { + port = "443"; + } else { + throw new Error("Unsupported URI scheme: " + uri.scheme); + } + + let ext = extra && extra.ext ? extra.ext : ""; + + let requestString = + ts.toString(10) + + "\n" + + nonce + + "\n" + + usedMethod + + "\n" + + uri.pathQueryRef + + "\n" + + host + + "\n" + + port + + "\n" + + ext + + "\n"; + + const mac = await CryptoUtils.hmacLegacy("SHA-1", key, requestString); + + function getHeader() { + return CryptoUtils.getHTTPMACSHA1Header( + this.identifier, + this.ts, + this.nonce, + this.mac, + this.ext + ); + } + + return { + identifier, + key, + method: usedMethod, + hostname: host, + port, + mac, + nonce, + ts, + ext, + getHeader, + }; + }, + + /** + * Obtain the HTTP MAC Authorization header value from fields. + * + * @param identifier + * (string) MAC key identifier. + * @param ts + * (number) Integer seconds since Unix epoch. + * @param nonce + * (string) Nonce value. + * @param mac + * (string) Computed HMAC digest (raw bytes). + * @param ext + * (optional) (string) Extra string content. + * @returns + * (string) Value to put in Authorization header. + */ + getHTTPMACSHA1Header: function getHTTPMACSHA1Header( + identifier, + ts, + nonce, + mac, + ext + ) { + let header = + 'MAC id="' + + identifier + + '", ' + + 'ts="' + + ts + + '", ' + + 'nonce="' + + nonce + + '", ' + + 'mac="' + + btoa(mac) + + '"'; + + if (!ext) { + return header; + } + + return (header += ', ext="' + ext + '"'); + }, + + /** + * Given an HTTP header value, strip out any attributes. + */ + + stripHeaderAttributes(value) { + value = value || ""; + let i = value.indexOf(";"); + return value + .substring(0, i >= 0 ? i : undefined) + .trim() + .toLowerCase(); + }, + + /** + * Compute the HAWK client values (mostly the header) for an HTTP request. + * + * @param URI + * (nsIURI) HTTP request URI. + * @param method + * (string) HTTP request method. + * @param options + * (object) extra parameters (all but "credentials" are optional): + * credentials - (object, mandatory) HAWK credentials object. + * All three keys are required: + * id - (string) key identifier + * key - (string) raw key bytes + * ext - (string) application-specific data, included in MAC + * localtimeOffsetMsec - (number) local clock offset (vs server) + * payload - (string) payload to include in hash, containing the + * HTTP request body. If not provided, the HAWK hash + * will not cover the request body, and the server + * should not check it either. This will be UTF-8 + * encoded into bytes before hashing. This function + * cannot handle arbitrary binary data, sorry (the + * UTF-8 encoding process will corrupt any codepoints + * between U+0080 and U+00FF). Callers must be careful + * to use an HTTP client function which encodes the + * payload exactly the same way, otherwise the hash + * will not match. + * contentType - (string) payload Content-Type. This is included + * (without any attributes like "charset=") in the + * HAWK hash. It does *not* affect interpretation + * of the "payload" property. + * hash - (base64 string) pre-calculated payload hash. If + * provided, "payload" is ignored. + * ts - (number) pre-calculated timestamp, secs since epoch + * now - (number) current time, ms-since-epoch, for tests + * nonce - (string) pre-calculated nonce. Should only be defined + * for testing as this function will generate a + * cryptographically secure random one if not defined. + * @returns + * Promise<Object> Contains results of operation. The object has the + * following keys: + * field - (string) HAWK header, to use in Authorization: header + * artifacts - (object) other generated values: + * ts - (number) timestamp, in seconds since epoch + * nonce - (string) + * method - (string) + * resource - (string) path plus querystring + * host - (string) + * port - (number) + * hash - (string) payload hash (base64) + * ext - (string) app-specific data + * MAC - (string) request MAC (base64) + */ + async computeHAWK(uri, method, options) { + let credentials = options.credentials; + let ts = + options.ts || + Math.floor( + ((options.now || Date.now()) + (options.localtimeOffsetMsec || 0)) / + 1000 + ); + let port; + if (uri.port != -1) { + port = uri.port; + } else if (uri.scheme == "http") { + port = 80; + } else if (uri.scheme == "https") { + port = 443; + } else { + throw new Error("Unsupported URI scheme: " + uri.scheme); + } + + let artifacts = { + ts, + nonce: options.nonce || btoa(CryptoUtils.generateRandomBytesLegacy(8)), + method: method.toUpperCase(), + resource: uri.pathQueryRef, // This includes both path and search/queryarg. + host: uri.asciiHost.toLowerCase(), // This includes punycoding. + port: port.toString(10), + hash: options.hash, + ext: options.ext, + }; + + let contentType = CryptoUtils.stripHeaderAttributes(options.contentType); + + if ( + !artifacts.hash && + options.hasOwnProperty("payload") && + options.payload + ) { + const buffer = textEncoder.encode( + `hawk.1.payload\n${contentType}\n${options.payload}\n` + ); + const hash = await crypto.subtle.digest("SHA-256", buffer); + // HAWK specifies this .hash to use +/ (not _-) and include the + // trailing "==" padding. + artifacts.hash = ChromeUtils.base64URLEncode(hash, { pad: true }) + .replace(/-/g, "+") + .replace(/_/g, "/"); + } + + let requestString = + "hawk.1.header\n" + + artifacts.ts.toString(10) + + "\n" + + artifacts.nonce + + "\n" + + artifacts.method + + "\n" + + artifacts.resource + + "\n" + + artifacts.host + + "\n" + + artifacts.port + + "\n" + + (artifacts.hash || "") + + "\n"; + if (artifacts.ext) { + requestString += artifacts.ext.replace("\\", "\\\\").replace("\n", "\\n"); + } + requestString += "\n"; + + const hash = await CryptoUtils.hmacLegacy( + "SHA-256", + credentials.key, + requestString + ); + artifacts.mac = btoa(hash); + // The output MAC uses "+" and "/", and padded== . + + function escape(attribute) { + // This is used for "x=y" attributes inside HTTP headers. + return attribute.replace(/\\/g, "\\\\").replace(/\"/g, '\\"'); + } + let header = + 'Hawk id="' + + credentials.id + + '", ' + + 'ts="' + + artifacts.ts + + '", ' + + 'nonce="' + + artifacts.nonce + + '", ' + + (artifacts.hash ? 'hash="' + artifacts.hash + '", ' : "") + + (artifacts.ext ? 'ext="' + escape(artifacts.ext) + '", ' : "") + + 'mac="' + + artifacts.mac + + '"'; + return { + artifacts, + field: header, + }; + }, +}; + +XPCOMUtils.defineLazyGetter(CryptoUtils, "_utf8Converter", function() { + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + + return converter; +}); + +var Svc = {}; + +XPCOMUtils.defineLazyServiceGetter( + Svc, + "KeyFactory", + "@mozilla.org/security/keyobjectfactory;1", + "nsIKeyObjectFactory" +); + +Observers.add("xpcom-shutdown", function unloadServices() { + Observers.remove("xpcom-shutdown", unloadServices); + + for (let k in Svc) { + delete Svc[k]; + } +}); diff --git a/services/crypto/moz.build b/services/crypto/moz.build new file mode 100644 index 0000000000..e03be96a81 --- /dev/null +++ b/services/crypto/moz.build @@ -0,0 +1,22 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Sync") + +DIRS += ["component"] + +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.ini"] + +EXTRA_JS_MODULES["services-crypto"] += [ + "modules/jwcrypto.jsm", + "modules/utils.js", + "modules/WeaveCrypto.js", +] + +EXTRA_COMPONENTS += [ + "cryptoComponents.manifest", +] diff --git a/services/crypto/tests/unit/head_helpers.js b/services/crypto/tests/unit/head_helpers.js new file mode 100644 index 0000000000..8849eff262 --- /dev/null +++ b/services/crypto/tests/unit/head_helpers.js @@ -0,0 +1,79 @@ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ + +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +try { + // In the context of xpcshell tests, there won't be a default AppInfo + // eslint-disable-next-line mozilla/use-services + Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); +} catch (ex) { + // Make sure to provide the right OS so crypto loads the right binaries + var OS = "XPCShell"; + if (mozinfo.os == "win") { + OS = "WINNT"; + } else if (mozinfo.os == "mac") { + OS = "Darwin"; + } else { + OS = "Linux"; + } + + ChromeUtils.import("resource://testing-common/AppInfo.jsm", this); + updateAppInfo({ + name: "XPCShell", + ID: "{3e3ba16c-1675-4e88-b9c8-afef81b3d2ef}", + version: "1", + platformVersion: "", + OS, + }); +} + +function base64UrlDecode(s) { + s = s.replace(/-/g, "+"); + s = s.replace(/_/g, "/"); + + // Replace padding if it was stripped by the sender. + // See http://tools.ietf.org/html/rfc4648#section-4 + switch (s.length % 4) { + case 0: + break; // No pad chars in this case + case 2: + s += "=="; + break; // Two pad chars + case 3: + s += "="; + break; // One pad char + default: + throw new Error("Illegal base64url string!"); + } + + // With correct padding restored, apply the standard base64 decoder + return atob(s); +} + +// Register resource alias. Normally done in SyncComponents.manifest. +function addResourceAlias() { + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const resProt = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + let uri = Services.io.newURI("resource://gre/modules/services-crypto/"); + resProt.setSubstitution("services-crypto", uri); +} +addResourceAlias(); + +/** + * Print some debug message to the console. All arguments will be printed, + * separated by spaces. + * + * @param [arg0, arg1, arg2, ...] + * Any number of arguments to print out + * @usage _("Hello World") -> prints "Hello World" + * @usage _(1, 2, 3) -> prints "1 2 3" + */ +var _ = function(some, debug, text, to) { + print(Array.from(arguments).join(" ")); +}; diff --git a/services/crypto/tests/unit/test_crypto_crypt.js b/services/crypto/tests/unit/test_crypto_crypt.js new file mode 100644 index 0000000000..b79f2d8daa --- /dev/null +++ b/services/crypto/tests/unit/test_crypto_crypt.js @@ -0,0 +1,227 @@ +const { WeaveCrypto } = ChromeUtils.import( + "resource://services-crypto/WeaveCrypto.js" +); +Cu.importGlobalProperties(["crypto"]); + +var cryptoSvc = new WeaveCrypto(); + +add_task(async function test_key_memoization() { + let cryptoGlobal = cryptoSvc._getCrypto(); + let oldImport = cryptoGlobal.subtle.importKey; + if (!oldImport) { + _("Couldn't swizzle crypto.subtle.importKey; returning."); + return; + } + + let iv = cryptoSvc.generateRandomIV(); + let key = await cryptoSvc.generateRandomKey(); + let c = 0; + cryptoGlobal.subtle.importKey = function( + format, + keyData, + algo, + extractable, + usages + ) { + c++; + return oldImport.call( + cryptoGlobal.subtle, + format, + keyData, + algo, + extractable, + usages + ); + }; + + // Encryption should cause a single counter increment. + Assert.equal(c, 0); + let cipherText = await cryptoSvc.encrypt("Hello, world.", key, iv); + Assert.equal(c, 1); + cipherText = await cryptoSvc.encrypt("Hello, world.", key, iv); + Assert.equal(c, 1); + + // ... as should decryption. + await cryptoSvc.decrypt(cipherText, key, iv); + await cryptoSvc.decrypt(cipherText, key, iv); + await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(c, 2); + + // Un-swizzle. + cryptoGlobal.subtle.importKey = oldImport; +}); + +// Just verify that it gets populated with the correct bytes. +add_task(async function test_makeUint8Array() { + ChromeUtils.import("resource://gre/modules/ctypes.jsm"); + + let item1 = cryptoSvc.makeUint8Array("abcdefghi", false); + Assert.ok(item1); + for (let i = 0; i < 8; ++i) { + Assert.equal(item1[i], "abcdefghi".charCodeAt(i)); + } +}); + +add_task(async function test_encrypt_decrypt() { + // First, do a normal run with expected usage... Generate a random key and + // iv, encrypt and decrypt a string. + var iv = cryptoSvc.generateRandomIV(); + Assert.equal(iv.length, 24); + + var key = await cryptoSvc.generateRandomKey(); + Assert.equal(key.length, 44); + + var mySecret = "bacon is a vegetable"; + var cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + Assert.equal(cipherText.length, 44); + + var clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(clearText.length, 20); + + // Did the text survive the encryption round-trip? + Assert.equal(clearText, mySecret); + Assert.notEqual(cipherText, mySecret); // just to be explicit + + // Do some more tests with a fixed key/iv, to check for reproducable results. + key = "St1tFCor7vQEJNug/465dQ=="; + iv = "oLjkfrLIOnK2bDRvW4kXYA=="; + + _("Testing small IV."); + mySecret = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo="; + let shortiv = "YWJj"; + let err; + try { + await cryptoSvc.encrypt(mySecret, key, shortiv); + } catch (ex) { + err = ex; + } + Assert.ok(!!err); + + _("Testing long IV."); + let longiv = "gsgLRDaxWvIfKt75RjuvFWERt83FFsY2A0TW+0b2iVk="; + try { + await cryptoSvc.encrypt(mySecret, key, longiv); + } catch (ex) { + err = ex; + } + Assert.ok(!!err); + + // Test small input sizes + mySecret = ""; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "OGQjp6mK1a3fs9k9Ml4L3w=="); + Assert.equal(clearText, mySecret); + + mySecret = "x"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "96iMl4vhOxFUW/lVHHzVqg=="); + Assert.equal(clearText, mySecret); + + mySecret = "xx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "olpPbETRYROCSqFWcH2SWg=="); + Assert.equal(clearText, mySecret); + + mySecret = "xxx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "rRbpHGyVSZizLX/x43Wm+Q=="); + Assert.equal(clearText, mySecret); + + mySecret = "xxxx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "HeC7miVGDcpxae9RmiIKAw=="); + Assert.equal(clearText, mySecret); + + // Test non-ascii input + // ("testuser1" using similar-looking glyphs) + mySecret = String.fromCharCode(355, 277, 349, 357, 533, 537, 101, 345, 185); + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "Pj4ixByXoH3SU3JkOXaEKPgwRAWplAWFLQZkpJd5Kr4="); + Assert.equal(clearText, mySecret); + + // Tests input spanning a block boundary (AES block size is 16 bytes) + mySecret = "123456789012345"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "e6c5hwphe45/3VN/M0bMUA=="); + Assert.equal(clearText, mySecret); + + mySecret = "1234567890123456"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "V6aaOZw8pWlYkoIHNkhsP1JOIQF87E2vTUvBUQnyV04="); + Assert.equal(clearText, mySecret); + + mySecret = "12345678901234567"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "V6aaOZw8pWlYkoIHNkhsP5GvxWJ9+GIAS6lXw+5fHTI="); + Assert.equal(clearText, mySecret); + + key = "iz35tuIMq4/H+IYw2KTgow=="; + iv = "TJYrvva2KxvkM8hvOIvWp3=="; + mySecret = "i like pie"; + + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "DLGx8BWqSCLGG7i/xwvvxg=="); + Assert.equal(clearText, mySecret); + + key = "c5hG3YG+NC61FFy8NOHQak1ZhMEWO79bwiAfar2euzI="; + iv = "gsgLRDaxWvIfKt75RjuvFW=="; + mySecret = "i like pie"; + + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "o+ADtdMd8ubzNWurS6jt0Q=="); + Assert.equal(clearText, mySecret); + + key = "St1tFCor7vQEJNug/465dQ=="; + iv = "oLjkfrLIOnK2bDRvW4kXYA=="; + mySecret = "does thunder read testcases?"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + Assert.equal(cipherText, "T6fik9Ros+DB2ablH9zZ8FWZ0xm/szSwJjIHZu7sjPs="); + + var badkey = "badkeybadkeybadkeybadk=="; + var badiv = "badivbadivbadivbadivbad="; + var badcipher = "crapinputcrapinputcrapinputcrapinputcrapinp="; + var failure; + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, badkey, iv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, key, badiv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, badkey, badiv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(badcipher, key, iv); + } catch (e) { + failure = true; + } + Assert.ok(failure); +}); diff --git a/services/crypto/tests/unit/test_crypto_random.js b/services/crypto/tests/unit/test_crypto_random.js new file mode 100644 index 0000000000..5162b5f3fd --- /dev/null +++ b/services/crypto/tests/unit/test_crypto_random.js @@ -0,0 +1,50 @@ +ChromeUtils.import("resource://services-crypto/WeaveCrypto.js", this); + +var cryptoSvc = new WeaveCrypto(); + +add_task(async function test_crypto_random() { + if (this.gczeal) { + _("Running crypto random tests with gczeal(2)."); + gczeal(2); + } + + // Test salt generation. + var salt; + + salt = cryptoSvc.generateRandomBytes(0); + Assert.equal(salt.length, 0); + salt = cryptoSvc.generateRandomBytes(1); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(2); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(3); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(4); + Assert.equal(salt.length, 8); + salt = cryptoSvc.generateRandomBytes(8); + Assert.equal(salt.length, 12); + + // sanity check to make sure salts seem random + var salt2 = cryptoSvc.generateRandomBytes(8); + Assert.equal(salt2.length, 12); + Assert.notEqual(salt, salt2); + + salt = cryptoSvc.generateRandomBytes(1024); + Assert.equal(salt.length, 1368); + salt = cryptoSvc.generateRandomBytes(16); + Assert.equal(salt.length, 24); + + // Test random key generation + var keydata, keydata2, iv; + + keydata = await cryptoSvc.generateRandomKey(); + Assert.equal(keydata.length, 44); + keydata2 = await cryptoSvc.generateRandomKey(); + Assert.notEqual(keydata, keydata2); // sanity check for randomness + iv = cryptoSvc.generateRandomIV(); + Assert.equal(iv.length, 24); + + if (this.gczeal) { + gczeal(0); + } +}); diff --git a/services/crypto/tests/unit/test_crypto_service.js b/services/crypto/tests/unit/test_crypto_service.js new file mode 100644 index 0000000000..8488924113 --- /dev/null +++ b/services/crypto/tests/unit/test_crypto_service.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const idService = Cc["@mozilla.org/identity/crypto-service;1"].getService( + Ci.nsIIdentityCryptoService +); + +const ALG_DSA = "DS160"; +const ALG_RSA = "RS256"; + +const BASE64_URL_ENCODINGS = [ + // The vectors from RFC 4648 are very silly, but we may as well include them. + ["", ""], + ["f", "Zg=="], + ["fo", "Zm8="], + ["foo", "Zm9v"], + ["foob", "Zm9vYg=="], + ["fooba", "Zm9vYmE="], + ["foobar", "Zm9vYmFy"], + + // It's quite likely you could get a string like this in an assertion audience + ["i-like-pie.com", "aS1saWtlLXBpZS5jb20="], + + // A few extra to be really sure + ["andré@example.com", "YW5kcsOpQGV4YW1wbGUuY29t"], + [ + "πόλλ' οἶδ' ἀλώπηξ, ἀλλ' ἐχῖνος ἓν μέγα", + "z4DPjM67zrsnIM6_4by2zrQnIOG8gM67z47PgM63zr4sIOG8gM67zrsnIOG8kM-H4b-Wzr3Ov8-CIOG8k869IM68zq3Os86x", + ], +]; + +let log = Log.repository.getLogger("crypto.service.test"); +(function() { + let appender = new Log.DumpAppender(); + log.level = Log.Level.Debug; + log.addAppender(appender); +})(); + +// When the output of an operation is a +function do_check_eq_or_slightly_less(x, y) { + Assert.ok(x >= y - 3 * 8); +} + +function test_base64_roundtrip() { + let message = "Attack at dawn!"; + let encoded = idService.base64UrlEncode(message); + let decoded = base64UrlDecode(encoded); + Assert.notEqual(message, encoded); + Assert.equal(decoded, message); + run_next_test(); +} + +function test_dsa() { + idService.generateKeyPair(ALG_DSA, function(rv, keyPair) { + log.debug("DSA generateKeyPair finished " + rv); + Assert.ok(Components.isSuccessCode(rv)); + Assert.equal(typeof keyPair.sign, "function"); + Assert.equal(keyPair.keyType, ALG_DSA); + do_check_eq_or_slightly_less( + keyPair.hexDSAGenerator.length, + (1024 / 8) * 2 + ); + do_check_eq_or_slightly_less(keyPair.hexDSAPrime.length, (1024 / 8) * 2); + do_check_eq_or_slightly_less(keyPair.hexDSASubPrime.length, (160 / 8) * 2); + do_check_eq_or_slightly_less( + keyPair.hexDSAPublicValue.length, + (1024 / 8) * 2 + ); + // XXX: test that RSA parameters throw the correct error + + log.debug("about to sign with DSA key"); + keyPair.sign("foo", function(rv2, signature) { + log.debug("DSA sign finished " + rv2 + " " + signature); + Assert.ok(Components.isSuccessCode(rv2)); + Assert.ok(signature.length > 1); + // TODO: verify the signature with the public key + run_next_test(); + }); + }); +} + +function test_rsa() { + idService.generateKeyPair(ALG_RSA, function(rv, keyPair) { + log.debug("RSA generateKeyPair finished " + rv); + Assert.ok(Components.isSuccessCode(rv)); + Assert.equal(typeof keyPair.sign, "function"); + Assert.equal(keyPair.keyType, ALG_RSA); + do_check_eq_or_slightly_less( + keyPair.hexRSAPublicKeyModulus.length, + 2048 / 8 + ); + Assert.ok(keyPair.hexRSAPublicKeyExponent.length > 1); + + log.debug("about to sign with RSA key"); + keyPair.sign("foo", function(rv2, signature) { + log.debug("RSA sign finished " + rv2 + " " + signature); + Assert.ok(Components.isSuccessCode(rv2)); + Assert.ok(signature.length > 1); + run_next_test(); + }); + }); +} + +function test_base64UrlEncode() { + for (let [source, target] of BASE64_URL_ENCODINGS) { + Assert.equal(target, idService.base64UrlEncode(source)); + } + run_next_test(); +} + +function test_base64UrlDecode() { + let utf8Converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + utf8Converter.charset = "UTF-8"; + + // We know the encoding of our inputs - on conversion back out again, make + // sure they're the same. + for (let [source, target] of BASE64_URL_ENCODINGS) { + let result = utf8Converter.ConvertToUnicode(base64UrlDecode(target)); + result += utf8Converter.Finish(); + Assert.equal(source, result); + } + run_next_test(); +} + +add_test(test_base64_roundtrip); +add_test(test_dsa); +add_test(test_rsa); +add_test(test_base64UrlEncode); +add_test(test_base64UrlDecode); diff --git a/services/crypto/tests/unit/test_jwcrypto.js b/services/crypto/tests/unit/test_jwcrypto.js new file mode 100644 index 0000000000..2c51395bb3 --- /dev/null +++ b/services/crypto/tests/unit/test_jwcrypto.js @@ -0,0 +1,240 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "jwcrypto", + "resource://services-crypto/jwcrypto.jsm" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "CryptoService", + "@mozilla.org/identity/crypto-service;1", + "nsIIdentityCryptoService" +); + +Cu.importGlobalProperties(["crypto"]); + +const RP_ORIGIN = "http://123done.org"; +const INTERNAL_ORIGIN = "browserid://"; + +const SECOND_MS = 1000; +const MINUTE_MS = SECOND_MS * 60; +const HOUR_MS = MINUTE_MS * 60; + +// Enable logging from jwcrypto.jsm. +Services.prefs.setCharPref("services.crypto.jwcrypto.log.level", "Debug"); + +function promisify(fn) { + return (...args) => { + return new Promise((res, rej) => { + fn(...args, (err, result) => { + err ? rej(err) : res(result); + }); + }); + }; +} +const generateKeyPair = promisify(jwcrypto.generateKeyPair); +const generateAssertion = promisify(jwcrypto.generateAssertion); + +add_task(async function test_jwe_roundtrip_ecdh_es_encryption() { + const plaintext = crypto.getRandomValues(new Uint8Array(123)); + const remoteKey = await crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveKey"] + ); + const remoteJWK = await crypto.subtle.exportKey("jwk", remoteKey.publicKey); + delete remoteJWK.key_ops; + const jwe = await jwcrypto.generateJWE(remoteJWK, plaintext); + const decrypted = await jwcrypto.decryptJWE(jwe, remoteKey.privateKey); + Assert.deepEqual(plaintext, decrypted); +}); + +add_task(async function test_jwe_header_includes_key_id() { + const plaintext = crypto.getRandomValues(new Uint8Array(123)); + const remoteKey = await crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveKey"] + ); + const remoteJWK = await crypto.subtle.exportKey("jwk", remoteKey.publicKey); + delete remoteJWK.key_ops; + remoteJWK.kid = "key identifier"; + const jwe = await jwcrypto.generateJWE(remoteJWK, plaintext); + let [header /* other items deliberately ignored */] = jwe.split("."); + header = JSON.parse( + new TextDecoder().decode( + ChromeUtils.base64URLDecode(header, { padding: "reject" }) + ) + ); + Assert.equal(header.kid, "key identifier"); +}); + +add_task(async function test_sanity() { + // Shouldn't reject. + await generateKeyPair("DS160"); +}); + +add_task(async function test_generate() { + let kp = await generateKeyPair("DS160"); + Assert.notEqual(kp, null); +}); + +add_task(async function test_get_assertion() { + let kp = await generateKeyPair("DS160"); + let backedAssertion = await generateAssertion("fake-cert", kp, RP_ORIGIN); + Assert.equal(backedAssertion.split("~").length, 2); + Assert.equal(backedAssertion.split(".").length, 3); +}); + +add_task(async function test_rsa() { + let kpo = await generateKeyPair("RS256"); + Assert.notEqual(kpo, undefined); + info(kpo.serializedPublicKey); + let pk = JSON.parse(kpo.serializedPublicKey); + Assert.equal(pk.algorithm, "RS"); + /* TODO + do_check_neq(kpo.sign, null); + do_check_eq(typeof kpo.sign, "function"); + do_check_neq(kpo.userID, null); + do_check_neq(kpo.url, null); + do_check_eq(kpo.url, INTERNAL_ORIGIN); + do_check_neq(kpo.exponent, null); + do_check_neq(kpo.modulus, null); + + // TODO: should sign be async? + let sig = kpo.sign("This is a message to sign"); + + do_check_neq(sig, null); + do_check_eq(typeof sig, "string"); + do_check_true(sig.length > 1); + */ +}); + +add_task(async function test_dsa() { + let kpo = await generateKeyPair("DS160"); + info(kpo.serializedPublicKey); + let pk = JSON.parse(kpo.serializedPublicKey); + Assert.equal(pk.algorithm, "DS"); + /* TODO + do_check_neq(kpo.sign, null); + do_check_eq(typeof kpo.sign, "function"); + do_check_neq(kpo.userID, null); + do_check_neq(kpo.url, null); + do_check_eq(kpo.url, INTERNAL_ORIGIN); + do_check_neq(kpo.generator, null); + do_check_neq(kpo.prime, null); + do_check_neq(kpo.subPrime, null); + do_check_neq(kpo.publicValue, null); + + let sig = kpo.sign("This is a message to sign"); + + do_check_neq(sig, null); + do_check_eq(typeof sig, "string"); + do_check_true(sig.length > 1); + */ +}); + +add_task(async function test_get_assertion_with_offset() { + // Use an arbitrary date in the past to ensure we don't accidentally pass + // this test with current dates, missing offsets, etc. + let serverMsec = Date.parse("Tue Oct 31 2000 00:00:00 GMT-0800"); + + // local clock skew + // clock is 12 hours fast; -12 hours offset must be applied + let localtimeOffsetMsec = -1 * 12 * HOUR_MS; + let localMsec = serverMsec - localtimeOffsetMsec; + + let kp = await generateKeyPair("DS160"); + let backedAssertion = await generateAssertion("fake-cert", kp, RP_ORIGIN, { + duration: MINUTE_MS, + localtimeOffsetMsec, + now: localMsec, + }); + // properly formed + let cert; + let assertion; + [cert, assertion] = backedAssertion.split("~"); + + Assert.equal(cert, "fake-cert"); + Assert.equal(assertion.split(".").length, 3); + + let components = extractComponents(assertion); + + // Expiry is within two minutes, corrected for skew + let exp = parseInt(components.payload.exp, 10); + Assert.ok(exp - serverMsec === MINUTE_MS); +}); + +add_task(async function test_assertion_lifetime() { + let kp = await generateKeyPair("DS160"); + let backedAssertion = await generateAssertion("fake-cert", kp, RP_ORIGIN, { + duration: MINUTE_MS, + }); + // properly formed + let cert; + let assertion; + [cert, assertion] = backedAssertion.split("~"); + + Assert.equal(cert, "fake-cert"); + Assert.equal(assertion.split(".").length, 3); + + let components = extractComponents(assertion); + + // Expiry is within one minute, as we specified above + let exp = parseInt(components.payload.exp, 10); + Assert.ok(Math.abs(Date.now() - exp) > 50 * SECOND_MS); + Assert.ok(Math.abs(Date.now() - exp) <= MINUTE_MS); +}); + +add_task(async function test_audience_encoding_bug972582() { + let audience = "i-like-pie.com"; + let kp = await generateKeyPair("DS160"); + let backedAssertion = await generateAssertion("fake-cert", kp, audience); + let [, /* cert */ assertion] = backedAssertion.split("~"); + let components = extractComponents(assertion); + Assert.equal(components.payload.aud, audience); +}); + +function extractComponents(signedObject) { + if (typeof signedObject != "string") { + throw new Error("malformed signature " + typeof signedObject); + } + + let parts = signedObject.split("."); + if (parts.length != 3) { + throw new Error( + "signed object must have three parts, this one has " + parts.length + ); + } + + let headerSegment = parts[0]; + let payloadSegment = parts[1]; + let cryptoSegment = parts[2]; + + let header = JSON.parse(base64UrlDecode(headerSegment)); + let payload = JSON.parse(base64UrlDecode(payloadSegment)); + + // Ensure well-formed header + Assert.equal(Object.keys(header).length, 1); + Assert.ok(!!header.alg); + + // Ensure well-formed payload + for (let field of ["exp", "aud"]) { + Assert.ok(!!payload[field]); + } + + return { header, payload, headerSegment, payloadSegment, cryptoSegment }; +} diff --git a/services/crypto/tests/unit/test_load_modules.js b/services/crypto/tests/unit/test_load_modules.js new file mode 100644 index 0000000000..1ab3a888d9 --- /dev/null +++ b/services/crypto/tests/unit/test_load_modules.js @@ -0,0 +1,12 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const modules = ["utils.js", "WeaveCrypto.js"]; + +function run_test() { + for (let m of modules) { + let resource = "resource://services-crypto/" + m; + _("Attempting to import: " + resource); + ChromeUtils.import(resource, {}); + } +} diff --git a/services/crypto/tests/unit/test_utils_hawk.js b/services/crypto/tests/unit/test_utils_hawk.js new file mode 100644 index 0000000000..bfeb5e859f --- /dev/null +++ b/services/crypto/tests/unit/test_utils_hawk.js @@ -0,0 +1,346 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CryptoUtils } = ChromeUtils.import( + "resource://services-crypto/utils.js" +); + +function run_test() { + initTestLogging(); + + run_next_test(); +} + +add_task(async function test_hawk() { + let compute = CryptoUtils.computeHAWK; + + let method = "POST"; + let ts = 1353809207; + let nonce = "Ygvqdz"; + + let credentials = { + id: "123456", + key: "2983d45yun89q", + }; + + let uri_https = CommonUtils.makeURI( + "https://example.net/somewhere/over/the/rainbow" + ); + let opts = { + credentials, + ext: "Bazinga!", + ts, + nonce, + payload: "something to write about", + contentType: "text/plain", + }; + + let result = await compute(uri_https, method, opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + Assert.equal(result.artifacts.ts, ts); + Assert.equal(result.artifacts.nonce, nonce); + Assert.equal(result.artifacts.method, method); + Assert.equal(result.artifacts.resource, "/somewhere/over/the/rainbow"); + Assert.equal(result.artifacts.host, "example.net"); + Assert.equal(result.artifacts.port, 443); + Assert.equal( + result.artifacts.hash, + "2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=" + ); + Assert.equal(result.artifacts.ext, "Bazinga!"); + + let opts_noext = { + credentials, + ts, + nonce, + payload: "something to write about", + contentType: "text/plain", + }; + result = await compute(uri_https, method, opts_noext); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'mac="HTgtd0jPI6E4izx8e4OHdO36q00xFCU0FolNq3RiCYs="' + ); + Assert.equal(result.artifacts.ts, ts); + Assert.equal(result.artifacts.nonce, nonce); + Assert.equal(result.artifacts.method, method); + Assert.equal(result.artifacts.resource, "/somewhere/over/the/rainbow"); + Assert.equal(result.artifacts.host, "example.net"); + Assert.equal(result.artifacts.port, 443); + Assert.equal( + result.artifacts.hash, + "2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=" + ); + + /* Leaving optional fields out should work, although of course then we can't + * assert much about the resulting hashes. The resulting header should look + * roughly like: + * Hawk id="123456", ts="1378764955", nonce="QkynqsrS44M=", mac="/C5NsoAs2fVn+d/I5wMfwe2Gr1MZyAJ6pFyDHG4Gf9U=" + */ + + result = await compute(uri_https, method, { credentials }); + let fields = result.field.split(" "); + Assert.equal(fields[0], "Hawk"); + Assert.equal(fields[1], 'id="123456",'); // from creds.id + Assert.ok(fields[2].startsWith('ts="')); + /* The HAWK spec calls for seconds-since-epoch, not ms-since-epoch. + * Warning: this test will fail in the year 33658, and for time travellers + * who journey earlier than 2001. Please plan accordingly. */ + Assert.ok(result.artifacts.ts > 1000 * 1000 * 1000); + Assert.ok(result.artifacts.ts < 1000 * 1000 * 1000 * 1000); + Assert.ok(fields[3].startsWith('nonce="')); + Assert.equal(fields[3].length, 'nonce="12345678901=",'.length); + Assert.equal(result.artifacts.nonce.length, "12345678901=".length); + + let result2 = await compute(uri_https, method, { credentials }); + Assert.notEqual(result.artifacts.nonce, result2.artifacts.nonce); + + /* Using an upper-case URI hostname shouldn't affect the hash. */ + + let uri_https_upper = CommonUtils.makeURI( + "https://EXAMPLE.NET/somewhere/over/the/rainbow" + ); + result = await compute(uri_https_upper, method, opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + + /* Using a lower-case method name shouldn't affect the hash. */ + result = await compute(uri_https_upper, method.toLowerCase(), opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + + /* The localtimeOffsetMsec field should be honored. HAWK uses this to + * compensate for clock skew between client and server: if the request is + * rejected with a timestamp out-of-range error, the error includes the + * server's time, and the client computes its clock offset and tries again. + * Clients can remember this offset for a while. + */ + + result = await compute(uri_https, method, { + credentials, + now: 1378848968650, + }); + Assert.equal(result.artifacts.ts, 1378848968); + + result = await compute(uri_https, method, { + credentials, + now: 1378848968650, + localtimeOffsetMsec: 1000 * 1000, + }); + Assert.equal(result.artifacts.ts, 1378848968 + 1000); + + /* Search/query-args in URIs should be included in the hash. */ + let makeURI = CommonUtils.makeURI; + result = await compute(makeURI("http://example.net/path"), method, opts); + Assert.equal(result.artifacts.resource, "/path"); + Assert.equal( + result.artifacts.mac, + "WyKHJjWaeYt8aJD+H9UeCWc0Y9C+07ooTmrcrOW4MPI=" + ); + + result = await compute(makeURI("http://example.net/path/"), method, opts); + Assert.equal(result.artifacts.resource, "/path/"); + Assert.equal( + result.artifacts.mac, + "xAYp2MgZQFvTKJT9u8nsvMjshCRRkuaeYqQbYSFp9Qw=" + ); + + result = await compute( + makeURI("http://example.net/path?query=search"), + method, + opts + ); + Assert.equal(result.artifacts.resource, "/path?query=search"); + Assert.equal( + result.artifacts.mac, + "C06a8pip2rA4QkBiosEmC32WcgFcW/R5SQC6kUWyqho=" + ); + + /* Test handling of the payload, which is supposed to be a bytestring + (String with codepoints from U+0000 to U+00FF, pre-encoded). */ + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal(result.artifacts.hash, undefined); + Assert.equal( + result.artifacts.mac, + "S3f8E4hAURAqJxOlsYugkPZxLoRYrClgbSQ/3FmKMbY=" + ); + + // Empty payload changes nothing. + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: null, + }); + Assert.equal(result.artifacts.hash, undefined); + Assert.equal( + result.artifacts.mac, + "S3f8E4hAURAqJxOlsYugkPZxLoRYrClgbSQ/3FmKMbY=" + ); + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "hello", + }); + Assert.equal( + result.artifacts.hash, + "uZJnFj0XVBA6Rs1hEvdIDf8NraM0qRNXdFbR3NEQbVA=" + ); + Assert.equal( + result.artifacts.mac, + "pLsHHzngIn5CTJhWBtBr+BezUFvdd/IadpTp/FYVIRM=" + ); + + // update, utf-8 payload + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "andré@example.org", // non-ASCII + }); + Assert.equal( + result.artifacts.hash, + "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=" + ); + Assert.equal( + result.artifacts.mac, + "2B++3x5xfHEZbPZGDiK3IwfPZctkV4DUr2ORg1vIHvk=" + ); + + /* If "hash" is provided, "payload" is ignored. */ + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + hash: "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=", + payload: "something else", + }); + Assert.equal( + result.artifacts.hash, + "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=" + ); + Assert.equal( + result.artifacts.mac, + "2B++3x5xfHEZbPZGDiK3IwfPZctkV4DUr2ORg1vIHvk=" + ); + + // the payload "hash" is also non-urlsafe base64 (+/) + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "something else", + }); + Assert.equal( + result.artifacts.hash, + "lERFXr/IKOaAoYw+eBseDUSwmqZTX0uKZpcWLxsdzt8=" + ); + Assert.equal( + result.artifacts.mac, + "jiZuhsac35oD7IdcblhFncBr8tJFHcwWLr8NIYWr9PQ=" + ); + + /* Test non-ascii hostname. HAWK (via the node.js "url" module) punycodes + * "ëxample.net" into "xn--xample-ova.net" before hashing. I still think + * punycode was a bad joke that got out of the lab and into a spec. + */ + + result = await compute(makeURI("http://ëxample.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "pILiHl1q8bbNQIdaaLwAFyaFmDU70MGehFuCs3AA5M0=" + ); + Assert.equal(result.artifacts.host, "xn--xample-ova.net"); + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + ext: 'backslash=\\ quote=" EOF', + }); + Assert.equal( + result.artifacts.mac, + "BEMW76lwaJlPX4E/dajF970T6+GzWvaeyLzUt8eOTOc=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ext="backslash=\\\\ quote=\\" EOF", mac="BEMW76lwaJlPX4E/dajF970T6+GzWvaeyLzUt8eOTOc="' + ); + + result = await compute(makeURI("http://example.net:1234/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", mac="6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE="' + ); + + /* HAWK (the node.js library) uses a URL parser which stores the "port" + * field as a string, but makeURI() gives us an integer. So we'll diverge + * on ports with a leading zero. This test vector would fail on the node.js + * library (HAWK-1.1.1), where they get a MAC of + * "T+GcAsDO8GRHIvZLeepSvXLwDlFJugcZroAy9+uAtcw=". I think HAWK should be + * updated to do what we do here, so port="01234" should get the same hash + * as port="1234". + */ + result = await compute(makeURI("http://example.net:01234/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", mac="6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE="' + ); +}); + +add_test(function test_strip_header_attributes() { + let strip = CryptoUtils.stripHeaderAttributes; + + Assert.equal(strip(undefined), ""); + Assert.equal(strip("text/plain"), "text/plain"); + Assert.equal(strip("TEXT/PLAIN"), "text/plain"); + Assert.equal(strip(" text/plain "), "text/plain"); + Assert.equal(strip("text/plain ; charset=utf-8 "), "text/plain"); + + run_next_test(); +}); diff --git a/services/crypto/tests/unit/test_utils_httpmac.js b/services/crypto/tests/unit/test_utils_httpmac.js new file mode 100644 index 0000000000..e0936b9142 --- /dev/null +++ b/services/crypto/tests/unit/test_utils_httpmac.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CryptoUtils } = ChromeUtils.import( + "resource://services-crypto/utils.js" +); + +add_test(function setup() { + initTestLogging(); + run_next_test(); +}); + +add_task(async function test_sha1() { + _("Ensure HTTP MAC SHA1 generation works as expected."); + + let id = "vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7"; + let key = "b8u1cc5iiio5o319og7hh8faf2gi5ym4aq0zwf112cv1287an65fudu5zj7zo7dz"; + let ts = 1329181221; + let method = "GET"; + let nonce = "wGX71"; + let uri = CommonUtils.makeURI("http://10.250.2.176/alias/"); + + let result = await CryptoUtils.computeHTTPMACSHA1(id, key, method, uri, { + ts, + nonce, + }); + + Assert.equal(btoa(result.mac), "jzh5chjQc2zFEvLbyHnPdX11Yck="); + + Assert.equal( + result.getHeader(), + 'MAC id="vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7", ' + + 'ts="1329181221", nonce="wGX71", mac="jzh5chjQc2zFEvLbyHnPdX11Yck="' + ); + + let ext = "EXTRA DATA; foo,bar=1"; + + result = await CryptoUtils.computeHTTPMACSHA1(id, key, method, uri, { + ts, + nonce, + ext, + }); + Assert.equal(btoa(result.mac), "bNf4Fnt5k6DnhmyipLPkuZroH68="); + Assert.equal( + result.getHeader(), + 'MAC id="vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7", ' + + 'ts="1329181221", nonce="wGX71", mac="bNf4Fnt5k6DnhmyipLPkuZroH68=", ' + + 'ext="EXTRA DATA; foo,bar=1"' + ); +}); + +add_task(async function test_nonce_length() { + _("Ensure custom nonce lengths are honoured."); + + function get_mac(length) { + let uri = CommonUtils.makeURI("http://example.com/"); + return CryptoUtils.computeHTTPMACSHA1("foo", "bar", "GET", uri, { + nonce_bytes: length, + }); + } + + let result = await get_mac(12); + Assert.equal(12, atob(result.nonce).length); + + result = await get_mac(2); + Assert.equal(2, atob(result.nonce).length); + + result = await get_mac(0); + Assert.equal(8, atob(result.nonce).length); + + result = await get_mac(-1); + Assert.equal(8, atob(result.nonce).length); +}); diff --git a/services/crypto/tests/unit/xpcshell.ini b/services/crypto/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..ec5f9a4211 --- /dev/null +++ b/services/crypto/tests/unit/xpcshell.ini @@ -0,0 +1,17 @@ +[DEFAULT] +head = head_helpers.js ../../../common/tests/unit/head_helpers.js +firefox-appdir = browser +support-files = + !/services/common/tests/unit/head_helpers.js + +[test_load_modules.js] + +[test_crypto_crypt.js] +[test_crypto_random.js] +[test_crypto_service.js] +skip-if = appname == 'thunderbird' +[test_jwcrypto.js] +skip-if = appname == 'thunderbird' + +[test_utils_hawk.js] +[test_utils_httpmac.js] |