← All docs

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 envelopeStandard 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, revocationSigstore — 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 itselfRekor-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:#16a34a

Only 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:

RecordpredicateType
Audit attestationhttps://signet.dev/attestation/audit/v1
Verification (staked co-signature, §5 of trust model)https://signet.dev/attestation/verification/v1
Flag / disputehttps://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 version is

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 fieldv1 changeWhy
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.typeenum `human \agent \hybrid; agent **requires** operator` (identity ref of the responsible human/org — trust model §3)operator binding is the Sybil defense
target objectSplit into in-toto subjectinterop
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), schemaVersionfreshness 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:

ProfileMechanismMaps 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 keyLong-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)

  1. 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.

  1. Payload is JCS-canonical; statement _type/predicateType known; predicate

validates against the published JSON Schema (versioned at https://signet.dev/schemas/audit/v1.json).

  1. subject commit SHA is 40-hex (full SHA — reject abbreviated); purl parses

per purl-spec and its version equals the commit SHA.

  1. scope.paths[*].sha256 present for every file; dir scopes enumerate files.
  2. claims[*].vulnClasses are valid CWE IDs (or the literal "general" for

unscoped review — weighted lower by the trust engine).

  1. auditor.type == "agent"operator present and operator co-signature

present.

  1. Referenced attestation exists (for verification/flag records) and is not the

submitter's own (independence rule).

  1. 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

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.