JWT auth proposal types and kv maps (#1851)

This commit is contained in:
Maik Riechert 2020-11-09 09:55:36 +01:00 коммит произвёл GitHub
Родитель efc3527b57
Коммит 70b09e53cf
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
21 изменённых файлов: 982 добавлений и 23 удалений

Просмотреть файл

@ -716,6 +716,12 @@ if(BUILD_TESTS)
${CCF_DIR}/src/runtime_config/gov_ca_cert_mismatch.lua
CONSENSUS cft
)
add_e2e_test(
NAME jwt_test
PYTHON_SCRIPT ${CMAKE_SOURCE_DIR}/tests/jwt_test.py
CONSENSUS cft
)
endif()
if(BUILD_SMALLBANK)

Просмотреть файл

@ -435,6 +435,35 @@ def update_ca_cert(cert_name, cert_path, skip_checks=False, **kwargs):
return build_proposal("update_ca_cert", args, **kwargs)
@cli_proposal
def set_jwt_issuer(json_path: str, **kwargs):
with open(json_path) as f:
obj = json.load(f)
args = {
"issuer": obj["issuer"],
"key_filter": obj.get("key_filter", "all"),
"key_policy": obj.get("key_policy"),
"jwks": obj.get("jwks"),
}
return build_proposal("set_jwt_issuer", args, **kwargs)
@cli_proposal
def remove_jwt_issuer(issuer: str, **kwargs):
args = {"issuer": issuer}
return build_proposal("remove_jwt_issuer", args, **kwargs)
@cli_proposal
def set_jwt_public_signing_keys(issuer: str, jwks_path: str, **kwargs):
with open(jwks_path) as f:
jwks = json.load(f)
if "keys" not in jwks:
raise ValueError("not a JWKS document")
args = {"issuer": issuer, "jwks": jwks}
return build_proposal("set_jwt_public_signing_keys", args, **kwargs)
if __name__ == "__main__":
parser = argparse.ArgumentParser()

Просмотреть файл

@ -26,6 +26,10 @@ namespace kv::serialisers
std::memcpy(s.data(), (uint8_t*)&t, sizeof(t));
return s;
}
else if constexpr (std::is_same_v<T, std::string>)
{
return SerialisedEntry(t.begin(), t.end());
}
else
{
static_assert(
@ -51,6 +55,10 @@ namespace kv::serialisers
}
return *(T*)rep.data();
}
else if constexpr (std::is_same_v<T, std::string>)
{
return T(rep.begin(), rep.end());
}
else
{
static_assert(

Просмотреть файл

@ -88,6 +88,11 @@ namespace ccf
static constexpr auto SNAPSHOT_EVIDENCE =
"public:ccf.gov.snapshot_evidence";
static constexpr auto CA_CERT_DERS = "public:ccf.gov.ca_cert_ders";
static constexpr auto JWT_ISSUERS = "public:ccf.gov.jwt_issuers";
static constexpr auto JWT_PUBLIC_SIGNING_KEYS =
"public:ccf.gov.jwt_public_signing_keys";
static constexpr auto JWT_PUBLIC_SIGNING_KEY_ISSUER =
"public:ccf.gov.jwt_public_signing_key_issuer";
static constexpr auto ENDPOINTS = "public:ccf.gov.endpoints";
static constexpr auto SIGNATURES = "public:ccf.internal.signatures";

61
src/node/jwt.h Normal file
Просмотреть файл

@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the Apache 2.0 License.
#pragma once
#include "ds/json.h"
#include "entities.h"
#include "kv/map.h"
#include <msgpack/msgpack.hpp>
#include <optional>
namespace ccf
{
struct JwtIssuerKeyPolicy
{
// OE claim name -> hex-encoded claim value
// See openenclave/attestation/verifier.h
std::optional<std::map<std::string, std::string>> sgx_claims;
bool operator!=(const JwtIssuerKeyPolicy& rhs) const
{
return rhs.sgx_claims != sgx_claims;
}
MSGPACK_DEFINE(sgx_claims);
};
DECLARE_JSON_TYPE(JwtIssuerKeyPolicy);
DECLARE_JSON_REQUIRED_FIELDS(JwtIssuerKeyPolicy, sgx_claims);
enum class JwtIssuerKeyFilter
{
All,
SGX
};
DECLARE_JSON_ENUM(
JwtIssuerKeyFilter,
{{JwtIssuerKeyFilter::All, "all"}, {JwtIssuerKeyFilter::SGX, "sgx"}});
struct JwtIssuerMetadata
{
JwtIssuerKeyFilter key_filter;
std::optional<JwtIssuerKeyPolicy> key_policy;
MSGPACK_DEFINE(key_filter, key_policy);
};
DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(JwtIssuerMetadata);
DECLARE_JSON_REQUIRED_FIELDS(JwtIssuerMetadata, key_filter);
DECLARE_JSON_OPTIONAL_FIELDS(JwtIssuerMetadata, key_policy);
using JwtIssuer = std::string;
using JwtKeyId = std::string;
using JwtIssuers = kv::Map<JwtIssuer, JwtIssuerMetadata>;
using JwtPublicSigningKeys = kv::RawCopySerialisedMap<JwtKeyId, Cert>;
using JwtPublicSigningKeyIssuer =
kv::RawCopySerialisedMap<JwtKeyId, JwtIssuer>;
}
MSGPACK_ADD_ENUM(ccf::JwtIssuerKeyFilter);

Просмотреть файл

@ -13,6 +13,7 @@
#include "consensus/aft/revealed_nonces.h"
#include "entities.h"
#include "governance_history.h"
#include "jwt.h"
#include "kv/map.h"
#include "kv/store.h"
#include "members.h"
@ -60,6 +61,10 @@ namespace ccf
CACertDERs ca_certs;
JwtIssuers jwt_issuers;
JwtPublicSigningKeys jwt_public_signing_keys;
JwtPublicSigningKeyIssuer jwt_public_signing_key_issuer;
//
// User tables
//
@ -118,6 +123,9 @@ namespace ccf
submitted_shares(Tables::SUBMITTED_SHARES),
config(Tables::CONFIGURATION),
ca_certs(Tables::CA_CERT_DERS),
jwt_issuers(Tables::JWT_ISSUERS),
jwt_public_signing_keys(Tables::JWT_PUBLIC_SIGNING_KEYS),
jwt_public_signing_key_issuer(Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER),
users(Tables::USERS),
user_certs(Tables::USER_CERT_DERS),
user_client_signatures(Tables::USER_CLIENT_SIGNATURES),
@ -154,6 +162,9 @@ namespace ccf
std::ref(member_client_signatures),
std::ref(config),
std::ref(ca_certs),
std::ref(jwt_issuers),
std::ref(jwt_public_signing_keys),
std::ref(jwt_public_signing_key_issuer),
std::ref(users),
std::ref(user_certs),
std::ref(user_client_signatures),

Просмотреть файл

@ -6,6 +6,7 @@
#include "lua_interp/lua_json.h"
#include "lua_interp/tx_script_runner.h"
#include "node/genesis_gen.h"
#include "node/jwt.h"
#include "node/members.h"
#include "node/nodes.h"
#include "node/quote.h"
@ -31,6 +32,20 @@
namespace ccf
{
static oe_result_t oe_verify_attestation_certificate_with_evidence_cb(
oe_claim_t* claims, size_t claims_length, void* arg)
{
auto claims_map = (std::map<std::string, std::vector<uint8_t>>*)arg;
for (size_t i = 0; i < claims_length; i++)
{
std::string claim_name(claims[i].name);
std::vector<uint8_t> claim_value(
claims[i].value, claims[i].value + claims[i].value_size);
claims_map->emplace(std::move(claim_name), std::move(claim_value));
}
return OE_OK;
}
class MemberTsr : public lua::TxScriptRunner
{
void setup_environment(
@ -54,20 +69,6 @@ namespace ccf
return 1;
}
static oe_result_t oe_verify_attestation_certificate_with_evidence_cb(
oe_claim_t* claims, size_t claims_length, void* arg)
{
auto claims_map = (std::map<std::string, std::vector<uint8_t>>*)arg;
for (size_t i = 0; i < claims_length; i++)
{
std::string claim_name(claims[i].name);
std::vector<uint8_t> claim_value(
claims[i].value, claims[i].value + claims[i].value_size);
claims_map->emplace(std::move(claim_name), std::move(claim_value));
}
return OE_OK;
}
static int lua_verify_cert_and_get_claims(lua_State* l)
{
LOG_INFO_FMT("lua_verify_cert_and_get_claims");
@ -170,6 +171,57 @@ namespace ccf
DECLARE_JSON_TYPE(DeployJsApp)
DECLARE_JSON_REQUIRED_FIELDS(DeployJsApp, bundle)
struct JsonWebKey
{
std::vector<std::string> x5c;
std::string kid;
std::string kty;
bool operator==(const JsonWebKey& rhs) const
{
return x5c == rhs.x5c && kid == rhs.kid && kty == rhs.kty;
}
};
DECLARE_JSON_TYPE(JsonWebKey)
DECLARE_JSON_REQUIRED_FIELDS(JsonWebKey, x5c, kid, kty)
struct JsonWebKeySet
{
std::vector<JsonWebKey> keys;
bool operator!=(const JsonWebKeySet& rhs) const
{
return keys != rhs.keys;
}
};
DECLARE_JSON_TYPE(JsonWebKeySet)
DECLARE_JSON_REQUIRED_FIELDS(JsonWebKeySet, keys)
struct SetJwtIssuer : public ccf::JwtIssuerMetadata
{
std::string issuer;
std::optional<JsonWebKeySet> jwks;
};
DECLARE_JSON_TYPE_WITH_BASE_AND_OPTIONAL_FIELDS(
SetJwtIssuer, ccf::JwtIssuerMetadata)
DECLARE_JSON_REQUIRED_FIELDS(SetJwtIssuer, issuer)
DECLARE_JSON_OPTIONAL_FIELDS(SetJwtIssuer, jwks)
struct RemoveJwtIssuer
{
std::string issuer;
};
DECLARE_JSON_TYPE(RemoveJwtIssuer)
DECLARE_JSON_REQUIRED_FIELDS(RemoveJwtIssuer, issuer)
struct SetJwtPublicSigningKeys
{
std::string issuer;
JsonWebKeySet jwks;
};
DECLARE_JSON_TYPE(SetJwtPublicSigningKeys)
DECLARE_JSON_REQUIRED_FIELDS(SetJwtPublicSigningKeys, issuer, jwks)
class MemberEndpoints : public CommonEndpointRegistry
{
private:
@ -330,6 +382,157 @@ namespace ccf
return tx_modules->remove(name);
}
void remove_jwt_keys(kv::Tx& tx, std::string issuer)
{
auto keys = tx.get_view(this->network.jwt_public_signing_keys);
auto key_issuer =
tx.get_view(this->network.jwt_public_signing_key_issuer);
key_issuer->foreach(
[&issuer, &keys, &key_issuer](const auto& k, const auto& v) {
if (v == issuer)
{
keys->remove(k);
key_issuer->remove(k);
}
return true;
});
}
bool set_jwt_public_signing_keys(
kv::Tx& tx,
ObjectId proposal_id,
std::string issuer,
const JwtIssuerMetadata& issuer_metadata,
const JsonWebKeySet& jwks)
{
auto keys = tx.get_view(this->network.jwt_public_signing_keys);
auto key_issuer =
tx.get_view(this->network.jwt_public_signing_key_issuer);
// add keys
if (jwks.keys.empty())
{
LOG_FAIL_FMT("Proposal {}: JWKS has no keys", proposal_id);
return false;
}
std::map<std::string, std::vector<uint8_t>> new_keys;
for (auto& jwk : jwks.keys)
{
if (keys->has(jwk.kid) && key_issuer->get(jwk.kid).value() != issuer)
{
LOG_FAIL_FMT(
"Proposal {}: key id {} already added for different issuer",
proposal_id,
jwk.kid);
return false;
}
if (jwk.x5c.empty())
{
LOG_FAIL_FMT("Proposal {}: JWKS is invalid (empty x5c)", proposal_id);
return false;
}
auto& der_base64 = jwk.x5c[0];
auto der = tls::raw_from_b64(der_base64);
std::map<std::string, std::vector<uint8_t>> claims;
bool has_key_policy_sgx_claims =
issuer_metadata.key_policy.has_value() &&
issuer_metadata.key_policy.value().sgx_claims.has_value() &&
!issuer_metadata.key_policy.value().sgx_claims.value().empty();
if (
issuer_metadata.key_filter == JwtIssuerKeyFilter::SGX ||
has_key_policy_sgx_claims)
{
oe_verifier_initialize();
oe_verify_attestation_certificate_with_evidence(
der.data(),
der.size(),
oe_verify_attestation_certificate_with_evidence_cb,
&claims);
}
if (
issuer_metadata.key_filter == JwtIssuerKeyFilter::SGX &&
claims.empty())
{
LOG_INFO_FMT(
"Proposal {}: Skipping JWT signing key with kid {} (not OE "
"attested)",
proposal_id,
jwk.kid);
continue;
}
if (has_key_policy_sgx_claims)
{
for (auto& [claim_name, expected_claim_val_hex] :
issuer_metadata.key_policy.value().sgx_claims.value())
{
if (claims.find(claim_name) == claims.end())
{
LOG_FAIL_FMT(
"Proposal {}: JWKS kid {} is missing the {} SGX claim",
proposal_id,
jwk.kid,
claim_name);
return false;
}
auto& actual_claim_val = claims[claim_name];
auto actual_claim_val_hex =
fmt::format("{:02x}", fmt::join(actual_claim_val, ""));
if (expected_claim_val_hex != actual_claim_val_hex)
{
LOG_FAIL_FMT(
"Proposal {}: JWKS kid {} has a mismatching {} SGX claim",
proposal_id,
jwk.kid,
claim_name);
return false;
}
}
}
else
{
try
{
tls::check_is_cert(der);
}
catch (std::exception& exc)
{
LOG_FAIL_FMT(
"Proposal {}: JWKS kid {} has an invalid X.509 certificate: "
"{}",
proposal_id,
jwk.kid,
exc.what());
return false;
}
}
LOG_INFO_FMT(
"Proposal {}: Storing JWT signing key with kid {}",
proposal_id,
jwk.kid);
new_keys.emplace(jwk.kid, der);
}
if (new_keys.empty())
{
LOG_FAIL_FMT(
"Proposal {}: no keys left after applying filter", proposal_id);
return false;
}
remove_jwt_keys(tx, issuer);
for (auto& [kid, der] : new_keys)
{
keys->put(kid, der);
key_issuer->put(kid, issuer);
}
return true;
}
void remove_endpoints(kv::Tx& tx)
{
auto endpoints_view =
@ -523,6 +726,60 @@ namespace ccf
users_view->put(parsed.user_id, user_info.value());
return true;
}},
{"set_jwt_issuer",
[this](ObjectId proposal_id, kv::Tx& tx, const nlohmann::json& args) {
const auto parsed = args.get<SetJwtIssuer>();
auto issuers = tx.get_view(this->network.jwt_issuers);
bool success = true;
if (parsed.jwks.has_value())
{
success = set_jwt_public_signing_keys(
tx, proposal_id, parsed.issuer, parsed, parsed.jwks.value());
}
if (success)
{
issuers->put(parsed.issuer, parsed);
}
return success;
}},
{"remove_jwt_issuer",
[this](ObjectId proposal_id, kv::Tx& tx, const nlohmann::json& args) {
const auto parsed = args.get<RemoveJwtIssuer>();
const auto issuer = parsed.issuer;
auto issuers = tx.get_view(this->network.jwt_issuers);
if (!issuers->remove(issuer))
{
LOG_FAIL_FMT(
"Proposal {}: {} is not a valid issuer", proposal_id, issuer);
return false;
}
remove_jwt_keys(tx, issuer);
return true;
}},
{"set_jwt_public_signing_keys",
[this](ObjectId proposal_id, kv::Tx& tx, const nlohmann::json& args) {
const auto parsed = args.get<SetJwtPublicSigningKeys>();
auto issuers = tx.get_view(this->network.jwt_issuers);
auto issuer_metadata_ = issuers->get(parsed.issuer);
if (!issuer_metadata_.has_value())
{
LOG_FAIL_FMT(
"Proposal {}: {} is not a valid issuer",
proposal_id,
parsed.issuer);
return false;
}
auto& issuer_metadata = issuer_metadata_.value();
return set_jwt_public_signing_keys(
tx, proposal_id, parsed.issuer, issuer_metadata, parsed.jwks);
}},
// accept a node
{"trust_node",
[this](ObjectId proposal_id, kv::Tx& tx, const nlohmann::json& args) {

Просмотреть файл

@ -27,7 +27,10 @@ namespace ccf
Tables::MODULES,
Tables::SERVICE,
Tables::CONFIGURATION,
Tables::CA_CERT_DERS}},
Tables::CA_CERT_DERS,
Tables::JWT_ISSUERS,
Tables::JWT_PUBLIC_SIGNING_KEYS,
Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER}},
{MEMBER_CAN_PROPOSE,
{Tables::USERS,
@ -38,7 +41,10 @@ namespace ccf
Tables::APP_SCRIPTS,
Tables::MODULES,
Tables::CONFIGURATION,
Tables::CA_CERT_DERS}},
Tables::CA_CERT_DERS,
Tables::JWT_ISSUERS,
Tables::JWT_PUBLIC_SIGNING_KEYS,
Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER}},
{USER_APP_CAN_READ_ONLY,
{Tables::MEMBERS,
@ -50,5 +56,8 @@ namespace ccf
Tables::APP_SCRIPTS,
Tables::MODULES,
Tables::GOV_HISTORY,
Tables::CA_CERT_DERS}}};
Tables::CA_CERT_DERS,
Tables::JWT_ISSUERS,
Tables::JWT_PUBLIC_SIGNING_KEYS,
Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER}}};
}

