Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions sdk/go/receipt/keyprovider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package receipt

import (
"errors"
"fmt"
"io"
"os"
"sync"
)
Comment thread
ojongerius marked this conversation as resolved.
Comment thread
ojongerius marked this conversation as resolved.

// productionEnvVar marks a production deployment. GeneratingKeyProvider refuses
// to run when it is set to the exact value "true" (see ADR-0018 § Key
// generation policy and ADR-0019 § S2).
const productionEnvVar = "AGENTRECEIPTS_PRODUCTION"

// devWarning is the one-line, dev-only warning emitted at most once per process
// when a GeneratingKeyProvider is constructed outside production.
const devWarning = "⚠ GeneratingKeyProvider is dev-only — set AGENTRECEIPTS_PRODUCTION=true to disable in production\n"

// ErrProductionKeyProvider is returned by NewGeneratingKeyProvider when it is
// constructed in a production deployment (AGENTRECEIPTS_PRODUCTION=true).
//
// Generating a keypair on the fly mints a fresh DID on every cold start,
// producing an unverifiable audit trail with no error surfaced. Production
// deployments must provision a keypair out-of-band and load it via a file,
// env-var, or secret-store key provider. See the ephemeral-compute deployment
// guide.
var ErrProductionKeyProvider = errors.New(
"GeneratingKeyProvider is disabled in production (AGENTRECEIPTS_PRODUCTION=true): " +
"provision a keypair out-of-band and load it via a file, env-var, or secret-store key provider",
)

// KeyProvider supplies the Ed25519 keypair the SDK signs with. It models
// environments where the private key bytes are accessible locally (files,
// env vars, in-memory fixtures). Environments where the private key is never
// extractable (KMS, HSM, TPM) implement Signer instead (see ADR-0018).
type KeyProvider interface {
GetKeyPair() (KeyPair, error)
}

// devWarnOnce guarantees the dev-only warning is written at most once per
// process, regardless of how many GeneratingKeyProviders are constructed.
var devWarnOnce sync.Once

// warnWriter is the sink for the dev-only warning. It defaults to os.Stderr
// and is only reassigned by tests.
var warnWriter io.Writer = os.Stderr

// GeneratingKeyProvider generates a fresh Ed25519 keypair for development and
// bootstrap use only. The keypair is stable for the lifetime of the provider.
//
// It is explicitly prohibited in production: constructing one when
// AGENTRECEIPTS_PRODUCTION=true causes NewGeneratingKeyProvider to return
// ErrProductionKeyProvider before any key is generated.
type GeneratingKeyProvider struct {
keyPair KeyPair
}

// NewGeneratingKeyProvider generates a fresh keypair for dev/bootstrap use.
//
// It returns ErrProductionKeyProvider if AGENTRECEIPTS_PRODUCTION=true. In all
// other cases it emits a one-time stderr warning that the provider is dev-only
// and returns a provider holding a freshly generated keypair.
func NewGeneratingKeyProvider() (*GeneratingKeyProvider, error) {
if os.Getenv(productionEnvVar) == "true" {
return nil, ErrProductionKeyProvider
Comment thread
ojongerius marked this conversation as resolved.
}

devWarnOnce.Do(func() {
_, _ = io.WriteString(warnWriter, devWarning)
})

kp, err := GenerateKeyPair()
if err != nil {
return nil, fmt.Errorf("generate keypair: %w", err)
}
Comment thread
ojongerius marked this conversation as resolved.
Comment thread
ojongerius marked this conversation as resolved.
return &GeneratingKeyProvider{keyPair: kp}, nil
}

// GetKeyPair returns the keypair generated when the provider was constructed.
func (g *GeneratingKeyProvider) GetKeyPair() (KeyPair, error) {
return g.keyPair, nil
}
127 changes: 127 additions & 0 deletions sdk/go/receipt/keyprovider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package receipt

import (
"bytes"
"errors"
"io"
"strings"
"sync"
"testing"
)

// resetDevWarning redirects the dev-only warning to w and resets the
// once-latch so a test starts from a clean process-wide state. The original
// sink and a fresh latch are restored on cleanup.
func resetDevWarning(t *testing.T, w io.Writer) {
t.Helper()
prev := warnWriter
warnWriter = w
devWarnOnce = sync.Once{}
t.Cleanup(func() {
warnWriter = prev
devWarnOnce = sync.Once{}
})
}

func TestGeneratingKeyProviderThrowsInProduction(t *testing.T) {
resetDevWarning(t, io.Discard)
t.Setenv(productionEnvVar, "true")

provider, err := NewGeneratingKeyProvider()
if !errors.Is(err, ErrProductionKeyProvider) {
t.Fatalf("expected ErrProductionKeyProvider, got %v", err)
}
if provider != nil {
t.Error("expected nil provider when production guard fires")
}
}

func TestGeneratingKeyProviderGeneratesOutsideProduction(t *testing.T) {
resetDevWarning(t, io.Discard)
t.Setenv(productionEnvVar, "")
Comment thread
ojongerius marked this conversation as resolved.

provider, err := NewGeneratingKeyProvider()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

kp, err := provider.GetKeyPair()
if err != nil {
t.Fatalf("GetKeyPair: %v", err)
}
if kp.PublicKey == "" || kp.PrivateKey == "" {
t.Fatal("expected a non-empty keypair")
}

// The keypair is stable for the lifetime of the provider.
kp2, err := provider.GetKeyPair()
if err != nil {
t.Fatalf("GetKeyPair: %v", err)
}
if kp2 != kp {
t.Error("expected GetKeyPair to return a stable keypair")
}
Comment thread
ojongerius marked this conversation as resolved.
Comment thread
ojongerius marked this conversation as resolved.

// The generated keypair must produce a verifiable signature.
unsigned := Create(CreateInput{
Issuer: Issuer{ID: "did:agent:test"},
Principal: Principal{ID: "did:user:test"},
Action: Action{Type: "filesystem.file.read", RiskLevel: RiskLow},
Outcome: Outcome{Status: StatusSuccess},
Chain: Chain{Sequence: 1, ChainID: "chain-1"},
})
signed, err := Sign(unsigned, kp.PrivateKey, "did:agent:test#key-1")
if err != nil {
t.Fatalf("Sign with generated key: %v", err)
}
valid, err := Verify(signed, kp.PublicKey)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !valid {
t.Error("expected the generated keypair to produce a valid signature")
}
}

func TestGeneratingKeyProviderWarnsExactlyOncePerProcess(t *testing.T) {
var buf bytes.Buffer
resetDevWarning(t, &buf)
t.Setenv(productionEnvVar, "")

for range 3 {
if _, err := NewGeneratingKeyProvider(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

got := strings.Count(buf.String(), "GeneratingKeyProvider is dev-only")
if got != 1 {
t.Errorf("expected exactly one dev-only warning, got %d", got)
}
}

func TestGeneratingKeyProviderOnlyExactTrueIsProduction(t *testing.T) {
resetDevWarning(t, io.Discard)
t.Setenv(productionEnvVar, "1")

provider, err := NewGeneratingKeyProvider()
if err != nil {
t.Fatalf("expected no error for AGENTRECEIPTS_PRODUCTION=1, got %v", err)
}
if provider == nil {
t.Error("expected a valid provider when AGENTRECEIPTS_PRODUCTION is not exactly \"true\"")
}
}

func TestGeneratingKeyProviderDoesNotWarnInProduction(t *testing.T) {
var buf bytes.Buffer
resetDevWarning(t, &buf)
t.Setenv(productionEnvVar, "true")

if _, err := NewGeneratingKeyProvider(); !errors.Is(err, ErrProductionKeyProvider) {
t.Fatalf("expected ErrProductionKeyProvider, got %v", err)
}
if buf.Len() != 0 {
t.Errorf("expected no warning when the production guard fires, got %q", buf.String())
}
}
9 changes: 9 additions & 0 deletions sdk/py/src/agent_receipts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
generate_forensic_key_pair,
)
from agent_receipts.receipt.hash import canonicalize, hash_receipt, sha256
from agent_receipts.receipt.key_provider import (
GeneratingKeyProvider,
KeyProvider,
ProductionKeyProviderError,
)
from agent_receipts.receipt.signing import (
KeyPair,
generate_key_pair,
Expand Down Expand Up @@ -194,6 +199,10 @@
"signReceipt",
"verify_receipt",
"verifyReceipt",
# Key providers (ADR-0018; production guard per ADR-0019 §S2)
"GeneratingKeyProvider",
"KeyProvider",
"ProductionKeyProviderError",
# Chain
"ChainVerification",
"ReceiptVerification",
Expand Down
9 changes: 9 additions & 0 deletions sdk/py/src/agent_receipts/receipt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
)
from agent_receipts.receipt.create import CreateReceiptInput, create_receipt
from agent_receipts.receipt.hash import canonicalize, hash_receipt, sha256
from agent_receipts.receipt.key_provider import (
GeneratingKeyProvider,
KeyProvider,
ProductionKeyProviderError,
)
from agent_receipts.receipt.signing import (
KeyPair,
generate_key_pair,
Expand Down Expand Up @@ -69,6 +74,10 @@
"generate_key_pair",
"sign_receipt",
"verify_receipt",
# Key providers (ADR-0018; production guard per ADR-0019 §S2)
"GeneratingKeyProvider",
"KeyProvider",
"ProductionKeyProviderError",
# Chain
"ChainVerification",
"ReceiptVerification",
Expand Down
87 changes: 87 additions & 0 deletions sdk/py/src/agent_receipts/receipt/key_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Dev-only key provider with a production guard (ADR-0018, ADR-0019 §S2)."""

from __future__ import annotations

import os
import sys
import threading
from dataclasses import replace
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from agent_receipts.receipt.signing import generate_key_pair

if TYPE_CHECKING:
from agent_receipts.receipt.signing import KeyPair

_PRODUCTION_ENV_VAR = "AGENTRECEIPTS_PRODUCTION"
"""Environment variable that marks a production deployment. A
:class:`GeneratingKeyProvider` refuses to run when it is set to the exact
value ``"true"`` (see ADR-0018 § Key generation policy and ADR-0019 § S2)."""

_DEV_WARNING = (
"⚠ GeneratingKeyProvider is dev-only — set AGENTRECEIPTS_PRODUCTION=true "
"to disable in production"
)
"""The one-line, dev-only warning emitted at most once per process."""

# One stderr warning per process, regardless of how many providers are built.
_dev_warning_lock = threading.Lock()
_dev_warning_emitted = False
Comment thread
ojongerius marked this conversation as resolved.

Comment thread
ojongerius marked this conversation as resolved.

class ProductionKeyProviderError(RuntimeError):
"""Raised when a :class:`GeneratingKeyProvider` is constructed in production.

Generating a keypair on the fly mints a fresh DID on every cold start,
producing an unverifiable audit trail with no error surfaced. Production
deployments must provision a keypair out-of-band and load it via a file,
env-var, or secret-store key provider. See the ephemeral-compute
deployment guide.
"""


@runtime_checkable
class KeyProvider(Protocol):
"""Supplies the Ed25519 keypair the SDK signs with.

Models environments where the private key bytes are accessible locally
(files, env vars, in-memory fixtures). Environments where the private key
is never extractable (KMS, HSM, TPM) implement ``Signer`` instead
(see ADR-0018).
"""

def get_key_pair(self) -> KeyPair:
raise NotImplementedError


class GeneratingKeyProvider:
"""Generates a fresh Ed25519 keypair for development and bootstrap use only.

The keypair is stable for the lifetime of the provider.

It is explicitly prohibited in production: constructing one when
``AGENTRECEIPTS_PRODUCTION=true`` raises :class:`ProductionKeyProviderError`
before any key is generated.
"""

def __init__(self) -> None:
global _dev_warning_emitted

if os.environ.get(_PRODUCTION_ENV_VAR) == "true":
raise ProductionKeyProviderError(
"GeneratingKeyProvider is disabled in production "
"(AGENTRECEIPTS_PRODUCTION=true): provision a keypair "
"out-of-band and load it via a file, env-var, or "
"secret-store key provider"
)

with _dev_warning_lock:
if not _dev_warning_emitted:
_dev_warning_emitted = True
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
ojongerius marked this conversation as resolved.
Dismissed
print(_DEV_WARNING, file=sys.stderr)

self._key_pair = generate_key_pair()

def get_key_pair(self) -> KeyPair:
"""Return the keypair generated when the provider was constructed."""
return replace(self._key_pair)
Loading
Loading