Skip to content
Merged
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
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ digests with public keys. It is designed to be reused by:
## What This Project Contains

- CycloneDX embedded signature verification
- AI-BOM embedded signature verification
- SPDX detached signature verification
- digest signature verification
- JSON canonicalization helpers used during verification
Expand Down Expand Up @@ -70,6 +71,34 @@ result, err := verifier.VerifyCycloneDXEmbeddedWithKeyVersioned(
)
```

For AI-BOM embedded signatures:

```go
verifier := securesbomverifier.NewVerifier()

result, err := verifier.VerifyAIBOMEmbeddedVersioned(
signedAIBOM,
string(publicKeyPEM),
securesbomverifier.VerificationV2,
)
```

AI-BOM verification is supported only with `VerificationV2`.

When key metadata is available, use the key-based root API:

```go
result, err := verifier.VerifyAIBOMEmbeddedWithKeyVersioned(
signedAIBOM,
securesbomverifier.VerificationKey{
KeyID: "production-ai-bom-key-2026-05",
Algorithm: "ES256",
PublicKey: string(publicKeyPEM),
},
securesbomverifier.VerificationV2,
)
```

For SPDX detached signatures:

```go
Expand Down Expand Up @@ -192,15 +221,20 @@ Use `VerificationV2` for new integrations.
source service. It is useful when verifying signatures produced by older
signing flows.
- `VerificationV2` uses the newer canonicalization behavior and is the default
recommendation for new signatures.
recommendation for new signatures, including AI-BOM embedded signatures.

AI-BOM embedded verification is only available with `VerificationV2`; the V1
verification behavior remains limited to the existing CycloneDX and SPDX flows.

Migration note: keep verifying historical artifacts with the version used when
they were signed. Sign new CycloneDX embedded and SPDX detached artifacts with
`VerificationV2`, then update callers to pass `securesbomverifier.VerificationV2`.
they were signed. Sign new CycloneDX embedded, AI-BOM embedded, and SPDX
detached artifacts with `VerificationV2`, then update callers to pass
`securesbomverifier.VerificationV2`.

```go
_, err = verifier.VerifyCycloneDXEmbeddedVersioned(signedSBOM, publicKeyPEM, securesbomverifier.VerificationV1)
_, err = verifier.VerifyCycloneDXEmbeddedVersioned(signedSBOM, publicKeyPEM, securesbomverifier.VerificationV2)
_, err = verifier.VerifyAIBOMEmbeddedVersioned(signedAIBOM, publicKeyPEM, securesbomverifier.VerificationV2)
_, err = verifier.VerifySPDXDetachedVersioned(spdxSBOM, signatureB64, publicKeyPEM, securesbomverifier.VerificationV1)
_, err = verifier.VerifySPDXDetachedVersioned(spdxSBOM, signatureB64, publicKeyPEM, securesbomverifier.VerificationV2)
```
Expand All @@ -221,9 +255,9 @@ tests:
fixtures := testsupport.NewContractFixtures(t)
```

The fixture set includes valid and invalid CycloneDX embedded signatures, SPDX
detached signatures, and digest signatures, along with public keys and expected
verification outcomes.
The fixture set includes valid and invalid CycloneDX embedded signatures,
AI-BOM embedded signatures, SPDX detached signatures, and digest signatures,
along with public keys and expected verification outcomes.

## Build Note

Expand Down Expand Up @@ -266,6 +300,10 @@ GOEXPERIMENT=jsonv2 go run ./cmd/sbom-offline-verification \
--pubkey ./path/to/public.pem
```

The same command verifies signed CycloneDX or signed AI-BOM files. The verifier
detects AI-BOM files as `AI-SBOM` and verifies the embedded
`metadata.sbomAuthorSignature` value with the supplied public key.

For detached SPDX verification:

```bash
Expand Down
109 changes: 109 additions & 0 deletions application/aibom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package application

import (
"bytes"
"encoding/json"
"encoding/json/jsontext"
"errors"
"fmt"
)

const aiBOMSignatureProperty = "sbomAuthorSignature"

func (v *VerifierApp) VerifyAIBOMEmbeddedVersioned(
signedSBOM []byte,
publicKeyPEM string,
version VerificationVersion,
) (*VerificationResult, error) {
if version != VerificationV2 {
return nil, errors.New("AI-BOM verification requires verification version v2")
}
return v.VerifyAIBOMEmbeddedV2(signedSBOM, publicKeyPEM)
}

func (v *VerifierApp) VerifyAIBOMEmbeddedV2(signedSBOM []byte, publicKeyPEM string) (*VerificationResult, error) {
if err := validateSBOM(signedSBOM, "AI-BOM"); err != nil {
return nil, err
}

canonical, signatureValue, algorithm, err := canonicalizeEmbeddedAIBOMForV2(signedSBOM)
if err != nil {
return nil, err
}

return v.verifyCanonicalJSON(canonical, signatureValue, publicKeyPEM, algorithm, "AI-BOM signature is valid", "ecdsa")
}

func canonicalizeEmbeddedAIBOMForV2(signedSBOM []byte) ([]byte, string, string, error) {
obj, signatureObj, err := extractAIBOMSignatureObject(signedSBOM)
if err != nil {
return nil, "", "", err
}

signatureValue, algorithm, err := decodeEmbeddedSignatureFields(signatureObj)
if err != nil {
return nil, "", "", err
}

delete(signatureObj, "value")

rawNoValue, err := json.Marshal(obj)
if err != nil {
return nil, "", "", fmt.Errorf("failed to serialize JSON: %w", err)
}

canonical := jsontext.Value(rawNoValue)
if err := canonical.Canonicalize(); err != nil {
return nil, "", "", fmt.Errorf("failed to canonicalize JSON: %w", err)
}

return []byte(canonical), signatureValue, algorithm, nil
}

func extractAIBOMSignatureObject(signedSBOM []byte) (map[string]any, map[string]any, error) {
var obj map[string]any
dec := json.NewDecoder(bytes.NewReader(signedSBOM))
dec.UseNumber()
if err := dec.Decode(&obj); err != nil {
return nil, nil, fmt.Errorf("invalid AI-BOM JSON: %w", err)
}

metadataAny, ok := obj["metadata"]
if !ok {
return nil, nil, errors.New("metadata object is missing")
}

metadata, ok := metadataAny.(map[string]any)
if !ok {
return nil, nil, errors.New("metadata must be an object")
}

sigAny, ok := metadata[aiBOMSignatureProperty]
if !ok {
return nil, nil, fmt.Errorf("%s object is missing", aiBOMSignatureProperty)
}

sigObj, ok := sigAny.(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("%s must be an object", aiBOMSignatureProperty)
}

return obj, sigObj, nil
}

func decodeEmbeddedSignatureFields(sigFields map[string]any) (string, string, error) {
signatureValue, ok := sigFields["value"].(string)
if !ok || signatureValue == "" {
return "", "", errors.New("signature value missing or invalid")
}

algorithm, _ := sigFields["alg"].(string)
if algorithm == "" {
algorithm, _ = sigFields["algorithm"].(string)
}
if algorithm == "" {
algorithm = "ES256"
}

return signatureValue, algorithm, nil
}
71 changes: 71 additions & 0 deletions application/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,77 @@ func TestVerifyCycloneDXEmbeddedVersioned_FactoryError(t *testing.T) {
}
}

func TestVerifyAIBOMEmbeddedVersioned_V2(t *testing.T) {
signed, publicKey := testsupport.SignedAIBOMV2(t, "ES256")

result, err := NewVerifierApp().VerifyAIBOMEmbeddedVersioned(signed, publicKey, VerificationV2)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Algorithm != "ecdsa" {
t.Fatalf("unexpected algorithm: %s", result.Algorithm)
}
}

func TestVerifyAIBOMEmbeddedVersioned_V1Unsupported(t *testing.T) {
signed, publicKey := testsupport.SignedAIBOMV2(t, "ES256")

_, err := NewVerifierApp().VerifyAIBOMEmbeddedVersioned(signed, publicKey, VerificationV1)
if err == nil || !strings.Contains(err.Error(), "requires verification version v2") {
t.Fatalf("expected v2 requirement error, got %v", err)
}
}

func TestVerifyAIBOMEmbeddedVersioned_MissingPublicKey(t *testing.T) {
signed, _ := testsupport.SignedAIBOMV2(t, "ES256")

_, err := NewVerifierApp().VerifyAIBOMEmbeddedVersioned(signed, "", VerificationV2)
if err == nil || !strings.Contains(err.Error(), "public key is required") {
t.Fatalf("expected public key error, got %v", err)
}
}

func TestVerifyAIBOMEmbeddedVersioned_InvalidSignature(t *testing.T) {
signed, publicKey := testsupport.SignedAIBOMV2(t, "ES256")

var obj map[string]any
if err := json.Unmarshal(signed, &obj); err != nil {
t.Fatalf("unmarshal AI-BOM: %v", err)
}
obj["system"].(map[string]any)["systemProducer"] = "tampered"
tampered, err := json.Marshal(obj)
if err != nil {
t.Fatalf("marshal tampered AI-BOM: %v", err)
}

_, err = NewVerifierApp().VerifyAIBOMEmbeddedVersioned(tampered, publicKey, VerificationV2)
if !errors.Is(err, ErrSignatureFail) && (err == nil || !strings.Contains(err.Error(), "signature verification failed")) {
t.Fatalf("expected signature failure, got %v", err)
}
}

func TestCanonicalizeEmbeddedAIBOMForV2_InvalidCases(t *testing.T) {
_, _, _, err := canonicalizeEmbeddedAIBOMForV2([]byte(`{`))
if err == nil {
t.Fatal("expected invalid JSON error")
}

_, _, _, err = canonicalizeEmbeddedAIBOMForV2([]byte(`{"schemaVersion":"1.0.0"}`))
if err == nil || !strings.Contains(err.Error(), "metadata object is missing") {
t.Fatalf("expected missing metadata error, got %v", err)
}

_, _, _, err = canonicalizeEmbeddedAIBOMForV2([]byte(`{"metadata":"bad"}`))
if err == nil || !strings.Contains(err.Error(), "metadata must be an object") {
t.Fatalf("expected invalid metadata type error, got %v", err)
}

_, _, _, err = canonicalizeEmbeddedAIBOMForV2([]byte(`{"metadata":{"sbomAuthorSignature":"bad"}}`))
if err == nil || !strings.Contains(err.Error(), "sbomAuthorSignature must be an object") {
t.Fatalf("expected invalid signature type error, got %v", err)
}
}

func TestVerifySPDXDetachedVersioned_V1(t *testing.T) {
sbom, publicKey, signature := testsupport.SignedSPDXDetachedV1(t)

Expand Down
12 changes: 1 addition & 11 deletions application/cyclonedx.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,7 @@ func decodeEmbeddedSignature(signatureJSON []byte) (string, string, error) {
return "", "", fmt.Errorf("malformed signature object: %w", err)
}

signatureValue, ok := sigFields["value"].(string)
if !ok || signatureValue == "" {
return "", "", errors.New("signature value missing or invalid")
}

algorithm, _ := sigFields["alg"].(string)
if algorithm == "" {
algorithm = "ES256"
}

return signatureValue, algorithm, nil
return decodeEmbeddedSignatureFields(sigFields)
}

func canonicalizeEmbeddedCycloneDXForV2(signedSBOM []byte) ([]byte, string, string, error) {
Expand Down
16 changes: 16 additions & 0 deletions cmd/sbom-offline-verification/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ func (c *command) verifySBOM(app *application.VerifierApp, sbom []byte, public s
switch strings.ToLower(result.SBOMType) {
case "cyclonedx":
return c.verifyCycloneDX(app, sbom, public, opts.verificationVersion)
case "ai-sbom":
return c.verifyAIBOM(app, sbom, public, opts.verificationVersion)
case "spdx":
return c.verifySPDX(app, sbom, opts.signature, public, opts.verificationVersion)
default:
Expand All @@ -195,6 +197,20 @@ func (c *command) verifyCycloneDX(
return result, "CycloneDX", nil
}

func (c *command) verifyAIBOM(
app *application.VerifierApp,
sbom []byte,
public string,
version application.VerificationVersion,
) (*application.VerificationResult, string, error) {
result, err := app.VerifyAIBOMEmbeddedVersioned(sbom, public, version)
if err != nil {
printResult(c.stdout, false, "SBOM", "SBOM Type", "AI-SBOM", "", "", err)
return nil, "", err
}
return result, "AI-SBOM", nil
}

func (c *command) verifySPDX(
app *application.VerifierApp,
sbom []byte,
Expand Down
23 changes: 23 additions & 0 deletions cmd/sbom-offline-verification/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ func TestCommandRun_CycloneDXSuccess(t *testing.T) {
}
}

func TestCommandRun_AIBOMSuccess(t *testing.T) {
signed, publicKey := testsupport.SignedAIBOMV2(t, "ES256")
tempDir := t.TempDir()
sbomPath := filepath.Join(tempDir, "signed.ai-bom.json")
pubPath := filepath.Join(tempDir, "public.pem")

if err := os.WriteFile(sbomPath, signed, 0o600); err != nil {
t.Fatalf("write AI-BOM: %v", err)
}
if err := os.WriteFile(pubPath, []byte(publicKey), 0o600); err != nil {
t.Fatalf("write public key: %v", err)
}

var stdout, stderr bytes.Buffer
exitCode := newCommand(&stdout, &stderr).run([]string{"--sbom", sbomPath, "--pubkey", pubPath, "--verification-version", "v2"})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "ai-sbom") {
t.Fatalf("expected AI-SBOM output, got %s", stdout.String())
}
}

func TestCommandRun_DigestSuccess(t *testing.T) {
privKey, publicKey := testsupport.GenerateKeyPair(t)
digest := sha256.Sum256([]byte("hello world"))
Expand Down
24 changes: 22 additions & 2 deletions examples/library/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"fmt"
"log"
"os"
Expand Down Expand Up @@ -33,10 +34,29 @@ func verifyExample(sbomPath, publicKeyPath string) (*securesbomverifier.Verifica
}

verifier := securesbomverifier.NewVerifier()
if isAIBOM(sbom) {
result, err := verifier.VerifyAIBOMEmbeddedVersioned(sbom, string(pub), securesbomverifier.VerificationV2)
if err != nil {
return nil, fmt.Errorf("verify AI-BOM: %w", err)
}
return result, nil
}

result, err := verifier.VerifyCycloneDXEmbeddedVersioned(sbom, string(pub), securesbomverifier.VerificationV2)
if err != nil {
return nil, fmt.Errorf("verify: %w", err)
return nil, fmt.Errorf("verify CycloneDX: %w", err)
}

return result, nil
}

func isAIBOM(sbom []byte) bool {
var doc struct {
Metadata struct {
BOMFormat string `json:"bomFormat"`
} `json:"metadata"`
}
if err := json.Unmarshal(sbom, &doc); err != nil {
return false
}
return doc.Metadata.BOMFormat == "AI-SBOM"
}
Loading
Loading