From 0159472ab0b28c59d92a3e481bc99414f7a6aafe Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Mon, 6 Jul 2026 09:38:00 +0100 Subject: [PATCH 1/2] signer: support a second signing key, staged but not enabled Add a selectable signing key so the store can be switched to the new rotation key without a code change: - config: signing_key_active (default: old) + signing_key_new_base64 - crypto.NewSigner takes the active selection and the new key material; with 'old' (the default) it keeps signing with the existing key, so behaviour is unchanged - cmd/store: only decode the new key when active is 'new' - the new private key is injected from the 'signing_key_new_base64' Drone secret via config/*/secret.yaml + deploy-prepare.sh; it never lives in source - new key is base64-encoded so it is a safe single-line secret value To flip later: set signing_key_active: new and redeploy. Pairs with the snapd change trusting the new key's public half. --- .drone.jsonnet | 2 ++ ci/deploy-prepare.sh | 5 +++++ cmd/store/main.go | 11 ++++++++++- config/prod/secret.yaml | 2 ++ config/test/secret.yaml | 2 ++ config/uat/secret.yaml | 2 ++ crypto/signer.go | 13 +++++++++++-- crypto/signer_test.go | 29 +++++++++++++++++++++++++++-- util/config.go | 16 +++++++++------- 9 files changed, 70 insertions(+), 12 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index d8d1c1a..cd86aff 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -188,6 +188,7 @@ local build(arch) = { SYNCLOUD_TOKEN: { from_secret: "uat_token" }, AWS_ACCESS_KEY_ID: { from_secret: "AWS_ACCESS_KEY_ID" }, AWS_SECRET_ACCESS_KEY: { from_secret: "AWS_SECRET_ACCESS_KEY" }, + SIGNING_KEY_NEW_BASE64: { from_secret: "signing_key_new_base64" }, }, commands: [ "./ci/deploy-prepare.sh uat", @@ -208,6 +209,7 @@ local build(arch) = { SYNCLOUD_TOKEN: { from_secret: "prod_token" }, AWS_ACCESS_KEY_ID: { from_secret: "AWS_ACCESS_KEY_ID" }, AWS_SECRET_ACCESS_KEY: { from_secret: "AWS_SECRET_ACCESS_KEY" }, + SIGNING_KEY_NEW_BASE64: { from_secret: "signing_key_new_base64" }, }, commands: [ "./ci/deploy-prepare.sh prod", diff --git a/ci/deploy-prepare.sh b/ci/deploy-prepare.sh index f3d74e9..8a4c5e7 100755 --- a/ci/deploy-prepare.sh +++ b/ci/deploy-prepare.sh @@ -41,6 +41,11 @@ set +x sed -i "s|@aws_secret_access_key@|${AWS_SECRET_ACCESS_KEY}|g" "$STAGE/secret.yaml" set -x +# optional: only consumed once signing_key_active is switched to "new"; empty otherwise +set +x +sed -i "s|@signing_key_new_base64@|${SIGNING_KEY_NEW_BASE64}|g" "$STAGE/secret.yaml" +set -x + $SSH $REMOTE "sudo -n rm -rf /tmp/syncloud-store && mkdir -p /tmp/syncloud-store/config/${ENV}" $SCP deploy "${REMOTE}:/tmp/syncloud-store/" $SCP "$STAGE/." "${REMOTE}:/tmp/syncloud-store/config/${ENV}/" diff --git a/cmd/store/main.go b/cmd/store/main.go index 8d61c1b..e689a8e 100644 --- a/cmd/store/main.go +++ b/cmd/store/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "fmt" "io/fs" "net/url" @@ -70,7 +71,15 @@ func start(listenAddress, configPath, metricsAddr string) error { return err } cache := storage.New(client, mp, config.BaseUrl, logger) - signer := crypto.NewSigner(logger) + newSigningKey := "" + if config.SigningKeyActive == "new" { + decoded, err := base64.StdEncoding.DecodeString(config.SigningKeyNewBase64) + if err != nil { + return fmt.Errorf("cannot decode signing_key_new_base64: %v", err) + } + newSigningKey = string(decoded) + } + signer := crypto.NewSigner(logger, config.SigningKeyActive, newSigningKey) popularity := storage.NewPopularity() snapdMetrics := api.NewSnapdMetrics() ui := api.NewWeb(webFS, cache, popularity) diff --git a/config/prod/secret.yaml b/config/prod/secret.yaml index 4ec5ce7..74375ff 100644 --- a/config/prod/secret.yaml +++ b/config/prod/secret.yaml @@ -5,3 +5,5 @@ aws_access_key_id: "@aws_access_key_id@" aws_secret_access_key: "@aws_secret_access_key@" aws_s3_endpoint: https://s3.us-west-2.amazonaws.com aws_region: us-west-2 +signing_key_active: old +signing_key_new_base64: "@signing_key_new_base64@" diff --git a/config/test/secret.yaml b/config/test/secret.yaml index 67a2038..a1dd5fe 100644 --- a/config/test/secret.yaml +++ b/config/test/secret.yaml @@ -5,3 +5,5 @@ aws_access_key_id: "@aws_access_key_id@" aws_secret_access_key: "@aws_secret_access_key@" aws_s3_endpoint: http://apps.s3 aws_region: garage +signing_key_active: old +signing_key_new_base64: "@signing_key_new_base64@" diff --git a/config/uat/secret.yaml b/config/uat/secret.yaml index 4ec5ce7..74375ff 100644 --- a/config/uat/secret.yaml +++ b/config/uat/secret.yaml @@ -5,3 +5,5 @@ aws_access_key_id: "@aws_access_key_id@" aws_secret_access_key: "@aws_secret_access_key@" aws_s3_endpoint: https://s3.us-west-2.amazonaws.com aws_region: us-west-2 +signing_key_active: old +signing_key_new_base64: "@signing_key_new_base64@" diff --git a/crypto/signer.go b/crypto/signer.go index 67496a3..192a83e 100644 --- a/crypto/signer.go +++ b/crypto/signer.go @@ -74,8 +74,17 @@ type PrivateKeySigner struct { logger *zap.Logger } -func NewSigner(logger *zap.Logger) *PrivateKeySigner { - privateKey, _ := ReadPrivateKey(syncloudPrivKey) +func NewSigner(logger *zap.Logger, activeKey string, newKeyArmored string) *PrivateKeySigner { + keyText := syncloudPrivKey + if activeKey == "new" { + if newKeyArmored == "" { + logger.Warn("signing_key_active is 'new' but no new key was provided, falling back to the old key") + } else { + logger.Info("signing assertions with the new signing key") + keyText = newKeyArmored + } + } + privateKey, _ := ReadPrivateKey(keyText) return &PrivateKeySigner{ privateKey: privateKey, logger: logger, diff --git a/crypto/signer_test.go b/crypto/signer_test.go index 3034f93..2df1317 100644 --- a/crypto/signer_test.go +++ b/crypto/signer_test.go @@ -1,15 +1,40 @@ package crypto import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "testing" + "time" + "github.com/stretchr/testify/assert" "github.com/syncloud/store/log" - "testing" + "golang.org/x/crypto/openpgp/armor" + "golang.org/x/crypto/openpgp/packet" ) func TestPrivateKeySigner_AccountKey(t *testing.T) { - signer := NewSigner(log.Default()) + signer := NewSigner(log.Default(), "old", "") content, err := signer.AccountKey("test") assert.NoError(t, err) assert.Contains(t, content, "public-key-sha3-384: hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR") } + +func TestPrivateKeySigner_NewKeySelected(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + var buf bytes.Buffer + arm, err := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil) + assert.NoError(t, err) + assert.NoError(t, packet.NewRSAPrivateKey(time.Date(2016, time.January, 1, 0, 0, 0, 0, time.UTC), rsaKey).Serialize(arm)) + assert.NoError(t, arm.Close()) + + priv := RSAPrivateKey(rsaKey) + want := priv.PublicKey().ID() + signer := NewSigner(log.Default(), "new", buf.String()) + content, err := signer.AccountKey("test") + assert.NoError(t, err) + assert.Contains(t, content, "public-key-sha3-384: "+want) + assert.NotContains(t, content, "hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR") +} diff --git a/util/config.go b/util/config.go index 626b1c6..fb90f90 100644 --- a/util/config.go +++ b/util/config.go @@ -6,13 +6,15 @@ import ( ) type Config struct { - Token string `yaml:"token"` - BaseUrl string `yaml:"base_url"` - Bucket string `yaml:"bucket"` - AwsAccessKeyId string `yaml:"aws_access_key_id"` - AwsSecretAccessKey string `yaml:"aws_secret_access_key"` - AwsS3Endpoint string `yaml:"aws_s3_endpoint"` - AwsRegion string `yaml:"aws_region"` + Token string `yaml:"token"` + BaseUrl string `yaml:"base_url"` + Bucket string `yaml:"bucket"` + AwsAccessKeyId string `yaml:"aws_access_key_id"` + AwsSecretAccessKey string `yaml:"aws_secret_access_key"` + AwsS3Endpoint string `yaml:"aws_s3_endpoint"` + AwsRegion string `yaml:"aws_region"` + SigningKeyActive string `yaml:"signing_key_active"` + SigningKeyNewBase64 string `yaml:"signing_key_new_base64"` } func LoadConfig(path string) (Config, error) { From 32e1625ea8ae154782e10bf0878008bd039008ce Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Mon, 6 Jul 2026 23:33:49 +0100 Subject: [PATCH 2/2] signer: emit canonical snapd assertions so signatures verify Replace the hand-assembled assertion text (wrong header set and framing, which is why the snapd fork had to disable signature verification) with snapd's canonical serialization: type, authority-id, primary keys, remaining headers lexicographically, body-length, sign-key-sha3-384, then body. Add the per-type required headers snapd validates (account-key: name/since; snap-declaration: publisher-id/timestamp; snap-revision: developer-id/timestamp). Use the request key as the snap-revision snap-sha3-384 primary key; the model's Sha384 json tag is 'snap-sha3-385' (a typo) so it parsed empty and was only ever masked by the disabled verification. Verified: every produced assertion (account-key, snap-declaration, snap-revision) passes snapd's real asserts.Decode plus independent raw openpgp signature verification, for BOTH the old and the new key. This is the prerequisite for removing the Syncloud signature-check hack. --- crypto/signer.go | 116 +++++++++++++++++++++++------------------- crypto/signer_test.go | 60 ++++++++++++++++++++++ 2 files changed, 125 insertions(+), 51 deletions(-) diff --git a/crypto/signer.go b/crypto/signer.go index 192a83e..c3ccc2a 100644 --- a/crypto/signer.go +++ b/crypto/signer.go @@ -1,12 +1,13 @@ package crypto import ( + "bytes" "encoding/json" - "fmt" + "sort" + "strconv" + "github.com/syncloud/store/model" "go.uber.org/zap" - "strconv" - "time" ) const ( @@ -91,30 +92,38 @@ func NewSigner(logger *zap.Logger, activeKey string, newKeyArmored string) *Priv } } +const assertionTimestamp = "2016-04-01T00:00:00Z" + +type assertionHeader struct { + key string + value string +} + func (s *PrivateKeySigner) SnapRevision(key, revision string) (string, error) { var snapRevision model.SnapRevision err := json.Unmarshal([]byte(revision), &snapRevision) if err != nil { return "", err } - headers := "" + - "snap-revision: " + snapRevision.Revision + "\n" + - "snap-id: " + snapRevision.Id + "\n" + - "snap-size: " + snapRevision.Size + "\n" + - "snap-sha3-384: " + snapRevision.Sha384 + "\n" - - return s.sign("snap-revision", key, headers, "") + return s.assemble("snap-revision", + []assertionHeader{{"snap-sha3-384", key}}, + map[string]string{ + "snap-id": snapRevision.Id, + "snap-revision": snapRevision.Revision, + "snap-size": snapRevision.Size, + "developer-id": "syncloud", + "timestamp": assertionTimestamp, + }, "") } func (s *PrivateKeySigner) SnapDeclaration(series, snapId string) (string, error) { - name := model.SnapId(snapId).Name() - headers := "" + - "series: " + series + "\n" + - "snap-id: " + snapId + "\n" + - "snap-name: " + name + "\n" - - return s.sign("snap-declaration", fmt.Sprintf("%s/%s", series, snapId), headers, "") - + return s.assemble("snap-declaration", + []assertionHeader{{"series", series}, {"snap-id", snapId}}, + map[string]string{ + "snap-name": model.SnapId(snapId).Name(), + "publisher-id": "syncloud", + "timestamp": assertionTimestamp, + }, "") } func (s *PrivateKeySigner) AccountKey(key string) (string, error) { @@ -122,42 +131,47 @@ func (s *PrivateKeySigner) AccountKey(key string) (string, error) { if err != nil { return "", err } - - return s.sign("account-key", key, "", string(publicKeyEnc)) + return s.assemble("account-key", + []assertionHeader{{"public-key-sha3-384", s.privateKey.PublicKey().ID()}}, + map[string]string{ + "account-id": "syncloud", + "name": "root", + "since": assertionTimestamp, + }, string(publicKeyEnc)) } -func (s *PrivateKeySigner) sign(assertType string, primaryKey string, headers string, body string) (string, error) { - publicKeyId := s.privateKey.PublicKey().ID() - - s.logger.Info("public key", zap.String("id", publicKeyId)) - - content := "type: " + assertType + "\n" + - "authority-id: syncloud\n" + - "primary-key: " + primaryKey + "\n" + - "publisher-id: syncloud\n" + - "developer-id: syncloud\n" + - "account-id: syncloud\n" + - // "display-name: syncloud\n" + - "revision: 1\n" + - "sign-key-sha3-384: " + publicKeyId + "\n" + - "sha3-384: " + publicKeyId + "\n" + - "public-key-sha3-384: " + publicKeyId + "\n" + - "timestamp: " + time.Now().Format(time.RFC3339) + "\n" + - "since: " + time.Now().Format(time.RFC3339) + "\n" + - headers + - "validation: certified\n" + - "body-length: " + strconv.Itoa(len(body)) + "\n\n" + - body + - "\n\n" - - fmt.Println("content:") - fmt.Println(content) - signature, err := s.privateKey.SignContent([]byte(content)) +// assemble builds an assertion in snapd's canonical serialization and +// self-signs it, so snapd verifies it with real signature checking. The header +// order must match asserts.assembleAndSign exactly: type, authority-id, primary +// keys, remaining headers in lexicographic order, body-length, then +// sign-key-sha3-384, followed by the body. +func (s *PrivateKeySigner) assemble(assertType string, primary []assertionHeader, other map[string]string, body string) (string, error) { + keyID := s.privateKey.PublicKey().ID() + var buf bytes.Buffer + buf.WriteString("type: " + assertType) + buf.WriteString("\nauthority-id: syncloud") + for _, h := range primary { + buf.WriteString("\n" + h.key + ": " + h.value) + } + keys := make([]string, 0, len(other)) + for k := range other { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + buf.WriteString("\n" + k + ": " + other[k]) + } + if body != "" { + buf.WriteString("\nbody-length: " + strconv.Itoa(len(body))) + } + buf.WriteString("\nsign-key-sha3-384: " + keyID) + if body != "" { + buf.WriteString("\n\n" + body) + } + content := buf.Bytes() + signature, err := s.privateKey.SignContent(content) if err != nil { return "", err } - fmt.Println("signature:") - fmt.Println(string(signature)) - assertionText := content + string(signature) + "\n" - return assertionText, nil + return string(content) + "\n\n" + string(signature) + "\n", nil } diff --git a/crypto/signer_test.go b/crypto/signer_test.go index 2df1317..2cb3ffa 100644 --- a/crypto/signer_test.go +++ b/crypto/signer_test.go @@ -4,15 +4,75 @@ import ( "bytes" "crypto/rand" "crypto/rsa" + "encoding/base64" + "strings" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/syncloud/store/log" "golang.org/x/crypto/openpgp/armor" "golang.org/x/crypto/openpgp/packet" ) +var fixedTs = time.Date(2016, time.January, 1, 0, 0, 0, 0, time.UTC) + +func armorKey(t *testing.T, rsaKey *rsa.PrivateKey) string { + var buf bytes.Buffer + arm, err := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil) + require.NoError(t, err) + require.NoError(t, packet.NewRSAPrivateKey(fixedTs, rsaKey).Serialize(arm)) + require.NoError(t, arm.Close()) + return buf.String() +} + +// honestVerify checks the assertion's signature the way real snapd does +// (raw openpgp), independent of the fork's disabled SignatureCheck. The signed +// content is everything before the final "\n\n"; the signature follows it. +func honestVerify(t *testing.T, assertionText string, pub *rsa.PublicKey) { + idx := strings.LastIndex(assertionText, "\n\n") + require.GreaterOrEqual(t, idx, 0) + content := assertionText[:idx] + sigB64 := strings.ReplaceAll(strings.TrimRight(assertionText[idx+2:], "\n"), "\n", "") + raw, err := base64.StdEncoding.DecodeString(sigB64) + require.NoError(t, err) + require.Equal(t, byte(0x01), raw[0]) + pkt, err := packet.Read(bytes.NewReader(raw[1:])) + require.NoError(t, err) + sig := pkt.(*packet.Signature) + pubPkt := packet.NewRSAPublicKey(fixedTs, pub) + h := sig.Hash.New() + h.Write([]byte(content)) + assert.NoError(t, pubPkt.VerifySignature(h, sig)) +} + +func assertSignerVerifies(t *testing.T, signer *PrivateKeySigner, pub *rsa.PublicKey) { + ak, err := signer.AccountKey("test") + require.NoError(t, err) + honestVerify(t, ak, pub) + + sd, err := signer.SnapDeclaration("16", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + require.NoError(t, err) + honestVerify(t, sd, pub) + + rev := `{"snap-revision":"7","snap-id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","snap-size":"1024"}` + sr, err := signer.SnapRevision("NX7IJUakITdsfLFyqWHkdS-YTp77S-v2-vckEZ1cVBwElYYld8_34m6Ni9JZ1aQL", rev) + require.NoError(t, err) + honestVerify(t, sr, pub) +} + +func TestSigner_AssertionsVerifyHonestly_OldKey(t *testing.T) { + _, oldRsa := ReadPrivateKey(syncloudPrivKey) + assertSignerVerifies(t, NewSigner(log.Default(), "old", ""), &oldRsa.PublicKey) +} + +func TestSigner_AssertionsVerifyHonestly_NewKey(t *testing.T) { + newRsa, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + assertSignerVerifies(t, NewSigner(log.Default(), "new", armorKey(t, newRsa)), &newRsa.PublicKey) +} + func TestPrivateKeySigner_AccountKey(t *testing.T) { signer := NewSigner(log.Default(), "old", "") content, err := signer.AccountKey("test")