Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 151 additions & 1 deletion overlord/certstate/certs.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ import (
"github.com/snapcore/snapd/strutil"
)

const (
CertificateStateUnset = "unset"
CertificateStateAccepted = "accepted"
CertificateStateBlocked = "blocked"
)

type CertificateData struct {
Raw *x509.Certificate
Digest string
Expand Down Expand Up @@ -309,7 +315,7 @@ func loadCertificates() (*certificates, error) {
// /var/lib/snapd/pki/v1/blocked/<digest>.crt (symlink)
// /var/lib/snapd/pki/v1/merged/*.crt (symlinks)
// /var/lib/snapd/pki/v1/merged/ca-certificates.crt
// /var/lib/snapd/pki/v1/<digest>.crt
// /var/lib/snapd/pki/v1/<name>.crt
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a semantic change based on a quick sync with @pedronis -- the specification has been updated to reflect this

func ensureDirectories() error {
dirsToEnsure := []string{
filepath.Join(dirs.SnapdPKIV1Dir, "added"),
Expand Down Expand Up @@ -378,3 +384,147 @@ func GenerateCertificateDatabaseImpl() error {
err = generateCACertificates(certs, mergedDir)
return err
}

func certificatePathWithExtension(dir, name string) string {
return filepath.Join(dir, name+".crt")
}

// CertificatePath returns a path to the certificate file itself,
// given the name of the certificate (without .crt extension).
func CertificatePath(name string) string {
return certificatePathWithExtension(dirs.SnapdPKIV1Dir, name)
}

// RemoveCertificateSymlinks removes the symlinks for the given certificate digest
// from the added and blocked directories.
func RemoveCertificateSymlinks(digest string) error {
addedDir := filepath.Join(dirs.SnapdPKIV1Dir, "added")
blockedDir := filepath.Join(dirs.SnapdPKIV1Dir, "blocked")

if err := os.Remove(certificatePathWithExtension(addedDir, digest)); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Remove(certificatePathWithExtension(blockedDir, digest)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

// RemoveCertificate removes the certificate file for the given name. This does
// not remove the symlinks in the added/blocked directories.
func RemoveCertificate(name string) error {
if err := os.Remove(certificatePathWithExtension(dirs.SnapdPKIV1Dir, name)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

// WriteCertificate writes the given contents as a new certificate file. Does not
// set the state of the certificate (i.e. does not create symlinks in the added/blocked directories).
func WriteCertificate(name, content string) error {
certPath := certificatePathWithExtension(dirs.SnapdPKIV1Dir, name)
if err := os.WriteFile(certPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("cannot write custom certificate %q: %v", name, err)
}
return nil
}

// SetCertificateState sets the state of the certificate with the given name and digest.
// The state can be either "accepted", "blocked" or "unset". This is done by creating a symlink
// to the certificate file in the corresponding directory (added/blocked), or removing any existing
// symlink if the state is set to "unset".
func SetCertificateState(name, digest, state string) error {
addedDir := filepath.Join(dirs.SnapdPKIV1Dir, "added")
blockedDir := filepath.Join(dirs.SnapdPKIV1Dir, "blocked")

addedPath := certificatePathWithExtension(addedDir, digest)
blockedPath := certificatePathWithExtension(blockedDir, digest)
customPath := certificatePathWithExtension("..", name)

if state == CertificateStateAccepted {
if err := os.Symlink(customPath, addedPath); err != nil {
return err
}
}
if state == CertificateStateBlocked {
if err := os.Symlink(customPath, blockedPath); err != nil {
return err
}
}
return nil
}

type CertificateInfo struct {
Name string `json:"name"`
Fingerprint string `json:"fingerprint"`
State string `json:"state"`
}

func certificateDigest(name, baseDir string) (string, error) {
certPath := certificatePathWithExtension(baseDir, name)
certBytes, err := os.ReadFile(certPath)
if err != nil {
return "", fmt.Errorf("cannot read certificate %q: %v", name, err)
}

cdata, err := ParseCertificateData(certBytes)
if err != nil {
return "", fmt.Errorf("cannot parse certificate %q: %v", name, err)
}
return cdata.Digest, nil
}

func certificateInfo(name, baseDir, addedDir, blockedDir string) (*CertificateInfo, error) {
digest, err := certificateDigest(name, baseDir)
if err != nil {
return nil, err
}

state := CertificateStateUnset
if osutil.IsSymlink(certificatePathWithExtension(blockedDir, digest)) {
state = CertificateStateBlocked
} else if osutil.IsSymlink(certificatePathWithExtension(addedDir, digest)) {
state = CertificateStateAccepted
}

return &CertificateInfo{
Name: name,
Fingerprint: digest,
State: state,
}, nil
}

// CustomCertificates returns the list of custom certificates with their name, fingerprint and state.
func CustomCertificates() ([]*CertificateInfo, error) {
addedDir := filepath.Join(dirs.SnapdPKIV1Dir, "added")
blockedDir := filepath.Join(dirs.SnapdPKIV1Dir, "blocked")

// Read the contents of the custom certificates directory to get the list of all custom certificates, and their content and state.
files, err := os.ReadDir(dirs.SnapdPKIV1Dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}

var certsInfo []*CertificateInfo
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".crt") {
continue
}
name := trimCrtExtension(f.Name())
info, err := certificateInfo(name, dirs.SnapdPKIV1Dir, addedDir, blockedDir)
if err != nil {
// Let us be resilient to errors here, and just skip the certificate if we
// cannot read it or parse it, as we don't want one broken certificate to
// cause the whole API to be unavailable.
logger.Noticef("Failed to read custom certificate %q: %v", name, err)
continue
}
if info != nil {
certsInfo = append(certsInfo, info)
}
}
return certsInfo, nil
}
109 changes: 109 additions & 0 deletions overlord/certstate/certs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,112 @@ func (s *certsTestSuite) TestGenerateCertificateDatabaseBlocksBaseCertByDigest(c
c.Check(bytes.Contains(out, aPEM), Equals, false)
c.Check(bytes.Contains(out, bPEM), Equals, true)
}

func (s *certsTestSuite) TestCertificatePathAddsCrtExtension(c *C) {
path := certstate.CertificatePath("my-cert")
c.Check(path, Equals, filepath.Join(dirs.SnapdPKIV1Dir, "my-cert.crt"))
}

func (s *certsTestSuite) TestWriteCertificateAndRemoveCertificate(c *C) {
certPEM, _, err := makeTestCertPEM("write-remove")
c.Assert(err, IsNil)
c.Assert(os.MkdirAll(dirs.SnapdPKIV1Dir, 0o755), IsNil)

err = certstate.WriteCertificate("cert-write", string(certPEM))
c.Assert(err, IsNil)

certPath := filepath.Join(dirs.SnapdPKIV1Dir, "cert-write.crt")
c.Assert(certPath, testutil.FileEquals, string(certPEM))

err = certstate.RemoveCertificate("cert-write")
c.Assert(err, IsNil)

_, err = os.Stat(certPath)
c.Check(os.IsNotExist(err), Equals, true)

// Removing again should be idempotent.
err = certstate.RemoveCertificate("cert-write")
c.Assert(err, IsNil)
}

func (s *certsTestSuite) TestSetCertificateStateAndRemoveCertificateSymlinks(c *C) {
certPEM, _, err := makeTestCertPEM("state-links")
c.Assert(err, IsNil)
c.Assert(os.MkdirAll(dirs.SnapdPKIV1Dir, 0o755), IsNil)
c.Assert(os.MkdirAll(filepath.Join(dirs.SnapdPKIV1Dir, "added"), 0o755), IsNil)
c.Assert(os.MkdirAll(filepath.Join(dirs.SnapdPKIV1Dir, "blocked"), 0o755), IsNil)

err = certstate.WriteCertificate("cert-state", string(certPEM))
c.Assert(err, IsNil)

digest := digestForPEM(c, certPEM)

err = certstate.SetCertificateState("cert-state", digest, certstate.CertificateStateAccepted)
c.Assert(err, IsNil)
addedPath := filepath.Join(dirs.SnapdPKIV1Dir, "added", digest+".crt")
target, err := os.Readlink(addedPath)
c.Assert(err, IsNil)
c.Check(target, Equals, "../cert-state.crt")

err = certstate.RemoveCertificateSymlinks(digest)
c.Assert(err, IsNil)

_, err = os.Lstat(addedPath)
c.Check(os.IsNotExist(err), Equals, true)

// Idempotent removal.
err = certstate.RemoveCertificateSymlinks(digest)
c.Assert(err, IsNil)

err = certstate.SetCertificateState("cert-state", digest, certstate.CertificateStateBlocked)
c.Assert(err, IsNil)
blockedPath := filepath.Join(dirs.SnapdPKIV1Dir, "blocked", digest+".crt")
target, err = os.Readlink(blockedPath)
c.Assert(err, IsNil)
c.Check(target, Equals, "../cert-state.crt")
}

func (s *certsTestSuite) TestCustomCertificatesReturnsInfoAndSkipsBroken(c *C) {
certAccepted, _, err := makeTestCertPEM("accepted")
c.Assert(err, IsNil)
certBlocked, _, err := makeTestCertPEM("blocked")
c.Assert(err, IsNil)
c.Assert(os.MkdirAll(dirs.SnapdPKIV1Dir, 0o755), IsNil)
c.Assert(os.MkdirAll(filepath.Join(dirs.SnapdPKIV1Dir, "added"), 0o755), IsNil)
c.Assert(os.MkdirAll(filepath.Join(dirs.SnapdPKIV1Dir, "blocked"), 0o755), IsNil)

err = certstate.WriteCertificate("cert-accepted", string(certAccepted))
c.Assert(err, IsNil)
err = certstate.WriteCertificate("cert-blocked", string(certBlocked))
c.Assert(err, IsNil)

acceptedDigest := digestForPEM(c, certAccepted)
blockedDigest := digestForPEM(c, certBlocked)

err = certstate.SetCertificateState("cert-accepted", acceptedDigest, certstate.CertificateStateAccepted)
c.Assert(err, IsNil)
err = certstate.SetCertificateState("cert-blocked", blockedDigest, certstate.CertificateStateBlocked)
c.Assert(err, IsNil)

// Broken cert should be ignored by CustomCertificates and not fail the call.
c.Assert(os.WriteFile(filepath.Join(dirs.SnapdPKIV1Dir, "broken.crt"), []byte("not-a-certificate"), 0o644), IsNil)

infos, err := certstate.CustomCertificates()
c.Assert(err, IsNil)

byName := make(map[string]*certstate.CertificateInfo)
for _, info := range infos {
byName[info.Name] = info
}

c.Assert(byName["cert-accepted"], NotNil)
c.Check(byName["cert-accepted"].Fingerprint, Equals, acceptedDigest)
c.Check(byName["cert-accepted"].State, Equals, certstate.CertificateStateAccepted)

c.Assert(byName["cert-blocked"], NotNil)
c.Check(byName["cert-blocked"].Fingerprint, Equals, blockedDigest)
c.Check(byName["cert-blocked"].State, Equals, certstate.CertificateStateBlocked)

_, exists := byName["broken"]
c.Check(exists, Equals, false)
}
Loading