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
6 changes: 5 additions & 1 deletion .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ jobs:
provenance/bomsh_verify.py \
tools/wolfglass-sync \
tests/test_gen_sbom.py \
tests/test_sbom.py
tests/test_sbom.py \
tests/test_sbom_identity.py

- name: Run generator unit tests
run: python -m unittest tests/test_gen_sbom.py

- name: Run advisory generator unit tests
run: python -m unittest central/test_gen_advisory.py

- name: Run SBOM identity tests
run: python -m unittest tests/test_sbom_identity.py

- name: Run self-test
run: python tests/test_sbom.py
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,39 @@ vendor ports are a separate, currently-unwired category of their own.
- Product-specific build knowledge stays inside the product's frontend, never
leaks into the shared engine.

## Document identity

The CycloneDX `serialNumber` and the SPDX `documentNamespace` are derived from
the package name, the version, and a digest of the build configuration, so two
SBOMs for one release but different configurations (FIPS vs non-FIPS, a
different `--srcs` set, a different feature-flag or dependency set, a different
supplier or component type) get distinct identifiers. SPDX 2.3 section 6.5 requires
`documentNamespace` to be unique, and a CycloneDX `version: 1` document sharing
a `serialNumber` with differing content is self-contradictory.

The rule the generator holds to: **every input that can change the document
body reaches the identity digest.** `tests/test_sbom_identity.py` enforces it
over the whole option set, so a new option cannot land without being
classified identity-relevant or identity-exempt.

Reproducibility is unaffected: an identical configuration still produces
byte-identical output, including the identifiers.

**One-time rotation.** Identifiers changed once when the configuration digest
was introduced, and again whenever a new input is folded in. SBOMs published
before that carry the old values. This is a rotation, not a break: the
identifiers were previously wrong (colliding across configurations), and
nothing downstream should pin a literal `serialNumber`.

**Identity follows the artefact on the `--lib` path.** The library's SHA-256
is part of the digest, so rebuilding identical sources with a different
toolchain moves the `serialNumber` even though no configuration input changed.
That is deliberate. An SBOM generated from `--lib` describes one specific
binary, and two binaries with different hashes are two artefacts; giving them
one identifier would be the same collision in a different disguise. The
`--srcs` and `--no-artifact-hash` paths do not have this property, because
neither hashes a compiler output.

## Distribution: vendor a snapshot, not a dependency

Products vendor a snapshot of the toolkit into their own tree (`tools/sbom/`
Expand Down Expand Up @@ -177,4 +210,4 @@ not something to design against yet.

For the full product-by-frontend matrix, the program plan, and maintainer-level
notes on the current state of the repository, see [`docs/PLAN.md`](docs/PLAN.md),
[`docs/TIERS.md`](docs/TIERS.md), and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).e sent us the sboms he generated, can we check the
[`docs/TIERS.md`](docs/TIERS.md), and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).e sent us the sboms he generated, can we check the
77 changes: 75 additions & 2 deletions share/gen-sbom
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,59 @@ def derived_uuid(*parts):
return str(uuid.uuid5(SBOM_UUID_NAMESPACE, '\x00'.join(parts)))


def config_identity(lib_hash, license_id, build_props, enabled_deps,
dep_versions, *, supplier=None, component_type=None,
license_text=None, hash_kind=None, hash_source=None,
file_names=None, wolfssl_subset=None, subset_basis=None):
"""Stable digest of the build configuration behind one name+version.

Folded into the SBOM serialNumber / SPDX documentNamespace so two builds
of one release with different configuration (FIPS vs non-FIPS, a different
--srcs set, a different feature-flag set) get distinct identifiers, while
an identical configuration still reproduces byte-for-byte. Fields are
tagged and NUL-delimited so no two configurations serialize alike.

Invariant: every CLI input that can change the document body reaches this
digest. One that does not is a collision -- two documents with differing
content under one serialNumber. tests/test_sbom_identity.py classifies
the whole option set and fails on an unclassified new option.

lib_hash is folded in deliberately, so on the --lib path the identity
follows the built binary: the same sources under a different toolchain get
a different serialNumber. An SBOM from --lib describes one artefact. The
--srcs and --no-artifact-hash paths do not have this property."""
h = hashlib.sha256()

def _field(tag, value):
h.update(tag.encode())
h.update(b'\0')
h.update((value or '').encode())
h.update(b'\0')

_field('lib_hash', lib_hash)
_field('license', license_id)
for k, v in sorted(build_props):
_field('prop', f'{k}={v}')
for key in sorted(enabled_deps):
_field('dep', f'{key}={dep_versions.get(key) or ""}')
_field('supplier', supplier)
_field('component_type', component_type)
# Digested, not embedded: the text is a document field of its own and can
# differ while the SPDX identifier is unchanged.
_field('license_text',
hashlib.sha256(license_text.encode()).hexdigest()
if license_text else '')
_field('hash_kind', hash_kind)
_field('hash_source', hash_source)
# Filenames reach the document body without passing through lib_hash,
# which covers bytes only: a rename changes the body but not the hash.
for fname in sorted(file_names or []):
_field('file', fname)
_field('wolfssl_subset', wolfssl_subset)
_field('subset_basis', subset_basis)
return h.hexdigest()


def build_timestamp():
"""Return (datetime, ISO-8601-Z string) honoring SOURCE_DATE_EPOCH.
Reproducible Builds convention: if the env var is set to a valid
Expand Down Expand Up @@ -1825,8 +1878,28 @@ def main():

dt, timestamp = build_timestamp()
year = dt.year
serial = derived_uuid(args.name, args.version, 'serial')
doc_ns_uuid = derived_uuid(args.name, args.version, 'document')
# SPDX 2.3 6.5 requires documentNamespace to be unique, and a CycloneDX
# version:1 sharing a serialNumber over differing content is
# self-contradictory. Every body-affecting input goes into the digest;
# see config_identity for the invariant.
identity_file_names = (
[fe['name'] for fe in file_entries] if file_entries
else (srcs_basenames or [])
)
config_id = config_identity(
lib_hash, license_id, build_props, enabled_deps,
dep_version_overrides,
supplier=args.supplier,
component_type=args.component_type,
license_text=license_text,
hash_kind=hash_kind,
hash_source=hash_source,
file_names=identity_file_names,
wolfssl_subset=wolfssl_subset,
subset_basis=subset_basis,
)
serial = derived_uuid(args.name, args.version, 'serial', config_id)
doc_ns_uuid = derived_uuid(args.name, args.version, 'document', config_id)

cdx = generate_cdx(
args.name, args.version, args.supplier,
Expand Down
Loading
Loading