Просмотреть файл

@ -881,4 +881,18 @@ namespace tls
mbedtls_x509_crt_free(&c);
return tls::Pem(data, len);
}
inline void check_is_cert(CBuffer der)
{
mbedtls_x509_crt cert;
mbedtls_x509_crt_init(&cert);
int rc = mbedtls_x509_crt_parse(&cert, der.p, der.n);
mbedtls_x509_crt_free(&cert);
if (rc != 0)
{
throw std::runtime_error(fmt::format(
"Failed to parse certificate, mbedtls_x509_crt_parse: {}",
tls::error_string(rc)));
}
}
}

Просмотреть файл

@ -328,6 +328,23 @@ class Consortium:
# Large apps take a long time to process - wait longer than normal for commit
return self.vote_using_majority(remote_node, proposal, careful_vote, timeout=10)
def set_jwt_issuer(self, remote_node, json_path):
proposal_body, careful_vote = self.make_proposal("set_jwt_issuer", json_path)
proposal = self.get_any_active_member().propose(remote_node, proposal_body)
return self.vote_using_majority(remote_node, proposal, careful_vote)
def remove_jwt_issuer(self, remote_node, issuer):
proposal_body, careful_vote = self.make_proposal("remove_jwt_issuer", issuer)
proposal = self.get_any_active_member().propose(remote_node, proposal_body)
return self.vote_using_majority(remote_node, proposal, careful_vote)
def set_jwt_public_signing_keys(self, remote_node, issuer, jwks_path):
proposal_body, careful_vote = self.make_proposal(
"set_jwt_public_signing_keys", issuer, jwks_path
)
proposal = self.get_any_active_member().propose(remote_node, proposal_body)
return self.vote_using_majority(remote_node, proposal, careful_vote)
def accept_recovery(self, remote_node):
proposal_body, careful_vote = self.make_proposal("accept_recovery")
proposal = self.get_any_active_member().propose(remote_node, proposal_body)

