Implement offline license verification (T-502)
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/internal/canonicaljson"
|
||||
)
|
||||
|
||||
type licenseVectorCorpus struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
PublicKeyBase64 string `json:"public_key_base64"`
|
||||
Vectors []licenseVector `json:"vectors"`
|
||||
}
|
||||
|
||||
type licenseVector struct {
|
||||
Name string `json:"name"`
|
||||
Document string `json:"document"`
|
||||
SignedPayloadBase64 string `json:"signed_payload_base64"`
|
||||
Signature string `json:"signature"`
|
||||
ExpectedMachineHash string `json:"expected_machine_hash"`
|
||||
ExpectedProducts []string `json:"expected_products"`
|
||||
WantError string `json:"want_error"`
|
||||
}
|
||||
|
||||
func TestVerifierStaticCorpus(t *testing.T) {
|
||||
corpus := readLicenseVectorCorpus(t)
|
||||
publicKey := corpusPublicKey(t, corpus)
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
valid := corpusValidVector(t, corpus)
|
||||
|
||||
for _, vector := range corpus.Vectors {
|
||||
vector := vector
|
||||
t.Run(vector.Name, func(t *testing.T) {
|
||||
license, err := verifier.Verify([]byte(vector.Document), valid.ExpectedMachineHash)
|
||||
if vector.WantError != "" {
|
||||
want := licenseVectorError(t, vector.WantError)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("Verify() error = %v, want %v", err, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Verify() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(license.Products, vector.ExpectedProducts) {
|
||||
t.Fatalf("Products = %q, want %q", license.Products, vector.ExpectedProducts)
|
||||
}
|
||||
if !license.AuthorizesProduct("product-json-parser") || license.AuthorizesProduct("other-product") {
|
||||
t.Fatal("AuthorizesProduct() did not preserve the verified product set")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
expectedPayload, err := base64.StdEncoding.DecodeString(valid.SignedPayloadBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode signed payload: %v", err)
|
||||
}
|
||||
payload := canonicalLicensePayload(t, []byte(valid.Document))
|
||||
if !bytes.Equal(payload, expectedPayload) {
|
||||
t.Fatalf("canonical signing bytes = %q, want static corpus %q", payload, expectedPayload)
|
||||
}
|
||||
signature, err := base64.StdEncoding.DecodeString(valid.Signature)
|
||||
if err != nil {
|
||||
t.Fatalf("decode signature: %v", err)
|
||||
}
|
||||
if !ed25519.Verify(publicKey, expectedPayload, signature) {
|
||||
t.Fatal("static public key does not verify static signing bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifierCanonicalWhitespaceAndKeyOrder(t *testing.T) {
|
||||
corpus := readLicenseVectorCorpus(t)
|
||||
publicKey := corpusPublicKey(t, corpus)
|
||||
valid := corpusValidVector(t, corpus)
|
||||
var wire licenseWire
|
||||
if err := json.Unmarshal([]byte(valid.Document), &wire); err != nil {
|
||||
t.Fatalf("decode static document: %v", err)
|
||||
}
|
||||
equivalent := ` { "update_policy" : "` + wire.UpdatePolicy + `", "signature" : "` + wire.Signature + `", "schema_version" : 1, "rebind_policy" : "` + wire.RebindPolicy + `", "products" : [ "` + wire.Products[0] + `", "` + wire.Products[1] + `" ], "perpetual" : true, "machine_hash" : "` + wire.MachineHash + `", "license_id" : "` + wire.LicenseID + `", "issued_at" : "` + wire.IssuedAt + `" } `
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
license, err := verifier.Verify([]byte(equivalent), valid.ExpectedMachineHash)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify(equivalent) error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(license.Products, valid.ExpectedProducts) {
|
||||
t.Fatalf("equivalent products = %q", license.Products)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifierFailsClosed(t *testing.T) {
|
||||
corpus := readLicenseVectorCorpus(t)
|
||||
publicKey := corpusPublicKey(t, corpus)
|
||||
valid := corpusValidVector(t, corpus)
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
document []byte
|
||||
hash string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "wrong machine", document: []byte(valid.Document), hash: strings.Repeat("f", 64), wantErr: ErrMachineMismatch},
|
||||
{name: "invalid expected hash", document: []byte(valid.Document), hash: strings.ToUpper(valid.ExpectedMachineHash), wantErr: ErrMachineHashInvalid},
|
||||
{name: "tampered", document: bytes.Replace([]byte(valid.Document), []byte("toolkit"), []byte("toolkitz"), 1), hash: valid.ExpectedMachineHash, wantErr: ErrSignatureInvalid},
|
||||
{name: "duplicate field", document: []byte(`{"schema_version":1,"schema_version":1,"signature":"x"}`), hash: valid.ExpectedMachineHash, wantErr: ErrDuplicateField},
|
||||
{name: "fractional number", document: []byte(`{"schema_version":1.5,"signature":"x"}`), hash: valid.ExpectedMachineHash, wantErr: ErrUnsupportedNumber},
|
||||
{name: "invalid signature encoding", document: []byte(strings.Replace(valid.Document, `=="`, `"`, 1)), hash: valid.ExpectedMachineHash, wantErr: ErrSignatureInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := verifier.Verify(test.document, test.hash)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Verify() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifierCopiesPublicKeyAndProducts(t *testing.T) {
|
||||
corpus := readLicenseVectorCorpus(t)
|
||||
publicKey := corpusPublicKey(t, corpus)
|
||||
valid := corpusValidVector(t, corpus)
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
publicKey[0] ^= 0xff
|
||||
first, err := verifier.Verify([]byte(valid.Document), valid.ExpectedMachineHash)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify(first) error = %v", err)
|
||||
}
|
||||
second, err := verifier.Verify([]byte(valid.Document), valid.ExpectedMachineHash)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify(second) error = %v", err)
|
||||
}
|
||||
first.Products[0] = "changed"
|
||||
if !second.AuthorizesProduct("product-json-parser") || second.AuthorizesProduct("changed") {
|
||||
t.Fatal("Verify() returned products sharing mutable state")
|
||||
}
|
||||
if _, err := NewVerifier(make([]byte, ed25519.PublicKeySize-1)); !errors.Is(err, ErrPublicKeyInvalid) {
|
||||
t.Fatalf("NewVerifier(short key) error = %v, want ErrPublicKeyInvalid", err)
|
||||
}
|
||||
var unconfigured Verifier
|
||||
if _, err := unconfigured.Verify([]byte(valid.Document), valid.ExpectedMachineHash); !errors.Is(err, ErrPublicKeyInvalid) {
|
||||
t.Fatalf("zero Verifier.Verify() error = %v, want ErrPublicKeyInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readLicenseVectorCorpus(t *testing.T) licenseVectorCorpus {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "testdata", "license", "license-v1-vectors.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read license vector corpus: %v", err)
|
||||
}
|
||||
var corpus licenseVectorCorpus
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&corpus); err != nil {
|
||||
t.Fatalf("decode license vector corpus: %v", err)
|
||||
}
|
||||
if corpus.SchemaVersion != 1 || len(corpus.Vectors) == 0 {
|
||||
t.Fatal("license vector corpus is incomplete")
|
||||
}
|
||||
return corpus
|
||||
}
|
||||
|
||||
func corpusPublicKey(t *testing.T, corpus licenseVectorCorpus) ed25519.PublicKey {
|
||||
t.Helper()
|
||||
publicKey, err := base64.StdEncoding.DecodeString(corpus.PublicKeyBase64)
|
||||
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
||||
t.Fatal("license vector corpus has an invalid public key")
|
||||
}
|
||||
return ed25519.PublicKey(publicKey)
|
||||
}
|
||||
|
||||
func corpusValidVector(t *testing.T, corpus licenseVectorCorpus) licenseVector {
|
||||
t.Helper()
|
||||
for _, vector := range corpus.Vectors {
|
||||
if vector.Name == "valid" {
|
||||
return vector
|
||||
}
|
||||
}
|
||||
t.Fatal("license vector corpus has no valid vector")
|
||||
return licenseVector{}
|
||||
}
|
||||
|
||||
func licenseVectorError(t *testing.T, value string) error {
|
||||
t.Helper()
|
||||
switch value {
|
||||
case "invalid_license":
|
||||
return ErrInvalidLicense
|
||||
default:
|
||||
t.Fatalf("unsupported license vector want_error %q", value)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalLicensePayload(t *testing.T, document []byte) []byte {
|
||||
t.Helper()
|
||||
value, err := canonicaljson.Parse(document)
|
||||
if err != nil {
|
||||
t.Fatalf("parse license document: %v", err)
|
||||
}
|
||||
root, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("license document root is not an object")
|
||||
}
|
||||
delete(root, "signature")
|
||||
payload, err := canonicaljson.Marshal(root)
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalize license document: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
Reference in New Issue
Block a user