diff --git a/Knot.files b/Knot.files
index 767e039eda55207c2dce6975dc8479ed6cf577d1..3f181255cf55b6052ca83aeab9c9d100883b895b 100644
--- a/Knot.files
+++ b/Knot.files
@@ -398,6 +398,8 @@ src/utils/keymgr/bind_privkey.h
 src/utils/keymgr/functions.c
 src/utils/keymgr/functions.h
 src/utils/keymgr/main.c
+src/utils/keymgr/offline_ksk.c
+src/utils/keymgr/offline_ksk.h
 src/utils/khost/khost_main.c
 src/utils/khost/khost_params.c
 src/utils/khost/khost_params.h
diff --git a/doc/man/keymgr.8in b/doc/man/keymgr.8in
index 416318358c6f83c7cb79cb64c04396108a628179..c49bf145dd3e0303a79af943a8c65ff57831924a 100644
--- a/doc/man/keymgr.8in
+++ b/doc/man/keymgr.8in
@@ -119,6 +119,18 @@ Remove the specified key from zone. If the key was not shared, it is also delete
 \fBshare\fP \fIkey_ID\fP
 Import a key (specified by full key ID) from another zone as shared. After this, the key is
 owned by both zones equally.
+.UNINDENT
+.SS Commands related to Offline KSK feature
+.INDENT 0.0
+.TP
+\fBpregenerate\fP \fIperiod_secs\fP
+Pre\-generate ZSKs for use with offline KSK, for the specified period starting from now.
+.TP
+\fBpresign\fP \fIperiod_secs\fP
+Sign pre\-generated ZSKs, for the specified period starting from now.
+.TP
+\fBshow\-rrsig\fP \fItimestamp\fP
+Print a pre\-generated DNSKEY RRSIG for specified timestamp.
 .TP
 \fBdel\-all\-old\fP
 Delete old keys that are in state \(aqremoved\(aq.
diff --git a/doc/man_keymgr.rst b/doc/man_keymgr.rst
index 5d14c042d2dc8f2e1d62f8067c3dd0227bc01c8e..9f0552a36c78a5c0431cee9ace7dae5bb05921dc 100644
--- a/doc/man_keymgr.rst
+++ b/doc/man_keymgr.rst
@@ -97,6 +97,18 @@ Commands
   Import a key (specified by full key ID) from another zone as shared. After this, the key is
   owned by both zones equally.
 
+Commands related to Offline KSK feature
+.......................................
+
+**pregenerate** *period_secs*
+  Pre-generate ZSKs for use with offline KSK, for the specified period starting from now.
+
+**presign** *period_secs*
+  Sign pre-generated ZSKs, for the specified period starting from now.
+
+**show-rrsig** *timestamp*
+  Print a pre-generated DNSKEY RRSIG for specified timestamp.
+
 **del-all-old**
   Delete old keys that are in state 'removed'.
 
diff --git a/src/knot/dnssec/context.h b/src/knot/dnssec/context.h
index 2d7da371277eeb0ab9a961e9b4ccabe25f9524d1..795c1235fbbe4c4f2fbe59ec78dfe635d6b99d7c 100644
--- a/src/knot/dnssec/context.h
+++ b/src/knot/dnssec/context.h
@@ -38,6 +38,8 @@ typedef struct {
 	char *kasp_zone_path;
 
 	bool rrsig_drop_existing;
+	bool keep_deleted_keys;
+	bool rollover_only_zsk;
 } kdnssec_ctx_t;
 
 /*!
diff --git a/src/knot/dnssec/kasp/kasp_db.c b/src/knot/dnssec/kasp/kasp_db.c
index 2d4235903a91e8540b2292c29a9eb6591903dc94..a342ed96c0e7bc05b0e96bb06294ebceba5037c5 100644
--- a/src/knot/dnssec/kasp/kasp_db.c
+++ b/src/knot/dnssec/kasp/kasp_db.c
@@ -22,6 +22,7 @@
 
 #include "contrib/files.h"
 #include "contrib/wire_ctx.h"
+#include "knot/journal/serialization.h"
 
 struct kasp_db {
 	knot_db_t *keys_db;
@@ -31,6 +32,7 @@ struct kasp_db {
 };
 
 typedef enum {
+	KASPDBKEY_OFFLINE_RRSIG = 0x0, // this MUST be always first, because we use LEQ operator
 	KASPDBKEY_PARAMS = 0x1,
 	KASPDBKEY_POLICYLAST = 0x2,
 	KASPDBKEY_NSEC3SALT = 0x3,
@@ -749,3 +751,54 @@ int kasp_db_list_zones(kasp_db_t *db, list_t *dst)
 	}
 	return (EMPTY_LIST(*dst) ? KNOT_ENOENT : KNOT_EOK);
 }
+
+static void for_time2string(char str[21], knot_time_t t)
+{
+	snprintf(str, 21, "%020lu", t);
+}
+
+int kasp_db_store_offline_rrsig(kasp_db_t *db, knot_time_t for_time, const knot_rrset_t *rrsig)
+{
+	if (db == NULL || rrsig == NULL || rrsig->type != KNOT_RRTYPE_RRSIG) {
+		return KNOT_EINVAL;
+	}
+
+	char for_time_str[21];
+	for_time2string(for_time_str, for_time);
+	knot_db_val_t key = make_key(KASPDBKEY_OFFLINE_RRSIG, rrsig->owner, for_time_str), val;
+	val.len = rrset_serialized_size(rrsig);
+	val.data = malloc(val.len);
+	if (val.data == NULL) {
+		free_key(&key);
+		return KNOT_ENOMEM;
+	}
+	with_txn(KEYS_RW, NULL);
+	wire_ctx_t wire = wire_ctx_init(val.data, val.len);
+	ret = serialize_rrset(&wire, rrsig);
+	if (ret == KNOT_EOK) {
+		ret = db_api->insert(txn, &key, &val, 0);
+	}
+	free_key(&key);
+	with_txn_end(NULL);
+	return ret;
+}
+
+int kasp_db_load_offline_rrsig(kasp_db_t *db, const knot_dname_t *for_dname, knot_time_t for_time, knot_rrset_t *rrsig)
+{
+	if (db == NULL || rrsig == NULL) {
+		return KNOT_EINVAL;
+	}
+
+	char for_time_str[21];
+	for_time2string(for_time_str, for_time);
+	with_txn(KEYS_RO, NULL);
+	knot_db_val_t key = make_key(KASPDBKEY_OFFLINE_RRSIG, for_dname, for_time_str), val;
+	ret = db_api->find(txn, &key, &val, KNOT_DB_LEQ);
+	if (ret == KNOT_EOK) {
+		wire_ctx_t wire = wire_ctx_init(val.data, val.len);
+		ret = deserialize_rrset(&wire, rrsig);
+	}
+	free_key(&key);
+	with_txn_end(NULL);
+	return ret;
+}
diff --git a/src/knot/dnssec/kasp/kasp_db.h b/src/knot/dnssec/kasp/kasp_db.h
index a80c4b458f5367604d6e3aa7d8f6d17febe3c318..c22b5bb3387d13e4cb8303e3e2c3378340f17ead 100644
--- a/src/knot/dnssec/kasp/kasp_db.h
+++ b/src/knot/dnssec/kasp/kasp_db.h
@@ -233,3 +233,26 @@ int kasp_db_set_policy_last(kasp_db_t *db, const char *policy_string, const char
  * \return KNOT_E*
  */
 int kasp_db_list_zones(kasp_db_t *db, list_t *dst);
+
+/*!
+ * \brief Store pre-generated RRSIG for offline KSK usage.
+ *
+ * \param db         KASP db.
+ * \param for_time   Timestamp in future in which the RRSIG shall be used.
+ * \param rrsig      The rrsig to be stored.
+ *
+ * \return KNOT_E*
+ */
+int kasp_db_store_offline_rrsig(kasp_db_t *db, knot_time_t for_time, const knot_rrset_t *rrsig);
+
+/*!
+ * \brief Load pregenerated RRSIG.
+ *
+ * \param db         KASP db.
+ * \param for_dname  Name of the related zone.
+ * \param for_time   Now. Closest RRSIG (timestamp equals or is closest lower).
+ * \param rrsig      Output: the RRSIG.
+ *
+ * \return KNOT_E*
+ */
+int kasp_db_load_offline_rrsig(kasp_db_t *db, const knot_dname_t *for_dname, knot_time_t for_time, knot_rrset_t *rrsig);
diff --git a/src/knot/dnssec/key-events.c b/src/knot/dnssec/key-events.c
index ee2c247ab94ded2e4a71e50017b33e6a8c1eee1c..2695993c9f9fe9b9d771e19469ba0d864aff9ae4 100644
--- a/src/knot/dnssec/key-events.c
+++ b/src/knot/dnssec/key-events.c
@@ -368,6 +368,10 @@ static roll_action_t next_action(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flag
 			continue;
 		}
 		if (key->is_ksk) {
+			if (ctx->rollover_only_zsk) {
+				continue;
+			}
+
 			switch (get_key_state(key, ctx->now)) {
 			case DNSSEC_KEY_STATE_PRE_ACTIVE:
 				keytime = alg_publish_time(key->timing.pre_active, ctx);
@@ -435,11 +439,14 @@ static roll_action_t next_action(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flag
 				keytime = alg_remove_time(key->timing.post_active, ctx);
 				restype = REMOVE;
 				break;
-			case DNSSEC_KEY_STATE_RETIRED:
 			case DNSSEC_KEY_STATE_REMOVED:
 				// ad REMOVED state: normally this wouldn't happen
 				// (key in removed state is instantly deleted)
 				// but if imported keys, they can be in this state
+				if (ctx->keep_deleted_keys) {
+					break;
+				} // else FALLBACK
+			case DNSSEC_KEY_STATE_RETIRED:
 				keytime = knot_time_min(key->timing.retire, key->timing.remove);
 				keytime = ksk_remove_time(keytime, ctx);;
 				restype = REMOVE;
@@ -557,7 +564,11 @@ static int exec_remove_old_key(kdnssec_ctx_t *ctx, knot_kasp_key_t *key)
 	       get_key_state(key, ctx->now) == DNSSEC_KEY_STATE_REMOVED);
 	key->timing.remove = ctx->now;
 
-	return kdnssec_delete_key(ctx, key);
+	if (ctx->keep_deleted_keys) {
+		return KNOT_EOK;
+	} else {
+		return kdnssec_delete_key(ctx, key);
+	}
 }
 
 int knot_dnssec_key_rollover(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flags,
@@ -616,7 +627,8 @@ int knot_dnssec_key_rollover(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flags,
 		}
 	}
 	// algorithm rollover
-	if (algorithm_present(ctx, ctx->policy->algorithm) == 0 &&
+	if (!ctx->rollover_only_zsk &&
+	    algorithm_present(ctx, ctx->policy->algorithm) == 0 &&
 	    !running_rollover(ctx) && allowed_general_roll && ret == KNOT_EOK) {
 		ret = generate_ksk(ctx, 0, true);
 		if (!ctx->policy->singe_type_signing && ret == KNOT_EOK) {
@@ -628,7 +640,8 @@ int knot_dnssec_key_rollover(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flags,
 		}
 	}
 	// scheme rollover
-	if (!signing_scheme_present(ctx) && allowed_general_roll &&
+	if (!ctx->rollover_only_zsk &&
+	    !signing_scheme_present(ctx) && allowed_general_roll &&
 	    !running_rollover(ctx) && ret == KNOT_EOK) {
 		ret = generate_ksk(ctx, 0, false);
 		if (!ctx->policy->singe_type_signing && ret == KNOT_EOK) {
diff --git a/src/knot/dnssec/zone-keys.c b/src/knot/dnssec/zone-keys.c
index 63211808c7d1c8dd89136a44d75f13e93e66b094..4c06804da22cf19dbab3acf3f082d4f5f47e67ff 100644
--- a/src/knot/dnssec/zone-keys.c
+++ b/src/knot/dnssec/zone-keys.c
@@ -319,7 +319,13 @@ static int load_private_keys(dnssec_keystore_t *keystore, zone_keyset_t *keyset)
 
 		zone_key_t *key = &keyset->keys[i];
 		int r = dnssec_key_import_keystore(key->key, keystore, key->id);
-		if (r != DNSSEC_EOK && r != DNSSEC_KEY_ALREADY_PRESENT) {
+		switch (r) {
+		case DNSSEC_EOK:
+		case DNSSEC_KEY_ALREADY_PRESENT:
+			break;
+		case DNSSEC_ENOENT: // we hope that this is just offline KSK
+			break;
+		default:
 			return r;
 		}
 	}
diff --git a/src/knot/dnssec/zone-sign.c b/src/knot/dnssec/zone-sign.c
index 4e17274145234085784bc5bda186bb17af2dedd3..3e2095a2929e8a15b0bd422771a5f13e5cf04532 100644
--- a/src/knot/dnssec/zone-sign.c
+++ b/src/knot/dnssec/zone-sign.c
@@ -21,6 +21,7 @@
 #include "libdnssec/key.h"
 #include "libdnssec/keytag.h"
 #include "libdnssec/sign.h"
+#include "knot/common/log.h"
 #include "knot/dnssec/key-events.h"
 #include "knot/dnssec/rrset-sign.h"
 #include "knot/dnssec/zone-sign.h"
@@ -102,7 +103,8 @@ static bool valid_signature_exists(const knot_rrset_t *covered,
 				   const knot_rrset_t *rrsigs,
 				   const dnssec_key_t *key,
 				   dnssec_sign_ctx_t *ctx,
-				   const kdnssec_ctx_t *dnssec_ctx)
+				   const kdnssec_ctx_t *dnssec_ctx,
+				   uint16_t *at)
 {
 	assert(key);
 
@@ -124,6 +126,9 @@ static bool valid_signature_exists(const knot_rrset_t *covered,
 
 		if (knot_check_signature(covered, rrsigs, i, key, ctx,
 		                         dnssec_ctx) == KNOT_EOK) {
+			if (at != NULL) {
+				*at = i;
+			}
 			return true;
 		}
 	}
@@ -156,7 +161,7 @@ static bool all_signatures_exist(const knot_rrset_t *covered,
 		}
 
 		if (!valid_signature_exists(covered, rrsigs, key->key,
-		                            key->ctx, dnssec_ctx)) {
+					    key->ctx, dnssec_ctx, NULL)) {
 			return false;
 		}
 	}
@@ -272,6 +277,28 @@ static int remove_expired_rrsigs(const knot_rrset_t *covered,
 	return result;
 }
 
+static bool can_have_offline_rrsig(const knot_rrset_t *rr, const knot_dname_t *zone_apex)
+{
+	return (rr->type == KNOT_RRTYPE_DNSKEY && knot_dname_cmp(rr->owner, zone_apex) == 0);
+}
+
+static int load_offline_rrsig(const knot_rrset_t *covered,
+                               knot_rrset_t *rrsig,
+                               const kdnssec_ctx_t *ctx)
+{
+        knot_rrset_init_empty(rrsig);
+
+	if (!can_have_offline_rrsig(covered, ctx->zone->dname)) {
+		return KNOT_EOK;
+	}
+
+	int ret = kasp_db_load_offline_rrsig(*ctx->kasp_db, covered->owner, ctx->now, rrsig);
+	if (ret == KNOT_ENOENT) {
+		ret = KNOT_EOK;
+	}
+	return ret;
+}
+
 /*!
  * \brief Add missing RRSIGs into the changeset for adding.
  *
@@ -295,9 +322,13 @@ static int add_missing_rrsigs(const knot_rrset_t *covered,
 	assert(zone_keys);
 	assert(changeset);
 
-	int result = KNOT_EOK;
-	knot_rrset_t to_add;
+	knot_rrset_t to_add, offline_rrsigs;
 	knot_rrset_init_empty(&to_add);
+	int result = load_offline_rrsig(covered, &offline_rrsigs, dnssec_ctx);
+	if (result != KNOT_EOK) {
+		log_zone_warning(dnssec_ctx->zone->dname, "DNSSEC, failed to load offline DNSKEY RRSIG (%s)",
+				 knot_strerror(result));
+	}
 
 	for (int i = 0; i < zone_keys->count; i++) {
 		const zone_key_t *key = &zone_keys->keys[i];
@@ -305,7 +336,7 @@ static int add_missing_rrsigs(const knot_rrset_t *covered,
 			continue;
 		}
 
-		if (valid_signature_exists(covered, rrsigs, key->key, key->ctx, dnssec_ctx)) {
+		if (valid_signature_exists(covered, rrsigs, key->key, key->ctx, dnssec_ctx, NULL)) {
 			continue;
 		}
 
@@ -313,6 +344,17 @@ static int add_missing_rrsigs(const knot_rrset_t *covered,
 			to_add = create_empty_rrsigs_for(covered);
 		}
 
+		uint16_t at_offline;
+		if (valid_signature_exists(covered, &offline_rrsigs, key->key, key->ctx, dnssec_ctx, &at_offline)) {
+			log_zone_info(dnssec_ctx->zone->dname, "DNSSEC, using offline DNSKEY RRSIG");
+			knot_rdata_t *offline_rd = knot_rdataset_at(&offline_rrsigs.rrs, at_offline);
+			result = knot_rrset_add_rdata(&to_add, offline_rd->data, offline_rd->len, NULL);
+			if (result != KNOT_EOK) {
+				break;
+			}
+			continue;
+		}
+
 		result = knot_sign_rrset(&to_add, covered, key->key, key->ctx,
 		                         dnssec_ctx, NULL, expires_at);
 		if (result != KNOT_EOK) {
@@ -325,6 +367,7 @@ static int add_missing_rrsigs(const knot_rrset_t *covered,
 	}
 
 	knot_rdataset_clear(&to_add.rrs, NULL);
+	knot_rrset_clear(&offline_rrsigs, NULL);
 
 	return result;
 }
@@ -588,7 +631,7 @@ typedef struct {
 
 /*- private API - DNSKEY handling --------------------------------------------*/
 
-static int rrset_add_zone_key(knot_rrset_t *rrset, zone_key_t *zone_key)
+int rrset_add_zone_key(knot_rrset_t *rrset, zone_key_t *zone_key)
 {
 	assert(rrset);
 	assert(zone_key);
diff --git a/src/knot/dnssec/zone-sign.h b/src/knot/dnssec/zone-sign.h
index 8796c4d5373f79cc67d23219922623928fba28df..7f833daef26a074d67c878d5e96935b5ee18c811 100644
--- a/src/knot/dnssec/zone-sign.h
+++ b/src/knot/dnssec/zone-sign.h
@@ -22,6 +22,8 @@
 #include "knot/dnssec/context.h"
 #include "knot/dnssec/zone-keys.h"
 
+int rrset_add_zone_key(knot_rrset_t *rrset, zone_key_t *zone_key);
+
 /*!
  * \brief Adds/removes DNSKEY (and CDNSKEY, CDS) records to zone according to zone keyset.
  *
diff --git a/src/knot/journal/serialization.c b/src/knot/journal/serialization.c
index ae91b2f6ccde7d45838c82ece05f20042eb76a11..d764fe606cc49efa5ae15c3686d990d3fc528fe3 100644
--- a/src/knot/journal/serialization.c
+++ b/src/knot/journal/serialization.c
@@ -190,7 +190,7 @@ void serialize_deinit(serialize_ctx_t *ctx)
 	free(ctx);
 }
 
-static int deserialize_rrset(wire_ctx_t *wire, knot_rrset_t *rrset, long *phase)
+static int _deserialize_rrset(wire_ctx_t *wire, knot_rrset_t *rrset, long *phase)
 {
 	assert(wire != NULL && rrset != NULL && phase != NULL);
 	assert(*phase >= SERIALIZE_RRSET_INIT && *phase < SERIALIZE_RRSET_DONE);
@@ -245,7 +245,7 @@ int deserialize_rrset_chunks(wire_ctx_t *wire, knot_rrset_t *rrset,
 {
 	long phase = SERIALIZE_RRSET_INIT;
 	while (1) {
-		int ret = deserialize_rrset(wire, rrset, &phase);
+		int ret = _deserialize_rrset(wire, rrset, &phase);
 		if (ret != KNOT_EOK || phase == SERIALIZE_RRSET_DONE) {
 			return ret;
 		}
@@ -378,3 +378,93 @@ int changeset_deserialize(changeset_t *ch, uint8_t *src_chunks[],
 
 	return wire.error;
 }
+
+int serialize_rrset(wire_ctx_t *wire, const knot_rrset_t *rrset)
+{
+	assert(wire != NULL && rrset != NULL);
+
+	// write owner, type, class, rrcnt
+	int size = knot_dname_to_wire(wire->position, rrset->owner,
+				      wire_ctx_available(wire));
+	if (size < 0 || wire_ctx_available(wire) < size + 3 * sizeof(uint16_t)) {
+			assert(0);
+	}
+	wire_ctx_skip(wire, size);
+	wire_ctx_write_u16(wire, rrset->type);
+	wire_ctx_write_u16(wire, rrset->rclass);
+	wire_ctx_write_u16(wire, rrset->rrs.count);
+
+	for (size_t phase = 0; phase < rrset->rrs.count; phase++) {
+		const knot_rdata_t *rr = knot_rdataset_at(&rrset->rrs, phase);
+		assert(rr);
+		uint16_t rdlen = rr->len;
+		if (wire_ctx_available(wire) < sizeof(uint32_t) + sizeof(uint16_t) + rdlen) {
+			assert(0);
+		}
+		wire_ctx_write_u32(wire, rrset->ttl);
+		wire_ctx_write_u16(wire, rdlen);
+		wire_ctx_write(wire, rr->data, rdlen);
+	}
+
+	return KNOT_EOK;
+}
+
+int deserialize_rrset(wire_ctx_t *wire, knot_rrset_t *rrset)
+{
+	assert(wire != NULL && rrset != NULL);
+
+	// Read owner, rtype, rclass and RR count.
+	int size = knot_dname_size(wire->position);
+	if (size < 0) {
+		assert(0);
+	}
+	knot_dname_t *owner = knot_dname_copy(wire->position, NULL);
+	if (owner == NULL || wire_ctx_available(wire) < size + 3 * sizeof(uint16_t)) {
+		return KNOT_EMALF;
+	}
+	wire_ctx_skip(wire, size);
+	uint16_t type = wire_ctx_read_u16(wire);
+	uint16_t rclass = wire_ctx_read_u16(wire);
+	uint16_t rrcount = wire_ctx_read_u16(wire);
+	if (wire->error != KNOT_EOK) {
+		return wire->error;
+	}
+	knot_rrset_init(rrset, owner, type, rclass, 0);
+
+	for (size_t phase = 0; phase < rrcount && wire_ctx_available(wire) > 0; phase++) {
+		uint32_t ttl = wire_ctx_read_u32(wire);
+		uint32_t rdata_size = wire_ctx_read_u16(wire);
+		if (phase == 0) {
+			rrset->ttl = ttl;
+		}
+		if (wire->error != KNOT_EOK ||
+		    wire_ctx_available(wire) < rdata_size ||
+		    knot_rrset_add_rdata(rrset, wire->position, rdata_size,
+					 NULL) != KNOT_EOK) {
+			knot_rrset_clear(rrset, NULL);
+			return KNOT_EMALF;
+		}
+		wire_ctx_skip(wire, rdata_size);
+	}
+
+	return KNOT_EOK;
+}
+
+size_t rrset_serialized_size(const knot_rrset_t *rrset)
+{
+	if (rrset == NULL || rrset->rrs.count == 0) {
+		return 0;
+	}
+
+	// Owner size + type + class + RR count.
+	size_t size = knot_dname_size(rrset->owner) + 3 * sizeof(uint16_t);
+
+	for (uint16_t i = 0; i < rrset->rrs.count; i++) {
+		const knot_rdata_t *rr = knot_rdataset_at(&rrset->rrs, i);
+		assert(rr);
+		// TTL + RR size + RR.
+		size += sizeof(uint32_t) + sizeof(uint16_t) + rr->len;
+	}
+
+	return size;
+}
diff --git a/src/knot/journal/serialization.h b/src/knot/journal/serialization.h
index 04afe394b683d84ef532078d10310531f48c30ef..bc1202711d5ee12f872d32a236ea11ee5bdba05f 100644
--- a/src/knot/journal/serialization.h
+++ b/src/knot/journal/serialization.h
@@ -96,3 +96,32 @@ int changeset_deserialize(changeset_t *ch, uint8_t *src_chunks[],
 int deserialize_rrset_chunks(wire_ctx_t *wire, knot_rrset_t *rrset,
                              uint8_t *src_chunks[], const size_t *chunk_sizes,
                              size_t chunks_count, size_t *cur_chunk);
+
+/*!
+ * \brief Simply serialize RRset w/o any chunking.
+ *
+ * \param wire
+ * \param rrset
+ *
+ * \return KNOT_E*
+ */
+int serialize_rrset(wire_ctx_t *wire, const knot_rrset_t *rrset);
+
+/*!
+ * \brief Simply deserialize RRset w/o any chunking.
+ *
+ * \param wire
+ * \param rrset
+ *
+ * \return KNOT_E*
+ */
+int deserialize_rrset(wire_ctx_t *wire, knot_rrset_t *rrset);
+
+/*!
+ * \brief Space needed to serialize RRset.
+ *
+ * \param rrset RRset.
+ *
+ * \return RRset binary size.
+ */
+size_t rrset_serialized_size(const knot_rrset_t *rrset);
diff --git a/src/utils/Makefile.inc b/src/utils/Makefile.inc
index abb86990061594f232d4c88648921ff556300d0b..1e3f91daa9a3b61febbbdba698a7a82ab169d867 100644
--- a/src/utils/Makefile.inc
+++ b/src/utils/Makefile.inc
@@ -123,6 +123,8 @@ keymgr_SOURCES = \
 	utils/keymgr/bind_privkey.h		\
 	utils/keymgr/functions.c		\
 	utils/keymgr/functions.h		\
+	utils/keymgr/offline_ksk.c		\
+	utils/keymgr/offline_ksk.h		\
 	utils/keymgr/main.c
 
 kjournalprint_SOURCES = \
diff --git a/src/utils/keymgr/functions.c b/src/utils/keymgr/functions.c
index b8dbecf7dd335e43c489945e4584c0dcc0302539..fd9ecf5f06ab91be0dc77fb82c5bb73a224b38dc 100644
--- a/src/utils/keymgr/functions.c
+++ b/src/utils/keymgr/functions.c
@@ -828,15 +828,3 @@ int keymgr_generate_dnskey(const knot_dname_t *dname, const knot_kasp_key_t *key
 	free(name);
 	return KNOT_EOK;
 }
-
-int keymgr_del_all_old(kdnssec_ctx_t *ctx)
-{
-	for (size_t i = 0; i < ctx->zone->num_keys; i++) {
-		knot_kasp_key_t *key = &ctx->zone->keys[i];
-		if (knot_time_cmp(key->timing.remove, ctx->now) < 0) {
-			int ret = kdnssec_delete_key(ctx, key);
-			printf("- %s\n", knot_strerror(ret));
-		}
-	}
-	return kdnssec_ctx_commit(ctx);
-}
diff --git a/src/utils/keymgr/functions.h b/src/utils/keymgr/functions.h
index 2fbdc56f11274918244b6b02b9e2aaab1a9bddf9..dda367f9399d95708ec534e849dcc38acb81133b 100644
--- a/src/utils/keymgr/functions.h
+++ b/src/utils/keymgr/functions.h
@@ -39,5 +39,3 @@ int keymgr_list_keys(kdnssec_ctx_t *ctx, knot_time_print_t format);
 int keymgr_generate_ds(const knot_dname_t *dname, const knot_kasp_key_t *key);
 
 int keymgr_generate_dnskey(const knot_dname_t *dname, const knot_kasp_key_t *key);
-
-int keymgr_del_all_old(kdnssec_ctx_t *ctx);
diff --git a/src/utils/keymgr/main.c b/src/utils/keymgr/main.c
index 47f08e7f223e692de3fde300974fe450e512c616..cd629deaa60baac913f7d433c349bc3c6a3ae21c 100644
--- a/src/utils/keymgr/main.c
+++ b/src/utils/keymgr/main.c
@@ -27,6 +27,7 @@
 #include "libknot/libknot.h"
 #include "utils/common/params.h"
 #include "utils/keymgr/functions.h"
+#include "utils/keymgr/offline_ksk.h"
 
 #define PROGRAM_NAME	"keymgr"
 
@@ -69,6 +70,14 @@ static void print_help(void)
 	       "                 (syntax: delete <key_spec>)\n"
 	       "  set           Set existing key's timing attribute.\n"
 	       "                 (syntax: set <key_spec> <attribute_name>=<value>...)\n"
+	       "\n"
+	       "Commands related to Offline KSK feature:\n"
+	       "  pregenerate   Pre-generate ZSKs for later rollovers with offline KSK.\n"
+	       "                 (syntax: pregenerate <period_secs>)\n"
+	       "  presign       Pre-generate RRSIG signatures for pregenerated ZSKs.\n"
+	       "                 (syntax: presign <period_secs>)\n"
+	       "  show-rrsig    Print a pre-generated DNSKEY RRSIG for specified timestamp.\n"
+	       "                 (syntax: show-rrsig <timestamp>)\n"
 	       "  del-all-old   Delete old keys that are in state 'removed'.\n"
 	       "\n"
 	       "Key specification:\n"
@@ -195,6 +204,15 @@ static int key_command(int argc, char *argv[], int optind)
 		if (ret == KNOT_EOK) {
 			ret = kdnssec_delete_key(&kctx, key2del);
 		}
+	} else if (strcmp(argv[1], "pregenerate") == 0) {
+		CHECK_MISSING_ARG("Period not specified");
+		ret = keymgr_pregenerate_zsks(&kctx, knot_time() + atol(argv[2]));
+	} else if (strcmp(argv[1], "presign") == 0) {
+		CHECK_MISSING_ARG("Timestamp not specified");
+		ret = keymgr_presign_zsks(&kctx, knot_time() + atol(argv[2]));
+	} else if (strcmp(argv[1], "show-rrsig") == 0) {
+		CHECK_MISSING_ARG("Timestamp not specified");
+		ret = keymgr_print_rrsig(&kctx, atol(argv[2]));
 	} else if (strcmp(argv[1], "del-all-old") == 0) {
 		ret = keymgr_del_all_old(&kctx);
 	} else {
diff --git a/src/utils/keymgr/offline_ksk.c b/src/utils/keymgr/offline_ksk.c
new file mode 100644
index 0000000000000000000000000000000000000000..cf1e92a07d6059508a8bb74b26baa16d38f14485
--- /dev/null
+++ b/src/utils/keymgr/offline_ksk.c
@@ -0,0 +1,183 @@
+/*  Copyright (C) 2018 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+#include <string.h>
+#include <stdio.h>
+#include <time.h>
+
+#include "utils/keymgr/offline_ksk.h"
+#include "knot/dnssec/kasp/policy.h"
+#include "knot/dnssec/key-events.h"
+#include "knot/dnssec/rrset-sign.h"
+#include "knot/dnssec/zone-events.h"
+#include "knot/dnssec/zone-keys.h"
+#include "knot/dnssec/zone-sign.h"
+#include "libzscanner/scanner.h"
+
+static int pregenerate_once(kdnssec_ctx_t *ctx, knot_time_t *next)
+{
+	zone_sign_reschedule_t resch = { 0 };
+
+	// generate ZSKs
+	int ret = knot_dnssec_key_rollover(ctx, KEY_ROLL_ALLOW_ZSK_ROLL, &resch);
+	if (ret != KNOT_EOK) {
+		printf("rollover failed\n");
+		return ret;
+	}
+	// we don't need to do anything explicitly with the generated ZSKs
+	// they're simply stored in KASP db
+
+	*next = resch.next_rollover;
+	return KNOT_EOK;
+}
+
+static void update_next_resign(knot_time_t *next, knot_time_t now, knot_time_t key_timer)
+{
+	if (knot_time_cmp(now, key_timer) < 0) {
+		*next = knot_time_min(*next, key_timer);
+	}
+}
+
+static int presign_once(kdnssec_ctx_t *ctx, knot_time_t *next)
+{
+	// prepare the DNSKEY rrset to be signed
+	zone_keyset_t keyset = { 0 };
+	int ret = load_zone_keys(ctx, &keyset, false);
+	if (ret != KNOT_EOK) {
+		printf("load keys failed\n");
+		return ret;
+	}
+	knot_rrset_t *dnskey = knot_rrset_new(ctx->zone->dname, KNOT_RRTYPE_DNSKEY, KNOT_CLASS_IN, ctx->policy->dnskey_ttl, NULL);
+	knot_rrset_t *rrsig = knot_rrset_new(ctx->zone->dname, KNOT_RRTYPE_RRSIG, KNOT_CLASS_IN, ctx->policy->dnskey_ttl, NULL);
+	if (dnskey == NULL || rrsig == NULL) {
+		ret = KNOT_ENOMEM;
+		goto done;
+	}
+	for (int i = 0; i < keyset.count; i++) {
+		zone_key_t *key = &keyset.keys[i];
+		if (key->is_public) {
+			ret = rrset_add_zone_key(dnskey, key);
+			if (ret != KNOT_EOK) {
+				printf("add zone key failed\n");
+				goto done;
+			}
+		}
+	}
+
+	// sign the DNSKEY rrset
+	for (int i = 0; i < keyset.count; i++) {
+		zone_key_t *key = &keyset.keys[i];
+		if (key->is_active && key->is_ksk) {
+			ret = knot_sign_rrset(rrsig, dnskey, key->key, key->ctx, ctx, NULL);
+			if (ret != KNOT_EOK) {
+				printf("sign rrset failed\n");
+				goto done;
+			}
+		}
+	}
+
+	// store it to KASP db
+	assert(!knot_rrset_empty(rrsig));
+	ret = kasp_db_store_offline_rrsig(*ctx->kasp_db, ctx->now, rrsig);
+	if (ret != KNOT_EOK) {
+		printf("store rrsig failed\n");
+		goto done;
+	}
+
+	// next re-sign when rrsig expire or dnskey rrset changes
+	*next = ctx->now + ctx->policy->rrsig_lifetime - ctx->policy->rrsig_refresh_before;
+	for (int i = 0; i < ctx->zone->num_keys; i++) {
+		update_next_resign(next, ctx->now, ctx->zone->keys[i].timing.publish);
+		update_next_resign(next, ctx->now, ctx->zone->keys[i].timing.retire_active);
+		update_next_resign(next, ctx->now, ctx->zone->keys[i].timing.remove);
+	}
+
+done:
+	knot_rrset_free(dnskey, NULL);
+	knot_rrset_free(rrsig, NULL);
+	free_zone_keys(&keyset);
+	return ret;
+}
+
+int keymgr_pregenerate_zsks(kdnssec_ctx_t *ctx, knot_time_t upto)
+{
+	knot_time_t next = ctx->now;
+	int ret = KNOT_EOK;
+
+	ctx->keep_deleted_keys = true;
+	ctx->rollover_only_zsk = true;
+	ctx->policy->manual = false;
+
+	while (ret == KNOT_EOK && knot_time_cmp(next, upto) <= 0) {
+		ctx->now = next;
+		printf("pregenerate %lu\n", ctx->now);
+		ret = pregenerate_once(ctx,  &next);
+	}
+
+	return ret;
+}
+
+int keymgr_presign_zsks(kdnssec_ctx_t *ctx, knot_time_t upto)
+{
+	knot_time_t next = ctx->now;
+	int ret = KNOT_EOK;
+
+	ctx->keep_deleted_keys = true;
+	ctx->policy->manual = true;
+
+	while (ret == KNOT_EOK && knot_time_cmp(next, upto) <= 0) {
+		ctx->now = next;
+		printf("presign %lu\n", ctx->now);
+		ret = presign_once(ctx, &next);
+	}
+
+	return ret;
+}
+
+int keymgr_print_rrsig(kdnssec_ctx_t *ctx, knot_time_t when)
+{
+	knot_rrset_t rrsig = { 0 };
+	knot_rrset_init_empty(&rrsig);
+	int ret = kasp_db_load_offline_rrsig(*ctx->kasp_db, ctx->zone->dname, when, &rrsig);
+	if (ret == KNOT_EOK) {
+		size_t out_size = 512;
+		char *out = malloc(out_size);
+		if (out == NULL) {
+			ret = KNOT_ENOMEM;
+		} else {
+			ret = knot_rrset_txt_dump(&rrsig, &out, &out_size, &KNOT_DUMP_STYLE_DEFAULT);
+			if (ret >= 0) {
+				printf("%s\n", out);
+				ret = KNOT_EOK;
+			}
+			free(out);
+		}
+	}
+	knot_rrset_clear(&rrsig, NULL);
+	return ret;
+}
+
+int keymgr_del_all_old(kdnssec_ctx_t *ctx)
+{
+	for (size_t i = 0; i < ctx->zone->num_keys; i++) {
+		knot_kasp_key_t *key = &ctx->zone->keys[i];
+		if (knot_time_cmp(key->timing.remove, ctx->now) < 0) {
+			int ret = kdnssec_delete_key(ctx, key);
+			printf("- %s\n", knot_strerror(ret));
+		}
+	}
+	return kdnssec_ctx_commit(ctx);
+}
diff --git a/src/utils/keymgr/offline_ksk.h b/src/utils/keymgr/offline_ksk.h
new file mode 100644
index 0000000000000000000000000000000000000000..37c1fab85bbb5fefe79e4e9ef67b53bf8cd3668b
--- /dev/null
+++ b/src/utils/keymgr/offline_ksk.h
@@ -0,0 +1,27 @@
+/*  Copyright (C) 2018 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+#pragma once
+
+#include "knot/dnssec/context.h"
+
+int keymgr_pregenerate_zsks(kdnssec_ctx_t *ctx, knot_time_t upto);
+
+int keymgr_presign_zsks(kdnssec_ctx_t *ctx, knot_time_t upto);
+
+int keymgr_print_rrsig(kdnssec_ctx_t *ctx, knot_time_t when);
+
+int keymgr_del_all_old(kdnssec_ctx_t *ctx);
diff --git a/tests-extra/tests/dnssec/offline_ksk/test.py b/tests-extra/tests/dnssec/offline_ksk/test.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ac056b58ac1ceb86d1198349159c4048ca16558
--- /dev/null
+++ b/tests-extra/tests/dnssec/offline_ksk/test.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+
+"""
+Test of offline KSK by trying pre-generated ZSK rollover.
+"""
+
+import collections
+import os
+import shutil
+import datetime
+import subprocess
+import time
+from subprocess import check_call
+
+from dnstest.utils import *
+from dnstest.keys import Keymgr
+from dnstest.test import Test
+
+# check zone if keys are present and used for signing
+def check_zone(server, zone, dnskeys, dnskey_rrsigs, soa_rrsigs, msg):
+    qdnskeys = server.dig("example.com", "DNSKEY", bufsize=4096)
+    found_dnskeys = qdnskeys.count("DNSKEY")
+
+    qdnskeyrrsig = server.dig("example.com", "DNSKEY", dnssec=True, bufsize=4096)
+    found_rrsigs = qdnskeyrrsig.count("RRSIG")
+
+    qsoa = server.dig("example.com", "SOA", dnssec=True, bufsize=4096)
+    found_soa_rrsigs = qsoa.count("RRSIG")
+
+    check_log("DNSKEYs: %d (expected %d)" % (found_dnskeys, dnskeys));
+    check_log("RRSIGs: %d (expected %d)" % (found_soa_rrsigs, soa_rrsigs));
+    check_log("DNSKEY-RRSIGs: %d (expected %d)" % (found_rrsigs, dnskey_rrsigs));
+
+    if found_dnskeys != dnskeys:
+        set_err("BAD DNSKEY COUNT: " + msg)
+        detail_log("!DNSKEYs not published and activated as expected: " + msg)
+
+    if found_soa_rrsigs != soa_rrsigs:
+        set_err("BAD RRSIG COUNT: " + msg)
+        detail_log("!RRSIGs not published and activated as expected: " + msg)
+
+    if found_rrsigs != dnskey_rrsigs:
+        set_err("BAD DNSKEY RRSIG COUNT: " + msg)
+        detail_log("!RRSIGs not published and activated as expected: " + msg)
+
+    detail_log(SEP)
+
+    # Valgrind delay breaks the timing!
+    if not server.valgrind:
+        server.zone_backup(zone, flush=True)
+        server.zone_verify(zone)
+
+def wait_for_rrsig_count(t, server, rrtype, rrsig_count, timeout):
+    rtime = 0
+    while True:
+        qdnskeyrrsig = server.dig("example.com", rrtype, dnssec=True, bufsize=4096)
+        found_rrsigs = qdnskeyrrsig.count("RRSIG")
+        if found_rrsigs == rrsig_count:
+            break
+        rtime = rtime + 1
+        t.sleep(1)
+        if rtime > timeout:
+            break
+
+def wait_for_dnskey_count(t, server, dnskey_count, timeout):
+    for rtime in range(1, timeout):
+        qdnskeyrrsig = server.dig("example.com", "DNSKEY", dnssec=True, bufsize=4096)
+        found_dnskeys = qdnskeyrrsig.count("DNSKEY")
+        if found_dnskeys == dnskey_count:
+            break
+        t.sleep(1)
+
+t = Test()
+
+knot = t.server("knot")
+ZONE = "example.com."
+FUTURE = 100
+
+zone = t.zone(ZONE)
+t.link(zone, knot)
+
+knot.zonefile_sync = 24 * 60 * 60
+
+knot.dnssec(zone).enable = True
+knot.dnssec(zone).manual = True
+knot.dnssec(zone).alg = "ECDSAP384SHA384"
+knot.dnssec(zone).dnskey_ttl = 2
+knot.dnssec(zone).zsk_lifetime = 12
+knot.dnssec(zone).ksk_lifetime = 300 # this can be possibly left also infinity
+knot.dnssec(zone).propagation_delay = 3
+knot.port = 1234 # dummy, will be overwritten
+knot.gen_confile()
+
+key_ksk = knot.gen_key(zone, ksk=True, alg="ECDSAP384SHA384", key_len=384)
+key_zsk = knot.gen_key(zone, ksk=False, alg="ECDSAP384SHA384", key_len=384)
+
+Keymgr.run_check(knot.confile, ZONE, "pregenerate", str(FUTURE))
+Keymgr.run_check(knot.confile, ZONE, "presign", str(FUTURE))
+
+os.remove(knot.keydir + "/keys/" + key_ksk.keyid + ".pem")
+
+# parameters
+
+t.start()
+knot.zone_wait(zone)
+check_zone(knot, zone, 2, 1, 1, "init")
+
+wait_for_dnskey_count(t, knot, 3, knot.dnssec(zone).zsk_lifetime)
+check_zone(knot, zone, 3, 1, 1, "ZSK rollover")
+
+t.sleep(2)
+
+wait_for_dnskey_count(t, knot, 2, 2 * (knot.dnssec(zone).propagation_delay + knot.dnssec(zone).dnskey_ttl))
+check_zone(knot, zone, 2, 1, 1, "end")
+
+t.end()