Просмотреть файл

@ -5,16 +5,23 @@ from typing import Tuple, Optional
import base64
from enum import IntEnum
import secrets
import datetime
import coincurve
from coincurve._libsecp256k1 import ffi, lib # pylint: disable=no-name-in-module
from coincurve.context import GLOBAL_CONTEXT
from cryptography.exceptions import InvalidSignature
from cryptography.x509 import load_der_x509_certificate
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.x509 import (
load_pem_x509_certificate,
load_der_x509_certificate,
)
from cryptography.hazmat.primitives.asymmetric import ec, rsa, padding
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
load_pem_public_key,
Encoding,
PrivateFormat,
PublicFormat,
@ -23,6 +30,8 @@ from cryptography.hazmat.primitives.serialization import (
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
import jwt
RECOMMENDED_RSA_PUBLIC_EXPONENT = 65537
@ -91,7 +100,7 @@ def verify_recover_secp256k1_bc(
def verify_request_sig(raw_cert, sig, req, request_body, md):
try:
cert = load_der_x509_certificate(raw_cert, backend=default_backend())
cert = x509.load_der_x509_certificate(raw_cert, backend=default_backend())
digest = (
hashes.SHA256()
@ -140,6 +149,28 @@ def generate_rsa_keypair(key_size: int) -> Tuple[str, str]:
return priv_pem, pub_pem
def generate_cert(priv_key_pem: str) -> str:
priv = load_pem_private_key(priv_key_pem.encode("ascii"), None, default_backend())
pub = priv.public_key()
subject = issuer = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, u"dummy"),
]
)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(pub)
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=10))
.sign(priv, hashes.SHA256(), default_backend())
)
return cert.public_bytes(Encoding.PEM).decode("ascii")
def unwrap_key_rsa_oaep(
wrapped_key: bytes, wrapping_key_priv_pem: str, label: Optional[bytes] = None
) -> bytes:
@ -155,3 +186,30 @@ def unwrap_key_rsa_oaep(
),
)
return unwrapped
def pub_key_pem_to_der(pem: str) -> bytes:
cert = load_pem_public_key(pem.encode("ascii"), default_backend())
return cert.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
def create_jwt(body_claims: dict, key_priv_pem: str, key_id: str) -> str:
return jwt.encode(
body_claims, key_priv_pem, algorithm="RS256", headers={"kid": key_id}
).decode("ascii")
def cert_pem_to_der(pem: str) -> bytes:
cert = load_pem_x509_certificate(pem.encode("ascii"), default_backend())
return cert.public_bytes(Encoding.DER)
def cert_der_to_pem(der: bytes) -> str:
cert = load_der_x509_certificate(der, default_backend())
return cert.public_bytes(Encoding.PEM).decode("ascii")
def are_certs_equal(pem1: str, pem2: str) -> bool:
cert1 = load_pem_x509_certificate(pem1.encode(), default_backend())
cert2 = load_pem_x509_certificate(pem2.encode(), default_backend())
return cert1 == cert2

