Skip to content
Open
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
173 changes: 173 additions & 0 deletions rfc/private-key-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# RFC: Standardizing Private Key Material Handling

## Summary

This RFC proposes standardizing how we handle private key material across our services, adopting PEM format as the single standard for private key storage and configuration.

## Problem Statement

Our services currently accept private keys in multiple formats:

- PEM files (with inconsistent encryption practices).
- JSON files (non-standard, service-specific schemas).
- Base64 encoded strings (raw key material, no metadata).
- Multibase base64 padded strings (uncommon in standard tooling).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok a few observations:

  1. I believe you introduced PEM.
  2. I don't know if we've ever actually used JSON to pass around private keys although I know the w3 CLI has the option to print them as JSON.
  3. I don't believe we've ever passed plain base64 encoded strings around but maybe I'm incorrect.

So there was only really the 1 format prior - for which some of the reasons below don't apply.

I'm not trying to oppose the proposal here but I think if this was phrased more as "switch from multibase base64 padded private keys to PEM" then I think it would be more accurate.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right on all points - thank you for the clarification.

I don't believe we've ever passed plain base64 encoded strings around but maybe I'm incorrect.

You're correct - looking at my shell history, we've been using multibase base64 padded strings, not plain base64.

I believe you introduced PEM

Yes, I did introduce PEM to the storage node. The current state is:

  • storage id gen returns JSON with a DID and multibase-base64-padded private key.
  • I added --type=pem option for PEM output (needed for Curio's API authentication).
  • I added storage id parse to convert between formats, and to extract DIDs from PEM files.

The PEM format was specifically introduced to work with Curio's API, which requires PEM-formatted public keys for authentication.

I think if this was phrased more as "switch from multibase base64 padded private keys to PEM" then I think it would be more accurate

Yeah, the accurate framing is probably: "Switch from multibase base64 padded private keys to PEM". JSON is only used as an output wrapper in some cases, not as a key format itself.

It's worth noting that at the end of this doc the need for custom tooling to extract DIDs from PEM files is outlined. Curious what your thoughts are on this.


This creates:

- **Security inconsistency**: Different formats have varying security properties.
- **Implementation complexity**: Each service implements multiple parsers.
- **Cognitive overhead**: Developers must remember which format works where.
- **Error-prone deployments**: Format mismatches cause configuration failures.

## Proposed Solution: PEM Standard

**Format**: PEM-encoded private keys using PKCS#8 format

```
-----BEGIN PRIVATE KEY-----
MC4CAQA...
-----END PRIVATE KEY-----
```

### Why PEM?

1. **Universal tooling support**: OpenSSL, ssh-keygen, and all major crypto libraries support PEM natively.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but typically we want to display the DID at the same time, so we have to dip back into our own tooling anyways.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. **Self-documenting format**: Clear headers (`-----BEGIN PRIVATE KEY-----`) identify content type.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤷 the multibase encoded string is also self describing - the decoded bytes are also prefixed with the alogorithm. PEM has the advantage of being human readable to some extent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main concerns with multibase for private key material center around tooling, file conventions, and security.

  • Most common cryptographic tools (OpenSSL, ssh-keygen, keytool, etc.) have native support for PEM format when working with private keys. With multibase-encoded keys, we typically need custom tooling or conversion steps for common operations like key generation, inspection, or format conversion.

  • PEM has well-established conventions - .pem files, standard permissions (600), and clear practices around key storage. With multibase strings, we'd need to define our own conventions: What file extension? Plain text files? JSON wrapper?

  • PEM has built-in support for passphrase encryption. For multibase, we'd need to implement our own encryption scheme or wrap it in another format, essentially recreating what PEM already provides

At the end of the day, specifically for the storage node, I'd prefer to maintain key material in a file, rather than a string passed over the CLI or environment variable.

3. **Algorithm agnostic**: Supports our current Ed25519 keys and future algorithms (RSA, ECDSA, etc.).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, same for existing format.

4. **Built-in security**: Native support for passphrase encryption.
5. **Human readable**: Base64 encoded with clear delimiters for debugging.
6. **Industry standard**: Developers already understand `.pem` files.

### Usage Examples

**Generating Ed25519 keys**:

```bash
# Generate Ed25519 private key
openssl genpkey -algorithm ed25519 -out service-key.pem

# View key details
openssl pkey -in service-key.pem -text -noout

# Extract public key
openssl pkey -in service-key.pem -pubout -out service-pubkey.pem
```

**Securing keys**:

```bash
# Set proper permissions
chmod 600 service-key.pem

# Encrypt for storage
openssl pkey -in service-key.pem -out service-key-encrypted.pem -aes256

# Decrypt when needed
openssl pkey -in service-key-encrypted.pem -out service-key.pem
```

**Future algorithm support** (no code changes needed):

```bash
# RSA
openssl genpkey -algorithm rsa -pkeyopt rsa_keygen_bits:4096 -out rsa-key.pem

# ECDSA
openssl genpkey -algorithm ec -pkeyopt ec_paramgen_curve:P-256 -out ecdsa-key.pem
```

## Security Requirements

1. **File permissions**: Private key files MUST have 600 permissions.
2. **No environment variables**: Private keys MUST NOT be passed via environment variables.
3. **Encryption at rest**: Keys SHOULD be encrypted when stored.
4. **Memory handling**: Keys SHOULD be loaded once at startup, not repeatedly read.
5. **Secure deletion**: Key material SHOULD be securely overwritten when removed from memory.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have historically used AWS SSM for deployed services and would continue to do so regardless of encoding (I understand this is not the same situation for storage nodes).


## Implementation Guidelines

### Service Configuration

```go
type Config struct {
PrivateKeyPath string `flag:"private-key" env:"-" description:"Path to PEM-encoded private key"`
}

func loadPrivateKey(path string) (crypto.PrivateKey, error) {
// Check file permissions
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("accessing private key file: %w", err)
}
if info.Mode().Perm() != 0600 {
return nil, fmt.Errorf("private key file must have 600 permissions, has %v", info.Mode().Perm())
}

pemData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading private key: %w", err)
}

block, _ := pem.Decode(pemData)
if block == nil {
return nil, errors.New("failed to parse PEM block")
}

// Require unencrypted keys for services
// NB: depending on the context this method is called in, we may prompt for passphrase.
if x509.IsEncryptedPEMBlock(block) {
return nil, errors.New("encrypted private keys are not supported; " +
"use file permissions (600) for security instead")
}

// Parse unencrypted PKCS#8 private key
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
// Fallback for legacy formats
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
privateKey = key
} else if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
privateKey = key
} else {
return nil, fmt.Errorf("parsing private key: %w", err)
}
}

// Validate and return supported key types
// NB: at present we only support ed25519
switch key := privateKey.(type) {
case ed25519.PrivateKey:
return key, nil
default:
return nil, fmt.Errorf("unsupported private key type: %T", privateKey)
}
}
```

### Standard File Locations

- Development: `./keys/service-key.pem`
- Production: `/etc/service/keys/service-key.pem`
- Container: `/run/secrets/service-key`

## Benefits

- **Enhanced security**: Consistent security model across all services.
- **Reduced complexity**: Single format reduces code and documentation.
- **Better tooling**: Leverage decades of OpenSSL development.
- **Future proof**: Algorithm changes require no format changes.
- **Developer friendly**: Well-understood format with extensive documentation.

## DID Derivation from Private Keys

Users often need to derive a DID (Decentralized Identifier) from the private key for identity purposes. This section briefly covers how to extract the DID from a PEM-encoded private key.

### What is a DID and How do we Encode ours?

A DID is a decentralized identifier that corresponds to your public key. In our system, Ed25519 keys produce DIDs in the format: `did:key:...` where the suffix is a Base58BTC-encoded representation of the public key with multicodec prefixes.

### Deriving DID from PEM

At present, there does not exist any standard tooling to derive a DID used by our system from a PEM File containing a private key, or public key for that matter. This necessitates the creation of a custom CLI tool which allows users to derive a DID string from their public key.
Comment on lines +162 to +172
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alanshaw assuming we align on PEM as our mechanism for handling key material, curious to hear your thoughts on these bits.