KIT

Reference snapshot · source revision 7a2e36f2b250934a095e542ae20e62dcfb0d5f70

SPEC-ATTESTATIONS — kit v1 attestation format

Status: Draft. This document defines a native attestation primitive for kit: the on-disk layout, the wire envelope, the signing contract, the CLI, and the transport additions.

Scope: everything about how kit produces, stores, transports, and verifies an attestation about a commit. Not in scope: what an attestation means — that's the business of the predicate-type URI, which is chosen by whoever makes the claim (kit itself, GitHub, a user's build server, etc.).

This spec is normative for the wire bytes and on-disk layout that the kit-attest crate produces and the kit attest / kit verify-attest CLI consume. Items marked (planned) are specified here but are not implemented in the current kit-attest crate or CLI; consumers MUST NOT rely on them.


1. Design constraints

  1. Content-addressed, same shape as everything else in kit. An attestation has a stable hash; the hash is the on-disk name; the transport carries bytes by hash.
  2. Standards-compliant envelope. The signed bytes MUST be consumable by any off-the-shelf tool that understands the format — today that means Sigstore cosign, GitHub gh attestation verify, the SLSA verifier, and any in-toto v1 consumer. No kit-specific framing around the signed bytes.
  3. Multi-signature native. One attestation envelope MAY carry signatures from multiple independent signers (repo-owner, reviewer, CI build bot, settlement service). Each signature verifies independently against its own trust root.
  4. Predicate-agnostic. kit never parses the predicate body. It only validates that the envelope is well-formed and at least one signature verifies.
  5. Minimal external dep surface. JCS, DSSE PAE, BLAKE3 hashing, and the on-disk store are hand-rolled in kit-attest. serde_json is used only to (a) parse-and-validate a caller-supplied predicate body as a JSON object before pass-through, and (b) extract the subject[].digest.blake3 field on the verify path. The canonical encoder never goes through serde_json::to_string — see §4.3. sha2 is an unconditional dependency (not feature-gated): native commit attestation subjects carry a sha256 digest alongside blake3 (§4.2) regardless of which signing-algorithm features are compiled in, so external consumers (cosign, gh attestation verify, the SLSA verifier) that only understand the in-toto/SLSA DigestSet sha256 key can still read kit's subjects.

2. Format stack

DSSE envelope                 ← signed container (RFC-quality)
  payload = in-toto v1 Statement (JCS-canonical JSON)
                              ← says "I assert <predicate> about <subject>"
  payloadType = "application/vnd.in-toto+json"
  signatures[] = { keyid, sig }
                              ← one or more; each verified independently

2.1 Pre-Authentication Encoding (PAE)

DSSE signs a canonical encoding of payloadType + payload, not the payload alone. Per the DSSE spec:

PAE(type, payload) =
    "DSSEv1"
    || SP || ASCII(len(type))   || SP || type
    || SP || ASCII(len(payload)) || SP || payload

