Skip to content

Commit d19a365

Browse files
committed
chore: add shared helpers for the integration:ng test suites
Shared device onboarding (auth requests signed with RSA, ECDSA P-224/256/384/521 and Ed25519 keys), artifact building, context-aware polling and raw-request helpers in tests/common, with the existing suites refactored onto them. Groundwork for porting the python integration tests suite by suite; the python tests keep running in parallel until the port is complete. Signed-off-by: Patryk Targowicz <patryk.targowicz@northern.tech>
1 parent 7ecd44a commit d19a365

10 files changed

Lines changed: 813 additions & 286 deletions

File tree

backend/tests/runner/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ require (
195195
golang.org/x/crypto v0.54.0 // indirect
196196
golang.org/x/net v0.56.0 // indirect
197197
golang.org/x/oauth2 v0.36.0 // indirect
198-
golang.org/x/sync v0.22.0 // indirect
198+
golang.org/x/sync v0.22.0
199199
golang.org/x/sys v0.47.0 // indirect
200200
golang.org/x/term v0.45.0 // indirect
201201
golang.org/x/text v0.40.0 // indirect
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
//nolint:all // This is all test code
2+
package common
3+
4+
import (
5+
"crypto/rand"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path"
10+
11+
"github.com/mendersoftware/mender-artifact/artifact"
12+
"github.com/mendersoftware/mender-artifact/awriter"
13+
"github.com/mendersoftware/mender-artifact/handlers"
14+
)
15+
16+
// ArtifactOption modifies the WriteArtifactArgs an artifact is built from.
17+
type ArtifactOption func(args *awriter.WriteArtifactArgs) error
18+
19+
func WithCompatibleDevices(compatibleDevices []string) ArtifactOption {
20+
return func(args *awriter.WriteArtifactArgs) error {
21+
args.Depends.CompatibleDevices = compatibleDevices
22+
return nil
23+
}
24+
}
25+
26+
func WithUpdates(updates []handlers.Composer) ArtifactOption {
27+
return func(args *awriter.WriteArtifactArgs) error {
28+
args.Updates.Updates = updates
29+
return nil
30+
}
31+
}
32+
33+
func WithModuleImage(module *handlers.ModuleImage) ArtifactOption {
34+
return func(args *awriter.WriteArtifactArgs) error {
35+
args.TypeInfoV3.Type = module.GetUpdateType()
36+
args.Updates.Updates = []handlers.Composer{module}
37+
return nil
38+
}
39+
}
40+
41+
// WithDependsProvides sets the type-info depends/provides maps, used by
42+
// the artifact selection semantics tests.
43+
func WithDependsProvides(depends, provides map[string]string) ArtifactOption {
44+
return func(args *awriter.WriteArtifactArgs) error {
45+
if depends != nil {
46+
deps := make(artifact.TypeInfoDepends, len(depends))
47+
for k, v := range depends {
48+
deps[k] = v
49+
}
50+
args.TypeInfoV3.ArtifactDepends = deps
51+
}
52+
if provides != nil {
53+
provs := make(artifact.TypeInfoProvides, len(provides))
54+
for k, v := range provides {
55+
provs[k] = v
56+
}
57+
args.TypeInfoV3.ArtifactProvides = provs
58+
}
59+
return nil
60+
}
61+
}
62+
63+
// WithPayloadFile attaches a randomly-generated update payload of the given
64+
// size (in bytes) to the artifact's module image, so the resulting artifact
65+
// file has a controllable, predictable size. This is needed by tests that
66+
// exercise deployments' "select the smallest matching artifact" tie-break
67+
// logic. Must be applied after the option that sets the module image (or
68+
// rely on the default one CreateArtifact installs).
69+
func WithPayloadFile(t interface{ TempDir() string }, size int) ArtifactOption {
70+
return func(args *awriter.WriteArtifactArgs) error {
71+
f, err := os.CreateTemp(t.TempDir(), "mender-artifact-payload-*")
72+
if err != nil {
73+
return fmt.Errorf("WithPayloadFile: failed to create payload file: %w", err)
74+
}
75+
defer f.Close()
76+
77+
data := make([]byte, size)
78+
if _, err := rand.Read(data); err != nil {
79+
return fmt.Errorf("WithPayloadFile: failed to generate random payload: %w", err)
80+
}
81+
if _, err := f.Write(data); err != nil {
82+
return fmt.Errorf("WithPayloadFile: failed to write payload file: %w", err)
83+
}
84+
85+
if len(args.Updates.Updates) == 0 {
86+
return fmt.Errorf("WithPayloadFile: no updates to attach a payload to")
87+
}
88+
mi, ok := args.Updates.Updates[len(args.Updates.Updates)-1].(*handlers.ModuleImage)
89+
if !ok {
90+
return fmt.Errorf("WithPayloadFile: last update is not a *handlers.ModuleImage")
91+
}
92+
if err := mi.SetUpdateFiles([]*handlers.DataFile{{Name: f.Name()}}); err != nil {
93+
return fmt.Errorf("WithPayloadFile: failed to set update files: %w", err)
94+
}
95+
return nil
96+
}
97+
}
98+
99+
// CreateArtifact writes a minimal module-image mender artifact to a temp
100+
// file and returns it seeked to the start, ready for upload.
101+
func CreateArtifact(
102+
name string,
103+
t interface{ TempDir() string },
104+
artifactArgsOpts ...ArtifactOption,
105+
) (*os.File, error) {
106+
107+
artifactDst := path.Join(t.TempDir(), fmt.Sprintf("%s.mender", name))
108+
file, err := os.Create(artifactDst)
109+
if err != nil {
110+
return nil, fmt.Errorf("failed to create %s: %w", artifactDst, err)
111+
}
112+
113+
w := awriter.NewWriter(file, artifact.NewCompressorGzip())
114+
i := handlers.NewModuleImage("foo")
115+
116+
args := &awriter.WriteArtifactArgs{
117+
Format: "mender",
118+
Version: 3,
119+
Name: name,
120+
Provides: &artifact.ArtifactProvides{
121+
ArtifactName: name,
122+
},
123+
Depends: &artifact.ArtifactDepends{
124+
CompatibleDevices: []string{"foo"},
125+
},
126+
TypeInfoV3: &artifact.TypeInfoV3{
127+
Type: i.GetUpdateType(),
128+
ArtifactProvides: artifact.TypeInfoProvides{"foo": "bar"},
129+
ArtifactDepends: artifact.TypeInfoDepends{"foo": "bar"},
130+
},
131+
Updates: &awriter.Updates{
132+
Updates: []handlers.Composer{i},
133+
},
134+
}
135+
136+
for _, opt := range artifactArgsOpts {
137+
if err := opt(args); err != nil {
138+
return nil, fmt.Errorf("failed to apply artifact option: %w", err)
139+
}
140+
}
141+
142+
err = w.WriteArtifact(args)
143+
if err != nil {
144+
return nil, fmt.Errorf("failed to write module-image artifact: %w", err)
145+
}
146+
147+
// Seek to the start of the file so the caller can read it
148+
_, err = file.Seek(0, io.SeekStart)
149+
if err != nil {
150+
return nil, fmt.Errorf("failed to prepare %s for reading: %w", artifactDst, err)
151+
}
152+
153+
return file, nil
154+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
//nolint:all // This is all test code
2+
package common
3+
4+
import (
5+
"crypto"
6+
"crypto/ecdsa"
7+
"crypto/ed25519"
8+
"crypto/elliptic"
9+
"crypto/rand"
10+
"crypto/rsa"
11+
"crypto/sha256"
12+
"crypto/x509"
13+
"encoding/base64"
14+
"encoding/pem"
15+
"fmt"
16+
"net"
17+
)
18+
19+
// generateDeviceKeys generates the RSA key-pair a test device
20+
// authenticates with.
21+
func generateDeviceKeys() (*rsa.PrivateKey, *rsa.PublicKey, error) {
22+
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
23+
if err != nil {
24+
return nil, nil, err
25+
}
26+
return privateKey, &privateKey.PublicKey, nil
27+
}
28+
29+
// signData signs data with SHA256+PKCS1v15 and returns the base64-encoded
30+
// signature, the format deviceauth expects in the X-MEN-Signature header.
31+
func signData(privateKey *rsa.PrivateKey, data []byte) (string, error) {
32+
hash := sha256.Sum256(data)
33+
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:])
34+
if err != nil {
35+
return "", err
36+
}
37+
return base64.StdEncoding.EncodeToString(signature), nil
38+
}
39+
40+
// ExportPublicKeyPEM renders a public key (RSA, ECDSA or Ed25519) as the
41+
// PKIX PEM string used in device auth requests. Panics if the key cannot be
42+
// marshaled.
43+
func ExportPublicKeyPEM(pubkey any) string {
44+
pubASN1, err := x509.MarshalPKIXPublicKey(pubkey)
45+
if err != nil {
46+
panic(fmt.Errorf("failed to marshal public key: %w", err))
47+
}
48+
pubBytes := pem.EncodeToMemory(&pem.Block{
49+
Type: "PUBLIC KEY",
50+
Bytes: pubASN1,
51+
})
52+
return string(pubBytes)
53+
}
54+
55+
// EC curve identifiers mirroring the Python test suite's EC_CURVE_* constants
56+
// (testutils/util/crypto.py).
57+
var (
58+
ECCurveP224 = elliptic.P224()
59+
ECCurveP256 = elliptic.P256()
60+
ECCurveP384 = elliptic.P384()
61+
ECCurveP521 = elliptic.P521()
62+
)
63+
64+
// generateECDeviceKeys generates an ECDSA key-pair on the given curve.
65+
func generateECDeviceKeys(curve elliptic.Curve) (*ecdsa.PrivateKey, *ecdsa.PublicKey, error) {
66+
privateKey, err := ecdsa.GenerateKey(curve, rand.Reader)
67+
if err != nil {
68+
return nil, nil, err
69+
}
70+
return privateKey, &privateKey.PublicKey, nil
71+
}
72+
73+
// generateEd25519DeviceKeys generates an Ed25519 key-pair.
74+
func generateEd25519DeviceKeys() (ed25519.PrivateKey, ed25519.PublicKey, error) {
75+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
76+
if err != nil {
77+
return nil, nil, err
78+
}
79+
return priv, pub, nil
80+
}
81+
82+
// signDataEC signs data with SHA256+ECDSA and returns the base64-encoded
83+
// ASN.1/DER signature, the format deviceauth expects in the X-MEN-Signature
84+
// header.
85+
func signDataEC(privateKey *ecdsa.PrivateKey, data []byte) (string, error) {
86+
hash := sha256.Sum256(data)
87+
signature, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:])
88+
if err != nil {
89+
return "", err
90+
}
91+
return base64.StdEncoding.EncodeToString(signature), nil
92+
}
93+
94+
// signDataEd25519 signs the raw (unhashed) data with Ed25519, per the Mender
95+
// device auth signing convention, and returns the base64-encoded signature.
96+
func signDataEd25519(privateKey ed25519.PrivateKey, data []byte) (string, error) {
97+
return base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, data)), nil
98+
}
99+
100+
// Key kinds exercised by the device auth test suite, mirroring the key types
101+
// generated by testutils/util/crypto.py.
102+
const (
103+
KeyKindRSA = "rsa"
104+
KeyKindECP224 = "ec-p224"
105+
KeyKindECP256 = "ec-p256"
106+
KeyKindECP384 = "ec-p384"
107+
KeyKindECP521 = "ec-p521"
108+
KeyKindEd25519 = "ed25519"
109+
)
110+
111+
// KeyKinds lists all key kinds exercised by the device auth test suite.
112+
var KeyKinds = []string{
113+
KeyKindRSA,
114+
KeyKindECP224,
115+
KeyKindECP256,
116+
KeyKindECP384,
117+
KeyKindECP521,
118+
KeyKindEd25519,
119+
}
120+
121+
// KeyPair is a signing key-pair for a test device, abstracting over the key
122+
// types deviceauth accepts (RSA, ECDSA P-224/256/384/521, Ed25519).
123+
type KeyPair struct {
124+
Kind string
125+
Private crypto.Signer
126+
Public any
127+
}
128+
129+
// NewKeyPair generates a key-pair of the given kind (one of the KeyKind*
130+
// constants).
131+
func NewKeyPair(kind string) (*KeyPair, error) {
132+
switch kind {
133+
case KeyKindRSA:
134+
priv, pub, err := generateDeviceKeys()
135+
if err != nil {
136+
return nil, err
137+
}
138+
return &KeyPair{Kind: kind, Private: priv, Public: pub}, nil
139+
case KeyKindECP224:
140+
return newECKeyPair(kind, ECCurveP224)
141+
case KeyKindECP256:
142+
return newECKeyPair(kind, ECCurveP256)
143+
case KeyKindECP384:
144+
return newECKeyPair(kind, ECCurveP384)
145+
case KeyKindECP521:
146+
return newECKeyPair(kind, ECCurveP521)
147+
case KeyKindEd25519:
148+
priv, pub, err := generateEd25519DeviceKeys()
149+
if err != nil {
150+
return nil, err
151+
}
152+
return &KeyPair{Kind: kind, Private: priv, Public: pub}, nil
153+
default:
154+
return nil, fmt.Errorf("unsupported key kind %q", kind)
155+
}
156+
}
157+
158+
func newECKeyPair(kind string, curve elliptic.Curve) (*KeyPair, error) {
159+
priv, pub, err := generateECDeviceKeys(curve)
160+
if err != nil {
161+
return nil, err
162+
}
163+
return &KeyPair{Kind: kind, Private: priv, Public: pub}, nil
164+
}
165+
166+
// PublicKeyPEM renders the public key as PKIX PEM.
167+
func (k *KeyPair) PublicKeyPEM() string {
168+
return ExportPublicKeyPEM(k.Public)
169+
}
170+
171+
// Sign signs data with the scheme appropriate for the key kind and returns
172+
// the base64-encoded signature for the X-MEN-Signature header.
173+
func (k *KeyPair) Sign(data []byte) (string, error) {
174+
return SignAuthRequest(k.Private, data)
175+
}
176+
177+
// SignAuthRequest signs data with the scheme appropriate for the concrete
178+
// type of privateKey (RSA, ECDSA or Ed25519) and returns the base64-encoded
179+
// signature for the X-MEN-Signature header.
180+
func SignAuthRequest(privateKey crypto.Signer, data []byte) (string, error) {
181+
switch priv := privateKey.(type) {
182+
case *rsa.PrivateKey:
183+
return signData(priv, data)
184+
case *ecdsa.PrivateKey:
185+
return signDataEC(priv, data)
186+
case ed25519.PrivateKey:
187+
return signDataEd25519(priv, data)
188+
default:
189+
return "", fmt.Errorf("unsupported private key type %T", priv)
190+
}
191+
}
192+
193+
// RandomMAC returns a random unicast, locally administered MAC address for
194+
// use as device identity data.
195+
func RandomMAC() (net.HardwareAddr, error) {
196+
mac := make([]byte, 6)
197+
_, err := rand.Read(mac)
198+
if err != nil {
199+
return nil, err
200+
}
201+
202+
mac[0] &= 0xfe // Set to Unicast
203+
mac[0] |= 0x02 // Set to Locally Administered
204+
205+
return mac, nil
206+
}

0 commit comments

Comments
 (0)