Skip to content

Commit d85ce80

Browse files
authored
feat: Add AI-BOM signature verification (#16)
* feat: Add AI-BOM signature verification * feat: Add AI-BOM signature verification * feat: Add AI-BOM signature verification * feat: add sample commands
1 parent 0ba37ef commit d85ce80

22 files changed

Lines changed: 1221 additions & 22 deletions

File tree

README.md

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ digests with public keys. It is designed to be reused by:
1111
## What This Project Contains
1212

1313
- CycloneDX embedded signature verification
14+
- AI-BOM embedded signature verification
1415
- SPDX detached signature verification
1516
- digest signature verification
1617
- JSON canonicalization helpers used during verification
@@ -70,6 +71,34 @@ result, err := verifier.VerifyCycloneDXEmbeddedWithKeyVersioned(
7071
)
7172
```
7273

74+
For AI-BOM embedded signatures:
75+
76+
```go
77+
verifier := securesbomverifier.NewVerifier()
78+
79+
result, err := verifier.VerifyAIBOMEmbeddedVersioned(
80+
signedAIBOM,
81+
string(publicKeyPEM),
82+
securesbomverifier.VerificationV2,
83+
)
84+
```
85+
86+
AI-BOM verification is supported only with `VerificationV2`.
87+
88+
When key metadata is available, use the key-based root API:
89+
90+
```go
91+
result, err := verifier.VerifyAIBOMEmbeddedWithKeyVersioned(
92+
signedAIBOM,
93+
securesbomverifier.VerificationKey{
94+
KeyID: "production-ai-bom-key-2026-05",
95+
Algorithm: "ES256",
96+
PublicKey: string(publicKeyPEM),
97+
},
98+
securesbomverifier.VerificationV2,
99+
)
100+
```
101+
73102
For SPDX detached signatures:
74103

75104
```go
@@ -192,15 +221,20 @@ Use `VerificationV2` for new integrations.
192221
source service. It is useful when verifying signatures produced by older
193222
signing flows.
194223
- `VerificationV2` uses the newer canonicalization behavior and is the default
195-
recommendation for new signatures.
224+
recommendation for new signatures, including AI-BOM embedded signatures.
225+
226+
AI-BOM embedded verification is only available with `VerificationV2`; the V1
227+
verification behavior remains limited to the existing CycloneDX and SPDX flows.
196228

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

201234
```go
202235
_, err = verifier.VerifyCycloneDXEmbeddedVersioned(signedSBOM, publicKeyPEM, securesbomverifier.VerificationV1)
203236
_, err = verifier.VerifyCycloneDXEmbeddedVersioned(signedSBOM, publicKeyPEM, securesbomverifier.VerificationV2)
237+
_, err = verifier.VerifyAIBOMEmbeddedVersioned(signedAIBOM, publicKeyPEM, securesbomverifier.VerificationV2)
204238
_, err = verifier.VerifySPDXDetachedVersioned(spdxSBOM, signatureB64, publicKeyPEM, securesbomverifier.VerificationV1)
205239
_, err = verifier.VerifySPDXDetachedVersioned(spdxSBOM, signatureB64, publicKeyPEM, securesbomverifier.VerificationV2)
206240
```
@@ -221,9 +255,9 @@ tests:
221255
fixtures := testsupport.NewContractFixtures(t)
222256
```
223257

224-
The fixture set includes valid and invalid CycloneDX embedded signatures, SPDX
225-
detached signatures, and digest signatures, along with public keys and expected
226-
verification outcomes.
258+
The fixture set includes valid and invalid CycloneDX embedded signatures,
259+
AI-BOM embedded signatures, SPDX detached signatures, and digest signatures,
260+
along with public keys and expected verification outcomes.
227261

228262
## Build Note
229263

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

303+
The same command verifies signed CycloneDX or signed AI-BOM files. The verifier
304+
detects AI-BOM files as `AI-SBOM` and verifies the embedded
305+
`metadata.sbomAuthorSignature` value with the supplied public key.
306+
269307
For detached SPDX verification:
270308

271309
```bash

application/aibom.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package application
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"encoding/json/jsontext"
7+
"errors"
8+
"fmt"
9+
)
10+
11+
const aiBOMSignatureProperty = "sbomAuthorSignature"
12+
13+
func (v *VerifierApp) VerifyAIBOMEmbeddedVersioned(
14+
signedSBOM []byte,
15+
publicKeyPEM string,
16+
version VerificationVersion,
17+
) (*VerificationResult, error) {
18+
if version != VerificationV2 {
19+
return nil, errors.New("AI-BOM verification requires verification version v2")
20+
}
21+
return v.VerifyAIBOMEmbeddedV2(signedSBOM, publicKeyPEM)
22+
}
23+
24+
func (v *VerifierApp) VerifyAIBOMEmbeddedV2(signedSBOM []byte, publicKeyPEM string) (*VerificationResult, error) {
25+
if err := validateSBOM(signedSBOM, "AI-BOM"); err != nil {
26+
return nil, err
27+
}
28+
29+
canonical, signatureValue, algorithm, err := canonicalizeEmbeddedAIBOMForV2(signedSBOM)
30+
if err != nil {
31+
return nil, err
32+
}
33+
34+
return v.verifyCanonicalJSON(canonical, signatureValue, publicKeyPEM, algorithm, "AI-BOM signature is valid", "ecdsa")
35+
}
36+
37+
func canonicalizeEmbeddedAIBOMForV2(signedSBOM []byte) ([]byte, string, string, error) {
38+
obj, signatureObj, err := extractAIBOMSignatureObject(signedSBOM)
39+
if err != nil {
40+
return nil, "", "", err
41+
}
42+
43+
signatureValue, algorithm, err := decodeEmbeddedSignatureFields(signatureObj)
44+
if err != nil {
45+
return nil, "", "", err
46+
}
47+
48+
delete(signatureObj, "value")
49+
50+
rawNoValue, err := json.Marshal(obj)
51+
if err != nil {
52+
return nil, "", "", fmt.Errorf("failed to serialize JSON: %w", err)
53+
}
54+
55+
canonical := jsontext.Value(rawNoValue)
56+
if err := canonical.Canonicalize(); err != nil {
57+
return nil, "", "", fmt.Errorf("failed to canonicalize JSON: %w", err)
58+
}
59+
60+
return []byte(canonical), signatureValue, algorithm, nil
61+
}
62+
63+
func extractAIBOMSignatureObject(signedSBOM []byte) (map[string]any, map[string]any, error) {
64+
var obj map[string]any
65+
dec := json.NewDecoder(bytes.NewReader(signedSBOM))
66+
dec.UseNumber()
67+
if err := dec.Decode(&obj); err != nil {
68+
return nil, nil, fmt.Errorf("invalid AI-BOM JSON: %w", err)
69+
}
70+
71+
metadataAny, ok := obj["metadata"]
72+
if !ok {
73+
return nil, nil, errors.New("metadata object is missing")
74+
}
75+
76+
metadata, ok := metadataAny.(map[string]any)
77+
if !ok {
78+
return nil, nil, errors.New("metadata must be an object")
79+
}
80+
81+
sigAny, ok := metadata[aiBOMSignatureProperty]
82+
if !ok {
83+
return nil, nil, fmt.Errorf("%s object is missing", aiBOMSignatureProperty)
84+
}
85+
86+
sigObj, ok := sigAny.(map[string]any)
87+
if !ok {
88+
return nil, nil, fmt.Errorf("%s must be an object", aiBOMSignatureProperty)
89+
}
90+
91+
return obj, sigObj, nil
92+
}
93+
94+
func decodeEmbeddedSignatureFields(sigFields map[string]any) (string, string, error) {
95+
signatureValue, ok := sigFields["value"].(string)
96+
if !ok || signatureValue == "" {
97+
return "", "", errors.New("signature value missing or invalid")
98+
}
99+
100+
algorithm, _ := sigFields["alg"].(string)
101+
if algorithm == "" {
102+
algorithm, _ = sigFields["algorithm"].(string)
103+
}
104+
if algorithm == "" {
105+
algorithm = "ES256"
106+
}
107+
108+
return signatureValue, algorithm, nil
109+
}