Просмотреть файл

@ -111,6 +111,12 @@ def cli_args(add=lambda x: None, parser=None, accept_unknown=False):
parser.add_argument("-s", "--app-script", help="Path to app script")
parser.add_argument("-j", "--js-app-script", help="Path to js app script")
parser.add_argument("--js-app-bundle", help="Path to js app bundle")
parser.add_argument(
"--jwt-issuer",
help="Path to JSON file with JWT issuer definition",
action="append",
default=[],
)
parser.add_argument(
"-o",
"--network-only",

Просмотреть файл

@ -381,6 +381,9 @@ class Network:
remote_node=primary, app_bundle_path=args.js_app_bundle
)
for path in args.jwt_issuer:
self.consortium.set_jwt_issuer(remote_node=primary, json_path=path)
self.consortium.add_users(primary, initial_users)
LOG.info("Initial set of users added")

270
tests/jwt_test.py Normal file
Просмотреть файл

@ -0,0 +1,270 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the Apache 2.0 License.
import os
import tempfile
import json
import base64
import infra.network
import infra.path
import infra.proc
import infra.net
import infra.e2e_args
import suite.test_requirements as reqs
from loguru import logger as LOG
this_dir = os.path.dirname(__file__)
def create_jwks(kid, cert_pem, test_invalid_is_key=False):
der_b64 = base64.b64encode(
infra.crypto.cert_pem_to_der(cert_pem)
if not test_invalid_is_key
else infra.crypto.pub_key_pem_to_der(cert_pem)
).decode("ascii")
return {"keys": [{"kty": "RSA", "kid": kid, "x5c": [der_b64]}]}
@reqs.description("JWT without key policy")
def test_jwt_without_key_policy(network, args):
primary, _ = network.find_nodes()
key_priv_pem, key_pub_pem = infra.crypto.generate_rsa_keypair(2048)
cert_pem = infra.crypto.generate_cert(key_priv_pem)
kid = "my_kid"
issuer = "my_issuer"
LOG.info("Try to add JWT signing key without matching issuer")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as jwks_fp:
json.dump(create_jwks(kid, cert_pem), jwks_fp)
jwks_fp.flush()
try:
network.consortium.set_jwt_public_signing_keys(
primary, issuer, jwks_fp.name
)
except infra.proposal.ProposalNotAccepted:
pass
else:
assert False, "Proposal should not have been created"
LOG.info("Add JWT issuer")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp:
json.dump({"issuer": issuer}, metadata_fp)
metadata_fp.flush()
network.consortium.set_jwt_issuer(primary, metadata_fp.name)
LOG.info("Try to add a public key instead of a certificate")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as jwks_fp:
json.dump(create_jwks(kid, key_pub_pem, test_invalid_is_key=True), jwks_fp)
jwks_fp.flush()
try:
network.consortium.set_jwt_public_signing_keys(
primary, issuer, jwks_fp.name
)
except infra.proposal.ProposalNotAccepted:
pass
else:
assert False, "Proposal should not have been created"
LOG.info("Add JWT signing key with matching issuer")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as jwks_fp:
json.dump(create_jwks(kid, cert_pem), jwks_fp)
jwks_fp.flush()
network.consortium.set_jwt_public_signing_keys(primary, issuer, jwks_fp.name)
LOG.info("Check if JWT signing key was stored correctly")
with primary.client(
f"member{network.consortium.get_any_active_member().member_id}"
) as c:
r = c.post(
"/gov/read", {"table": "public:ccf.gov.jwt_public_signing_keys", "key": kid}
)
assert r.status_code == 200, r.status_code
# Note that /gov/read returns all data as JSON.
# Here, the stored data is a uint8 array, therefore it
# is returned as an array of integers.
cert_kv_der = bytes(r.body.json())
cert_kv_pem = infra.crypto.cert_der_to_pem(cert_kv_der)
assert infra.crypto.are_certs_equal(
cert_pem, cert_kv_pem
), "stored cert not equal to input cert"
LOG.info("Remove JWT issuer")
network.consortium.remove_jwt_issuer(primary, issuer)
LOG.info("Check if JWT signing key was deleted")
with primary.client(
f"member{network.consortium.get_any_active_member().member_id}"
) as c:
r = c.post(
"/gov/read", {"table": "public:ccf.gov.jwt_public_signing_keys", "key": kid}
)
assert r.status_code == 400, r.status_code
LOG.info("Add JWT issuer with initial keys")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp:
json.dump({"issuer": issuer, "jwks": create_jwks(kid, cert_pem)}, metadata_fp)
metadata_fp.flush()
network.consortium.set_jwt_issuer(primary, metadata_fp.name)
LOG.info("Check if JWT signing key was stored correctly")
with primary.client(
f"member{network.consortium.get_any_active_member().member_id}"
) as c:
r = c.post(
"/gov/read", {"table": "public:ccf.gov.jwt_public_signing_keys", "key": kid}
)
assert r.status_code == 200, r.status_code
cert_kv_der = bytes(r.body.json())
cert_kv_pem = infra.crypto.cert_der_to_pem(cert_kv_der)
assert infra.crypto.are_certs_equal(
cert_pem, cert_kv_pem
), "stored cert not equal to input cert"
return network
@reqs.description("JWT with SGX key policy")
def test_jwt_with_sgx_key_policy(network, args):
primary, _ = network.find_nodes()
oe_cert_path = os.path.join(this_dir, "ca_cert.pem")
with open(oe_cert_path) as f:
oe_cert_pem = f.read()
kid = "my_kid"
issuer = "my_issuer"
matching_key_policy = {
"sgx_claims": {
"signer_id": "ca9ad7331448980aa28890ce73e433638377f179ab4456b2fe237193193a8d0a",
"attributes": "0300000000000000",
}
}
mismatching_key_policy = {
"sgx_claims": {
"signer_id": "da9ad7331448980aa28890ce73e433638377f179ab4456b2fe237193193a8d0a",
"attributes": "0300000000000000",
}
}
LOG.info("Add JWT issuer with SGX key policy")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp:
json.dump({"issuer": issuer, "key_policy": matching_key_policy}, metadata_fp)
metadata_fp.flush()
network.consortium.set_jwt_issuer(primary, metadata_fp.name)
LOG.info("Try to add a non-OE-attested cert")
key_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048)
non_oe_cert_pem = infra.crypto.generate_cert(key_priv_pem)
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as jwks_fp:
json.dump(create_jwks(kid, non_oe_cert_pem), jwks_fp)
jwks_fp.flush()
try:
network.consortium.set_jwt_public_signing_keys(
primary, issuer, jwks_fp.name
)
except infra.proposal.ProposalNotAccepted:
pass
else:
assert False, "Proposal should not have been created"
LOG.info("Add an OE-attested cert with matching claims")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as jwks_fp:
json.dump(create_jwks(kid, oe_cert_pem), jwks_fp)
jwks_fp.flush()
network.consortium.set_jwt_public_signing_keys(primary, issuer, jwks_fp.name)
LOG.info("Update JWT issuer with mismatching SGX key policy")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp:
json.dump(
{
"issuer": issuer,
"key_policy": mismatching_key_policy,
},
metadata_fp,
)
metadata_fp.flush()
network.consortium.set_jwt_issuer(primary, metadata_fp.name)
LOG.info("Try to add an OE-attested cert with mismatching claims")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as jwks_fp:
json.dump(create_jwks(kid, non_oe_cert_pem), jwks_fp)
jwks_fp.flush()
try:
network.consortium.set_jwt_public_signing_keys(
primary, issuer, jwks_fp.name
)
except infra.proposal.ProposalNotAccepted:
pass
else:
assert False, "Proposal should not have been created"
return network
@reqs.description("JWT with SGX key filter")
def test_jwt_with_sgx_key_filter(network, args):
primary, _ = network.find_nodes()
oe_cert_path = os.path.join(this_dir, "ca_cert.pem")
with open(oe_cert_path) as f:
oe_cert_pem = f.read()
oe_kid = "oe_kid"
key_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048)
non_oe_cert_pem = infra.crypto.generate_cert(key_priv_pem)
non_oe_kid = "non_oe_kid"
issuer = "my_issuer"
LOG.info("Add JWT issuer with SGX key filter")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp:
json.dump({"issuer": issuer, "key_filter": "sgx"}, metadata_fp)
metadata_fp.flush()
network.consortium.set_jwt_issuer(primary, metadata_fp.name)
LOG.info("Add multiple certs (1 SGX, 1 non-SGX)")
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as jwks_fp:
oe_jwks = create_jwks(oe_kid, oe_cert_pem)
non_oe_jwks = create_jwks(non_oe_kid, non_oe_cert_pem)
jwks = {"keys": non_oe_jwks["keys"] + oe_jwks["keys"]}
json.dump(jwks, jwks_fp)
jwks_fp.flush()
network.consortium.set_jwt_public_signing_keys(primary, issuer, jwks_fp.name)
LOG.info("Check that only SGX cert was added")
with primary.client(
f"member{network.consortium.get_any_active_member().member_id}"
) as c:
r = c.post(
"/gov/read",
{"table": "public:ccf.gov.jwt_public_signing_keys", "key": non_oe_kid},
)
assert r.status_code == 400, r.status_code
r = c.post(
"/gov/read",
{"table": "public:ccf.gov.jwt_public_signing_keys", "key": oe_kid},
)
assert r.status_code == 200, r.status_code
return network
def run(args):
with infra.network.network(
args.nodes, args.binary_dir, args.debug_nodes, args.perf_nodes, pdb=args.pdb
) as network:
network.start_and_join(args)
network = test_jwt_without_key_policy(network, args)
network = test_jwt_with_sgx_key_policy(network, args)
network = test_jwt_with_sgx_key_filter(network, args)
if __name__ == "__main__":
args = infra.e2e_args.cli_args()
args.package = "liblogging"
args.nodes = infra.e2e_args.max_nodes(args, f=0)
run(args)