Where len(x) is the decimal-ASCII length of the UTF-8 bytes of x and SP is a single ASCII space (0x20). kit signs PAE(type, payload) directly — no further hashing wrapper. (Ed25519 internally pre-hashes with SHA-512 inside ed25519-dalek; ECDSA backends pre-hash with SHA-256. The signer trait always hands the signer the raw PAE; the signer's algorithm is responsible for any required digest.)

Reference encoding (kit-attest::envelope::pae_of):

PAE("hello", "body")  ==  b"DSSEv1 5 hello 4 body"
PAE("t",     "")      ==  b"DSSEv1 1 t 0 "

3. On-disk layout

.kit/
  attestations/
    <commit-hash-hex>/                      ← one directory per subject
      <attestation-id-hex>.dsse              ← one DSSE envelope per file

There is no index file, no central manifest. ls .kit/attestations/<commit-hash>/ lists every attestation attached to that commit. This is the same discoverability model as refs/.

3.1 Relationship to objects/

Attestations are NOT objects in the objects/ tree. They are a separate resource class. Rationale:


4. Envelope format

4.1 DSSE envelope JSON

JCS-canonical key order is strictly ascending UCS-2 codepoint, so the wire bytes have keys in this exact order:

{
  "payload":     "<base64(statement_json)>",
  "payloadType": "application/vnd.in-toto+json",
  "signatures":  [
    { "keyid": "<signer-identifier>", "sig": "<base64(sig_bytes)>" }
  ]
}

4.2 in-toto v1 Statement body

JCS-canonical key order. Note predicate precedes predicateType (by codepoint sort), _type is first (_ = 0x5F sorts before p = 0x70), and subject is last (s = 0x73 > p):

{
  "_type": "https://in-toto.io/Statement/v1",
  "predicate": { ... arbitrary JSON, predicate-type's own schema ... },
  "predicateType": "<uri-identifying-the-predicate>",
  "subject": [
    {
      "digest": { "blake3": "<commit-hash-hex>", "sha256": "<commit-hash-hex>" },
      "name": "<optional-human-readable-string>"
    }
  ]
}

4.3 Canonicalization

Both the outer DSSE envelope and the inner Statement are serialized via RFC 8785 JCS. This makes <attestation-id> (hash of envelope bytes) a stable function of the logical content. Implementation rules:

The JCS writer lives in kit-attest::jcs. It is restricted to the JSON subset used by DSSE + in-toto (string, u64, bool, null, object, array). serde_json::to_string is never called on the emit path — it does NOT honor JCS sort and number-format rules. serde_json IS used for parsing on the verify side (extract subject[].digest.blake3) and for validating caller-supplied predicate bodies.

The strict envelope decoder in kit-attest::envelope::decode accepts only the exact byte shape encode emits — no whitespace tolerance, no key-order flexibility. Third-party DSSE envelopes written by other tools must be re-canonicalized before kit's decoder will accept them. (This is intentional: a kit producer always writes through encode, so the strict decoder gives the strongest possible "what you wrote is what came back" guarantee.)


5. Attestation lifecycle

5.1 Create

  1. User or service constructs a predicate (arbitrary JSON object).
  2. kit builds the in-toto Statement (§4.2) with the commit hash as subject and the caller-supplied predicateType + predicate.
  3. Statement is JCS-canonicalized; bytes become the envelope payload.
  4. Signer.sign(PAE(payloadType, payload)) is called once per configured signer (primary --signer first, then each --additional-signer in order); signatures are collected into the envelope's signatures[] array in that same order.
  5. Envelope assembled (§4.1), JCS-canonicalized, BLAKE3-hashed for the attestation id.
  6. Envelope written to .kit/attestations/<commit>/<att-id>.dsse.

Multi-signature envelopes are produced in a single kit attest invocation by repeating --additional-signer (§8). Any signer failure aborts the attest with no partial envelope on disk.

5.2 Adding signatures after the fact (planned)

The current CLI does not support attaching a new signature to an already-on-disk envelope. The wire format does not preclude this — a future tool could decode, append to signatures[], re-encode, and write under the new <att-id> derived from the new bytes — but no kit attest --add-signature <att-id> flag exists today. Producers that need multi-party attestations must collect all signatures at create time via --additional-signer.

If implemented, the new envelope MUST be written under the att-id derived from its new bytes; the previous-att-id file is NOT overwritten or deleted, so the two envelopes co-exist on disk.

5.3 Verify

For each envelope attached to the commit:

  1. Decode envelope bytes via the strict decoder; reject if it doesn't match the canonical shape, if payloadType is not application/vnd.in-toto+json, or if signatures[] is empty.
  2. For each signature, look up the keyid in the trust-root Registry (§6.3). On hit, run the algorithm-specific verifier over PAE(payloadType, payload) + signature bytes:
    • TrustRoot::Ed25519PubKey([u8; 32]) — the SPEC-SIGNING §1 acceptance predicate (rejects malleability: non-canonical R, out-of-range S, non-canonical or low-order A/R).
    • TrustRoot::P256PubKeySec1(Vec<u8>) — ECDSA-P-256/SHA-256 over a SEC1-encoded pubkey (33-byte compressed or 65-byte uncompressed).
    • TrustRoot::Secp256k1PubKeySec1(Vec<u8>) — ECDSA-secp256k1/ SHA-256 over a SEC1-encoded pubkey.
    • TrustRoot::SigstoreCa — placeholder; any signature dispatched to it surfaces Reason::UnsupportedTrustRoot. Real Sigstore verification (Fulcio cert chain + Rekor inclusion) is not implemented.
  3. Each signature returns a typed Reason: Ok, UnknownKeyid, UnknownKeyidPrefix (keyid prefix not recognized by Algorithm::from_keyid), SignatureMismatch, UnsupportedTrustRoot, or AlgorithmNotEnabled (the algorithm's crypto backend was compiled out of this build).
  4. The envelope verifies if at least one signature returns Reason::Ok. The caller is also responsible for binding the envelope to the commit being asked about — kit-attest exposes verify::extract_primary_commit_hash to read the first subject's blake3 digest out of the Statement payload for that purpose. A signature-valid envelope whose subject digest does not match the commit actually being queried MUST NOT be treated as an attestation for that commit — the attestation directory name (.kit/attestations/<commit-hex>/) is not itself proof of what a given envelope attests to, since it is ordinary repo-tree state any process with write access to the repo can populate.

Conformance requirement: every caller-facing verification entry point MUST perform the binding check in the previous paragraph itself — it MUST NOT be an opt-in step callers can forget. kit verify-attest (the CLI) already does this correctly: it calls extract_primary_commit_hash and rejects any envelope whose subject hash does not equal the commit hex the directory is keyed under (kit-bin's verify_attest command), before considering the envelope's signature at all.

The kit verify-attest CLI enumerates every envelope under .kit/attestations/<commit>/, prints a per-signature verdict, and exits 0 iff every envelope has at least one verifying signature whose subject binds to that commit.

There is no revocation list. Revocation is: stop trusting the keyid. Transparency-log-backed signatures would solve this differently by binding signatures to timestamps; that's future work gated on a real Sigstore backend.


6. Signers, trust roots, and predicate types

6.1 Signer trait

The Signer trait (kit_attest::Signer) has three methods:

Verification is symmetric but NOT attached to the same trait, because the verifier often has no knowledge of how the signature was produced (for example, for a future Sigstore-keyless signature the verifier is Fulcio's cert chain, which no signer owns). Verifiers dispatch on keyid (§6.3) and the resolved TrustRoot variant.

6.2 Built-in signers

kit ships three production signer kinds, plus a scaffold for Sigstore. Each is opt-in; the CLI picks the signer via --signer flag or the attest.signer config key.

Signer name What it signs with Where the trust root lives
repo-key (default) Ed25519/secp256k1/P-256 over a raw 32-byte secret on disk under .kit/keys/ The corresponding public key, registered by keyid in the trust-roots file.
keystore A user-scoped kit-keystore key reference (the keystore performs the signing) The keystore backend's canonical key id.
external Subprocess wire conversation per SPEC-EXTERNAL-SIGNER Whatever the external signer's trust model is.
sigstore (scaffold) — — — Error::SigstoreNotImplemented is returned. Not selectable from the CLI factory.

repo-key covers all three algorithms:

external is the extension point an HSM, Secure Enclave, browser- crypto wallet, etc. wraps. The full wire contract — invocation, request/response shapes, capability discovery, PIN prompts, error semantics, size caps, timeout, determinism, versioning — is specified normatively in SPEC-EXTERNAL-SIGNER.md, with the underlying length-prefixed buffa framing defined in SPEC-RPC.md. Protocol version is v1; that document is the source of truth for any new signer implementation.

In summary, the wire is length-prefixed SignerFrame protobuf messages (NOT one-line JSON) carrying:

parent → child:   Hello { protocol = v1, want_capabilities = true, ... }
child  → parent:  HelloResponse { protocol = v1, capabilities, ... }
parent → child:   SignRequest { algorithm, key_form, key_ref, payload }
child  → parent:  SignResponse { signature, public_key, key_id }
                    OR Error { code, message }

The binary path comes from user-scoped attest.external_signer_path and MUST be absolute (relative paths are rejected at construction to close a $PATH resolution race). The child receives the PAE inside SignRequest.payload on stdin — never on argv.

Bounded execution. kit bounds the whole signer conversation with a single wall-clock deadline (spawn → request-write → response-read → stderr-drain → child-exit) and concurrently drains the child's stderr so a stderr-heavy signer cannot deadlock against a blocked stdout read. On timeout the child is killed and reaped. The deadline defaults to 120s (generous for hardware touch/PIN/biometric) and is configurable via the user-scoped attest.external_signer_timeout_secs. See SPEC-EXTERNAL-SIGNER §2.1. WebAuthn assertions are additionally validated against a configurable ceremony policy (RP-ID, origin, UP/UV, crossOrigin, counter) — see SPEC-EXTERNAL-SIGNER §6.1.

Signer argv. The subprocess is spawned with an optional argv vector in addition to the protobuf request on stdin. By default the vector is empty. There are three ways to populate it, each overriding the previous:

  1. User-scoped attest.external_signer_args — a pipe-separated list (sign|--tag|prod). Pipe instead of comma because the multi-sig spec already uses , as its key=value separator.
  2. --external-signer-arg <V> on kit attest, repeatable. When any instance appears, the collected values REPLACE (not append to) the config value — per-invocation override for "sign with tag X just this once."
  3. args=<a>|<b>|<c> on --additional-signer for the multi-sig envelope — see §6.2.1 below.

Every token is passed verbatim to Command::args — no shell interpolation, no splitting on whitespace.

6.2.1 Multi-sig spec: --additional-signer clause

kit attest --additional-signer takes a comma-separated key=value spec. The recognized keys are:

Key Meaning
algorithm ed25519 / secp256k1 / p256. Required.
signer repo-key / external. Required.
path Overrides key path (repo-key) or binary path (external).
args Pipe-separated argv for external signers only (optional).

Keystore-backed signing is available as the primary attest.signer. --additional-signer is currently limited to repo-key and external until the multi-sig grammar grows a key-ref field for keystore selection.

Example:

kit attest --additional-signer \
  "algorithm=p256,signer=external,path=/usr/local/bin/kit-sign-file,args=--key|/path/to/key"

| is used inside args= because , is the outer separator. When args= is present it overrides attest.external_signer_args for this signer only; absent means "fall through to config." An explicit args= with no value is a valid "zero argv" override.

6.3 keyid conventions

Free-form UTF-8. Strongly recommended canonical shapes:

The keyid is purely a dispatch key into the verifier registry. It is NOT authoritative — the signature itself is the only thing that proves identity. Algorithm::from_keyid recognizes the prefixes ed25519:, secp256k1:, p256:, and blake3:; anything else surfaces Reason::UnknownKeyidPrefix on the verify side.

6.4 Predicate types — conventions, not requirements

kit knows about zero predicate types and validates none of them. Ecosystem conventions to encourage (non-normative, for users to opt into):

6.5 Trust-roots file

The verify path resolves a keyid to a TrustRoot variant via a user-scoped trust-roots file. By default this lives at $XDG_CONFIG_HOME/kit/trust-roots.toml (typically ~/.config/kit/trust-roots.toml). kit verify-attest --trust-roots <path> overrides the location.

An in-repo trust-roots file is REJECTED unless --trust-roots is explicitly passed. A hostile clone could otherwise ship .kit/attest-trust-roots.toml listing attacker keys and the verify loop would print "ok" against attacker-controlled signatures.

Schema (a strict subset of TOML — the parser is hand-rolled and recognizes only this exact grammar). The file is a trust anchor, so the parser fails closed. It rejects the whole file on an unknown key, a repeated key, a line outside a block, an unquoted value, a value with a comment, escape or inner quote, invalid hex, a wrong key length, a keyid that does not match its key, or a repeated keyid. Only comment lines (#) and blank lines are skipped. A block may use algorithm as an alias for kind but not both. If kind is absent, it is ed25519:

# Repeat one block per trusted key.
[[trust_root]]
keyid      = "ed25519:9f2c7e1a4b6d0358f7a12e9c3d5b8064af731c0e29d4b8f651a3c7e0d9b2f4a6"   # exact match against the keyid carried in the DSSE signature
kind       = "ed25519"             # see table below
pubkey_hex = "3a7c9e1f4b2d6058a9c3e7f1b4d6082a5c9e3f7b1d4602a8c5e9f3b7d1042a6c"         # raw public-key bytes, lowercase hex
kind Maps to TrustRoot variant pubkey_hex expects
ed25519 Ed25519PubKey([u8; 32]) exactly 32 bytes (64 hex chars)
p256-sec1 or p256 P256PubKeySec1(Vec<u8>) SEC1: 33 bytes compressed (66 hex) or 65 bytes uncompressed (130 hex)
secp256k1 or secp256k1-sec1 Secp256k1PubKeySec1(Vec<u8>) SEC1: 33 bytes compressed (66 hex) or 65 bytes uncompressed (130 hex)
anything else the file is rejected —

Missing file ⇒ empty registry ⇒ every signature surfaces UnknownKeyid ⇒ no envelope verifies. The CLI prints a note: trust-roots file not found at ... hint in that case.


7. Integration with existing kit primitives

7.1 Commits

No change to the commit object format. An attestation is a separate resource; a commit with zero attestations and a commit with five attestations are indistinguishable at the commit layer.

7.2 Signing code (SPEC-SIGNING.md)

The Ed25519 DSSE signer for repo-key reuses ed25519-dalek and the same .kit/keys/default.key as commit signing. It signs the DSSE PAE per §2.1 — note this is a DIFFERENT signed-bytes domain from commit signing:

COMMIT_DOMAIN       = "kit.commit\x00"      (SPEC-SIGNING §2)
REMIX_DOMAIN        = "kit.remix\x00"       (SPEC-SIGNING §2)
DSSE signing        = "DSSEv1 ..." (PAE, §2.1)

The PAE prefix "DSSEv1 " cannot collide with either domain prefix (no \x00 byte, different leading bytes). Cross-domain signature confusion (SPEC-SIGNING §2) is therefore extended to cover attestations with no additional work.

7.3 Transport (planned)

Push/pull of attestations across the wire is not yet implemented in any of the kit-transport-{file,http,s3,ssh,memory} backends, and the SPEC-RPC opcode table does not yet carry attestation verbs. kit push / kit pull / kit fetch move only the object DAG today; attestations remain repo-local until a future transport revision adds them.

When that revision lands it MUST:

Opcode numbers and exact frame shapes will be specified in SPEC-RPC once the wire revision is cut.


8. CLI surface

kit attest [--commit <hash>]
            [--algorithm ed25519|secp256k1|p256]
            [--signer repo-key|external|keystore]
            [--predicate-type <URI>]
            [--predicate-file <path>]
            [--external-signer-arg <V>]...
            [--additional-signer "<spec>"]...
    Produce a signed DSSE attestation for a commit. Prints the
    att-id (64 hex chars) on success.

kit verify-attest [--commit <hash>]
                   [--trust-roots <path>]
                   [--algorithm <filter>]
    Verify every attestation attached to a commit. Exits 0 iff every
    listed attestation has at least one verifying signature. The
    --algorithm filter narrows the per-signature display to one of
    ed25519 / secp256k1 / p256; the verdict is unchanged.

Defaults:

The following subcommands appear in earlier drafts but are not implemented:


9. Config

Keys in the merged config (.kit/config repo-local + user-scoped $XDG_CONFIG_HOME/kit/config). Security-sensitive keys are user-scoped only: setting them in repo-local config is rejected with a stderr warning. See THREAT-MODEL.md for the motivation.

[attest]
default_algorithm    = "ed25519" | "secp256k1" | "p256"   (user-scoped)
signer               = "repo-key" | "external" | "keystore"  (user-scoped)
external_signer_path = /abs/path/to/binary               (user-scoped; required when signer = external)
external_signer_args = a|b|c                             (user-scoped; pipe-separated argv, optional)
external_signer_timeout_secs = 120                       (user-scoped; whole-conversation deadline, default 120)
secp256k1_key_path   = .kit/keys/secp256k1.key          (user-scoped; default shown)
p256_key_path        = .kit/keys/p256.key               (user-scoped; default shown)

[key]                                                    # see SPEC-KEYSTORE
ed25519_ref     = "<backend>:<label>"                    (user-scoped)
secp256k1_ref   = "<backend>:<label>"                    (user-scoped)
p256_ref        = "<backend>:<label>"                    (user-scoped)

signer = "keystore" signs with the keystore reference selected for the configured algorithm (key.<alg>_ref, falling back to key.default_ref). Repo-local config MUST NOT select keystore or populate any key.*_ref — a hostile clone would otherwise pin the victim's signing identity to an attacker-chosen key alias.

attest.auto_sign_commit (planned). Earlier drafts proposed an "auto-attest every commit with a minimal commit-signed predicate" toggle. This is not implemented; kit commit does not invoke the attest pipeline today. Producers that want commit-level attestations must run kit attest explicitly after the commit lands.


10. Security properties (what this does and doesn't protect)

Protects against:

Does NOT protect against:


11. Non-goals


12. Stability

Any breaking change to the on-disk layout or wire envelope requires a version bump and a SPEC-ATTESTATIONS-v2.md (same pattern as SPEC-OBJECTS). The DSSE + in-toto bodies are governed by their upstream specs; kit tracks those as they evolve.

Items currently marked (planned) — §5.2 (--add-signature), §7.3 (transport verbs), §8 (kit attest ls/show/rm, --require-predicate/--require-signer), §9 (attest.auto_sign_commit) — are forward-compatible additions and will not require a version bump when implemented.


13. Invariants

§10 is the authoritative statement of what this format does and does not protect. This table maps each guarantee to its enforcement point.

Invariant Enforced by
Any byte change to an envelope yields a new att-id att-id = BLAKE3(envelope-bytes); JCS makes the bytes a stable function of content (§3, §4.3)
Reposting an identical attestation is idempotent filename derived from the envelope hash (§3)
An envelope round-trips only if byte-identical to what encode emits strict decoder: exact key order, no whitespace tolerance (§4.3)
A DSSE signature can never be replayed as a commit/remix/tag signature PAE prefix "DSSEv1 " is disjoint from every SPEC-SIGNING domain (§7.2)
keyid alone never authenticates keyid is a dispatch key only; trust-root lookup + signature verification decide (§6.3, §5.3)
A hostile clone cannot plant trust roots in-repo trust-roots file rejected unless --trust-roots is explicit (§6.5, §10)
Missing trust roots fail closed empty registry ⇒ every signature UnknownKeyid ⇒ no envelope verifies (§6.5)
A hostile repo cannot redirect signing attest.* signer/key/path keys are user-scoped only (§9)
Ed25519 verdicts are identical across honest verifiers verify_strict rejects malleable encodings (§5.3, §10)
A malicious predicate body cannot subvert kit the predicate is never parsed beyond "is a JSON object" (§1, §10)
No partial envelope reaches disk any signer failure aborts before write; writes are tmp + fsync + rename atomic (§5.1, §3)
Malformed reads are bounded 1 MiB envelope size cap (§3)

What the format does NOT guarantee — revocation timing, predicate semantics, honesty of an external signer — is enumerated in §10 and is not restated here.