Signet — Attestation Schema (Design v0.1)
Hardens the baseline audit-record draft into an interoperable, verifiable format. Strategy: adopt, don't invent — reuse DSSE for signing, the in-toto Statement layer for subject binding, and Sigstore for identity + transparency, and spend our design budget only on the Signet-specific predicate. Related: requirements.md (FR-1, FR-2, NFR-3/4), reputation-trust-model.md
1. Why Not the Custom Envelope
The baseline draft carries its own signature field beside the payload. That design has known failure modes and zero ecosystem leverage:
| Problem with custom envelope | Standard that already solves it |
|---|---|
| Signature over what bytes, exactly? (canonicalization ambiguity, signature-stripping/confusion attacks) | DSSE (Dead Simple Signing Envelope) with PAE encoding — signs payloadType + payload, immune to type confusion |
| How do verifiers bind a claim to an artifact? | in-toto Statement v1 — standardized subject[] with digest sets |
| Key distribution, identity proof, revocation | Sigstore — Fulcio keyless certs bound to OIDC identity (GitHub login → FR-3 for free), or self-managed keys in the same bundle format |
| Tamper-evidence / non-repudiation of the registry itself | Rekor-style transparency log — an attestation can't be silently deleted or backdated |
| Tool interop (policy engines, SLSA verifiers, GUAC) | All of the above are what the supply-chain ecosystem already parses |
Every FR-3 identity option ("PGP, SSH keys, or OIDC/Sigstore tokens") is expressible as a DSSE signature; we standardize the envelope and stay pluggable on key type.
Layering
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px' }, 'flowchart': { 'useMaxWidth': true }}}%%
flowchart TB
A["Sigstore bundle<br/>(cert / key hint + log proof)"]:::sig
B["DSSE envelope<br/>payloadType + payload + signatures"]:::dsse
C["in-toto Statement v1<br/>subject digests + predicateType"]:::stmt
D["Signet predicate<br/>audit / verification / flag / vouch"]:::pred
A --> B --> C --> D
classDef sig fill:#fee2e2,stroke:#dc2626
classDef dsse fill:#fef3c7,stroke:#d97706
classDef stmt fill:#dbeafe,stroke:#2563eb
classDef pred fill:#dcfce7,stroke:#16a34aOnly the innermost layer (green) is Signet-specific.
2. Record Types
All four social objects from the trust model are the same envelope, different predicate — one verification pipeline, one storage model, one export format:
| Record | predicateType |
|---|---|
| Audit attestation | https://signet.dev/attestation/audit/v1 |
| Verification (staked co-signature, §5 of trust model) | https://signet.dev/attestation/verification/v1 |
| Flag / dispute | https://signet.dev/attestation/flag/v1 |
| Vouch (tier T3 endorsement) | https://signet.dev/attestation/vouch/v1 |
| Dispute resolution (trust model §5) | https://signet.dev/attestation/resolution/v1 |
3. Identifiers: Content-Addressed IDs
The draft's "id": "urn:audit:registry:attestation:123456" implies a central sequence — a hidden single point of authority that breaks NFR-3. Replace with content addressing:
id = "urn:signet:att:sha256:" + hex(sha256(dsse_envelope_bytes))- Any mirror derives the same ID from the same bytes — no coordinator.
- Duplicate submissions dedupe for free.
- References between records (verification → attestation, flag → attestation) are
tamper-proof: you can't repoint a verification at different content.
- The envelope bytes are the canonical form; the JSON *inside* is canonicalized
with RFC 8785 (JCS) before signing so independent implementations produce byte-identical payloads.
4. Subject Binding (the cryptographic mapping, FR-1)
The in-toto subject carries the immutable software state. Two subjects, always:
"subject": [
{
"name": "https://github.com/expressjs/express",
"digest": { "gitCommit": "e569941a99ee9df2f609e99279541a0e71360bf3" }
},
{
"name": "pkg:github/expressjs/express@e569941a99ee9df2f609e99279541a0e71360bf3",
"digest": { "gitCommit": "e569941a99ee9df2f609e99279541a0e71360bf3" }
}
]- Subject 1 = repo URL + full commit SHA (the FR-2 query key).
- Subject 2 = purl (the FR-2 package-identifier query key). purl
versionis
always the full commit SHA, never semver — semver is a moving target; release-tag purls (pkg:npm/express@4.18.2) are resolved to commits by the registry's *mapping layer* at query time, not baked into attestations (package→source mapping is lossy and must stay revisable; see §9-Q2).
- **File-level digests live in the predicate scope, not in
subject** — the
audit is *about* the repo state; files are its scope. This keeps subject matching cheap for indexes (one digest per record) while file hashes stay signed content.
5. The Audit Predicate (audit/v1)
Full example — the baseline draft record, migrated:
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{ "name": "https://github.com/expressjs/express",
"digest": { "gitCommit": "e569941a99ee9df2f609e99279541a0e71360bf3" } },
{ "name": "pkg:github/expressjs/express@e569941a99ee9df2f609e71360bf3",
"digest": { "gitCommit": "e569941a99ee9df2f609e99279541a0e71360bf3" } }
],
"predicateType": "https://signet.dev/attestation/audit/v1",
"predicate": {
"auditor": {
"type": "human",
"operator": null
},
"scope": {
"type": "files",
"paths": [
{
"file": "lib/router/index.js",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"lines": [{ "start": 1, "end": 150 }]
}
]
},
"methodology": {
"tools": [
{ "name": "manual-code-review", "version": null, "config": null },
{ "name": "semgrep", "version": "1.79.0",
"config": { "ruleset": "p/owasp-top-ten",
"rulesetDigest": { "sha256": "ab12..." } } }
],
"effortLevel": "in-depth"
},
"claims": [
{
"status": "vetted-clean",
"vulnClasses": ["CWE-79", "CWE-89"],
"notes": "Input validation at line 42 sanitizes all query parameters before execution."
}
],
"falsePositives": [],
"timestamp": "2026-07-14T09:30:00Z",
"expiresAt": null,
"schemaVersion": "1.0.0"
}
}5.1 Field-by-field hardening vs. the draft
| Draft field | v1 change | Why | |||
|---|---|---|---|---|---|
id (sequential URN) | Content-addressed URN (§3) | decentralization, dedup, tamper-proof refs | |||
signature (inline string) | DSSE envelope + Sigstore bundle | §1 | |||
auditor.identity (free string) | Removed from payload — identity comes from the signature layer (Fulcio cert SAN, or key fingerprint) | a self-declared identity string is unverifiable and spoofable; never trust payload identity | |||
auditor.type | enum `human \ | agent \ | hybrid; agent **requires** operator` (identity ref of the responsible human/org — trust model §3) | operator binding is the Sybil defense | |
target object | Split into in-toto subject | interop | |||
scope.type: "file-range" | `repo \ | dirs \ | files; line ranges optional *within* file entries, as structured {start,end} objects (not "1-150"` strings) | machine-parseable; one scope type fewer | |
scope (dir case) | dir scope must also enumerate resolved file hashes at attestation time | "I audited /src/crypto/" is unverifiable later unless contents are pinned; also feeds per-file coverage (FR-2) | |||
tools_used (string list) | Structured {name, version, config{ruleset, rulesetDigest}} | "semgrep" without ruleset+version is not a methodology; reputation-weighted claims need reproducibility | |||
analyzed_vulnerabilities + single status | **claims[]: one status per vuln-class group** | trust model §6.2 computes coverage per CWE; a flat record can't express "clean for CWE-89, found CWE-79" | |||
| — (missing) | claims[].status enum: `vetted-clean \ | vulnerability-found \ | reported-upstream \ | inconclusive` | inconclusive matters: "I looked and couldn't determine" is honest signal, weighted as neither clean nor dirty |
| — (missing) | falsePositives[] (§5.2) | FR-1's false-positive registry had no schema in the draft | |||
| — (missing) | timestamp (signed, plus Rekor log time as the *trusted* time), expiresAt (optional self-expiry), schemaVersion | freshness decay (trust model §6.1) needs a trustworthy clock; auditors may self-limit validity |
5.2 False-positive entry (FR-1 registry)
{
"flaggedBy": { "tool": "semgrep", "version": "1.79.0",
"ruleId": "javascript.express.security.audit.express-open-redirect" },
"location": { "file": "lib/router/index.js",
"sha256": "e3b0c44298fc...",
"lines": [{ "start": 41, "end": 45 }] },
"verdict": "false-positive",
"category": "sanitized-elsewhere",
"justification": "Redirect target is validated against an allowlist in lib/utils.js:88 before this sink is reachable.",
"evidenceRefs": ["lib/utils.js#L88"]
}category enum (drives triage UX and lets consumers auto-suppress by class): sanitized-elsewhere | unreachable-code | constant-input | intentional-by-design | test-code-only | tool-model-gap. The ruleId + file sha256 pair is the join key scanners use to suppress: *this rule, on this exact content* — a byte changes, the suppression dies with the hash match (same mechanism as coverage freshness).
5.3 Verification / flag / vouch predicates (summary)
- **
verification/v1**: `{ attestation: "urn:signet:att:sha256:...", depth:
"acknowledged" | "reviewed" | "reproduced", notes }`. Subject = same subjects as the target attestation (so one index query finds audits *and* their verifications).
- **
flag/v1**: `{ attestation, reason: "incorrect" | "insufficient-methodology" |
"malicious" | "spam", evidence, notes }`. Staked per trust model §5.
- **
vouch/v1**:{ identity: <key fingerprint or Fulcio identity>, context }.
Unlike the other records, a vouch binds to an identity subject, not a repo state: exactly one subject named identity:<uri> whose digest is { "sha256": sha256(uri) }. Statement validation is predicate-aware — repo-state records (audit/verification/flag/resolution) require the git-commit subject rules; vouch requires the identity subject.
6. Signing & Identity Profiles
Two supported profiles, one envelope:
| Profile | Mechanism | Maps to tier |
|---|---|---|
| keyless (recommended) | Sigstore: OIDC (GitHub, Google, CI OIDC) → Fulcio short-lived cert → signature + transparency-log entry. Identity = cert SAN (e.g., GitHub handle). | T1–T4 depending on IdP + vouches |
| self-managed key | Long-lived SSH/PGP/ed25519 key; DSSE signature; key fingerprint = identity. PGP accepted for signing but normalized into the DSSE envelope (no detached .asc sidecars). | T0 until vouched |
Requirements:
- Every accepted record is entered in an append-only transparency log (Rekor
or self-hosted equivalent). The inclusion proof ships with the record on export, so offline replicas verify integrity without trusting the mirror (NFR-3 + NFR-4 in one mechanism).
- Multiple signatures on one envelope are allowed (DSSE native) — e.g., an agent
key and its operator key co-signing, which is how auditor.operator is proven rather than merely declared.
7. Export & Interchange (NFR-3)
- Unit of exchange = the signed bundle (DSSE envelope + verification material
+ log proof), never bare predicate JSON. Anything unsigned is display data.
- Daily dumps: JSONL of bundles, one per line, partitioned by ecosystem;
dump manifest lists each partition's sha256 and is itself signed by the registry key. signet mirror sync = fetch manifest → verify → fetch partitions → verify each bundle independently.
- Media types:
application/vnd.signet.bundle+json;
payloadType inside DSSE: application/vnd.in-toto+json.
- Because trust scores are *derived* (trust model §9: signed parameter config +
deterministic recompute), dumps contain only records, never scores — mirrors recompute reputation locally with their chosen seed set.
- One exception: mutation incidents (
mutations.jsonl, partition
kind: "mutations"). A refresh-witnessed mapping change is *observational* evidence — it can never be recomputed from bundles, so registry death would erase every recorded tampering. Incident lines are unsigned JSON (ecosystem/name/version, both commits, grade, detection time) integrity- covered by the signed manifest's sha256; mirror verify validates each line's shape, and mirror sync federates records only (a mirror records what its own refresh witnesses — the dump is the archival home for upstream incidents). Partitions without a kind are records (pre-v1.21 manifests stay valid).
8. Validation Rules (registry-side, on submit)
- DSSE signature verifies. For keyless bundles the Fulcio cert must chain to a
trusted Fulcio root (PKIX); the OIDC issuer must be in the allow-list when one is configured (signet.keyless.allowed-issuers); an embedded Signed Certificate Timestamp must verify against a known CT log when CT is required (signet.keyless.require-sct); and a client-supplied transparency-log entry (verificationMaterial.transparencyLogEntry), if present, must have a valid Rekor inclusion proof + SET and bind to this envelope and certificate.
- Payload is JCS-canonical; statement
_type/predicateTypeknown; predicate
validates against the published JSON Schema (versioned at https://signet.dev/schemas/audit/v1.json).
subjectcommit SHA is 40-hex (full SHA — reject abbreviated); purl parses
per purl-spec and its version equals the commit SHA.
scope.paths[*].sha256present for every file; dir scopes enumerate files.claims[*].vulnClassesare valid CWE IDs (or the literal"general"for
unscoped review — weighted lower by the trust engine).
auditor.type == "agent"⇒operatorpresent and operator co-signature
present.
- Referenced attestation exists (for verification/flag records) and is not the
submitter's own (independence rule).
- Rate-limit check by identity tier — *after* signature verification, so limits
bind to proven identities, not IPs alone.
Registry rejection ≠ censorship: because records are self-verifying bundles, a rejected-but-valid record can be submitted to any other mirror; acceptance policy is a mirror-level concern (NFR-3).
9. Open Questions
- in-toto Statement vs. fully custom statement layer. v1 commits to in-toto
for ecosystem leverage; the cost is that subject.digest.gitCommit semantics for *a repo state* (vs. an artifact) are a slight stretch of the spec's intent. Watch the in-toto attestation-spec SCAI/source-track work and align when it stabilizes.
- purl ↔ source mapping.
pkg:npm/express@4.18.2→ repo+commit is a hard,
lossy mapping (rebuilds, vendored code, tag mutation). v1 keeps it as a registry-side *advisory* index, never signed content. Candidate future alignment: deps.dev / OSV mapping data.
- Monorepo & submodule scoping. ~~Open~~ — resolved by PD-9: the
attestation *subject* stays (repo, commit) (no schema change, conformance vectors untouched). Scoping lives on the registry's read path: a package release carries the directory it occupies in the repo (purl's #subpath notion; npm's repository.directory is the first source), and the package↔coverage bridge — catalog status, dossier rollup, badge, deps status — counts only coverage whose file paths fall under that subtree. Worked example: npm/langchain (subpath libs/langchain) and npm/@langchain/core (subpath langchain-core) at one langchainjs commit — an audit of langchain-core/src/... makes only @langchain/core audited.
- Line-range stability. v1 voids line-anchored claims on any file-hash change
(conservative, per trust model). Diff-aware carry-forward (re-anchoring ranges across commits with identity-preserving hunks) is a v2 candidate — high value for the false-positive registry, where code moves but the FP persists.
- Bundle size. Sigstore bundles with inclusion proofs are kilobytes each;
a full-registry dump at scale needs measurement — possibly proof-elision in dumps (verify against the log on demand) as an opt-out.