Просмотреть файл

@ -1,5 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the Apache 2.0 License.
import base64
import tempfile
import http
import subprocess
@ -324,6 +325,62 @@ def test_npm_app(network, args):
assert {"id": 42, "msg": "Saluton!"} in body, body
assert {"id": 43, "msg": "Bonjour!"} in body, body
r = c.get("/app/jwt")
assert r.status_code == http.HTTPStatus.UNAUTHORIZED, r.status_code
body = r.body.json()
assert body["msg"] == "authorization header missing", r.body
r = c.get("/app/jwt", headers={"authorization": "Bearer not-a-jwt"})
assert r.status_code == http.HTTPStatus.UNAUTHORIZED, r.status_code
body = r.body.json()
assert body["msg"].startswith("malformed jwt:"), r.body
jwt_key_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048)
jwt_cert_pem = infra.crypto.generate_cert(jwt_key_priv_pem)
jwt_kid = "my_key_id"
jwt = infra.crypto.create_jwt({}, jwt_key_priv_pem, jwt_kid)
r = c.get("/app/jwt", headers={"authorization": "Bearer " + jwt})
assert r.status_code == http.HTTPStatus.UNAUTHORIZED, r.status_code
body = r.body.json()
assert body["msg"].startswith("token signing key not found"), r.body
LOG.info("Store JWT signing keys")
issuer = "https://example.issuer"
with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp:
jwt_cert_der = infra.crypto.cert_pem_to_der(jwt_cert_pem)
der_b64 = base64.b64encode(jwt_cert_der).decode("ascii")
data = {
"issuer": issuer,
"jwks": {"keys": [{"kty": "RSA", "kid": jwt_kid, "x5c": [der_b64]}]},
}
json.dump(data, metadata_fp)
metadata_fp.flush()
network.consortium.set_jwt_issuer(primary, metadata_fp.name)
LOG.info("Calling jwt endpoint after storing keys")
with primary.client("user0") as c:
jwt_mismatching_key_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048)
jwt = infra.crypto.create_jwt({}, jwt_mismatching_key_priv_pem, jwt_kid)
r = c.get("/app/jwt", headers={"authorization": "Bearer " + jwt})
assert r.status_code == http.HTTPStatus.UNAUTHORIZED, r.status_code
body = r.body.json()
assert body["msg"] == "jwt validation failed", r.body
jwt = infra.crypto.create_jwt({}, jwt_key_priv_pem, jwt_kid)
r = c.get("/app/jwt", headers={"authorization": "Bearer " + jwt})
assert r.status_code == http.HTTPStatus.UNAUTHORIZED, r.status_code
body = r.body.json()
assert body["msg"] == "jwt invalid, sub claim missing", r.body
user_id = "user0"
jwt = infra.crypto.create_jwt({"sub": user_id}, jwt_key_priv_pem, jwt_kid)
r = c.get("/app/jwt", headers={"authorization": "Bearer " + jwt})
assert r.status_code == http.HTTPStatus.OK, r.status_code
body = r.body.json()
assert body["userId"] == user_id, r.body
return network

Просмотреть файл

@ -1,5 +1,60 @@
{
"endpoints": {
"/jwt": {
"get": {
"js_module": "src/endpoints/jwt.js",
"js_function": "jwt",
"forwarding_required": "always",
"execute_locally": false,
"require_client_signature": false,
"require_client_identity": false,
"readonly": true,
"openapi": {
"responses": {
"200": {
"description": "Ok",
"content": {
"application/json": {
"schema": {
"properties": {
"userId": {
"type": "string"
}
},
"required": [
"userId"
],
"type": "object",
"additionalProperties": false
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"properties": {
"msg": {
"type": "string"
}
},
"required": [
"msg"
],
"type": "object",
"additionalProperties": false
}
}
}
}
},
"security": [],
"parameters": []
}
}
},
"/crypto": {
"get": {
"js_module": "src/endpoints/crypto.js",

Просмотреть файл

@ -7,8 +7,9 @@
"type": "module",
"dependencies": {
"js-base64": "^3.5.2",
"jsrsasign": "^8.0.22",
"jsrsasign-util": "^1.0.0",
"jsrsasign": "^10.0.4",
"jsrsasign-util": "^1.0.2",
"jwt-decode": "^3.0.0",
"lodash-es": "^4.17.15",
"protobufjs": "^6.10.1"
},
@ -16,7 +17,8 @@
"@rollup/plugin-commonjs": "^14.0.0",
"@rollup/plugin-node-resolve": "^8.4.0",
"@rollup/plugin-typescript": "^5.0.2",
"@types/jsrsasign": "^8.0.5",
"@types/jsrsasign": "^8.0.7",
"@types/jwt-decode": "^2.2.1",
"@types/lodash-es": "^4.17.3",
"del-cli": "^3.0.1",
"http-server": "^0.12.3",

Просмотреть файл

@ -1,3 +1,4 @@
export * from './jwt'
export * from './crypto'
export * from './partition'
export * from './proto'

Просмотреть файл

@ -0,0 +1,88 @@
import { KJUR, KEYUTIL, ArrayBuffertohex } from 'jsrsasign'
import jwt_decode from 'jwt-decode'
import { Base64 } from 'js-base64'
import * as ccf from '../types/ccf'
interface JwtResponse {
userId: string
}
interface ErrorResponse {
msg: string
}
interface HeaderClaims {
kid: string
}
interface BodyClaims {
sub: string
}
export function jwt(request: ccf.Request): ccf.Response<JwtResponse | ErrorResponse> {
const authHeader = request.headers['authorization']
if (!authHeader) {
return unauthorized('authorization header missing')
}
const parts = authHeader.split(' ', 2)
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return unauthorized('unexpected authentication type')
}
const token = parts[1]
// Extract header claims to select the correct signing key.
// We use jwt_decode() instead of jsrsasign's parse() as the latter does unnecessary work.
let headerClaims: HeaderClaims
try {
headerClaims = jwt_decode(token, { header: true }) as HeaderClaims
} catch (e) {
return unauthorized(`malformed jwt: ${e.message}`)
}
const signingKeyId = headerClaims.kid
if (!signingKeyId) {
return unauthorized('kid missing in header claims')
}
// Get the stored signing key to validate the token.
const keysMap = new ccf.TypedKVMap(ccf.kv['public:ccf.gov.jwt_public_signing_keys'], ccf.string, ccf.typedArray(Uint8Array))
const publicKeyDer = keysMap.get(signingKeyId)
if (publicKeyDer === undefined) {
return unauthorized(`token signing key not found: ${signingKeyId}`)
}
// jsrsasign can only load X.509 certs from PEM strings
const publicKeyB64 = Base64.fromUint8Array(publicKeyDer)
const publicKeyPem = "-----BEGIN CERTIFICATE-----\n" + publicKeyB64 + "\n-----END CERTIFICATE-----";
const publicKey = KEYUTIL.getKey(publicKeyPem)
// Validate the token signature.
const valid = KJUR.jws.JWS.verifyJWT(token, <any>publicKey, <any>{
alg: ['RS256'],
// No trusted time, disable time validation.
verifyAt: Date.parse('2020-01-01T00:00:00') / 1000,
gracePeriod: 10 * 365 * 24 * 60 * 60
})
if (!valid) {
return unauthorized('jwt validation failed')
}
// Custom body claims validation, app-specific.
const claims = jwt_decode(token) as BodyClaims
if (!claims.sub) {
return unauthorized('jwt invalid, sub claim missing')
}
return {
body: {
userId: claims.sub
}
}
}
function unauthorized(msg: string): ccf.Response<ErrorResponse> {
return {
statusCode: 401,
body: {
msg: msg
}
}
}

Просмотреть файл

@ -4,6 +4,7 @@
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"noImplicitAny": false,
"removeComments": true,
"preserveConstEnums": true,

Просмотреть файл

@ -5,4 +5,5 @@ coincurve
psutil
cimetrics>=0.2.1
pynacl
openapi-spec-validator
openapi-spec-validator
PyJWT