Commit 9162b035 authored by Grigorii Demidov's avatar Grigorii Demidov Committed by Marek Vavrusa

dnssec: wildcard answer proof

parent 5b357f3d
......@@ -129,17 +129,17 @@ static int wildcard_radix_len_diff(const knot_dname_t *expanded,
return knot_dname_labels(expanded, NULL) - knot_rrsig_labels(&rrsigs->rrs, sig_pos);
}
int kr_rrset_validate(const knot_pkt_t *pkt, knot_section_t section_id,
const knot_rrset_t *covered, const knot_rrset_t *keys,
const knot_dname_t *zone_name, uint32_t timestamp,
bool has_nsec3)
int kr_rrset_validate(kr_rrset_validation_ctx_t *vctx, const knot_rrset_t *covered)
{
if (!pkt || !covered || !keys || !zone_name) {
if (!vctx) {
return kr_error(EINVAL);
}
if (!vctx->pkt || !covered || !vctx->keys || !vctx->zone_name) {
return kr_error(EINVAL);
}
for (unsigned i = 0; i < keys->rrs.rr_count; ++i) {
int ret = kr_rrset_validate_with_key(pkt, section_id, covered, keys, i, NULL, zone_name, timestamp, has_nsec3);
for (unsigned i = 0; i < vctx->keys->rrs.rr_count; ++i) {
int ret = kr_rrset_validate_with_key(vctx, covered, i, NULL);
if (ret == 0) {
return ret;
}
......@@ -148,19 +148,24 @@ int kr_rrset_validate(const knot_pkt_t *pkt, knot_section_t section_id,
return kr_error(ENOENT);
}
int kr_rrset_validate_with_key(const knot_pkt_t *pkt, knot_section_t section_id,
const knot_rrset_t *covered, const knot_rrset_t *keys,
size_t key_pos, const struct dseckey *key,
const knot_dname_t *zone_name, uint32_t timestamp,
bool has_nsec3)
int kr_rrset_validate_with_key(kr_rrset_validation_ctx_t *vctx,
const knot_rrset_t *covered,
size_t key_pos, const struct dseckey *key)
{
const knot_pkt_t *pkt = vctx->pkt;
knot_section_t section_id = vctx->section_id;
const knot_rrset_t *keys = vctx->keys;
const knot_dname_t *zone_name = vctx->zone_name;
uint32_t timestamp = vctx->timestamp;
bool has_nsec3 = vctx->has_nsec3;
struct dseckey *created_key = NULL;
if (key == NULL) {
const knot_rdata_t *krr = knot_rdataset_at(&keys->rrs, key_pos);
int ret = kr_dnssec_key_from_rdata(&created_key, keys->owner,
knot_rdata_data(krr), knot_rdata_rdlen(krr));
if (ret != 0) {
return ret;
vctx->result = ret;
return vctx->result;
}
key = created_key;
}
......@@ -211,21 +216,72 @@ int kr_rrset_validate_with_key(const knot_pkt_t *pkt, knot_section_t section_id,
if (ret != 0) {
continue;
}
vctx->flags |= KR_DNSSEC_VFLG_WEXPAND;
}
/* Validated with current key, OK */
kr_dnssec_key_free(&created_key);
return kr_ok();
vctx->result = kr_ok();
return vctx->result;
}
}
/* No applicable key found, cannot be validated. */
kr_dnssec_key_free(&created_key);
return kr_error(ENOENT);
vctx->result = kr_error(ENOENT);
return vctx->result;
}
int kr_dnskeys_trusted(const knot_pkt_t *pkt, knot_section_t section_id, const knot_rrset_t *keys,
const knot_rrset_t *ta, const knot_dname_t *zone_name, uint32_t timestamp,
bool has_nsec3)
int kr_section_check_wcard(kr_rrset_validation_ctx_t *vctx)
{
const knot_pkt_t *pkt = vctx->pkt;
knot_section_t section_id = vctx->section_id;
const knot_dname_t *zone_name = vctx->zone_name;
const knot_pktsection_t *sec = knot_pkt_section(pkt, section_id);
for (unsigned i = 0; i < sec->count; ++i) {
const knot_rrset_t *rr = knot_pkt_rr(sec, i);
if (rr->type == KNOT_RRTYPE_RRSIG) {
continue;
}
if ((rr->type == KNOT_RRTYPE_NS) && (vctx->section_id == KNOT_AUTHORITY)) {
continue;
}
if (!knot_dname_in(zone_name, rr->owner)) {
continue;
}
int covered_labels = knot_dname_labels(rr->owner, NULL);
if (knot_dname_is_wildcard(rr->owner)) {
/* The asterisk does not count, RFC4034 3.1.3, paragraph 3. */
--covered_labels;
}
for (unsigned j = 0; j < sec->count; ++j) {
const knot_rrset_t *rrsig = knot_pkt_rr(sec, j);
if (rrsig->type != KNOT_RRTYPE_RRSIG) {
continue;
}
if ((rr->rclass != rrsig->rclass) || !knot_dname_is_equal(rr->owner, rrsig->owner)) {
continue;
}
for (uint16_t k = 0; k < rrsig->rrs.rr_count; ++k) {
if (knot_rrsig_type_covered(&rrsig->rrs, k) != rr->type) {
continue;
}
int rrsig_labels = knot_rrsig_labels(&rrsig->rrs, k);
if (rrsig_labels > covered_labels) {
return kr_error(EINVAL);
}
if (rrsig_labels < covered_labels) {
vctx->flags |= KR_DNSSEC_VFLG_WEXPAND;
}
}
}
}
return kr_ok();
}
int kr_dnskeys_trusted(kr_rrset_validation_ctx_t *vctx, const knot_rrset_t *ta)
{
const knot_pkt_t *pkt = vctx->pkt;
const knot_rrset_t *keys = vctx->keys;
if (!pkt || !keys || !ta) {
return kr_error(EINVAL);
}
......@@ -250,15 +306,17 @@ int kr_dnskeys_trusted(const knot_pkt_t *pkt, knot_section_t section_id, const k
kr_dnssec_key_free(&key);
continue;
}
if (kr_rrset_validate_with_key(pkt, section_id, keys, keys, i, key, zone_name, timestamp, has_nsec3) != 0) {
if (kr_rrset_validate_with_key(vctx, keys, i, key) != 0) {
kr_dnssec_key_free(&key);
continue;
}
kr_dnssec_key_free(&key);
return kr_ok();
assert (vctx->result == 0);
return vctx->result;
}
/* No useable key found */
return kr_error(ENOENT);
vctx->result = kr_error(ENOENT);
return vctx->result;
}
bool kr_dnssec_key_zsk(const uint8_t *dnskey_rdata)
......
......@@ -41,55 +41,50 @@ void kr_crypto_reinit(void);
/** Opaque DNSSEC key pointer. */
struct dseckey;
#define KR_DNSSEC_VFLG_WEXPAND 0x01
/** DNSSEC validation context. */
struct kr_rrset_validation_ctx {
const knot_pkt_t *pkt; /*!< Packet to be validated. */
knot_section_t section_id; /*!< Section to work with. */
const knot_rrset_t *keys; /*!< DNSKEY RRSet. */
const knot_dname_t *zone_name; /*!< Name of the zone containing the RRSIG RRSet. */
uint32_t timestamp; /*!< Validation time. */
bool has_nsec3; /*!< Whether to use NSEC3 validation. */
uint32_t flags; /*!< Output - Flags. */
int result; /*!< Output - 0 or error code. */
};
typedef struct kr_rrset_validation_ctx kr_rrset_validation_ctx_t;
/**
* Validate RRSet.
* @param pkt Packet to be validated.
* @param section_id Section to work with.
* @param covered RRSet covered by a signature. It must be in canonical format.
* @param keys DNSKEY RRSet.
* @param zone_name Name of the zone containing the RRSIG RRSet.
* @param timestamp Validation time.
* @param has_nsec3 Whether to use NSEC3 validation.
* @return 0 or error code.
* @param vctx Pointer to validation context.
* @param covered RRSet covered by a signature. It must be in canonical format.
* @return 0 or error code, same as vctx->result.
*/
int kr_rrset_validate(const knot_pkt_t *pkt, knot_section_t section_id,
const knot_rrset_t *covered, const knot_rrset_t *keys,
const knot_dname_t *zone_name, uint32_t timestamp,
bool has_nsec3);
int kr_rrset_validate(kr_rrset_validation_ctx_t *vctx,
const knot_rrset_t *covered);
/**
* Validate RRSet using a specific key.
* @param pkt Packet to be validated.
* @param section_id Section to work with.
* @param covered RRSet covered by a signature. It must be in canonical format.
* @param keys DNSKEY RRSet.
* @param key_pos Position of the key to be validated with.
* @param key Key to be used to validate. If NULL, then key from DNSKEY RRSet is used.
* @param zone_name Name of the zone containing the RRSIG RRSet.
* @param timestamp Validation time.
* @param has_nsec3 Whether to use NSEC3 validation.
* @return 0 or error code.
* @param vctx Pointer to validation context.
* @param covered RRSet covered by a signature. It must be in canonical format.
* @param key_pos Position of the key to be validated with.
* @param key Key to be used to validate.
* If NULL, then key from DNSKEY RRSet is used.
* @return 0 or error code, same as vctx->result.
*/
int kr_rrset_validate_with_key(const knot_pkt_t *pkt, knot_section_t section_id,
const knot_rrset_t *covered, const knot_rrset_t *keys,
size_t key_pos, const struct dseckey *key,
const knot_dname_t *zone_name, uint32_t timestamp,
bool has_nsec3);
int kr_rrset_validate_with_key(kr_rrset_validation_ctx_t *vctx,
const knot_rrset_t *covered,
size_t key_pos, const struct dseckey *key);
/**
* Check whether the DNSKEY rrset matches the supplied trust anchor RRSet.
* @param pkt Packet to be validated.
* @param section_id Section to work with.
* @param keys DNSKEY RRSet to check.
* @param ta Trust anchor RRSet against which to validate the DNSKEY RRSet.
* @param zone_name Name of the zone containing the RRSet.
* @param timestamp Time stamp.
* @param has_nsec3 Whether to use NSEC3 validation.
* @return 0 or error code.
* @param vctx Pointer to validation context.
* @param ta Trust anchor RRSet against which to validate the DNSKEY RRSet.
* @return 0 or error code, same as vctx->result.
*/
int kr_dnskeys_trusted(const knot_pkt_t *pkt, knot_section_t section_id, const knot_rrset_t *keys,
const knot_rrset_t *ta, const knot_dname_t *zone_name, uint32_t timestamp,
bool has_nsec3);
int kr_dnskeys_trusted(kr_rrset_validation_ctx_t *vctx, const knot_rrset_t *ta);
/** Return true if the DNSKEY can be used as a ZSK. */
KR_EXPORT KR_PURE
......@@ -124,6 +119,14 @@ KR_EXPORT KR_PURE
int kr_dnssec_key_match(const uint8_t *key_a_rdata, size_t key_a_rdlen,
const uint8_t *key_b_rdata, size_t key_b_rdlen);
/** Return 0 if wildcard expansion occurs in specified section.
* @param vctx Pointer to validation context.
* @note vctx->keys, vctx->timestamp, vctx->has_nsec3 has no meanings.
* @return 0 if wildcard expansion occurs or an error code.
*/
KR_EXPORT KR_PURE
int kr_section_check_wcard(kr_rrset_validation_ctx_t *vctx);
/**
* Construct a DNSSEC key.
* @param key Pointer to be set to newly created DNSSEC key.
......
......@@ -315,7 +315,7 @@ static void finalize_answer(knot_pkt_t *pkt, struct kr_query *qry, struct kr_req
const uint16_t qtype = knot_pkt_qtype(answer);
struct kr_zonecut *cut = &qry->zone_cut;
int pkt_class = kr_response_classify(pkt);
if (pkt_class & (PKT_NXDOMAIN|PKT_NODATA)) {
if ((pkt_class & (PKT_NXDOMAIN|PKT_NODATA))) {
const knot_pktsection_t *ns = knot_pkt_section(pkt, KNOT_AUTHORITY);
for (unsigned i = 0; i < ns->count; ++i) {
const knot_rrset_t *rr = knot_pkt_rr(ns, i);
......@@ -414,17 +414,31 @@ static int process_answer(knot_pkt_t *pkt, struct kr_request *req)
query->flags |= QUERY_RESOLVED;
/* Follow canonical name as next SNAME. */
if (!knot_dname_is_equal(cname, query->sname)) {
DEBUG_MSG("<= cname chain, following\n");
/* Check if already resolved */
if (cname && !knot_dname_is_equal(cname, query->sname)) {
for (int i = 0; i < req->rplan.resolved.len; ++i) {
struct kr_query * q = req->rplan.resolved.at[i];
if (q->sclass == query->sclass &&
q->stype == query->stype &&
knot_dname_is_equal(q->sname, cname)) {
DEBUG_MSG("<= cname chain loop\n");
return KNOT_STATE_FAIL;
/* Check if target record has been already copied */
if (is_final) {
const knot_pktsection_t *an = knot_pkt_section(req->answer, KNOT_ANSWER);
for (unsigned i = 0; i < an->count; ++i) {
const knot_rrset_t *rr = knot_pkt_rr(an, i);
if (!knot_dname_is_equal(rr->owner, cname)) {
continue;
}
if ((rr->rclass != query->sclass) ||
(rr->type != query->stype)) {
continue;
}
finalize_answer(pkt, query, req);
return KNOT_STATE_DONE;
}
}
DEBUG_MSG("<= cname chain, following\n");
/* Check if the same query was already resolved */
for (int i = 0; i < req->rplan.resolved.len; ++i) {
struct kr_query * q = req->rplan.resolved.at[i];
if (q->sclass == query->sclass &&
q->stype == query->stype &&
knot_dname_is_equal(q->sname, cname)) {
DEBUG_MSG("<= cname chain loop\n");
return KNOT_STATE_FAIL;
}
}
struct kr_query *next = kr_rplan_push(&req->rplan, query->parent, cname, query->sclass, query->stype);
......
......@@ -171,11 +171,14 @@ static int pktcache_stash(knot_layer_t *ctx, knot_pkt_t *pkt)
if (!knot_wire_get_aa(pkt->wire) || knot_pkt_qclass(pkt) != KNOT_CLASS_IN) {
return ctx->state;
}
/* Cache only NODATA/NXDOMAIN or metatype/RRSIG answers. */
/* Cache only NODATA/NXDOMAIN or metatype/RRSIG or
* wildcard expanded answers. */
const uint16_t qtype = knot_pkt_qtype(pkt);
const bool is_eligible = (knot_rrtype_is_metatype(qtype) || qtype == KNOT_RRTYPE_RRSIG);
const bool is_eligible = (knot_rrtype_is_metatype(qtype) ||
qtype == KNOT_RRTYPE_RRSIG);
int pkt_class = kr_response_classify(pkt);
if (!(is_eligible || (pkt_class & (PKT_NODATA|PKT_NXDOMAIN)))) {
if (!(is_eligible || (pkt_class & (PKT_NODATA|PKT_NXDOMAIN)) ||
(qry->flags & QUERY_DNSSEC_WEXPAND))) {
return ctx->state;
}
uint32_t ttl = packet_ttl(pkt);
......
......@@ -302,6 +302,12 @@ static int rrcache_stash(knot_layer_t *ctx, knot_pkt_t *pkt)
if (knot_wire_get_tc(pkt->wire)) {
return ctx->state;
}
/* Do not cache wildcard expanded anwsers,
* as they must deal with packet cache */
if (qry->flags & QUERY_DNSSEC_WEXPAND) {
return ctx->state;
}
/* Cache only positive answers, not meta types or RRSIG. */
const uint16_t qtype = knot_pkt_qtype(pkt);
const bool is_eligible = !(knot_rrtype_is_metatype(qtype) || qtype == KNOT_RRTYPE_RRSIG);
......@@ -351,7 +357,6 @@ static int rrcache_stash(knot_layer_t *ctx, knot_pkt_t *pkt)
}
}
}
return ctx->state;
}
......
......@@ -74,37 +74,25 @@ static bool pkt_has_type(const knot_pkt_t *pkt, uint16_t type)
return section_has_type(knot_pkt_section(pkt, KNOT_ADDITIONAL), type);
}
/** @internal Baton for validate_section */
struct validate_baton {
const knot_pkt_t *pkt;
knot_section_t section_id;
const knot_rrset_t *keys;
const knot_dname_t *zone_name;
uint32_t timestamp;
bool has_nsec3;
int result;
};
static int validate_rrset(const char *key, void *val, void *data)
{
knot_rrset_t *rr = val;
struct validate_baton *baton = data;
if (baton->result != 0) {
return baton->result;
kr_rrset_validation_ctx_t *vctx = data;
if (vctx->result != 0) {
return vctx->result;
}
baton->result = kr_rrset_validate(baton->pkt, baton->section_id, rr,
baton->keys, baton->zone_name,
baton->timestamp, baton->has_nsec3);
return baton->result;
return kr_rrset_validate(vctx, rr);
}
static int validate_section(struct kr_query *qry, knot_pkt_t *answer,
knot_section_t section_id, knot_mm_t *pool,
bool has_nsec3)
static int validate_section(kr_rrset_validation_ctx_t *vctx, knot_mm_t *pool)
{
const knot_pktsection_t *sec = knot_pkt_section(answer, section_id);
if (!vctx) {
return kr_error(EINVAL);
}
const knot_pktsection_t *sec = knot_pkt_section(vctx->pkt,
vctx->section_id);
if (!sec) {
return kr_ok();
}
......@@ -122,11 +110,11 @@ static int validate_section(struct kr_query *qry, knot_pkt_t *answer,
if (rr->type == KNOT_RRTYPE_RRSIG) {
continue;
}
if ((rr->type == KNOT_RRTYPE_NS) && (section_id == KNOT_AUTHORITY)) {
if ((rr->type == KNOT_RRTYPE_NS) && (vctx->section_id == KNOT_AUTHORITY)) {
continue;
}
/* Only validate answers from current cut, records above the cut are stripped. */
if (!knot_dname_in(qry->zone_cut.name, rr->owner)) {
if (!knot_dname_in(vctx->zone_name, rr->owner)) {
continue;
}
ret = kr_rrmap_add(&stash, rr, 0, pool);
......@@ -135,24 +123,16 @@ static int validate_section(struct kr_query *qry, knot_pkt_t *answer,
}
}
struct validate_baton baton = {
.pkt = answer,
.section_id = section_id,
.keys = qry->zone_cut.key,
/* Can't use qry->zone_cut.name directly, as this name can
* change when updating cut information before validation.
*/
.zone_name = qry->zone_cut.key ? qry->zone_cut.key->owner : NULL,
.timestamp = qry->timestamp.tv_sec,
.has_nsec3 = has_nsec3,
.result = 0
};
/* Can't use qry->zone_cut.name directly, as this name can
* change when updating cut information before validation.
*/
vctx->zone_name = vctx->keys ? vctx->keys->owner : NULL;
ret = map_walk(&stash, &validate_rrset, &baton);
ret = map_walk(&stash, &validate_rrset, vctx);
if (ret != 0) {
return ret;
}
ret = baton.result;
ret = vctx->result;
fail:
return ret;
......@@ -165,14 +145,67 @@ static int validate_records(struct kr_query *qry, knot_pkt_t *answer, knot_mm_t
return kr_error(EBADMSG);
}
int ret = validate_section(qry, answer, KNOT_ANSWER, pool, has_nsec3);
kr_rrset_validation_ctx_t vctx = {
.pkt = answer,
.section_id = KNOT_ANSWER,
.keys = qry->zone_cut.key,
.zone_name = qry->zone_cut.name,
.timestamp = qry->timestamp.tv_sec,
.has_nsec3 = has_nsec3,
.flags = 0,
.result = 0
};
int ret = validate_section(&vctx, pool);
if (ret != 0) {
return ret;
}
return validate_section(qry, answer, KNOT_AUTHORITY, pool, has_nsec3);
uint32_t an_flags = vctx.flags;
vctx.section_id = KNOT_AUTHORITY;
/* zone_name can be changed by validate_section(), restore it */
vctx.zone_name = qry->zone_cut.name;
vctx.flags = 0;
vctx.result = 0;
ret = validate_section(&vctx, pool);
if (ret != 0) {
return ret;
}
/* Records were validated.
* If there is wildcard expansion in answer, flag the query.
*/
if (an_flags & KR_DNSSEC_VFLG_WEXPAND) {
qry->flags |= QUERY_DNSSEC_WEXPAND;
}
return ret;
}
static int check_wcard_expanded(struct kr_query *qry, knot_pkt_t *pkt, knot_section_t section_id)
{
kr_rrset_validation_ctx_t vctx = {
.pkt = pkt,
.section_id = section_id,
.keys = NULL,
.zone_name = qry->zone_cut.name,
.timestamp = 0,
.has_nsec3 = false,
.flags = 0,
.result = 0
};
int ret = kr_section_check_wcard(&vctx);
if (ret != 0) {
return ret;
}
if (vctx.flags & KR_DNSSEC_VFLG_WEXPAND) {
qry->flags |= QUERY_DNSSEC_WEXPAND;
}
return kr_ok();
}
static int validate_keyset(struct kr_query *qry, knot_pkt_t *answer, bool has_nsec3)
{
/* Merge DNSKEY records from answer that are below/at current cut. */
......@@ -203,13 +236,28 @@ static int validate_keyset(struct kr_query *qry, knot_pkt_t *answer, bool has_ns
/* Check if there's a key for current TA. */
if (updated_key && !(qry->flags & QUERY_CACHED)) {
int ret = kr_dnskeys_trusted(answer, KNOT_ANSWER, qry->zone_cut.key,
qry->zone_cut.trust_anchor, qry->zone_cut.name,
qry->timestamp.tv_sec, has_nsec3);
kr_rrset_validation_ctx_t vctx = {
.pkt = answer,
.section_id = KNOT_ANSWER,
.keys = qry->zone_cut.key,
.zone_name = qry->zone_cut.name,
.timestamp = qry->timestamp.tv_sec,
.has_nsec3 = has_nsec3,
.flags = 0,
.result = 0
};
int ret = kr_dnskeys_trusted(&vctx, qry->zone_cut.trust_anchor);
if (ret != 0) {
knot_rrset_free(&qry->zone_cut.key, qry->zone_cut.pool);
return ret;
}
if (vctx.flags & KR_DNSSEC_VFLG_WEXPAND)
{
qry->flags |= QUERY_DNSSEC_WEXPAND;
}
}
return kr_ok();
}
......@@ -458,10 +506,24 @@ static int validate(knot_layer_t *ctx, knot_pkt_t *pkt)
* Do not revalidate data from cache, as it's already trusted. */
if (!(qry->flags & QUERY_CACHED)) {
ret = validate_records(qry, pkt, req->rplan.pool, has_nsec3);
if (ret != 0) {
DEBUG_MSG(qry, "<= couldn't validate RRSIGs\n");
qry->flags |= QUERY_DNSSEC_BOGUS;
return KNOT_STATE_FAIL;
} else {
/* Records already were validated.
* Check if wildcard answer. */
ret = check_wcard_expanded(qry, pkt, KNOT_ANSWER);
}
if (ret != 0) {
DEBUG_MSG(qry, "<= couldn't validate RRSIGs\n");
qry->flags |= QUERY_DNSSEC_BOGUS;
return KNOT_STATE_FAIL;
}
if ((qry->parent == NULL) && (qry->flags & QUERY_DNSSEC_WEXPAND)) {
/* Wildcard expansion detected for final query.
* Copy authority. */
const knot_pktsection_t *auth = knot_pkt_section(pkt, KNOT_AUTHORITY);
for (unsigned i = 0; i < auth->count; ++i) {
const knot_rrset_t *rr = knot_pkt_rr(auth, i);
kr_rrarray_add(&req->authority, rr, &pkt->mm);
}
}
......
......@@ -43,7 +43,8 @@
X(DNSSEC_BOGUS, 1 << 15) /**< Query response is DNSSEC bogus. */ \
X(DNSSEC_INSECURE, 1 << 16) /**< Query response is DNSSEC insecure. */ \
X(STUB, 1 << 17) /**< Stub resolution, accept received answer as solved. */ \
X(ALWAYS_CUT, 1 << 18) /**< Always recover zone cut (even if cached). */
X(ALWAYS_CUT, 1 << 18) /**< Always recover zone cut (even if cached). */ \
X(DNSSEC_WEXPAND, 1 << 19) /**< Query response has wildcard expansion. */
/** Query flags */
enum kr_query_flag {
......@@ -151,4 +152,4 @@ struct kr_query *kr_rplan_resolved(struct kr_rplan *rplan);
/** Return query predecessor. */
KR_EXPORT KR_PURE
struct kr_query *kr_rplan_next(struct kr_query *qry);
\ No newline at end of file
struct kr_query *kr_rplan_next(struct kr_query *qry);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment