safing-jess/filesig/json.go
2024-11-08 14:28:09 +01:00

279 lines
6.5 KiB
Go

package filesig
import (
"encoding/base64"
"errors"
"fmt"
"github.com/tidwall/gjson"
"github.com/tidwall/pretty"
"github.com/tidwall/sjson"
"golang.org/x/exp/slices"
"github.com/safing/jess"
"github.com/safing/jess/lhash"
"github.com/safing/structures/dsd"
)
// JSON file metadata keys.
const (
JSONKeyPrefix = "_jess-"
JSONChecksumKey = JSONKeyPrefix + "checksum"
JSONSignatureKey = JSONKeyPrefix + "signature"
)
// AddJSONChecksum adds a checksum to a text file.
func AddJSONChecksum(data []byte) ([]byte, error) {
// Extract content and metadata from json.
content, checksums, signatures, err := jsonSplit(data)
if err != nil {
return nil, err
}
// Calculate checksum.
h := lhash.BLAKE2b_256.Digest(content)
checksums = append(checksums, h.Base58())
// Sort and deduplicate checksums and sigs.
slices.Sort(checksums)
checksums = slices.Compact(checksums)
slices.Sort(signatures)
signatures = slices.Compact(signatures)
// Add metadata and return.
return jsonAddMeta(content, checksums, signatures)
}
// VerifyJSONChecksum checks a checksum in a text file.
func VerifyJSONChecksum(data []byte) error {
// Extract content and metadata from json.
content, checksums, _, err := jsonSplit(data)
if err != nil {
return err
}
// Verify all checksums.
var checksumsVerified int
for _, checksum := range checksums {
// Parse checksum.
h, err := lhash.FromBase58(checksum)
if err != nil {
return fmt.Errorf("%w: failed to parse labeled hash: %w", ErrChecksumFailed, err)
}
// Verify checksum.
if !h.Matches(content) {
return ErrChecksumFailed
}
checksumsVerified++
}
// Fail when no checksums were verified.
if checksumsVerified == 0 {
return ErrChecksumMissing
}
return nil
}
func AddJSONSignature(data []byte, envelope *jess.Envelope, trustStore jess.TrustStore) (signedData []byte, err error) {
// Create session.
session, err := envelope.Correspondence(trustStore)
if err != nil {
return nil, fmt.Errorf("invalid signing envelope: %w", err)
}
// Check if the envelope is suitable for signing.
if err := envelope.Suite().Provides.CheckComplianceTo(fileSigRequirements); err != nil {
return nil, fmt.Errorf("envelope not suitable for signing: %w", err)
}
// Extract content and metadata from json.
content, checksums, signatures, err := jsonSplit(data)
if err != nil {
return nil, fmt.Errorf("invalid json structure: %w", err)
}
// Sign data.
letter, err := session.Close(content)
if err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
// Serialize signature and add it.
letter.Data = nil
sig, err := letter.ToDSD(dsd.CBOR)
if err != nil {
return nil, fmt.Errorf("serialize sig: %w", err)
}
signatures = append(signatures, base64.RawURLEncoding.EncodeToString(sig))
// Sort and deduplicate checksums and sigs.
slices.Sort(checksums)
checksums = slices.Compact(checksums)
slices.Sort(signatures)
signatures = slices.Compact(signatures)
// Add metadata and return.
return jsonAddMeta(data, checksums, signatures)
}
func VerifyJSONSignature(data []byte, trustStore jess.TrustStore) (err error) {
// Extract content and metadata from json.
content, _, signatures, err := jsonSplit(data)
if err != nil {
return fmt.Errorf("invalid json structure: %w", err)
}
var signaturesVerified int
for i, sig := range signatures {
// Deserialize signature.
sigData, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil {
return fmt.Errorf("signature %d malformed: %w", i+1, err)
}
letter := &jess.Letter{}
_, err = dsd.Load(sigData, letter)
if err != nil {
return fmt.Errorf("signature %d malformed: %w", i+1, err)
}
// Verify signature.
letter.Data = content
err = letter.Verify(fileSigRequirements, trustStore)
if err != nil {
return fmt.Errorf("signature %d invalid: %w", i+1, err)
}
signaturesVerified++
}
// Fail when no signatures were verified.
if signaturesVerified == 0 {
return ErrSignatureMissing
}
return nil
}
func jsonSplit(data []byte) (
content []byte,
checksums []string,
signatures []string,
err error,
) {
// Check json.
if !gjson.ValidBytes(data) {
return nil, nil, nil, errors.New("invalid json")
}
content = data
// Get checksums.
result := gjson.GetBytes(content, JSONChecksumKey)
if result.Exists() {
if result.IsArray() {
array := result.Array()
checksums = make([]string, 0, len(array))
for _, result := range array {
if result.Type == gjson.String {
checksums = append(checksums, result.String())
}
}
} else if result.Type == gjson.String {
checksums = []string{result.String()}
}
// Delete key.
content, err = sjson.DeleteBytes(content, JSONChecksumKey)
if err != nil {
return nil, nil, nil, err
}
}
// Get signatures.
result = gjson.GetBytes(content, JSONSignatureKey)
if result.Exists() {
if result.IsArray() {
array := result.Array()
signatures = make([]string, 0, len(array))
for _, result := range array {
if result.Type == gjson.String {
signatures = append(signatures, result.String())
}
}
} else if result.Type == gjson.String {
signatures = []string{result.String()}
}
// Delete key.
content, err = sjson.DeleteBytes(content, JSONSignatureKey)
if err != nil {
return nil, nil, nil, err
}
}
// Format for reproducible checksums and signatures.
content = pretty.PrettyOptions(content, &pretty.Options{
Width: 200, // Must not change!
Prefix: "", // Must not change!
Indent: " ", // Must not change!
SortKeys: true, // Must not change!
})
return content, checksums, signatures, nil
}
func jsonAddMeta(data []byte, checksums, signatures []string) ([]byte, error) {
var (
err error
opts = &sjson.Options{
ReplaceInPlace: true,
}
)
// Add checksums.
switch len(checksums) {
case 0:
// Skip
case 1:
// Add single checksum.
data, err = sjson.SetBytesOptions(
data, JSONChecksumKey, checksums[0], opts,
)
default:
// Add multiple checksums.
data, err = sjson.SetBytesOptions(
data, JSONChecksumKey, checksums, opts,
)
}
if err != nil {
return nil, err
}
// Add signatures.
switch len(signatures) {
case 0:
// Skip
case 1:
// Add single signature.
data, err = sjson.SetBytesOptions(
data, JSONSignatureKey, signatures[0], opts,
)
default:
// Add multiple signatures.
data, err = sjson.SetBytesOptions(
data, JSONSignatureKey, signatures, opts,
)
}
if err != nil {
return nil, err
}
// Final pretty print.
data = pretty.PrettyOptions(data, &pretty.Options{
Width: 200, // Must not change!
Prefix: "", // Must not change!
Indent: " ", // Must not change!
})
return data, nil
}