application/app_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,77 @@ func TestVerifyCycloneDXEmbeddedVersioned_FactoryError(t *testing.T) {
8181
}
8282
}
8383

84+
func TestVerifyAIBOMEmbeddedVersioned_V2(t *testing.T) {
85+
signed, publicKey := testsupport.SignedAIBOMV2(t, "ES256")
86+
87+
result, err := NewVerifierApp().VerifyAIBOMEmbeddedVersioned(signed, publicKey, VerificationV2)
88+
if err != nil {
89+
t.Fatalf("expected no error, got %v", err)
90+
}
91+
if result.Algorithm != "ecdsa" {
92+
t.Fatalf("unexpected algorithm: %s", result.Algorithm)
93+
}
94+
}
95+
96+
func TestVerifyAIBOMEmbeddedVersioned_V1Unsupported(t *testing.T) {
97+
signed, publicKey := testsupport.SignedAIBOMV2(t, "ES256")
98+
99+
_, err := NewVerifierApp().VerifyAIBOMEmbeddedVersioned(signed, publicKey, VerificationV1)
100+
if err == nil || !strings.Contains(err.Error(), "requires verification version v2") {
101+
t.Fatalf("expected v2 requirement error, got %v", err)
102+
}
103+
}
104+
105+
func TestVerifyAIBOMEmbeddedVersioned_MissingPublicKey(t *testing.T) {
106+
signed, _ := testsupport.SignedAIBOMV2(t, "ES256")
107+
108+
_, err := NewVerifierApp().VerifyAIBOMEmbeddedVersioned(signed, "", VerificationV2)
109+
if err == nil || !strings.Contains(err.Error(), "public key is required") {
110+
t.Fatalf("expected public key error, got %v", err)
111+
}
112+
}
113+
114+
func TestVerifyAIBOMEmbeddedVersioned_InvalidSignature(t *testing.T) {
115+
signed, publicKey := testsupport.SignedAIBOMV2(t, "ES256")
116+
117+
var obj map[string]any
118+
if err := json.Unmarshal(signed, &obj); err != nil {
119+
t.Fatalf("unmarshal AI-BOM: %v", err)
120+
}
121+
obj["system"].(map[string]any)["systemProducer"] = "tampered"
122+
tampered, err := json.Marshal(obj)
123+
if err != nil {
124+
t.Fatalf("marshal tampered AI-BOM: %v", err)
125+
}
126+
127+
_, err = NewVerifierApp().VerifyAIBOMEmbeddedVersioned(tampered, publicKey, VerificationV2)
128+
if !errors.Is(err, ErrSignatureFail) && (err == nil || !strings.Contains(err.Error(), "signature verification failed")) {
129+
t.Fatalf("expected signature failure, got %v", err)
130+
}
131+
}
132+
133+
func TestCanonicalizeEmbeddedAIBOMForV2_InvalidCases(t *testing.T) {
134+
_, _, _, err := canonicalizeEmbeddedAIBOMForV2([]byte(`{`))
135+
if err == nil {
136+
t.Fatal("expected invalid JSON error")
137+
}
138+
139+
_, _, _, err = canonicalizeEmbeddedAIBOMForV2([]byte(`{"schemaVersion":"1.0.0"}`))
140+
if err == nil || !strings.Contains(err.Error(), "metadata object is missing") {
141+
t.Fatalf("expected missing metadata error, got %v", err)
142+
}
143+
144+
_, _, _, err = canonicalizeEmbeddedAIBOMForV2([]byte(`{"metadata":"bad"}`))
145+
if err == nil || !strings.Contains(err.Error(), "metadata must be an object") {
146+
t.Fatalf("expected invalid metadata type error, got %v", err)
147+
}
148+
149+
_, _, _, err = canonicalizeEmbeddedAIBOMForV2([]byte(`{"metadata":{"sbomAuthorSignature":"bad"}}`))
150+
if err == nil || !strings.Contains(err.Error(), "sbomAuthorSignature must be an object") {
151+
t.Fatalf("expected invalid signature type error, got %v", err)
152+
}
153+
}
154+
84155
func TestVerifySPDXDetachedVersioned_V1(t *testing.T) {
85156
sbom, publicKey, signature := testsupport.SignedSPDXDetachedV1(t)
86157

application/cyclonedx.go

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,7 @@ func decodeEmbeddedSignature(signatureJSON []byte) (string, string, error) {
4848
return "", "", fmt.Errorf("malformed signature object: %w", err)
4949
}
5050

51-
signatureValue, ok := sigFields["value"].(string)
52-
if !ok || signatureValue == "" {
53-
return "", "", errors.New("signature value missing or invalid")
54-
}
55-
56-
algorithm, _ := sigFields["alg"].(string)
57-
if algorithm == "" {
58-
algorithm = "ES256"
59-
}
60-
61-
return signatureValue, algorithm, nil
51+
return decodeEmbeddedSignatureFields(sigFields)
6252
}
6353

6454
func canonicalizeEmbeddedCycloneDXForV2(signedSBOM []byte) ([]byte, string, string, error) {

cmd/sbom-offline-verification/run.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ func (c *command) verifySBOM(app *application.VerifierApp, sbom []byte, public s
172172
switch strings.ToLower(result.SBOMType) {
173173
case "cyclonedx":
174174
return c.verifyCycloneDX(app, sbom, public, opts.verificationVersion)
175+
case "ai-sbom":
176+
return c.verifyAIBOM(app, sbom, public, opts.verificationVersion)
175177
case "spdx":
176178
return c.verifySPDX(app, sbom, opts.signature, public, opts.verificationVersion)
177179
default:
@@ -195,6 +197,20 @@ func (c *command) verifyCycloneDX(
195197
return result, "CycloneDX", nil
196198
}
197199

200+
func (c *command) verifyAIBOM(
201+
app *application.VerifierApp,
202+
sbom []byte,
203+
public string,
204+
version application.VerificationVersion,
205+
) (*application.VerificationResult, string, error) {
206+
result, err := app.VerifyAIBOMEmbeddedVersioned(sbom, public, version)
207+
if err != nil {
208+
printResult(c.stdout, false, "SBOM", "SBOM Type", "AI-SBOM", "", "", err)
209+
return nil, "", err
210+
}
211+
return result, "AI-SBOM", nil
212+
}
213+
198214
func (c *command) verifySPDX(
199215
app *application.VerifierApp,
200216
sbom []byte,

cmd/sbom-offline-verification/run_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ func TestCommandRun_CycloneDXSuccess(t *testing.T) {
3535
}
3636
}
3737

38+
func TestCommandRun_AIBOMSuccess(t *testing.T) {
39+
signed, publicKey := testsupport.SignedAIBOMV2(t, "ES256")
40+
tempDir := t.TempDir()
41+
sbomPath := filepath.Join(tempDir, "signed.ai-bom.json")
42+
pubPath := filepath.Join(tempDir, "public.pem")
43+
44+
if err := os.WriteFile(sbomPath, signed, 0o600); err != nil {
45+
t.Fatalf("write AI-BOM: %v", err)
46+
}
47+
if err := os.WriteFile(pubPath, []byte(publicKey), 0o600); err != nil {
48+
t.Fatalf("write public key: %v", err)
49+
}
50+
51+
var stdout, stderr bytes.Buffer
52+
exitCode := newCommand(&stdout, &stderr).run([]string{"--sbom", sbomPath, "--pubkey", pubPath, "--verification-version", "v2"})
53+
if exitCode != 0 {
54+
t.Fatalf("expected exit code 0, got %d stderr=%s", exitCode, stderr.String())
55+
}
56+
if !strings.Contains(stdout.String(), "ai-sbom") {
57+
t.Fatalf("expected AI-SBOM output, got %s", stdout.String())
58+
}
59+
}
60+
3861
func TestCommandRun_DigestSuccess(t *testing.T) {
3962
privKey, publicKey := testsupport.GenerateKeyPair(t)
4063
digest := sha256.Sum256([]byte("hello world"))

examples/library/main.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"log"
67
"os"
@@ -33,10 +34,29 @@ func verifyExample(sbomPath, publicKeyPath string) (*securesbomverifier.Verifica
3334
}
3435

3536
verifier := securesbomverifier.NewVerifier()
37+
if isAIBOM(sbom) {
38+
result, err := verifier.VerifyAIBOMEmbeddedVersioned(sbom, string(pub), securesbomverifier.VerificationV2)
39+
if err != nil {
40+
return nil, fmt.Errorf("verify AI-BOM: %w", err)
41+
}
42+
return result, nil
43+
}
44+
3645
result, err := verifier.VerifyCycloneDXEmbeddedVersioned(sbom, string(pub), securesbomverifier.VerificationV2)
3746
if err != nil {
38-
return nil, fmt.Errorf("verify: %w", err)
47+
return nil, fmt.Errorf("verify CycloneDX: %w", err)
3948
}
40-
4149
return result, nil
4250
}
51+
52+
func isAIBOM(sbom []byte) bool {
53+
var doc struct {
54+
Metadata struct {
55+
BOMFormat string `json:"bomFormat"`
56+
} `json:"metadata"`
57+
}
58+
if err := json.Unmarshal(sbom, &doc); err != nil {
59+
return false
60+
}
61+
return doc.Metadata.BOMFormat == "AI-SBOM"
62+
}

0 commit comments

Comments
 (0)