Skip to content

Signed approvals: cryptographic proof of actor identity #26

@lsmonki

Description

@lsmonki

Signed approvals: cryptographic proof of actor identity

Problem

Approvals in specd are currently a string in a JSON manifest — anyone can change their git config and approve as another person. There is no verification, no signature, no proof that the claimed actor actually performed the action.

{ "type": "spec-approved", "by": { "name": "Juan", "email": "j@x.com" }, "at": "..." }

This is fine for trust-based small teams, but insufficient for:

  • Regulated environments requiring audit trails
  • Open source projects with maintainer approvals
  • Teams where approval authority matters (not everyone can approve)
  • Any context where non-repudiation is needed

What's missing

Capability Current state
Identity verification None — git config is self-declared
Cryptographic signature None — approval is a plain JSON event
Authorization None — anyone who runs the CLI can approve
Non-repudiation None — actor can deny having approved
Signature verification at archive time None — archive trusts the manifest blindly

Design

Core decides what to sign, adapters decide how

The Signer is a port defined in core. Core calls signer.sign(payload) when it needs a signature — it decides what to sign and when. The adapter (CLI, MCP) provides the concrete implementation (SSH, GPG, sigstore, or any custom mechanism) via dependency injection. The CLI never touches the payload or the signature — it just wires the Signer at composition time.

core defines:     interface Signer { sign(payload): Promise<SignedPayload> }
cli implements:   class SSHSigner implements Signer { ... }
cli composes:     new ApproveSpec({ signer: new SSHSigner(keyPath) })
core executes:    await this.signer.sign(payload)

Standard dependency inversion — same pattern as ChangeRepository/FsChangeRepository.

Core is method-agnostic

Core never hardcodes signing methods, trust stores, or signer resolution strategies. All three ports — Signer, SignatureVerifier, and AllowedSignersResolver — are opaque to core. It calls them; it never inspects how they work internally. This allows custom implementations without changing core.

Ports

// Defined in core — used by use cases (approve, sign-off)
interface Signer {
  sign(payload: string): Promise<SignedPayload>
}

// Defined in core — used by use cases (archive, verify)
interface SignatureVerifier {
  verify(actor: ActorIdentity, payload: string, signature: SignedPayload): Promise<boolean>
}

// Defined in core — used by SignatureVerifier implementations to check trust
interface AllowedSignersResolver {
  isAllowed(actor: ActorIdentity, publicKey: string): Promise<boolean>
}

interface SignedPayload {
  signature: string
  method: string          // opaque — defined by the adapter, not by core
  publicKey?: string
}

Three separate ports with distinct responsibilities:

  • Signer.sign() — may be interactive (read key from disk, open browser). Called via core use case.
  • SignatureVerifier.verify() — never interactive, pure computation. Called by core in archive/verify.
  • AllowedSignersResolver.isAllowed() — resolves whether an actor's key is trusted. Used by SignatureVerifier implementations, not by core directly.

Use case flow

// ApproveSpec use case
async execute({ actor, artifactHashes }) {
  const payload = sha256(JSON.stringify(artifactHashes))

  const signature = this.config.requiresSignature
    ? await this.signer.sign(payload)   // core decides what and when
    : undefined

  change.approveSpec(actor, artifactHashes, signature)
  await this.changeRepo.save(change)
}

// ArchiveChange use case
async execute({ changeName }) {
  // ...
  for (const approval of change.approvals) {
    if (approval.signature) {
      const valid = await this.verifier.verify(
        approval.actor, approval.payload, approval.signature
      )
      if (!valid) throw new InvalidSignatureError(approval.actor, specId)
    }
  }
  // ...
}

Event structure

{
  "type": "spec-approved",
  "by": { "name": "Juan", "email": "j@x.com" },
  "artifactHashes": { "spec.md": "sha256:abc...", "verify.md": "sha256:def..." },
  "signature": {
    "signature": "-----BEGIN SSH SIGNATURE-----...",
    "method": "ssh",
    "publicKey": "ssh-ed25519 AAAAC3..."
  }
}

The signature field is optional — unsigned approvals remain valid for teams that don't configure signing. The method value is whatever the adapter reports — core treats it as opaque metadata.

What gets signed

The hash of the artifact hashes — the same data that's already in artifactHashes. This binds the approval to a specific content snapshot. If the spec changes after approval, the hash won't match and the approval is invalidated (this already works today — signing adds cryptographic proof of who approved).

Configuration

# specd.yaml
signing:
  required: true          # approvals must be signed (default: false)
  method: ssh             # string — resolved by composition to concrete Signer/Verifier/Resolver

The method value is a free string — composition maps it to the corresponding Signer, SignatureVerifier, and AllowedSignersResolver implementations. Custom methods are supported by registering custom adapters. Core never interprets this value.

When signing.required: false (default), the NoOpSigner is wired and approvals work exactly as today. No behavior change for existing users.

Recommended default: SSH signing

For the built-in ssh method:

  1. Every developer already has SSH keys
  2. Git already supports SSH signing (since 2.34)
  3. No key servers, no trust model complexity
  4. Tooling exists: ssh-keygen -Y sign / ssh-keygen -Y verify

Example infrastructure implementations

Signer:

Adapter Implementation
ssh Reads key from user.signingkey, calls ssh-keygen -Y sign
gpg Uses gpg --sign
sigstore OIDC auth flow, keyless signing
(disabled) NoOpSigner — returns undefined
(custom) Any implementation of Signer port

AllowedSignersResolver:

Adapter Implementation
FileAllowedSigners Reads .specd/allowed-signers file (git-compatible format)
GitHubKeysResolver Fetches public keys from https://github.com/<user>.keys
LDAPResolver Looks up keys in corporate directory
AlwaysTrustResolver Returns true for all actors (signing without trust store)
(custom) Any implementation of AllowedSignersResolver port

SignatureVerifier:

Adapter Implementation
SSHSignatureVerifier Calls ssh-keygen -Y verify, uses injected AllowedSignersResolver
GPGSignatureVerifier Uses gpg --verify with keyring
SigstoreVerifier Verifies against transparency log
(disabled) Always returns true
(custom) Any implementation of SignatureVerifier port

What this does NOT cover

  • Authorization — who CAN approve what. Trust store says who has a valid key, not who has permission to approve a specific spec. That's a separate concern (roles, CODEOWNERS-style rules).
  • Key revocation — if a key is compromised, update the trust store (remove from file, revoke in LDAP, etc.) and re-approve. No CRL/OCSP mechanism built into core.

Questions to explore

  • Should sign-off events also be signed, or just spec-approved?
  • Should the archive itself be signed (batch signature over all approvals)?
  • How does this interact with CI environments (no interactive key access)?
  • Is there value in signing change creation events too (proof of authorship)?

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions