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:
- Every developer already has SSH keys
- Git already supports SSH signing (since 2.34)
- No key servers, no trust model complexity
- 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
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:
What's missing
Design
Core decides what to sign, adapters decide how
The
Signeris a port defined in core. Core callssigner.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 theSignerat composition time.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, andAllowedSignersResolver— are opaque to core. It calls them; it never inspects how they work internally. This allows custom implementations without changing core.Ports
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 bySignatureVerifierimplementations, not by core directly.Use case flow
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
signaturefield is optional — unsigned approvals remain valid for teams that don't configure signing. Themethodvalue 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
The
methodvalue is a free string — composition maps it to the correspondingSigner,SignatureVerifier, andAllowedSignersResolverimplementations. Custom methods are supported by registering custom adapters. Core never interprets this value.When
signing.required: false(default), theNoOpSigneris wired and approvals work exactly as today. No behavior change for existing users.Recommended default: SSH signing
For the built-in
sshmethod:ssh-keygen -Y sign/ssh-keygen -Y verifyExample infrastructure implementations
Signer:
sshuser.signingkey, callsssh-keygen -Y signgpggpg --signsigstoreNoOpSigner— returns undefinedSignerportAllowedSignersResolver:
FileAllowedSigners.specd/allowed-signersfile (git-compatible format)GitHubKeysResolverhttps://github.com/<user>.keysLDAPResolverAlwaysTrustResolverAllowedSignersResolverportSignatureVerifier:
SSHSignatureVerifierssh-keygen -Y verify, uses injectedAllowedSignersResolverGPGSignatureVerifiergpg --verifywith keyringSigstoreVerifierSignatureVerifierportWhat this does NOT cover
Questions to explore
Related
ActorResolverport)