Repository: scionproto/scion
Pull request #3453 (2019-12-05 11:39:59)

SPKI: Generate certificate chains
This PR adds the capability to generate certificate chains.
Generated chains are verified before writing to file system.

<!-- 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/3453)
<!-- Reviewable:end -->



Patch:
diff --git a/go/tools/scion-pki/internal/v2/certs/BUILD.bazel b/go/tools/scion-pki/internal/v2/certs/BUILD.bazel
index 33ccdb0fc..5d60c8085 100644
--- a/go/tools/scion-pki/internal/v2/certs/BUILD.bazel
+++ b/go/tools/scion-pki/internal/v2/certs/BUILD.bazel
@@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
 go_library(
     name = "go_default_library",
     srcs = [
+        "chain.go",
         "cmd.go",
         "human.go",
         "issuer.go",
@@ -30,6 +31,7 @@ go_library(
 go_test(
     name = "go_default_test",
     srcs = [
+        "chain_test.go",
         "issuer_test.go",
         "loader_test.go",
         "main_test.go",
diff --git a/go/tools/scion-pki/internal/v2/certs/chain.go b/go/tools/scion-pki/internal/v2/certs/chain.go
new file mode 100644
index 000000000..ad12242bf
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/chain.go
@@ -0,0 +1,254 @@
+// 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"
+	"path/filepath"
+	"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/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 chainMeta struct {
+	Chain   cert.Chain
+	Version scrypto.Version
+}
+
+type chainGen struct {
+	Dirs    pkicmn.Dirs
+	Version scrypto.Version
+}
+
+func (g chainGen) Run(asMap pkicmn.ASMap) error {
+	cfgs, err := loader{Dirs: g.Dirs, Version: g.Version}.LoadASConfigs(asMap)
+	if err != nil {
+		return serrors.WrapStr("unable to load AS certificate configs", err)
+	}
+	certs, err := g.generateAll(cfgs)
+	if err != nil {
+		return serrors.WrapStr("unable to generate AS certificates", err)
+	}
+	if err := g.signAll(certs, cfgs); err != nil {
+		return serrors.WrapStr("unable to sign AS certificates", err)
+	}
+	if err := g.verify(certs); err != nil {
+		return serrors.WrapStr("unable to verify AS 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 AS certificates", err)
+	}
+	return nil
+}
+
+func (g chainGen) generateAll(cfgs map[addr.IA]conf.AS) (map[addr.IA]chainMeta, error) {
+	certs := make(map[addr.IA]chainMeta)
+	for ia, cfg := range cfgs {
+		chain, 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] = chain
+	}
+	return certs, nil
+}
+
+func (g chainGen) generate(ia addr.IA, cfg conf.AS) (chainMeta, error) {
+	pubKeys, err := g.loadPubKeys(ia, cfg)
+	if err != nil {
+		return chainMeta{}, serrors.WrapStr("unable to load all public keys", err)
+	}
+	enc, err := cert.EncodeAS(newASCert(ia, cfg, pubKeys))
+	if err != nil {
+		return chainMeta{}, serrors.WrapStr("unable to encode AS certificate", err)
+	}
+	file := IssuerFile(g.Dirs.Out, cfg.IssuerIA, cfg.IssuerCertVersion)
+	raw, err := ioutil.ReadFile(file)
+	if err != nil {
+		return chainMeta{}, serrors.WrapStr("unable to read issuer certificate", err, "file", file)
+	}
+	issuer, err := cert.ParseSignedIssuer(raw)
+	if err != nil {
+		return chainMeta{}, serrors.WrapStr("unable to parse issuer certificate", err, "file", file)
+	}
+	meta := chainMeta{
+		Chain: cert.Chain{
+			Issuer: issuer,
+			AS:     cert.SignedAS{Encoded: enc},
+		},
+		Version: cfg.Version,
+	}
+	return meta, nil
+}
+
+func (g chainGen) loadPubKeys(ia addr.IA, cfg conf.AS) (map[cert.KeyType]keyconf.Key, error) {
+	keys := make(map[cert.KeyType]keyconf.Key)
+	type meta struct {
+		Usage   keyconf.Usage
+		Version scrypto.KeyVersion
+	}
+	load := map[cert.KeyType]keyconf.ID{
+		cert.SigningKey: {
+			IA:      ia,
+			Version: *cfg.SigningKeyVersion,
+			Usage:   keyconf.ASSigningKey,
+		},
+		cert.EncryptionKey: {
+			IA:      ia,
+			Version: *cfg.EncryptionKeyVersion,
+			Usage:   keyconf.ASDecryptionKey,
+		},
+	}
+	if cfg.RevocationKeyVersion != nil {
+		load[cert.RevocationKey] = keyconf.ID{
+			IA:      ia,
+			Version: *cfg.RevocationKeyVersion,
+			Usage:   keyconf.ASRevocationKey,
+		}
+	}
+	for keyType, id := range load {
+		key, err := g.loadPubKey(id)
+		if err != nil {
+			return nil, serrors.WrapStr("unable to load key", err, "usage", id.Usage)
+		}
+		keys[keyType] = key
+	}
+	return keys, nil
+}
+
+func (g chainGen) 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 chainGen) signAll(protos map[addr.IA]chainMeta, cfgs map[addr.IA]conf.AS) error {
+	for ia, meta := range protos {
+		var err error
+		if meta.Chain, err = g.sign(cfgs[ia], meta.Chain); err != nil {
+			return serrors.WrapStr("unable to sign AS certificate", err, "ia", ia)
+		}
+		protos[ia] = meta
+	}
+	return nil
+}
+
+func (g chainGen) sign(cfg conf.AS, chain cert.Chain) (cert.Chain, error) {
+	file := conf.IssuerFile(g.Dirs.Root, cfg.IssuerIA, cfg.IssuerCertVersion)
+	issCfg, err := conf.LoadIssuer(file)
+	if err != nil {
+		return cert.Chain{}, serrors.WrapStr("unable to load issuer config", err, "file", file)
+	}
+	file = filepath.Join(keys.PrivateDir(g.Dirs.Out, cfg.IssuerIA),
+		keyconf.PrivateKeyFile(keyconf.IssCertSigningKey, *issCfg.IssuingKeyVersion))
+	id := keyconf.ID{
+		IA:      cfg.IssuerIA,
+		Usage:   keyconf.IssCertSigningKey,
+		Version: *issCfg.IssuingKeyVersion,
+	}
+	key, err := keyconf.LoadKeyFromFile(file, keyconf.PrivateKey, id)
+	if err != nil {
+		return cert.Chain{}, serrors.WrapStr("unable to load issuing key", err, "file", file)
+	}
+	protected := cert.ProtectedAS{
+		Algorithm:          key.Algorithm,
+		IA:                 cfg.IssuerIA,
+		CertificateVersion: cfg.IssuerCertVersion,
+	}
+	if chain.AS.EncodedProtected, err = cert.EncodeProtectedAS(protected); err != nil {
+		return cert.Chain{}, serrors.WrapStr("unable to encode protected", err)
+	}
+	chain.AS.Signature, err = scrypto.Sign(chain.AS.SigInput(), key.Bytes, key.Algorithm)
+	if err != nil {
+		return cert.Chain{}, serrors.WrapStr("unable to sign issuer certificate", err)
+	}
+	return chain, nil
+}
+
+func (g chainGen) verify(certs map[addr.IA]chainMeta) error {
+	v := verifier{Dirs: g.Dirs}
+	for ia, meta := range certs {
+		raw, err := meta.Chain.MarshalJSON()
+		if err != nil {
+			return serrors.WrapStr("unable to encode certificate chain", err)
+		}
+		if err := v.VerifyChain(raw); err != nil {
+			return serrors.WrapStr("unable to verify certificate chain", err, "ia", ia)
+		}
+	}
+	return nil
+}
+
+func (g chainGen) createDirs(certs map[addr.IA]chainMeta) 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 chainGen) write(certs map[addr.IA]chainMeta) error {
+	for ia, meta := range certs {
+		raw, err := meta.Chain.MarshalJSON()
+		if err != nil {
+			return serrors.WrapStr("unable to encode signed issuer certificate", err)
+		}
+		file := ASFile(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
+}
+
+func newASCert(ia addr.IA, cfg conf.AS, pubKeys map[cert.KeyType]keyconf.Key) *cert.AS {
+	val := cfg.Validity.Eval(time.Now())
+	c := &cert.AS{
+		Base: cert.Base{
+			Subject:                    ia,
+			Version:                    cfg.Version,
+			FormatVersion:              1,
+			Description:                cfg.Description,
+			OptionalDistributionPoints: cfg.OptDistPoints,
+			Validity:                   &val,
+			Keys:                       translateKeys(pubKeys),
+		},
+		Issuer: cert.IssuerCertID{
+			IA:                 cfg.IssuerIA,
+			CertificateVersion: cfg.IssuerCertVersion,
+		},
+	}
+	return c
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/cmd.go b/go/tools/scion-pki/internal/v2/certs/cmd.go
index c4e414315..471121e8c 100644
--- a/go/tools/scion-pki/internal/v2/certs/cmd.go
+++ b/go/tools/scion-pki/internal/v2/certs/cmd.go
@@ -77,6 +77,23 @@ var genIssuerCmd = &cobra.Command{
 	},
 }
 
+var genChainCmd = &cobra.Command{
+	Use:   "chain",
+	Short: "Generate the certificate chain",
+	Args:  cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		g := chainGen{
+			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)
+	},
+}
+
 var humanCmd = &cobra.Command{
 	Use:   "human",
 	Short: "Display human readable issuer certificates and certificate chains",
@@ -86,14 +103,12 @@ var humanCmd = &cobra.Command{
 `,
 	Args: cobra.MinimumNArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		if err := runHuman(args); err != nil {
-			return err
-		}
-		return nil
+		return runHuman(args)
 	},
 }
 
 func init() {
+	Cmd.AddCommand(genChainCmd)
 	Cmd.AddCommand(genIssuerCmd)
 	Cmd.AddCommand(humanCmd)
 }
diff --git a/go/tools/scion-pki/internal/v2/certs/issuer.go b/go/tools/scion-pki/internal/v2/certs/issuer.go
index 6916435cf..d5294c3b2 100644
--- a/go/tools/scion-pki/internal/v2/certs/issuer.go
+++ b/go/tools/scion-pki/internal/v2/certs/issuer.go
@@ -81,7 +81,7 @@ func (g issGen) generate(ia addr.IA, cfg conf.Issuer) (issMeta, error) {
 	if err != nil {
 		return issMeta{}, serrors.WrapStr("unable to load all public keys", err)
 	}
-	enc, err := cert.EncodeIssuer(g.newCert(ia, cfg, pubKeys))
+	enc, err := cert.EncodeIssuer(newIssuerCert(ia, cfg, pubKeys))
 	if err != nil {
 		return issMeta{}, serrors.WrapStr("unable to encode issuer certificate", err)
 	}
@@ -131,27 +131,6 @@ func (g issGen) loadPubKey(id keyconf.ID) (keyconf.Key, error) {
 	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
@@ -234,3 +213,23 @@ func (g issGen) write(certs map[addr.IA]issMeta) error {
 	}
 	return nil
 }
+
+func newIssuerCert(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
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/loader.go b/go/tools/scion-pki/internal/v2/certs/loader.go
index 3355ef968..88e7a2003 100644
--- a/go/tools/scion-pki/internal/v2/certs/loader.go
+++ b/go/tools/scion-pki/internal/v2/certs/loader.go
@@ -54,6 +54,29 @@ func (l loader) LoadIssuerConfigs(asMap pkicmn.ASMap) (map[addr.IA]conf.Issuer,
 	return cfgs, nil
 }
 
+func (l loader) LoadASConfigs(asMap pkicmn.ASMap) (map[addr.IA]conf.AS, error) {
+	s := selector{
+		File:  conf.ASFile,
+		All:   conf.AllASFiles,
+		Regex: `as-v(\d*)\.toml$`,
+	}
+	cfgs := make(map[addr.IA]conf.AS)
+	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.LoadAS(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
diff --git a/go/tools/scion-pki/internal/v2/certs/util.go b/go/tools/scion-pki/internal/v2/certs/util.go
index 7189de183..8a77c57cb 100644
--- a/go/tools/scion-pki/internal/v2/certs/util.go
+++ b/go/tools/scion-pki/internal/v2/certs/util.go
@@ -36,6 +36,11 @@ 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))
 }
 
+// ASFile returns the file path for the certificate chain.
+func ASFile(dir string, ia addr.IA, ver scrypto.Version) string {
+	return filepath.Join(Dir(dir, ia), fmt.Sprintf(pkicmn.CertNameFmt, 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)
diff --git a/go/tools/scion-pki/internal/v2/certs/verify.go b/go/tools/scion-pki/internal/v2/certs/verify.go
index bfb74cb5c..46189a942 100644
--- a/go/tools/scion-pki/internal/v2/certs/verify.go
+++ b/go/tools/scion-pki/internal/v2/certs/verify.go
@@ -37,6 +37,40 @@ func (v verifier) VerifyIssuer(raw []byte) error {
 	if err != nil {
 		return serrors.WrapStr("unable to parse signed issuer certificate", err)
 	}
+	return v.verifyIssuer(signed)
+}
+
+func (v verifier) VerifyChain(raw []byte) error {
+	chain, err := cert.ParseChain(raw)
+	if err != nil {
+		return serrors.WrapStr("unable to parse signed certificate chain", err)
+	}
+	if err := v.verifyIssuer(chain.Issuer); err != nil {
+		return err
+	}
+	issCert, err := chain.Issuer.Encoded.Decode()
+	if err != nil {
+		return serrors.WrapStr("unable to parse issuer certificate payload", err)
+	}
+	asCert, err := chain.AS.Encoded.Decode()
+	if err != nil {
+		return serrors.WrapStr("unable to parse AS certificate payload", err)
+	}
+	if err := asCert.Validate(); err != nil {
+		return serrors.WrapStr("unable to validate AS certificate", err)
+	}
+	asVer := cert.ASVerifier{
+		Issuer:   issCert,
+		AS:       asCert,
+		SignedAS: &chain.AS,
+	}
+	if err := asVer.Verify(); err != nil {
+		return serrors.WrapStr("unable to verify AS certificate", err)
+	}
+	return nil
+}
+
+func (v verifier) verifyIssuer(signed cert.SignedIssuer) error {
 	c, err := signed.Encoded.Decode()
 	if err != nil {
 		return serrors.WrapStr("unable to parse issuer certificate payload", err)
diff --git a/go/tools/scion-pki/internal/v2/conf/as.go b/go/tools/scion-pki/internal/v2/conf/as.go
index 9932cbb66..032ddc520 100644
--- a/go/tools/scion-pki/internal/v2/conf/as.go
+++ b/go/tools/scion-pki/internal/v2/conf/as.go
@@ -32,6 +32,11 @@ func ASFile(dir string, ia addr.IA, version scrypto.Version) string {
 	return filepath.Join(pkicmn.GetAsPath(dir, ia), fmt.Sprintf("as-v%d.toml", version))
 }
 
+// AllASFiles returns a glob string that matches all AS files for the given IA.
+func AllASFiles(dir string, ia addr.IA) string {
+	return filepath.Join(pkicmn.GetAsPath(dir, ia), "as-v*.toml")
+}
+
 // AS holds the AS certificate configuration.
 type AS struct {
 	Description          string              `toml:"description"`


Test patch:
diff --git a/go/tools/scion-pki/internal/v2/certs/chain_test.go b/go/tools/scion-pki/internal/v2/certs/chain_test.go
new file mode 100644
index 000000000..4dfd256a7
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/chain_test.go
@@ -0,0 +1,96 @@
+// 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 (
+	ia111      = xtest.MustParseIA("1-ff00:0:111")
+	chainASMap = pkicmn.ASMap{1: {ia111}}
+)
+
+// TestChainGenRun checks that the chain generator creates verifiable chains.
+//
+// Given the folders:
+// - ISD1                   with the trc config
+// - ISD1/trcs              with the issuing TRC
+// - ISD1/ASff00_0_110      with the issuer AS, all its configs, keys and issuer certificate
+// - ISD1/ASff00_0_111      with the AS certifcate config
+//
+// When running chain.Run with the AS map that contains AS 1-ff00:0:111.
+//
+// Then a certificate chain is generated under ISD1/ASff00_0_111/certs.
+// The certificate chain is:
+// - valid
+// - verifiable using TRC ISD1-V1.trc that can be found at ISD1/trcs
+// - Byte for byte the same as the golden file.
+func TestChainGenRun(t *testing.T) {
+	tmpDir, cleanF := xtest.MustTempDir("", "test-certs-chain")
+	defer cleanF()
+
+	isdDir := filepath.Join(tmpDir, "ISD1")
+	require.NoError(t, os.MkdirAll(isdDir, 0777))
+	err := exec.Command("cp", "-r",
+		"./testdata/ISD1/ASff00_0_110",
+		"./testdata/ISD1/trcs",
+		"./testdata/ISD1/trc-v1.toml",
+		isdDir).Run()
+	require.NoError(t, err)
+
+	asDir := filepath.Join(isdDir, "ASff00_0_111")
+	require.NoError(t, os.MkdirAll(asDir, 0777))
+	err = exec.Command("cp", "-r",
+		"./testdata/ISD1/ASff00_0_111/keys",
+		"./testdata/ISD1/ASff00_0_111/as-v1.toml",
+		asDir).Run()
+	require.NoError(t, err)
+
+	g := chainGen{
+		Dirs: pkicmn.Dirs{Root: "./testdata", Out: tmpDir},
+	}
+	err = g.Run(chainASMap)
+	require.NoError(t, err)
+
+	golden, err := ioutil.ReadFile(ASFile("./testdata", ia111, 1))
+	require.NoError(t, err)
+	result, err := ioutil.ReadFile(ASFile(tmpDir, ia111, 1))
+	require.NoError(t, err)
+	assert.Equal(t, golden, result)
+}
+
+// TestUpdateGoldenChain provides an easy way to update the golden file after
+// the format has changed.
+func TestUpdateGoldenChain(t *testing.T) {
+	if *update {
+		force := pkicmn.Force
+		pkicmn.Force = true
+		defer func() { pkicmn.Force = force }()
+		g := chainGen{Dirs: pkicmn.Dirs{Root: "./testdata", Out: "./testdata"}, Version: 1}
+		err := g.Run(chainASMap)
+		require.NoError(t, err)
+	}
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/issuer_test.go b/go/tools/scion-pki/internal/v2/certs/issuer_test.go
index 66755b6fe..a3222ccfc 100644
--- a/go/tools/scion-pki/internal/v2/certs/issuer_test.go
+++ b/go/tools/scion-pki/internal/v2/certs/issuer_test.go
@@ -29,8 +29,8 @@ import (
 )
 
 var (
-	ia110     = xtest.MustParseIA("1-ff00:0:110")
-	testASMap = pkicmn.ASMap{1: {ia110}}
+	ia110    = xtest.MustParseIA("1-ff00:0:110")
+	issASMap = pkicmn.ASMap{1: {ia110}}
 )
 
 func TestIssuerGenRun(t *testing.T) {
@@ -56,7 +56,7 @@ func TestIssuerGenRun(t *testing.T) {
 	g := issGen{
 		Dirs: pkicmn.Dirs{Root: "./testdata", Out: tmpDir},
 	}
-	err = g.Run(testASMap)
+	err = g.Run(issASMap)
 	require.NoError(t, err)
 
 	golden, err := ioutil.ReadFile(IssuerFile("./testdata", ia110, 1))
@@ -74,7 +74,7 @@ func TestUpdateGoldenIssuer(t *testing.T) {
 		pkicmn.Force = true
 		defer func() { pkicmn.Force = force }()
 		g := issGen{Dirs: pkicmn.Dirs{Root: "./testdata", Out: "./testdata"}, Version: 1}
-		err := g.Run(testASMap)
+		err := g.Run(issASMap)
 		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
index 6c35371a2..5572c64d4 100644
--- a/go/tools/scion-pki/internal/v2/certs/loader_test.go
+++ b/go/tools/scion-pki/internal/v2/certs/loader_test.go
@@ -40,9 +40,32 @@ func TestLoaderLoadIssuerConfigs(t *testing.T) {
 				Dirs:    pkicmn.Dirs{Root: "./testdata", Out: "./testdata"},
 				Version: test.Version,
 			}
-			cfgs, err := l.LoadIssuerConfigs(testASMap)
+			cfgs, err := l.LoadIssuerConfigs(issASMap)
 			require.NoError(t, err)
 			assert.Equal(t, test.Expected, cfgs[ia110].Version)
 		})
 	}
 }
+
+func TestLoaderLoadASConfigs(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
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			l := loader{
+				Dirs:    pkicmn.Dirs{Root: "./testdata", Out: "./testdata"},
+				Version: test.Version,
+			}
+			cfgs, err := l.LoadASConfigs(chainASMap)
+			require.NoError(t, err)
+			assert.Equal(t, test.Expected, cfgs[ia111].Version)
+		})
+	}
+}
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/as-v1.toml b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/as-v1.toml
new file mode 100644
index 000000000..1517c0f49
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/as-v1.toml
@@ -0,0 +1,12 @@
+description = "AS certificate 1-ff00:0:111"
+version = 1
+signing_key_version = 1
+encryption_key_version = 2
+revocation_key_version = 2
+issuer_ia = "1-ff00:0:110"
+issuer_cert_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_111/certs/ISD1-ASff00_0_111-V1.crt b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/certs/ISD1-ASff00_0_111-V1.crt
new file mode 100644
index 000000000..a54a73a71
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/certs/ISD1-ASff00_0_111-V1.crt
@@ -0,0 +1,1 @@
+[{"payload":"ZXlKemRXSnFaV04wSWpvaU1TMW1aakF3T2pBNk1URXdJaXdpZG1WeWMybHZiaUk2TVN3aVptOXliV0YwWDNabGNuTnBiMjRpT2pFc0ltUmxjMk55YVhCMGFXOXVJam9pU1hOemRXVnlJR05sY25ScFptbGpZWFJsSURFdFptWXdNRG93T2pFeE1DSXNJbTl3ZEdsdmJtRnNYMlJwYzNSeWFXSjFkR2x2Ymw5d2IybHVkSE1pT2x0ZExDSjJZV3hwWkdsMGVTSTZleUp1YjNSZlltVm1iM0psSWpveE5UY3pNRE16TnpZNUxDSnViM1JmWVdaMFpYSWlPakUyTURRMU5qazNOamw5TENKclpYbHpJanA3SW1semMzVnBibWNpT25zaWEyVjVYM1psY25OcGIyNGlPakVzSW1Gc1oyOXlhWFJvYlNJNkltVmtNalUxTVRraUxDSnJaWGtpT2lKVVVXaDVORzVGTkdSalFtNXpUa0prVVVOaVNFNHdOR1Z2ZUZCU1JIWjRkamt4TUVVdlNGRnllaTlqUFNKOUxDSnlaWFp2WTJGMGFXOXVJanA3SW10bGVWOTJaWEp6YVc5dUlqb3lMQ0poYkdkdmNtbDBhRzBpT2lKbFpESTFOVEU1SWl3aWEyVjVJam9pVXpKR2IxcGxLM1JHVXpBM1FsVnNaWEpRWWtKaGJqVlJRVlo2VGswMlJGUTNTVGxNV210clJHRlFPRDBpZlgwc0ltbHpjM1ZsY2lJNmV5SjBjbU5mZG1WeWMybHZiaUk2TVgwc0ltTmxjblJwWm1sallYUmxYM1I1Y0dVaU9pSnBjM04xWlhJaWZR","protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpZEhsd1pTSTZJblJ5WXlJc0luUnlZMTkyWlhKemFXOXVJam94TENKamNtbDBJanBiSW5SNWNHVWlMQ0owY21OZmRtVnljMmx2YmlKZGZR","signature":"k0GLHNQJjByF8fFb+Mscv6bvwLObJSDq5RX3f9u1UFu7m/UMdpbuWRRksQNKWo7WBo7Xtj29WaqXmQBhLQm2AA=="},{"payload":"ZXlKemRXSnFaV04wSWpvaU1TMW1aakF3T2pBNk1URXhJaXdpZG1WeWMybHZiaUk2TVN3aVptOXliV0YwWDNabGNuTnBiMjRpT2pFc0ltUmxjMk55YVhCMGFXOXVJam9pUVZNZ1kyVnlkR2xtYVdOaGRHVWdNUzFtWmpBd09qQTZNVEV4SWl3aWIzQjBhVzl1WVd4ZlpHbHpkSEpwWW5WMGFXOXVYM0J2YVc1MGN5STZXMTBzSW5aaGJHbGthWFI1SWpwN0ltNXZkRjlpWldadmNtVWlPakUxTnpNd016TTNOamtzSW01dmRGOWhablJsY2lJNk1UWXdORFUyT1RjMk9YMHNJbXRsZVhNaU9uc2laVzVqY25sd2RHbHZiaUk2ZXlKclpYbGZkbVZ5YzJsdmJpSTZNaXdpWVd4bmIzSnBkR2h0SWpvaVpXUXlOVFV4T1NJc0ltdGxlU0k2SW5SUk5IZFFhVEF4YzBGdU5tSlZTVlJKY1VGWVMyRnpjakE1U0RsdFZsaHNkRWRFTUhKWE1FSlFNVms5SW4wc0luSmxkbTlqWVhScGIyNGlPbnNpYTJWNVgzWmxjbk5wYjI0aU9qSXNJbUZzWjI5eWFYUm9iU0k2SW1Wa01qVTFNVGtpTENKclpYa2lPaUpZUkdWTmVUWTROMk5HYlRaUVZuRjVPV2hYTVZwUWVGRkZNa0ZEZUVock5rYzVjbXRHTW1aeVV6UTRQU0o5TENKemFXZHVhVzVuSWpwN0ltdGxlVjkyWlhKemFXOXVJam94TENKaGJHZHZjbWwwYUcwaU9pSmxaREkxTlRFNUlpd2lhMlY1SWpvaWVFaGFkUzloV1VSWVRIUjBWM0JYVldsVVZWaHJiRFZKVVZOSGVGcDBkblpDWVU1bmFHcFBOWFJLVVQwaWZYMHNJbWx6YzNWbGNpSTZleUpwWVNJNklqRXRabVl3TURvd09qRXhNQ0lzSW1ObGNuUnBabWxqWVhSbFgzWmxjbk5wYjI0aU9qRjlMQ0pqWlhKMGFXWnBZMkYwWlY5MGVYQmxJam9pWVhNaWZR","protected":"ZXlKaGJHY2lPaUpsWkRJMU5URTVJaXdpWTNKcGRDSTZXeUowZVhCbElpd2lZMlZ5ZEdsbWFXTmhkR1ZmZG1WeWMybHZiaUlzSW1saElsMHNJblI1Y0dVaU9pSmpaWEowYVdacFkyRjBaU0lzSW1ObGNuUnBabWxqWVhSbFgzWmxjbk5wYjI0aU9qRXNJbWxoSWpvaU1TMW1aakF3T2pBNk1URXdJbjA=","signature":"bMPZwb0sLByit5A6KX6GpTCZBoxa4JNULHyboJ1gjyrQtLokG88euwuJaOwdIOozxpaEIWTcuNpIldv4evAUBA=="}]
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys.toml b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys.toml
new file mode 100644
index 000000000..644f87094
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys.toml
@@ -0,0 +1,21 @@
+[as_cert]
+  [as_cert.signing]
+    [as_cert.signing.1]
+      algorithm = "ed25519"
+      [as_cert.signing.1.validity]
+          not_before = 1573033769
+          validity = "1y"
+
+  [as_cert.encryption]
+    [as_cert.encryption.2]
+      algorithm = "ed25519"
+      [as_cert.encryption.2.validity]
+          not_before = 1573033769
+          validity = "1y"
+
+  [as_cert.revocation]
+    [as_cert.revocation.2]
+      algorithm = "ed25519"
+      [as_cert.revocation.2.validity]
+          not_before = 1573033769
+          validity = "1y"
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-decrypt-v2.key b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-decrypt-v2.key
new file mode 100644
index 000000000..f57be0a2c
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-decrypt-v2.key
@@ -0,0 +1,11 @@
+-----BEGIN PRIVATE KEY-----
+algorithm: ed25519
+ia: 1-ff00:0:111
+not_after: 2020-11-05 09:49:29+0000
+not_before: 2019-11-06 09:49:29+0000
+usage: as-decrypt
+version: 2
+
+GZuzjBJFlpI++AFuFTc+5rtvE37ORK8+wEJAQ3V0gj0=
+-----END PRIVATE KEY-----
+
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-revocation-v2.key b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-revocation-v2.key
new file mode 100644
index 000000000..d27f485dc
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-revocation-v2.key
@@ -0,0 +1,11 @@
+-----BEGIN PRIVATE KEY-----
+algorithm: ed25519
+ia: 1-ff00:0:111
+not_after: 2020-11-05 09:49:29+0000
+not_before: 2019-11-06 09:49:29+0000
+usage: as-revocation
+version: 2
+
+cyxE6qnz5H2EEGNSuZPP+x/HOa9u9IhXZJ71C2FN+Ok=
+-----END PRIVATE KEY-----
+
diff --git a/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-signing-v1.key b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-signing-v1.key
new file mode 100644
index 000000000..c86084459
--- /dev/null
+++ b/go/tools/scion-pki/internal/v2/certs/testdata/ISD1/ASff00_0_111/keys/as-signing-v1.key
@@ -0,0 +1,11 @@
+-----BEGIN PRIVATE KEY-----
+algorithm: ed25519
+ia: 1-ff00:0:111
+not_after: 2020-11-05 09:49:29+0000
+not_before: 2019-11-06 09:49:29+0000
+usage: as-signing
+version: 1
+
+dSpcYnKWtVUAv1PPnsFS+XRNnDn04As8WP3/7TFh1to=
+-----END PRIVATE KEY-----
+