Repository: scionproto/scion
Pull request #3373 (2019-11-13 14:51:35)

SPKI: Issuer certificate generation
This change adds issuer certificate generation and signing.

<!-- Reviewable:start -->
---
This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/scionproto/scion/3373)
<!-- Reviewable:end -->



Patch:
diff --git a/go/lib/keyconf/key.go b/go/lib/keyconf/key.go
index 4530034e9..25d607732 100644
--- a/go/lib/keyconf/key.go
+++ b/go/lib/keyconf/key.go
@@ -17,6 +17,7 @@ package keyconf
 import (
 	"encoding/pem"
 	"fmt"
+	"io/ioutil"
 	"strconv"
 	"time"
 
@@ -28,10 +29,15 @@ import (
 )
 
 var (
+	// ErrContentsMismatch indicates that the contents of a loaded key do not
+	// match the expected identifiers.
+	ErrContentsMismatch = serrors.New("contents mismatch")
 	// ErrNoAlgorithm indicates no algorithm was provided.
 	ErrNoAlgorithm = serrors.New("no algorithm")
 	// ErrNoKey indicates no key was provided.
 	ErrNoKey = serrors.New("no key")
+	// ErrReadFile indicates an error while reading a file.
+	ErrReadFile = serrors.New("error reading file")
 	// ErrUnsupportedUsage indicates the key usage is not known.
 	ErrUnsupportedUsage = serrors.New("unsupported key usage")
 	// ErrUnsupportedType indicates the key type is not known.
@@ -109,6 +115,13 @@ func (t *Type) UnmarshalText(text []byte) error {
 	return serrors.WithCtx(ErrUnsupportedType, "input", string(text))
 }
 
+// ID identifies a specific key.
+type ID struct {
+	Usage   Usage
+	IA      addr.IA
+	Version scrypto.KeyVersion
+}
+
 // Key contains the key with additional metada.
 //
 // On disk, the key is encoded in PEM with a file name specific to the type,
@@ -117,15 +130,47 @@ func (t *Type) UnmarshalText(text []byte) error {
 //
 // To see the resulting filename, check the example.
 type Key struct {
+	ID
 	Type      Type
-	Usage     Usage
 	Algorithm string
 	Validity  scrypto.Validity
-	Version   scrypto.KeyVersion
-	IA        addr.IA
 	Bytes     []byte
 }
 
+// LoadKeyFromFile loads the key from file and checks that the contained
+// identifiers match.
+func LoadKeyFromFile(file string, keyType Type, id ID) (Key, error) {
+	raw, err := ioutil.ReadFile(file)
+	if err != nil {
+		return Key{}, serrors.Wrap(ErrReadFile, err)
+	}
+	block, _ := pem.Decode(raw)
+	if block == nil {
+		return Key{}, serrors.New("unable to parse PEM")
+	}
+	key, err := KeyFromPEM(block)
+	if err != nil {
+		return Key{}, serrors.WrapStr("unable to decode key", err)
+	}
+	if key.Type != keyType {
+		return Key{}, serrors.WithCtx(ErrContentsMismatch, "field", "type",
+			"expected", keyType, "actual", key.Type)
+	}
+	if !key.IA.Equal(id.IA) {
+		return Key{}, serrors.WithCtx(ErrContentsMismatch, "field", "ia",
+			"expected", id.IA, "actual", key.IA)
+	}
+	if key.Usage != id.Usage {
+		return Key{}, serrors.WithCtx(ErrContentsMismatch, "field", "usage",
+			"expected", id.Usage, "actual", key.Usage)
+	}
+	if key.Version != id.Version {
+		return Key{}, serrors.WithCtx(ErrContentsMismatch, "field", "version",
+			"expected", id.Version, "actual", key.Version)
+	}
+	return key, nil
+}
+
 // KeyFromPEM parses the PEM block.
 func KeyFromPEM(block *pem.Block) (Key, error) {
 	k := Key{}
diff --git a/go/lib/scrypto/cert/v2/issuer_signed.go b/go/lib/scrypto/cert/v2/issuer_signed.go
index a95b1298a..21e9f7e8d 100644
--- a/go/lib/scrypto/cert/v2/issuer_signed.go
+++ b/go/lib/scrypto/cert/v2/issuer_signed.go
@@ -41,6 +41,20 @@ func (s SignedIssuer) SigInput() common.RawBytes {
 	return scrypto.JWSignatureInput(s.EncodedProtected, s.Encoded)
 }
 
+// ParseSignedIssuer parses the raw signed issuer certificate.
+func ParseSignedIssuer(raw []byte) (SignedIssuer, error) {
+	var signed SignedIssuer
+	if err := json.Unmarshal(raw, &signed); err != nil {
+		return SignedIssuer{}, err
+	}
+	return signed, nil
+}
+
+// EncodeSignedIssuer encodes the signed issuer certificate to raw bytes.
+func EncodeSignedIssuer(signed SignedIssuer) ([]byte, error) {
+	return json.Marshal(signed)
+}
+
 // EncodedIssuer is the the base64url encoded marshaled issuer certificate.
 type EncodedIssuer []byte
 
diff --git a/go/tools/scion-pki/internal/pkicmn/pkicmn.go b/go/tools/scion-pki/internal/pkicmn/pkicmn.go
index 186d82a86..8f438b97e 100644
--- a/go/tools/scion-pki/internal/pkicmn/pkicmn.go
+++ b/go/tools/scion-pki/internal/pkicmn/pkicmn.go
@@ -30,6 +30,7 @@ import (
 const (
 	CertNameFmt     = "ISD%d-AS%s-V%d.crt"
 	CoreCertNameFmt = "ISD%d-AS%s-V%d-core.crt"
+	IssuerNameFmt   = "ISD%d-AS%s-V%d.issuer"
 	TrcNameFmt      = "ISD%d-V%d.trc"
 	TRCPartsDirFmt  = "ISD%d-V%d.parts"
 	TRCSigPartFmt   = "ISD%d-V%d.%s.sig"
diff --git a/go/tools/scion-pki/internal/v2/certs/BUILD.bazel b/go/tools/scion-pki/internal/v2/certs/BUILD.bazel
new file mode 100644
index 000000000..dceeb13d0
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/BUILD.bazel
@@ -0,0 +1,45 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "cmd.go",
+        "issuer.go",
+        "loader.go",
+        "util.go",
+        "verify.go",
+    ],
+    importpath = "github.com/scionproto/scion/go/tools/scion-pki/internal/v2/certs",
+    visibility = ["//go/tools/scion-pki:__subpackages__"],
+    deps = [
+        "//go/lib/addr:go_default_library",
+        "//go/lib/keyconf:go_default_library",
+        "//go/lib/scrypto:go_default_library",
+        "//go/lib/scrypto/cert/v2:go_default_library",
+        "//go/lib/scrypto/trc/v2:go_default_library",
+        "//go/lib/serrors:go_default_library",
+        "//go/tools/scion-pki/internal/pkicmn:go_default_library",
+        "//go/tools/scion-pki/internal/v2/conf:go_default_library",
+        "//go/tools/scion-pki/internal/v2/keys:go_default_library",
+        "//go/tools/scion-pki/internal/v2/trcs:go_default_library",
+        "@com_github_spf13_cobra//:go_default_library",
+    ],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = [
+        "issuer_test.go",
+        "loader_test.go",
+        "main_test.go",
+    ],
+    data = glob(["testdata/**"]),
+    embed = [":go_default_library"],
+    deps = [
+        "//go/lib/scrypto:go_default_library",
+        "//go/lib/xtest:go_default_library",
+        "//go/tools/scion-pki/internal/pkicmn:go_default_library",
+        "@com_github_stretchr_testify//assert:go_default_library",
+        "@com_github_stretchr_testify//require:go_default_library",
+    ],
+)
diff --git a/go/tools/scion-pki/internal/v2/certs/cmd.go b/go/tools/scion-pki/internal/v2/certs/cmd.go
new file mode 100644
index 000000000..dffd38d88
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/cmd.go
@@ -0,0 +1,82 @@
+// Copyright 2018 ETH Zurich
+// Copyright 2019 ETH Zurich, Anapaya Systems
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package certs
+
+import (
+	"github.com/spf13/cobra"
+
+	"github.com/scionproto/scion/go/lib/scrypto"
+	"github.com/scionproto/scion/go/lib/serrors"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
+)
+
+var version uint64
+
+var Cmd = &cobra.Command{
+	Use:   "certs",
+	Short: "Interact with certificates for the SCION control plane PKI.",
+	Long: `
+'certs' can be used to generate and verify certificates for the SCION control plane PKI.
+
+Selector:
+	*-*
+		All ISDs and ASes under the root directory.
+	X-*
+		All ASes in ISD X.
+	X-Y
+		A specific AS X-Y, e.g. AS 1-ff00:0:300
+
+'certs' needs to be pointed to the root directory where all keys and certificates are
+stored on disk (-d flag). It expects the contents of the root directory to follow
+a predefined structure:
+	<root>/
+		ISD1/
+			trc-v1.toml
+			ASff00_0_110/
+				as-v1.toml
+				issuer-v1.toml
+				keys.toml
+				certs/
+				keys/
+			ASff00_0_120/
+			...
+		ISD2/
+			ASff00_0_210/
+			...
+		...
+`,
+}
+
+var genIssuerCmd = &cobra.Command{
+	Use:   "issuer",
+	Short: "Generate the issuer certificate",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		g := issGen{
+			Dirs:    pkicmn.GetDirs(),
+			Version: scrypto.Version(version),
+		}
+		asMap, err := pkicmn.ProcessSelector(args[0])
+		if err != nil {
+			return serrors.WrapStr("unable to select target ISDs", err, "selector", args[0])
+		}
+		return g.Run(asMap)
+	},
+}
+
+func init() {
+	Cmd.AddCommand(genIssuerCmd)
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/issuer.go b/go/tools/scion-pki/internal/v2/certs/issuer.go
new file mode 100644
index 000000000..6916435cf
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/issuer.go
@@ -0,0 +1,236 @@
+// Copyright 2019 Anapaya Systems
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package certs
+
+import (
+	"os"
+	"time"
+
+	"github.com/scionproto/scion/go/lib/addr"
+	"github.com/scionproto/scion/go/lib/keyconf"
+	"github.com/scionproto/scion/go/lib/scrypto"
+	"github.com/scionproto/scion/go/lib/scrypto/cert/v2"
+	"github.com/scionproto/scion/go/lib/scrypto/trc/v2"
+	"github.com/scionproto/scion/go/lib/serrors"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/v2/conf"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/v2/keys"
+)
+
+type issMeta struct {
+	Cert    cert.SignedIssuer
+	Version scrypto.Version
+}
+
+type issGen struct {
+	Dirs    pkicmn.Dirs
+	Version scrypto.Version
+}
+
+func (g issGen) Run(asMap pkicmn.ASMap) error {
+	cfgs, err := loader{Dirs: g.Dirs, Version: g.Version}.LoadIssuerConfigs(asMap)
+	if err != nil {
+		return serrors.WrapStr("unable to load issuer certifcate configs", err)
+	}
+	certs, err := g.generateAll(cfgs)
+	if err != nil {
+		return serrors.WrapStr("unable to generate issuer certificates", err)
+	}
+	if err := g.signAll(certs, cfgs); err != nil {
+		return serrors.WrapStr("unable to sign issuer certificates", err)
+	}
+	if err := g.verify(certs); err != nil {
+		return serrors.WrapStr("unable to verify issuer certificates", err)
+	}
+	if err := g.createDirs(certs); err != nil {
+		return serrors.WrapStr("unable to create output directories", err)
+	}
+	if err := g.write(certs); err != nil {
+		return serrors.WrapStr("unable to write issuer certificates", err)
+	}
+	return nil
+}
+
+func (g issGen) generateAll(cfgs map[addr.IA]conf.Issuer) (map[addr.IA]issMeta, error) {
+	certs := make(map[addr.IA]issMeta)
+	for ia, cfg := range cfgs {
+		signed, err := g.generate(ia, cfg)
+		if err != nil {
+			return nil, serrors.WrapStr("unable to generate issuer certificate", err,
+				"ia", ia, "version", cfg.Version)
+		}
+		certs[ia] = signed
+	}
+	return certs, nil
+}
+
+func (g issGen) generate(ia addr.IA, cfg conf.Issuer) (issMeta, error) {
+	pubKeys, err := g.loadPubKeys(ia, cfg)
+	if err != nil {
+		return issMeta{}, serrors.WrapStr("unable to load all public keys", err)
+	}
+	enc, err := cert.EncodeIssuer(g.newCert(ia, cfg, pubKeys))
+	if err != nil {
+		return issMeta{}, serrors.WrapStr("unable to encode issuer certificate", err)
+	}
+	meta := issMeta{
+		Cert:    cert.SignedIssuer{Encoded: enc},
+		Version: cfg.Version,
+	}
+	return meta, nil
+}
+
+func (g issGen) loadPubKeys(ia addr.IA, cfg conf.Issuer) (map[cert.KeyType]keyconf.Key, error) {
+	keys := make(map[cert.KeyType]keyconf.Key)
+	ids := map[cert.KeyType]keyconf.ID{
+		cert.IssuingKey: {
+			IA:      ia,
+			Usage:   keyconf.IssCertSigningKey,
+			Version: *cfg.IssuingKeyVersion,
+		},
+	}
+	if cfg.RevocationKeyVersion != nil {
+		ids[cert.RevocationKey] = keyconf.ID{
+			IA:      ia,
+			Usage:   keyconf.IssRevocationKey,
+			Version: *cfg.RevocationKeyVersion,
+		}
+	}
+	for keyType, id := range ids {
+		key, err := g.loadPubKey(id)
+		if err != nil {
+			return nil, serrors.WithCtx(err, "usage", id.Usage)
+		}
+		keys[keyType] = key
+	}
+	return keys, nil
+}
+
+func (g issGen) loadPubKey(id keyconf.ID) (keyconf.Key, error) {
+	key, fromPriv, err := keys.LoadPublicKey(g.Dirs.Out, id)
+	if err != nil {
+		return keyconf.Key{}, err
+	}
+	if fromPriv {
+		pkicmn.QuietPrint("Using private %s key for %s\n", id.Usage, id.IA)
+		return key, nil
+	}
+	pkicmn.QuietPrint("Using public %s key for %s\n", id.Usage, id.IA)
+	return key, nil
+}
+
+func (g issGen) newCert(ia addr.IA, cfg conf.Issuer,
+	pubKeys map[cert.KeyType]keyconf.Key) *cert.Issuer {
+
+	val := cfg.Validity.Eval(time.Now())
+	c := &cert.Issuer{
+		Base: cert.Base{
+			Subject:                    ia,
+			Version:                    cfg.Version,
+			FormatVersion:              1,
+			Description:                cfg.Description,
+			OptionalDistributionPoints: cfg.OptDistPoints,
+			Validity:                   &val,
+			Keys:                       translateKeys(pubKeys),
+		},
+		Issuer: cert.IssuerTRC{
+			TRCVersion: cfg.TRCVersion,
+		},
+	}
+	return c
+}
+
+func (g issGen) signAll(protos map[addr.IA]issMeta, cfgs map[addr.IA]conf.Issuer) error {
+	for ia, meta := range protos {
+		var err error
+		if meta.Cert, err = g.sign(ia, cfgs[ia], meta.Cert); err != nil {
+			return serrors.WrapStr("unable to sign issuer certificate", err, "ia", ia)
+		}
+		protos[ia] = meta
+	}
+	return nil
+}
+
+func (g issGen) sign(ia addr.IA, cfg conf.Issuer,
+	signed cert.SignedIssuer) (cert.SignedIssuer, error) {
+
+	file := conf.TRCFile(g.Dirs.Root, ia.I, cfg.TRCVersion)
+	trcCfg, err := conf.LoadTRC(file)
+	if err != nil {
+		return cert.SignedIssuer{}, serrors.WrapStr("unable to load TRC config", err, "file", file)
+	}
+	primary, ok := trcCfg.PrimaryASes[ia.A]
+	if !ok || !primary.Attributes.Contains(trc.Issuing) || primary.IssuingKeyVersion == nil {
+		return cert.SignedIssuer{}, serrors.New("not an issuing AS")
+	}
+	id := keyconf.ID{
+		IA:      ia,
+		Usage:   keyconf.TRCIssuingKey,
+		Version: *primary.IssuingKeyVersion,
+	}
+	key, err := keyconf.LoadKeyFromFile(keys.PrivateFile(g.Dirs.Out, id), keyconf.PrivateKey, id)
+	if err != nil {
+		return cert.SignedIssuer{}, serrors.WrapStr("unable to load issuing key", err, "file", file)
+	}
+	protected := cert.ProtectedIssuer{
+		Algorithm:  key.Algorithm,
+		TRCVersion: cfg.TRCVersion,
+	}
+	if signed.EncodedProtected, err = cert.EncodeProtectedIssuer(protected); err != nil {
+		return cert.SignedIssuer{}, serrors.WrapStr("unable to encode protected", err)
+	}
+	signed.Signature, err = scrypto.Sign(signed.SigInput(), key.Bytes, key.Algorithm)
+	if err != nil {
+		return cert.SignedIssuer{}, serrors.WrapStr("unable to sign issuer certificate", err)
+	}
+	return signed, nil
+}
+
+func (g issGen) verify(certs map[addr.IA]issMeta) error {
+	v := verifier{Dirs: g.Dirs}
+	for ia, meta := range certs {
+		raw, err := cert.EncodeSignedIssuer(meta.Cert)
+		if err != nil {
+			return serrors.WrapStr("unable to encode signed issuer certificate", err)
+		}
+		if err := v.VerifyIssuer(raw); err != nil {
+			return serrors.WrapStr("unable to verify issuer certificate", err, "ia", ia)
+		}
+	}
+	return nil
+}
+
+func (g issGen) createDirs(certs map[addr.IA]issMeta) error {
+	for ia := range certs {
+		if err := os.MkdirAll(Dir(g.Dirs.Out, ia), 0755); err != nil {
+			return serrors.WrapStr("unable to make certs directory", err, "ia", ia)
+		}
+	}
+	return nil
+}
+
+func (g issGen) write(certs map[addr.IA]issMeta) error {
+	for ia, meta := range certs {
+		raw, err := cert.EncodeSignedIssuer(meta.Cert)
+		if err != nil {
+			return serrors.WrapStr("unable to encode signed issuer certificate", err)
+		}
+		file := IssuerFile(g.Dirs.Out, ia, meta.Version)
+		if err := pkicmn.WriteToFile(raw, file, 0644); err != nil {
+			return serrors.WrapStr("unable to write signed issuer certificate", err, "file", file)
+		}
+	}
+	return nil
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/loader.go b/go/tools/scion-pki/internal/v2/certs/loader.go
new file mode 100644
index 000000000..3355ef968
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/loader.go
@@ -0,0 +1,94 @@
+// Copyright 2019 Anapaya Systems
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package certs
+
+import (
+	"path/filepath"
+	"regexp"
+	"strconv"
+
+	"github.com/scionproto/scion/go/lib/addr"
+	"github.com/scionproto/scion/go/lib/scrypto"
+	"github.com/scionproto/scion/go/lib/serrors"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/v2/conf"
+)
+
+type loader struct {
+	Dirs    pkicmn.Dirs
+	Version scrypto.Version
+}
+
+func (l loader) LoadIssuerConfigs(asMap pkicmn.ASMap) (map[addr.IA]conf.Issuer, error) {
+	s := selector{
+		File:  conf.IssuerFile,
+		All:   conf.AllIssuerFiles,
+		Regex: `issuer-v(\d*)\.toml$`,
+	}
+	cfgs := make(map[addr.IA]conf.Issuer)
+	for _, ias := range asMap {
+		for _, ia := range ias {
+			file, err := l.selectConfig(ia, s)
+			if err != nil {
+				return nil, serrors.WrapStr("unable to select config", err, "ia", ia)
+			}
+			cfg, err := conf.LoadIssuer(file)
+			if err != nil {
+				return nil, serrors.WithCtx(err, "ia", ia)
+			}
+			cfgs[ia] = cfg
+		}
+	}
+	return cfgs, nil
+}
+
+func (l loader) selectConfig(ia addr.IA, s selector) (string, error) {
+	if l.Version != scrypto.LatestVer {
+		return s.File(l.Dirs.Root, ia, l.Version), nil
+	}
+	files, err := filepath.Glob(s.All(l.Dirs.Root, ia))
+	if err != nil {
+		return "", serrors.WrapStr("unable to search all available versions", err)
+	}
+	if len(files) == 0 {
+		return "", serrors.WrapStr("no config files found", err)
+	}
+	max, err := findMaxVersion(files, s.Regex)
+	if err != nil {
+		return "", serrors.WrapStr("unable to find max version", err)
+	}
+	return s.File(l.Dirs.Root, ia, max), nil
+}
+
+func findMaxVersion(files []string, matcher string) (scrypto.Version, error) {
+	re := regexp.MustCompile(matcher)
+	var max uint64
+	for _, file := range files {
+		ver, err := strconv.ParseUint(re.FindStringSubmatch(file)[1], 10, 64)
+		if err != nil {
+			return 0, serrors.WrapStr("unable to parse version", err, "file", file)
+		}
+		if ver > max {
+			max = ver
+		}
+	}
+	return scrypto.Version(max), nil
+}
+
+type selector struct {
+	File  func(string, addr.IA, scrypto.Version) string
+	All   func(string, addr.IA) string
+	Regex string
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/util.go b/go/tools/scion-pki/internal/v2/certs/util.go
new file mode 100644
index 000000000..b66e48d84
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/util.go
@@ -0,0 +1,49 @@
+// Copyright 2019 Anapaya Systems
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package certs
+
+import (
+	"fmt"
+	"path/filepath"
+
+	"github.com/scionproto/scion/go/lib/addr"
+	"github.com/scionproto/scion/go/lib/keyconf"
+	"github.com/scionproto/scion/go/lib/scrypto"
+	"github.com/scionproto/scion/go/lib/scrypto/cert/v2"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
+)
+
+// Dir returns the directory where certificates are written to.
+func Dir(dir string, ia addr.IA) string {
+	return filepath.Join(pkicmn.GetAsPath(dir, ia), pkicmn.CertsDir)
+}
+
+// IssuerFile returns the file path for the issuer certificate.
+func IssuerFile(dir string, ia addr.IA, ver scrypto.Version) string {
+	return filepath.Join(Dir(dir, ia), fmt.Sprintf(pkicmn.IssuerNameFmt, ia.I, ia.A.FileFmt(), ver))
+}
+
+// translateKeys translates the keys to certificate key metadata.
+func translateKeys(keys map[cert.KeyType]keyconf.Key) map[cert.KeyType]scrypto.KeyMeta {
+	m := make(map[cert.KeyType]scrypto.KeyMeta)
+	for keyType, key := range keys {
+		m[keyType] = scrypto.KeyMeta{
+			Algorithm:  key.Algorithm,
+			Key:        append([]byte{}, key.Bytes...),
+			KeyVersion: key.Version,
+		}
+	}
+	return m
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/verify.go b/go/tools/scion-pki/internal/v2/certs/verify.go
new file mode 100644
index 000000000..bfb74cb5c
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/verify.go
@@ -0,0 +1,76 @@
+// Copyright 2019 Anapaya Systems
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package certs
+
+import (
+	"io/ioutil"
+
+	"github.com/scionproto/scion/go/lib/addr"
+	"github.com/scionproto/scion/go/lib/scrypto"
+	"github.com/scionproto/scion/go/lib/scrypto/cert/v2"
+	"github.com/scionproto/scion/go/lib/scrypto/trc/v2"
+	"github.com/scionproto/scion/go/lib/serrors"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/v2/trcs"
+)
+
+type verifier struct {
+	Dirs pkicmn.Dirs
+}
+
+// VerifyIssuer validates and verifies a raw signed issuer certificate. For
+// verification, the issuing TRC is loaded from the file system.
+func (v verifier) VerifyIssuer(raw []byte) error {
+	signed, err := cert.ParseSignedIssuer(raw)
+	if err != nil {
+		return serrors.WrapStr("unable to parse signed issuer certificate", err)
+	}
+	c, err := signed.Encoded.Decode()
+	if err != nil {
+		return serrors.WrapStr("unable to parse issuer certificate payload", err)
+	}
+	if err := c.Validate(); err != nil {
+		return serrors.WrapStr("unable to validate issuer certificate", err)
+	}
+	t, err := v.loadTRC(c.Subject.I, c.Issuer.TRCVersion)
+	if err != nil {
+		return err
+	}
+	issVer := cert.IssuerVerifier{
+		Issuer:       c,
+		SignedIssuer: &signed,
+		TRC:          t,
+	}
+	if err := issVer.Verify(); err != nil {
+		return serrors.WrapStr("unable to verify issuer certificate", err)
+	}
+	return nil
+}
+
+func (v verifier) loadTRC(isd addr.ISD, version scrypto.Version) (*trc.TRC, error) {
+	raw, err := ioutil.ReadFile(trcs.SignedFile(v.Dirs.Out, isd, version))
+	if err != nil {
+		return nil, serrors.WrapStr("unable to read issuing TRC", err)
+	}
+	signed, err := trc.ParseSigned(raw)
+	if err != nil {
+		return nil, serrors.WrapStr("unable to parse issuing TRC", err)
+	}
+	t, err := signed.EncodedTRC.Decode()
+	if err != nil {
+		return nil, serrors.WrapStr("unable to decode issuing TRC payload", err)
+	}
+	return t, nil
+}
diff --git a/go/tools/scion-pki/internal/v2/cmd/BUILD.bazel b/go/tools/scion-pki/internal/v2/cmd/BUILD.bazel
index 56b6eef93..06f96a322 100644
--- a/go/tools/scion-pki/internal/v2/cmd/BUILD.bazel
+++ b/go/tools/scion-pki/internal/v2/cmd/BUILD.bazel
@@ -6,6 +6,7 @@ go_library(
     importpath = "github.com/scionproto/scion/go/tools/scion-pki/internal/v2/cmd",
     visibility = ["//go/tools/scion-pki:__subpackages__"],
     deps = [
+        "//go/tools/scion-pki/internal/v2/certs:go_default_library",
         "//go/tools/scion-pki/internal/v2/keys:go_default_library",
         "//go/tools/scion-pki/internal/v2/tmpl:go_default_library",
         "//go/tools/scion-pki/internal/v2/trcs:go_default_library",
diff --git a/go/tools/scion-pki/internal/v2/cmd/cmd.go b/go/tools/scion-pki/internal/v2/cmd/cmd.go
index 6c956bb24..ee2be8dbe 100644
--- a/go/tools/scion-pki/internal/v2/cmd/cmd.go
+++ b/go/tools/scion-pki/internal/v2/cmd/cmd.go
@@ -17,6 +17,7 @@ package cmd
 import (
 	"github.com/spf13/cobra"
 
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/v2/certs"
 	"github.com/scionproto/scion/go/tools/scion-pki/internal/v2/keys"
 	"github.com/scionproto/scion/go/tools/scion-pki/internal/v2/tmpl"
 	"github.com/scionproto/scion/go/tools/scion-pki/internal/v2/trcs"
@@ -28,6 +29,7 @@ var Cmd = &cobra.Command{
 }
 
 func init() {
+	Cmd.AddCommand(certs.Cmd)
 	Cmd.AddCommand(tmpl.Cmd)
 	Cmd.AddCommand(keys.Cmd)
 	Cmd.AddCommand(trcs.Cmd)
diff --git a/go/tools/scion-pki/internal/v2/conf/issuer.go b/go/tools/scion-pki/internal/v2/conf/issuer.go
index eee9b31ad..fcad316bf 100644
--- a/go/tools/scion-pki/internal/v2/conf/issuer.go
+++ b/go/tools/scion-pki/internal/v2/conf/issuer.go
@@ -32,6 +32,11 @@ func IssuerFile(dir string, ia addr.IA, version scrypto.Version) string {
 	return filepath.Join(pkicmn.GetAsPath(dir, ia), fmt.Sprintf("issuer-v%d.toml", version))
 }
 
+// AllIssuerFiles returns a glob string that matches all issuer files for the given IA.
+func AllIssuerFiles(dir string, ia addr.IA) string {
+	return filepath.Join(pkicmn.GetAsPath(dir, ia), "issuer-v*.toml")
+}
+
 // Issuer holds the issuer certificate configuration.
 type Issuer struct {
 	Description          string              `toml:"description"`
diff --git a/go/tools/scion-pki/internal/v2/keys/BUILD.bazel b/go/tools/scion-pki/internal/v2/keys/BUILD.bazel
index 50b38117f..c4d8d2c0d 100644
--- a/go/tools/scion-pki/internal/v2/keys/BUILD.bazel
+++ b/go/tools/scion-pki/internal/v2/keys/BUILD.bazel
@@ -21,6 +21,7 @@ go_library(
         "//go/tools/scion-pki/internal/v2/conf:go_default_library",
         "@com_github_spf13_cobra//:go_default_library",
         "@org_golang_x_crypto//ed25519:go_default_library",
+        "@org_golang_x_xerrors//:go_default_library",
     ],
 )
 
diff --git a/go/tools/scion-pki/internal/v2/keys/priv.go b/go/tools/scion-pki/internal/v2/keys/priv.go
index e5c90dc7a..3887914c1 100644
--- a/go/tools/scion-pki/internal/v2/keys/priv.go
+++ b/go/tools/scion-pki/internal/v2/keys/priv.go
@@ -139,12 +139,14 @@ func (g privGen) generateKey(ia addr.IA, version scrypto.KeyVersion,
 		return keyconf.Key{}, err
 	}
 	key := keyconf.Key{
+		ID: keyconf.ID{
+			Usage:   usage,
+			IA:      ia,
+			Version: version,
+		},
 		Type:      keyconf.PrivateKey,
-		Usage:     usage,
 		Algorithm: meta.Algorithm,
 		Validity:  meta.Validity.Eval(time.Now()),
-		Version:   version,
-		IA:        ia,
 		Bytes:     raw,
 	}
 	return key, nil
diff --git a/go/tools/scion-pki/internal/v2/keys/pub-pub b/go/tools/scion-pki/internal/v2/keys/pub-pub
new file mode 100644
index 000000000..11360c7e7
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/keys/pub-pub
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+algorithm: 
+ia: 1-ff00:0:110
+not_after: 0001-01-01 00:00:00+0000
+not_before: 0001-01-01 00:00:00+0000
+usage: as-signing
+version: 1
+
+-----END PUBLIC KEY-----
diff --git a/go/tools/scion-pki/internal/v2/keys/pub.go b/go/tools/scion-pki/internal/v2/keys/pub.go
index e4a88b84e..debf736a2 100644
--- a/go/tools/scion-pki/internal/v2/keys/pub.go
+++ b/go/tools/scion-pki/internal/v2/keys/pub.go
@@ -21,6 +21,8 @@ import (
 	"os"
 	"path/filepath"
 
+	"golang.org/x/xerrors"
+
 	"github.com/scionproto/scion/go/lib/addr"
 	"github.com/scionproto/scion/go/lib/keyconf"
 	"github.com/scionproto/scion/go/lib/scrypto"
@@ -121,6 +123,28 @@ func (g pubGen) writeKeys(pubKeys map[addr.IA][]keyconf.Key) error {
 	return nil
 }
 
+// LoadPublicKey attempts to load the private key and use that to generate the
+// public key. If that fails, it attempts to load the public key directly. The
+// boolean return value indicates whether the public key was derived from the
+// private key.
+func LoadPublicKey(dir string, id keyconf.ID) (keyconf.Key, bool, error) {
+	priv, err := keyconf.LoadKeyFromFile(PrivateFile(dir, id), keyconf.PrivateKey, id)
+	if err == nil {
+		pub, err := PublicKey(priv)
+		return pub, true, err
+	}
+	if !xerrors.Is(err, keyconf.ErrReadFile) {
+		return keyconf.Key{}, false, err
+	}
+	pkicmn.QuietPrint("Unable to load private key for %s. Trying public key.\n", id.IA)
+	file := PublicFile(dir, id)
+	pub, err := keyconf.LoadKeyFromFile(file, keyconf.PublicKey, id)
+	if err != nil {
+		return keyconf.Key{}, false, serrors.WrapStr("unable to load public key", err, "file", file)
+	}
+	return pub, false, nil
+}
+
 // PublicKey translates a private to a public key.
 func PublicKey(priv keyconf.Key) (keyconf.Key, error) {
 	if priv.Type != keyconf.PrivateKey {
@@ -131,12 +155,14 @@ func PublicKey(priv keyconf.Key) (keyconf.Key, error) {
 		return keyconf.Key{}, serrors.WrapStr("error generating public key", err)
 	}
 	key := keyconf.Key{
+		ID: keyconf.ID{
+			Usage:   priv.Usage,
+			IA:      priv.IA,
+			Version: priv.Version,
+		},
 		Type:      keyconf.PublicKey,
-		Usage:     priv.Usage,
 		Algorithm: priv.Algorithm,
 		Validity:  priv.Validity,
-		Version:   priv.Version,
-		IA:        priv.IA,
 		Bytes:     raw,
 	}
 	return key, nil
diff --git a/go/tools/scion-pki/internal/v2/keys/util.go b/go/tools/scion-pki/internal/v2/keys/util.go
index 618455a75..2857c0220 100644
--- a/go/tools/scion-pki/internal/v2/keys/util.go
+++ b/go/tools/scion-pki/internal/v2/keys/util.go
@@ -18,6 +18,7 @@ import (
 	"path/filepath"
 
 	"github.com/scionproto/scion/go/lib/addr"
+	"github.com/scionproto/scion/go/lib/keyconf"
 	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
 )
 
@@ -26,7 +27,18 @@ func PrivateDir(out string, ia addr.IA) string {
 	return filepath.Join(pkicmn.GetAsPath(out, ia), "keys")
 }
 
+// PrivateFile returns the file where the private key is written to.
+func PrivateFile(out string, id keyconf.ID) string {
+	return filepath.Join(PrivateDir(out, id.IA), keyconf.PrivateKeyFile(id.Usage, id.Version))
+}
+
 // PublicDir returns the directory where the public keys are written to.
 func PublicDir(out string, ia addr.IA) string {
 	return filepath.Join(pkicmn.GetAsPath(out, ia), "pub")
 }
+
+// PublicFile returns the file where the public key is written to.
+func PublicFile(out string, id keyconf.ID) string {
+	return filepath.Join(PublicDir(out, id.IA),
+		keyconf.PublicKeyFile(id.Usage, id.IA, id.Version))
+}
diff --git a/go/tools/scion-pki/internal/v2/trcs/BUILD.bazel b/go/tools/scion-pki/internal/v2/trcs/BUILD.bazel
index a98c541f3..876a20e66 100644
--- a/go/tools/scion-pki/internal/v2/trcs/BUILD.bazel
+++ b/go/tools/scion-pki/internal/v2/trcs/BUILD.bazel
@@ -26,7 +26,6 @@ go_library(
         "//go/tools/scion-pki/internal/v2/conf:go_default_library",
         "//go/tools/scion-pki/internal/v2/keys:go_default_library",
         "@com_github_spf13_cobra//:go_default_library",
-        "@org_golang_x_xerrors//:go_default_library",
     ],
 )
 
diff --git a/go/tools/scion-pki/internal/v2/trcs/prototype.go b/go/tools/scion-pki/internal/v2/trcs/prototype.go
index dacbb3d8f..857cefee0 100644
--- a/go/tools/scion-pki/internal/v2/trcs/prototype.go
+++ b/go/tools/scion-pki/internal/v2/trcs/prototype.go
@@ -20,8 +20,6 @@ import (
 	"sort"
 	"time"
 
-	"golang.org/x/xerrors"
-
 	"github.com/scionproto/scion/go/lib/addr"
 	"github.com/scionproto/scion/go/lib/keyconf"
 	"github.com/scionproto/scion/go/lib/scrypto"
@@ -124,7 +122,12 @@ func (g protoGen) insertIssuingKey(dst pubKeys, ia addr.IA, primary conf.Primary
 	if !primary.Attributes.Contains(trc.Issuing) {
 		return nil
 	}
-	key, err := g.loadPubKey(ia, keyconf.TRCIssuingKey, *primary.IssuingKeyVersion)
+	desc := keyconf.ID{
+		IA:      ia,
+		Usage:   keyconf.TRCIssuingKey,
+		Version: *primary.IssuingKeyVersion,
+	}
+	key, err := g.loadPubKey(desc)
 	if err != nil {
 		return serrors.WrapStr("unable to load issuing key", err)
 	}
@@ -138,40 +141,41 @@ func (g protoGen) insertVotingKeys(dst pubKeys, ia addr.IA, primary conf.Primary
 	if !primary.Attributes.Contains(trc.Voting) {
 		return nil
 	}
-	online, err := g.loadPubKey(ia, keyconf.TRCVotingOnlineKey, *primary.VotingOnlineKeyVersion)
-	if err != nil {
-		return serrors.WrapStr("unable to load voting online key", err)
-	}
-	dst[trc.OnlineKey] = online
-	offline, err := g.loadPubKey(ia, keyconf.TRCVotingOfflineKey, *primary.VotingOfflineKeyVersion)
-	if err != nil {
-		return serrors.WrapStr("unable to load voting offline key", err)
+	ids := map[trc.KeyType]keyconf.ID{
+		trc.OnlineKey: {
+			IA:      ia,
+			Usage:   keyconf.TRCVotingOnlineKey,
+			Version: *primary.VotingOnlineKeyVersion,
+		},
+		trc.OfflineKey: {
+			IA:      ia,
+			Usage:   keyconf.TRCVotingOfflineKey,
+			Version: *primary.VotingOfflineKeyVersion,
+		},
+	}
+	for keyType, id := range ids {
+		key, err := g.loadPubKey(id)
+		if err != nil {
+			return serrors.WithCtx(err, "usage", id.Usage)
+		}
+		dst[keyType] = key
 	}
-	dst[trc.OfflineKey] = offline
 	return nil
 }
 
 // loadPubKey attempts to load the private key and use it to generate the public
 // key. If that fails, loadPubKey attempts to load the public key directly.
-func (g protoGen) loadPubKey(ia addr.IA, usage keyconf.Usage,
-	version scrypto.KeyVersion) (keyconf.Key, error) {
-
-	file := filepath.Join(keys.PrivateDir(g.Dirs.Out, ia), keyconf.PrivateKeyFile(usage, version))
-	priv, err := loadKey(file, ia, usage, version)
-	if err == nil {
-		pkicmn.QuietPrint("Using private key %s\n", file)
-		return keys.PublicKey(priv)
-	}
-	if !xerrors.Is(err, errReadFile) {
-		return keyconf.Key{}, err
-	}
-	file = filepath.Join(keys.PublicDir(g.Dirs.Out, ia), keyconf.PublicKeyFile(usage, ia, version))
-	pub, err := loadKey(file, ia, usage, version)
+func (g protoGen) loadPubKey(id keyconf.ID) (keyconf.Key, error) {
+	key, fromPriv, err := keys.LoadPublicKey(g.Dirs.Out, id)
 	if err != nil {
-		return keyconf.Key{}, serrors.WrapStr("unable to load public key", err, "file", file)
+		return keyconf.Key{}, nil
+	}
+	if fromPriv {
+		pkicmn.QuietPrint("Using private %s key for %s\n", id.Usage, id.IA)
+		return key, nil
 	}
-	pkicmn.QuietPrint("Using public key %s\n", file)
-	return pub, nil
+	pkicmn.QuietPrint("Using public %s key for %s\n", id.Usage, id.IA)
+	return key, nil
 }
 
 func (g protoGen) newTRC(isd addr.ISD, cfg conf.TRC2, keys map[addr.AS]pubKeys) (*trc.TRC, error) {
diff --git a/go/tools/scion-pki/internal/v2/trcs/sign.go b/go/tools/scion-pki/internal/v2/trcs/sign.go
index 78cf8db3a..9d65a2bcc 100644
--- a/go/tools/scion-pki/internal/v2/trcs/sign.go
+++ b/go/tools/scion-pki/internal/v2/trcs/sign.go
@@ -15,8 +15,6 @@
 package trcs
 
 import (
-	"path/filepath"
-
 	"github.com/scionproto/scion/go/lib/addr"
 	"github.com/scionproto/scion/go/lib/keyconf"
 	"github.com/scionproto/scion/go/lib/scrypto"
@@ -127,11 +125,16 @@ func (g signatureGen) castVote(signatures map[trc.Protected]trc.Signature, ia ad
 	if !ok {
 		return nil
 	}
-	usage, err := keys.UsageFromTRCKeyType(vote.KeyType)
+	id := keyconf.ID{
+		IA:      ia,
+		Version: vote.KeyVersion,
+	}
+	var err error
+	id.Usage, err = keys.UsageFromTRCKeyType(vote.KeyType)
 	if err != nil {
 		return err
 	}
-	priv, err := g.loadKey(ia, usage, vote.KeyVersion)
+	priv, err := g.loadKey(id)
 	if err != nil {
 		return err
 	}
@@ -155,11 +158,16 @@ func (g signatureGen) showPOP(signatures map[trc.Protected]trc.Signature, ia add
 	cfg conf.TRC2, signed trc.Signed, t *trc.TRC) error {
 
 	for _, keyType := range t.ProofOfPossession[ia.A] {
-		usage, err := keys.UsageFromTRCKeyType(keyType)
+		id := keyconf.ID{
+			IA:      ia,
+			Version: t.PrimaryASes[ia.A].Keys[keyType].KeyVersion,
+		}
+		var err error
+		id.Usage, err = keys.UsageFromTRCKeyType(keyType)
 		if err != nil {
 			return err
 		}
-		priv, err := g.loadKey(ia, usage, t.PrimaryASes[ia.A].Keys[keyType].KeyVersion)
+		priv, err := g.loadKey(id)
 		if err != nil {
 			return err
 		}
@@ -180,11 +188,9 @@ func (g signatureGen) showPOP(signatures map[trc.Protected]trc.Signature, ia add
 	return nil
 }
 
-func (g signatureGen) loadKey(ia addr.IA, usage keyconf.Usage,
-	version scrypto.KeyVersion) (keyconf.Key, error) {
-
-	file := filepath.Join(keys.PrivateDir(g.Dirs.Out, ia), keyconf.PrivateKeyFile(usage, version))
-	priv, err := loadKey(file, ia, usage, version)
+func (g signatureGen) loadKey(id keyconf.ID) (keyconf.Key, error) {
+	file := keys.PrivateFile(g.Dirs.Out, id)
+	priv, err := keyconf.LoadKeyFromFile(file, keyconf.PrivateKey, id)
 	if err != nil {
 		return keyconf.Key{}, serrors.WrapStr("unable to load private key", err, "file", file)
 	}
diff --git a/go/tools/scion-pki/internal/v2/trcs/util.go b/go/tools/scion-pki/internal/v2/trcs/util.go
index 24d5d1bbb..bf09f6a02 100644
--- a/go/tools/scion-pki/internal/v2/trcs/util.go
+++ b/go/tools/scion-pki/internal/v2/trcs/util.go
@@ -15,22 +15,17 @@
 package trcs
 
 import (
-	"encoding/pem"
 	"fmt"
 	"io/ioutil"
 	"path/filepath"
 	"sort"
 
 	"github.com/scionproto/scion/go/lib/addr"
-	"github.com/scionproto/scion/go/lib/keyconf"
 	"github.com/scionproto/scion/go/lib/scrypto"
 	"github.com/scionproto/scion/go/lib/scrypto/trc/v2"
-	"github.com/scionproto/scion/go/lib/serrors"
 	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
 )
 
-var errReadFile = serrors.New("error reading file")
-
 // Dir returns the directory where TRCs are written to.
 func Dir(dir string, isd addr.ISD) string {
 	return filepath.Join(pkicmn.GetIsdPath(dir, isd), pkicmn.TRCsDir)
@@ -100,32 +95,3 @@ func loadTRC(file string) (*trc.TRC, trc.Encoded, error) {
 	}
 	return t, signed.EncodedTRC, nil
 }
-
-func loadKey(file string, ia addr.IA, usage keyconf.Usage,
-	version scrypto.KeyVersion) (keyconf.Key, error) {
-
-	raw, err := ioutil.ReadFile(file)
-	if err != nil {
-		return keyconf.Key{}, serrors.Wrap(errReadFile, err)
-	}
-	block, _ := pem.Decode(raw)
-	if block == nil {
-		return keyconf.Key{}, serrors.New("unable to parse PEM")
-	}
-	key, err := keyconf.KeyFromPEM(block)
-	if err != nil {
-		return keyconf.Key{}, serrors.WrapStr("unable to decode key", err)
-	}
-	if !key.IA.Equal(ia) {
-		return keyconf.Key{}, serrors.New("IA does not match", "expected", ia, "actual", key.IA)
-	}
-	if key.Usage != usage {
-		return keyconf.Key{}, serrors.New("usage does not match",
-			"expected", usage, "actual", key.Usage)
-	}
-	if key.Version != version {
-		return keyconf.Key{}, serrors.New("version does not match",
-			"expected", version, "actual", key.Version)
-	}
-	return key, nil
-}


Test patch:
diff --git a/go/lib/keyconf/doc_test.go b/go/lib/keyconf/doc_test.go
index 5aad95ea8..f6b2ea0c7 100644
--- a/go/lib/keyconf/doc_test.go
+++ b/go/lib/keyconf/doc_test.go
@@ -27,16 +27,18 @@ import (
 
 func ExampleKey_encoding() {
 	k := Key{
+		ID: ID{
+			Usage:   ASSigKeyFile,
+			IA:      xtest.MustParseIA("1-ff00:0:110"),
+			Version: 2,
+		},
 		Type:      PublicKey,
-		Usage:     ASSigKeyFile,
 		Algorithm: scrypto.Ed25519,
 		Validity: scrypto.Validity{
 			NotBefore: util.UnixTime{Time: util.SecsToTime(1560000000)},
 			NotAfter:  util.UnixTime{Time: util.SecsToTime(1600000000)},
 		},
-		Version: 2,
-		IA:      xtest.MustParseIA("1-ff00:0:110"),
-		Bytes:   make([]byte, ed25519.PublicKeySize),
+		Bytes: make([]byte, ed25519.PublicKeySize),
 	}
 	block := k.PEM()
 	fmt.Println(string(pem.EncodeToMemory(&block)))
@@ -55,16 +57,20 @@ func ExampleKey_encoding() {
 
 func ExampleKey_filename() {
 	publicKey := Key{
-		Type:    PublicKey,
-		Usage:   ASSigningKey,
-		Version: 2,
-		IA:      xtest.MustParseIA("1-ff00:0:110"),
+		ID: ID{
+			Usage:   ASSigningKey,
+			Version: 2,
+			IA:      xtest.MustParseIA("1-ff00:0:110"),
+		},
+		Type: PublicKey,
 	}
 	privateKey := Key{
-		Type:    PrivateKey,
-		Usage:   ASRevocationKey,
-		Version: 10,
-		IA:      xtest.MustParseIA("1-ff00:0:110"),
+		ID: ID{
+			Usage:   ASRevocationKey,
+			Version: 10,
+			IA:      xtest.MustParseIA("1-ff00:0:110"),
+		},
+		Type: PrivateKey,
 	}
 	fmt.Println("Public key: ", publicKey.File())
 	fmt.Println("Private key:", privateKey.File())
diff --git a/go/lib/keyconf/key_test.go b/go/lib/keyconf/key_test.go
index 911e40f4b..e29dbef80 100644
--- a/go/lib/keyconf/key_test.go
+++ b/go/lib/keyconf/key_test.go
@@ -16,6 +16,8 @@ package keyconf_test
 
 import (
 	"encoding/pem"
+	"io/ioutil"
+	"path"
 	"strconv"
 	"testing"
 	"time"
@@ -30,6 +32,101 @@ import (
 	"github.com/scionproto/scion/go/lib/xtest"
 )
 
+func TestLoadKeyFromFile(t *testing.T) {
+	block := pemBlock(t)
+	tests := map[string]struct {
+		Key          []byte
+		Type         keyconf.Type
+		ID           keyconf.ID
+		ErrAssertion require.ErrorAssertionFunc
+	}{
+		"valid": {
+			Key:  pem.EncodeToMemory(&block),
+			Type: keyconf.PrivateKey,
+			ID: keyconf.ID{
+				Usage:   keyconf.ASSigningKey,
+				IA:      xtest.MustParseIA("1-ff00:0:110"),
+				Version: 2,
+			},
+			ErrAssertion: require.NoError,
+		},
+		"invalid pem": {
+			Key:          []byte{},
+			ErrAssertion: require.Error,
+		},
+		"invalid key": {
+			Key:          pem.EncodeToMemory(&pem.Block{Type: "garbage"}),
+			ErrAssertion: require.Error,
+		},
+		"invalid type": {
+			Key:  pem.EncodeToMemory(&block),
+			Type: keyconf.PublicKey,
+			ID: keyconf.ID{
+				Usage:   keyconf.ASSigningKey,
+				IA:      xtest.MustParseIA("1-ff00:0:111"),
+				Version: 2,
+			},
+			ErrAssertion: require.Error,
+		},
+		"invalid IA": {
+			Key:  pem.EncodeToMemory(&block),
+			Type: keyconf.PrivateKey,
+			ID: keyconf.ID{
+				Usage:   keyconf.ASSigningKey,
+				IA:      xtest.MustParseIA("1-ff00:0:111"),
+				Version: 2,
+			},
+			ErrAssertion: require.Error,
+		},
+		"invalid Usage": {
+			Key:  pem.EncodeToMemory(&block),
+			Type: keyconf.PrivateKey,
+			ID: keyconf.ID{
+				Usage:   keyconf.ASRevocationKey,
+				IA:      xtest.MustParseIA("1-ff00:0:110"),
+				Version: 2,
+			},
+			ErrAssertion: require.Error,
+		},
+		"invalid version": {
+			Key:  pem.EncodeToMemory(&block),
+			Type: keyconf.PrivateKey,
+			ID: keyconf.ID{
+				Usage:   keyconf.ASSigningKey,
+				IA:      xtest.MustParseIA("1-ff00:0:110"),
+				Version: 3,
+			},
+			ErrAssertion: require.Error,
+		},
+	}
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			tmpDir, cleanF := xtest.MustTempDir("", "test-trust")
+			defer cleanF()
+			file := path.Join(tmpDir, name)
+			err := ioutil.WriteFile(file, test.Key, 0644)
+			require.NoError(t, err)
+
+			k, err := keyconf.LoadKeyFromFile(file, test.Type, test.ID)
+			test.ErrAssertion(t, err)
+			if err != nil {
+				return
+			}
+			assert.Equal(t, block.Type, string(k.Type))
+			assert.Equal(t, block.Headers["usage"], string(k.Usage))
+			assert.Equal(t, block.Headers["algorithm"], k.Algorithm)
+			assert.Equal(t, block.Headers["not_after"],
+				util.TimeToCompact(k.Validity.NotAfter.Time))
+			assert.Equal(t, block.Headers["not_before"],
+				util.TimeToCompact(k.Validity.NotBefore.Time))
+			assert.Equal(t, block.Headers["version"], strconv.FormatUint(uint64(k.Version), 10))
+			assert.Equal(t, block.Headers["ia"], k.IA.String())
+			assert.Equal(t, block.Bytes, k.Bytes)
+			assert.Equal(t, block, k.PEM())
+		})
+	}
+}
+
 func TestKeyFromPEM(t *testing.T) {
 	tests := map[string]struct {
 		Modify       func(b *pem.Block)
@@ -150,10 +247,12 @@ func TestKeyFile(t *testing.T) {
 	for name, test := range tests {
 		t.Run(name, func(t *testing.T) {
 			k := keyconf.Key{
-				Type:    keyconf.PublicKey,
-				Usage:   test.Usage,
-				Version: test.Version,
-				IA:      test.IA,
+				Type: keyconf.PublicKey,
+				ID: keyconf.ID{
+					Usage:   test.Usage,
+					Version: test.Version,
+					IA:      test.IA,
+				},
 			}
 			assert.Equal(t, test.Public, k.File())
 			k.Type = keyconf.PrivateKey
diff --git a/go/tools/scion-pki/internal/v2/certs/issuer_test.go b/go/tools/scion-pki/internal/v2/certs/issuer_test.go
new file mode 100644
index 000000000..66755b6fe
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/issuer_test.go
@@ -0,0 +1,80 @@
+// Copyright 2019 Anapaya Systems
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package certs
+
+import (
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/scionproto/scion/go/lib/xtest"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
+)
+
+var (
+	ia110     = xtest.MustParseIA("1-ff00:0:110")
+	testASMap = pkicmn.ASMap{1: {ia110}}
+)
+
+func TestIssuerGenRun(t *testing.T) {
+	tmpDir, cleanF := xtest.MustTempDir("", "test-certs-issuer")
+	defer cleanF()
+
+	isdDir := filepath.Join(tmpDir, "ISD1")
+	require.NoError(t, os.MkdirAll(isdDir, 0777))
+	err := exec.Command("cp", "-r",
+		"./testdata/ISD1/trcs",
+		"./testdata/ISD1/trc-v1.toml",
+		isdDir).Run()
+	require.NoError(t, err)
+
+	asDir := filepath.Join(isdDir, "ASff00_0_110")
+	require.NoError(t, os.MkdirAll(asDir, 0777))
+	err = exec.Command("cp", "-r",
+		"./testdata/ISD1/ASff00_0_110/keys",
+		"./testdata/ISD1/ASff00_0_110/issuer-v1.toml",
+		asDir).Run()
+	require.NoError(t, err)
+
+	g := issGen{
+		Dirs: pkicmn.Dirs{Root: "./testdata", Out: tmpDir},
+	}
+	err = g.Run(testASMap)
+	require.NoError(t, err)
+
+	golden, err := ioutil.ReadFile(IssuerFile("./testdata", ia110, 1))
+	require.NoError(t, err)
+	result, err := ioutil.ReadFile(IssuerFile(tmpDir, ia110, 1))
+	require.NoError(t, err)
+	assert.Equal(t, golden, result)
+}
+
+// TestUpdateGoldenIssuer provides an easy way to update the golden file after
+// the format has changed.
+func TestUpdateGoldenIssuer(t *testing.T) {
+	if *update {
+		force := pkicmn.Force
+		pkicmn.Force = true
+		defer func() { pkicmn.Force = force }()
+		g := issGen{Dirs: pkicmn.Dirs{Root: "./testdata", Out: "./testdata"}, Version: 1}
+		err := g.Run(testASMap)
+		require.NoError(t, err)
+	}
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/loader_test.go b/go/tools/scion-pki/internal/v2/certs/loader_test.go
new file mode 100644
index 000000000..6c35371a2
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/loader_test.go
@@ -0,0 +1,48 @@
+// Copyright 2019 Anapaya Systems
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package certs
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/scionproto/scion/go/lib/scrypto"
+	"github.com/scionproto/scion/go/tools/scion-pki/internal/pkicmn"
+)
+
+func TestLoaderLoadIssuerConfigs(t *testing.T) {
+	tests := map[string]struct {
+		Version  scrypto.Version
+		Expected scrypto.Version
+	}{
+		"v1":  {Version: 1, Expected: 1},
+		"max": {Version: 0, Expected: 1},
+	}
+	for n, tc := range tests {
+		name, test := n, tc // Enable t.Parallel().
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			l := loader{
+				Dirs:    pkicmn.Dirs{Root: "./testdata", Out: "./testdata"},
+				Version: test.Version,
+			}
+			cfgs, err := l.LoadIssuerConfigs(testASMap)
+			require.NoError(t, err)
+			assert.Equal(t, test.Expected, cfgs[ia110].Version)
+		})
+	}
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/main_test.go b/go/tools/scion-pki/internal/v2/certs/main_test.go
new file mode 100644
index 000000000..9c5aad102
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/main_test.go
@@ -0,0 +1,23 @@
+// Copyright 2019 Anapaya Systems
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package certs
+
+import (
+	"flag"
+)
+
+// update is a cmd line flag that enables golden file updates. To update the
+// golden files simply run 'go test -run Update -update ./...'.
+var update = flag.Bool("update", false, "set to true to regenerate golden files")
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/certs/ISD1-ASff00_0_110-V1.issuer b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/certs/ISD1-ASff00_0_110-V1.issuer
new file mode 100644
index 000000000..7461755fa
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/certs/ISD1-ASff00_0_110-V1.issuer
@@ -0,0 +1,1 @@
+{"payload":"ZXlKemRXSnFaV04wSWpvaU1TMW1aakF3T2pBNk1URXdJaXdpZG1WeWMybHZiaUk2TVN3aVptOXliV0YwWDNabGNuTnBiMjRpT2pFc0ltUmxjMk55YVhCMGFXOXVJam9pU1hOemRXVnlJR05sY25ScFptbGpZWFJsSURFdFptWXdNRG93T2pFeE1DSXNJbTl3ZEdsdmJtRnNYMlJwYzNSeWFXSjFkR2x2Ymw5d2IybHVkSE1pT2x0ZExDSjJZV3hwWkdsMGVTSTZleUp1YjNSZlltVm1iM0psSWpveE5UY3pNRE16TnpZNUxDSnViM1JmWVdaMFpYSWlPakUyTURRMU5qazNOamw5TENKclpYbHpJanA3SW1semMzVnBibWNpT25zaWEyVjVYM1psY25OcGIyNGlPakVzSW1Gc1oyOXlhWFJvYlNJNkltVmtNalUxTVRraUxDSnJaWGtpT2lKVVVXaDVORzVGTkdSalFtNXpUa0prVVVOaVNFNHdOR1Z2ZUZCU1JIWjRkamt4TUVVdlNGRnllaTlqUFNKOUxDSnlaWFp2WTJGMGFXOXVJanA3SW10bGVWOTJaWEp6YVc5dUlqb3lMQ0poYkdkdmNtbDBhRzBpT2lKbFpESTFOVEU1SWl3aWEyVjVJam9pVXpKR2IxcGxLM1JHVXpBM1FsVnNaWEpRWWtKaGJqVlJRVlo2VGswMlJGUTNTVGxNV210clJHRlFPRDBpZlgwc0ltbHpjM1ZsY2lJNmV5SjBjbU5mZG1WeWMybHZiaUk2TVgwc0ltTmxjblJwWm1sallYUmxYM1I1Y0dVaU9pSnBjM04xWlhJaWZR","protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJblJ5WXlJc0luUnlZMTkyWlhKemFXOXVJam94TENKamNtbDBJanBiSW5SNWNHVWlMQ0owY21OZmRtVnljMmx2YmlKZGZR","signature":"k0GLHNQJjByF8fFb+Mscv6bvwLObJSDq5RX3f9u1UFu7m/UMdpbuWRRksQNKWo7WBo7Xtj29WaqXmQBhLQm2AA=="}
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/issuer-v1.toml b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/issuer-v1.toml
new file mode 100644
index 000000000..7dff536b0
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/issuer-v1.toml
@@ -0,0 +1,10 @@
+description = "Issuer certificate 1-ff00:0:110"
+version = 1
+issuing_key_version = 1
+revocation_key_version = 2
+trc_version = 1
+optional_distribution_points = []
+
+[validity]
+  not_before = 1573033769
+  validity = "1y"
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys.toml b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys.toml
new file mode 100644
index 000000000..6675cb52d
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys.toml
@@ -0,0 +1,22 @@
+[primary]
+  [primary.issuing]
+    [primary.issuing.1]
+      algorithm = "ed25519"
+      [primary.issuing.1.validity]
+        not_before = 1573033769
+        validity = "1y"
+
+[issuer_cert]
+  [issuer_cert.issuing]
+    [issuer_cert.issuing.1]
+      algorithm = "ed25519"
+      [issuer_cert.issuing.1.validity]
+          not_before = 1573033769
+          validity = "1y"
+
+  [issuer_cert.revocation]
+    [issuer_cert.revocation.2]
+      algorithm = "ed25519"
+      [issuer_cert.revocation.2.validity]
+          not_before = 1573033769
+          validity = "1y"
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/issuer-cert-signing-v1.key b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/issuer-cert-signing-v1.key
new file mode 100644
index 000000000..9042350ef
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/issuer-cert-signing-v1.key
@@ -0,0 +1,11 @@
+-----BEGIN PRIVATE KEY-----
+algorithm: ed25519
+ia: 1-ff00:0:110
+not_after: 2020-11-05 09:49:29+0000
+not_before: 2019-11-06 09:49:29+0000
+usage: issuer-cert-signing
+version: 1
+
+3d/Ie34c177ibPl+ja8btscJwu90G2xNUECrmFEP4/4=
+-----END PRIVATE KEY-----
+
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/issuer-revocation-v2.key b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/issuer-revocation-v2.key
new file mode 100644
index 000000000..49c44bee8
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/issuer-revocation-v2.key
@@ -0,0 +1,11 @@
+-----BEGIN PRIVATE KEY-----
+algorithm: ed25519
+ia: 1-ff00:0:110
+not_after: 2020-11-05 09:49:29+0000
+not_before: 2019-11-06 09:49:29+0000
+usage: issuer-revocation
+version: 2
+
+ONVAUqhAs1bq5vpJZkhn5t3LD/5RyD8m3rYevq4TF8g=
+-----END PRIVATE KEY-----
+
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/trc-issuing-v1.key b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/trc-issuing-v1.key
new file mode 100644
index 000000000..af7cba0f3
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_110/keys/trc-issuing-v1.key
@@ -0,0 +1,11 @@
+-----BEGIN PRIVATE KEY-----
+algorithm: ed25519
+ia: 1-ff00:0:110
+not_after: 2020-11-05 09:49:29+0000
+not_before: 2019-11-06 09:49:29+0000
+usage: trc-issuing
+version: 1
+
+/iU+76cLc9A74+cJ5hk9aWiljUlr8iN5luB2fHBcIuQ=
+-----END PRIVATE KEY-----
+
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/trc-v1.toml b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/trc-v1.toml
new file mode 100644
index 000000000..d727d71b1
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/trc-v1.toml
@@ -0,0 +1,29 @@
+description = "ISD 1"
+version = 1
+base_version = 1
+voting_quorum = 2
+grace_period = "0s"
+trust_reset_allowed = true
+votes = []
+
+[validity]
+  not_before = 1573033769
+  validity = "1y"
+
+[primary_ases]
+  [primary_ases."ff00:0:110"]
+    attributes = ["issuing", "voting"]
+    issuing_key_version = 1
+    voting_online_key_version = 1
+    voting_offline_key_version = 1
+  [primary_ases."ff00:0:120"]
+    attributes = ["authoritative", "core", "voting"]
+    issuing_key_version = 1
+    voting_online_key_version = 1
+    voting_offline_key_version = 1
+  [primary_ases."ff00:0:130"]
+    attributes = ["authoritative", "core", "issuing", "voting"]
+    issuing_key_version = 1
+    voting_online_key_version = 1
+    voting_offline_key_version = 1
+
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/trcs/ISD1-V1.trc b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/trcs/ISD1-V1.trc
new file mode 100644
index 000000000..3fc83f2ea
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/trcs/ISD1-V1.trc
@@ -0,0 +1,1 @@
+{"payload":"ZXlKcGMyUWlPakVzSW5SeVkxOTJaWEp6YVc5dUlqb3hMQ0ppWVhObFgzWmxjbk5wYjI0aU9qRXNJbVJsYzJOeWFYQjBhVzl1SWpvaVNWTkVJREVpTENKMmIzUnBibWRmY1hWdmNuVnRJam95TENKbWIzSnRZWFJmZG1WeWMybHZiaUk2TVN3aVozSmhZMlZmY0dWeWFXOWtJam93TENKMGNuVnpkRjl5WlhObGRGOWhiR3h2ZDJWa0lqcDBjblZsTENKMllXeHBaR2wwZVNJNmV5SnViM1JmWW1WbWIzSmxJam94TlRjek1ETXpOelk1TENKdWIzUmZZV1owWlhJaU9qRTJNRFExTmprM05qbDlMQ0p3Y21sdFlYSjVYMkZ6WlhNaU9uc2labVl3TURvd09qRXhNQ0k2ZXlKaGRIUnlhV0oxZEdWeklqcGJJbWx6YzNWcGJtY2lMQ0oyYjNScGJtY2lYU3dpYTJWNWN5STZleUpwYzNOMWFXNW5JanA3SW10bGVWOTJaWEp6YVc5dUlqb3hMQ0poYkdkdmNtbDBhRzBpT2lKbFpESTFOVEU1SWl3aWEyVjVJam9pYXk5dVNrRlhPVFYzZVRWRmExZGtlbTFIYUVWR1UweG5TRUpKTm5WQmRuSkRSMGs1VjAxM1VVUTBNRDBpZlN3aWIyWm1iR2x1WlNJNmV5SnJaWGxmZG1WeWMybHZiaUk2TVN3aVlXeG5iM0pwZEdodElqb2laV1F5TlRVeE9TSXNJbXRsZVNJNkluVkNSMHRSVTNCM1ZtNVNObTAxTlhsblJ6aEtNVll6VVhwcE1tTjJkbGh5VWtwSGRreHdjMlpOUTAwOUluMHNJbTl1YkdsdVpTSTZleUpyWlhsZmRtVnljMmx2YmlJNk1Td2lZV3huYjNKcGRHaHRJam9pWldReU5UVXhPU0lzSW10bGVTSTZJbWxQVXk4MmVIUXZSemQyVG5CNVdGQXlibnB1Y3pGcFJsbDFValoyUVRoeGFXRXhWWFo2ZFRKelNsVTlJbjE5ZlN3aVptWXdNRG93T2pFeU1DSTZleUpoZEhSeWFXSjFkR1Z6SWpwYkltRjFkR2h2Y21sMFlYUnBkbVVpTENKamIzSmxJaXdpZG05MGFXNW5JbDBzSW10bGVYTWlPbnNpYjJabWJHbHVaU0k2ZXlKclpYbGZkbVZ5YzJsdmJpSTZNU3dpWVd4bmIzSnBkR2h0SWpvaVpXUXlOVFV4T1NJc0ltdGxlU0k2SWxWUGIwdGtVa2t2YmtKRVNIbElOSGR6YmpoMmF6Rk1TbkZ6TjFkcFkyb3pia2xpZGk5VGEySklMMDA5SW4wc0ltOXViR2x1WlNJNmV5SnJaWGxmZG1WeWMybHZiaUk2TVN3aVlXeG5iM0pwZEdodElqb2laV1F5TlRVeE9TSXNJbXRsZVNJNkluVkVhM0pOWW5OMlkyTTBjR3REZEZncmJXSkJSbGcxVXpNeFMwdDRaMUZaWldwNldIQnVZbXhCWVVVOUluMTlmU3dpWm1Zd01Eb3dPakV6TUNJNmV5SmhkSFJ5YVdKMWRHVnpJanBiSW1GMWRHaHZjbWwwWVhScGRtVWlMQ0pqYjNKbElpd2lhWE56ZFdsdVp5SXNJblp2ZEdsdVp5SmRMQ0pyWlhseklqcDdJbWx6YzNWcGJtY2lPbnNpYTJWNVgzWmxjbk5wYjI0aU9qRXNJbUZzWjI5eWFYUm9iU0k2SW1Wa01qVTFNVGtpTENKclpYa2lPaUpzWjNwQ2FteFZRWEV2TjFBNVVsQjJkRTVsUVRSek1qYzROMVV5TUc5MU0xSnNNRlp1ZGxZMlVYRnpQU0o5TENKdlptWnNhVzVsSWpwN0ltdGxlVjkyWlhKemFXOXVJam94TENKaGJHZHZjbWwwYUcwaU9pSmxaREkxTlRFNUlpd2lhMlY1SWpvaWFucDFjMXB5V1dOWU1VOUZUWE01VTJsVmJUQlJUREJHTDJkaVpXNVBhSFIyTXpGQlNVdDBaakIyVlQwaWZTd2liMjVzYVc1bElqcDdJbXRsZVY5MlpYSnphVzl1SWpveExDSmhiR2R2Y21sMGFHMGlPaUpsWkRJMU5URTVJaXdpYTJWNUlqb2lhME53WWtOeGVscExOR3hhWkdWTFp6ZEhNRE5PWjA5S2VYZGpNRkpHY25neU5EaENWa0ZHWjNoall6MGlmWDE5ZlN3aWRtOTBaWE1pT250OUxDSndjbTl2Wmw5dlpsOXdiM056WlhOemFXOXVJanA3SW1abU1EQTZNRG94TVRBaU9sc2lhWE56ZFdsdVp5SXNJbTl1YkdsdVpTSXNJbTltWm14cGJtVWlYU3dpWm1Zd01Eb3dPakV5TUNJNld5SnZibXhwYm1VaUxDSnZabVpzYVc1bElsMHNJbVptTURBNk1Eb3hNekFpT2xzaWFYTnpkV2x1WnlJc0ltOXViR2x1WlNJc0ltOW1abXhwYm1VaVhYMTk=","signatures":[{"protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJbkJ5YjI5bVgyOW1YM0J2YzNObGMzTnBiMjRpTENKclpYbGZkSGx3WlNJNkltbHpjM1ZwYm1jaUxDSnJaWGxmZG1WeWMybHZiaUk2TVN3aVlYTWlPaUptWmpBd09qQTZNVEV3SWl3aVkzSnBkQ0k2V3lKMGVYQmxJaXdpYTJWNVgzUjVjR1VpTENKclpYbGZkbVZ5YzJsdmJpSXNJbUZ6SWwxOQ==","signature":"0efcIb72qqfzZLzrGzeFf3IIy1dZvX7lNU9vjs3iDq27knFwcSP2c5AOvn3xkikJ1We7MhJstD61hmU1LdBYCA=="},{"protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJbkJ5YjI5bVgyOW1YM0J2YzNObGMzTnBiMjRpTENKclpYbGZkSGx3WlNJNkltOXViR2x1WlNJc0ltdGxlVjkyWlhKemFXOXVJam94TENKaGN5STZJbVptTURBNk1Eb3hNVEFpTENKamNtbDBJanBiSW5SNWNHVWlMQ0pyWlhsZmRIbHdaU0lzSW10bGVWOTJaWEp6YVc5dUlpd2lZWE1pWFgw","signature":"AbxMcUfw69gQTqBpH9nlgMsF2KmS+IpgmguviTxdGKmTKx08H2/DpGIdBwy8TjMt2y4+vX7rlrhKe70uG1M2Dg=="},{"protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJbkJ5YjI5bVgyOW1YM0J2YzNObGMzTnBiMjRpTENKclpYbGZkSGx3WlNJNkltOW1abXhwYm1VaUxDSnJaWGxmZG1WeWMybHZiaUk2TVN3aVlYTWlPaUptWmpBd09qQTZNVEV3SWl3aVkzSnBkQ0k2V3lKMGVYQmxJaXdpYTJWNVgzUjVjR1VpTENKclpYbGZkbVZ5YzJsdmJpSXNJbUZ6SWwxOQ==","signature":"MKRSaRpUlvDWKheCtpWbCEJ9+ShHBZlwqSf7lrDNDQMyeAPcs4SnuRQur5qb+PjKprX3GPE1iLb52CLsnlTuBw=="},{"protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJbkJ5YjI5bVgyOW1YM0J2YzNObGMzTnBiMjRpTENKclpYbGZkSGx3WlNJNkltOXViR2x1WlNJc0ltdGxlVjkyWlhKemFXOXVJam94TENKaGN5STZJbVptTURBNk1Eb3hNakFpTENKamNtbDBJanBiSW5SNWNHVWlMQ0pyWlhsZmRIbHdaU0lzSW10bGVWOTJaWEp6YVc5dUlpd2lZWE1pWFgw","signature":"gw1sLQ+u7cR9wNOapE0RebctSNlsdXyFoJYn0L9Zc+YYC3WKcma4n58oFWpsa1lVxG9UQWg/oEjQ52tpvdHaAQ=="},{"protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJbkJ5YjI5bVgyOW1YM0J2YzNObGMzTnBiMjRpTENKclpYbGZkSGx3WlNJNkltOW1abXhwYm1VaUxDSnJaWGxmZG1WeWMybHZiaUk2TVN3aVlYTWlPaUptWmpBd09qQTZNVEl3SWl3aVkzSnBkQ0k2V3lKMGVYQmxJaXdpYTJWNVgzUjVjR1VpTENKclpYbGZkbVZ5YzJsdmJpSXNJbUZ6SWwxOQ==","signature":"3MkI9AL/ecOVO2TrzdW5TL73dUPADRHu73FYiDJeALr7pBxj2OZOy1ZP4mPkEpUYSMWI64BACUYQWDB2LBp6BA=="},{"protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJbkJ5YjI5bVgyOW1YM0J2YzNObGMzTnBiMjRpTENKclpYbGZkSGx3WlNJNkltbHpjM1ZwYm1jaUxDSnJaWGxmZG1WeWMybHZiaUk2TVN3aVlYTWlPaUptWmpBd09qQTZNVE13SWl3aVkzSnBkQ0k2V3lKMGVYQmxJaXdpYTJWNVgzUjVjR1VpTENKclpYbGZkbVZ5YzJsdmJpSXNJbUZ6SWwxOQ==","signature":"RBtXVFxyYwPXbjFgGQYgTzN6rvJIf4Jup24bgy23h65rucAZwl9oM1JQtdUJhikJw78pKIx+MTpUzJDgUM/DAg=="},{"protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJbkJ5YjI5bVgyOW1YM0J2YzNObGMzTnBiMjRpTENKclpYbGZkSGx3WlNJNkltOXViR2x1WlNJc0ltdGxlVjkyWlhKemFXOXVJam94TENKaGN5STZJbVptTURBNk1Eb3hNekFpTENKamNtbDBJanBiSW5SNWNHVWlMQ0pyWlhsZmRIbHdaU0lzSW10bGVWOTJaWEp6YVc5dUlpd2lZWE1pWFgw","signature":"+fa5wC9Y92meOtBU8mtpZn7rZgOrNKmRPUOnyYuYJYc2b4N2e+rwmzZCqF+/ZktIyuW+cD8y0+dRtK/IJgV+BQ=="},{"protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJbkJ5YjI5bVgyOW1YM0J2YzNObGMzTnBiMjRpTENKclpYbGZkSGx3WlNJNkltOW1abXhwYm1VaUxDSnJaWGxmZG1WeWMybHZiaUk2TVN3aVlYTWlPaUptWmpBd09qQTZNVE13SWl3aVkzSnBkQ0k2V3lKMGVYQmxJaXdpYTJWNVgzUjVjR1VpTENKclpYbGZkbVZ5YzJsdmJpSXNJbUZ6SWwxOQ==","signature":"Zojh+2fHDELMt+BVmpdFk5OLFhnkj3j2M73UfqvvwYLBsV4ZsS9PufIZoVpkmYy43Ld35fCcVrY9qKOg/a8/Bg=="}]}
diff --git a/go/tools/scion-pki/internal/v2/keys/pub_test.go b/go/tools/scion-pki/internal/v2/keys/pub_test.go
index 374605293..024adb494 100644
--- a/go/tools/scion-pki/internal/v2/keys/pub_test.go
+++ b/go/tools/scion-pki/internal/v2/keys/pub_test.go
@@ -19,6 +19,7 @@ import (
 	"encoding/pem"
 	"io/ioutil"
 	"os"
+	"path"
 	"path/filepath"
 	"testing"
 	"time"
@@ -167,6 +168,79 @@ func TestPubGenRun(t *testing.T) {
 	}
 }
 
+func TestLoadPublicKey(t *testing.T) {
+	tmpDir, clean := xtest.MustTempDir("", "test-keys")
+	defer clean()
+	protoKey := keyconf.Key{
+		ID: keyconf.ID{
+			Usage:   keyconf.ASSigningKey,
+			IA:      xtest.MustParseIA("1-ff00:0:110"),
+			Version: 1,
+		},
+		Type: keyconf.PublicKey,
+	}
+	t.Run("load pub file", func(t *testing.T) {
+		dir := path.Join(tmpDir, "pub")
+		err := os.MkdirAll(PublicDir(dir, protoKey.IA), 0777)
+		require.NoError(t, err)
+		block := protoKey.PEM()
+		err = ioutil.WriteFile(PublicFile(dir, protoKey.ID), pem.EncodeToMemory(&block), 0644)
+		require.NoError(t, err)
+
+		_, derived, err := LoadPublicKey(dir, protoKey.ID)
+		require.NoError(t, err)
+		assert.False(t, derived)
+	})
+	t.Run("load priv file", func(t *testing.T) {
+		dir := path.Join(tmpDir, "priv")
+		err := os.MkdirAll(PublicDir(dir, protoKey.IA), 0777)
+		require.NoError(t, err)
+		block := protoKey.PEM()
+		err = ioutil.WriteFile(PublicFile(dir, protoKey.ID), pem.EncodeToMemory(&block), 0644)
+		require.NoError(t, err)
+
+		err = os.MkdirAll(PrivateDir(dir, protoKey.IA), 0777)
+		require.NoError(t, err)
+		priv := keyconf.Key{
+			ID:        protoKey.ID,
+			Type:      keyconf.PrivateKey,
+			Algorithm: scrypto.Ed25519,
+			Bytes:     make([]byte, 32),
+		}
+		block = priv.PEM()
+		err = ioutil.WriteFile(PrivateFile(dir, priv.ID), pem.EncodeToMemory(&block), 0644)
+		require.NoError(t, err)
+
+		_, derived, err := LoadPublicKey(dir, protoKey.ID)
+		require.NoError(t, err)
+		assert.True(t, derived)
+	})
+	t.Run("invalid contents", func(t *testing.T) {
+		dir := path.Join(tmpDir, "priv")
+		err := os.MkdirAll(PrivateDir(dir, protoKey.IA), 0777)
+		require.NoError(t, err)
+		priv := keyconf.Key{
+			ID:        protoKey.ID,
+			Type:      keyconf.PublicKey,
+			Algorithm: scrypto.Ed25519,
+			Bytes:     make([]byte, 32),
+		}
+		block := priv.PEM()
+		err = ioutil.WriteFile(PrivateFile(dir, priv.ID), pem.EncodeToMemory(&block), 0644)
+		require.NoError(t, err)
+
+		_, derived, err := LoadPublicKey(dir, protoKey.ID)
+		require.Error(t, err)
+		assert.False(t, derived)
+	})
+	t.Run("no key", func(t *testing.T) {
+		_, derived, err := LoadPublicKey(tmpDir, protoKey.ID)
+		require.Error(t, err)
+		assert.False(t, derived)
+	})
+
+}
+
 func loadKey(t *testing.T, file string) keyconf.Key {
 	raw, err := ioutil.ReadFile(file)
 	require.NoError(t, err)