Initial project snapshot
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# Installation Authority Tooling
|
||||
|
||||
The Product Root private key must stay outside the repository and outside the
|
||||
cluster database. The backend stores only the public key and signed activation
|
||||
records.
|
||||
|
||||
Generate a Product Root key pair:
|
||||
|
||||
```powershell
|
||||
go run scripts/installation/product-root-tool.go generate-key
|
||||
```
|
||||
|
||||
Configure production backend nodes with the generated `public_key_b64`:
|
||||
|
||||
```powershell
|
||||
$env:INSTALLATION_AUTHORITY_MODE = "strict"
|
||||
$env:INSTALLATION_PRODUCT_ROOT_PUBLIC_KEY_B64 = "<public_key_b64>"
|
||||
```
|
||||
|
||||
Create a signed first-owner activation manifest:
|
||||
|
||||
```powershell
|
||||
go run scripts/installation/product-root-tool.go activate `
|
||||
-private-key-file C:\secure\rap-product-root.json `
|
||||
-install-id install-prod-001 `
|
||||
-owner-email owner@example.com `
|
||||
-expires-at 2026-05-01T00:00:00Z `
|
||||
-environment production
|
||||
```
|
||||
|
||||
Use the output `activation_payload` and `activation_signature` in the admin
|
||||
panel first-owner screen or in `POST /api/v1/installation/bootstrap-owner`.
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const activationSchemaVersion = "rap.installation.activation.v1"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fail("usage: go run scripts/installation/product-root-tool.go <generate-key|activate> [flags]")
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "generate-key":
|
||||
generateKey()
|
||||
case "activate":
|
||||
activate(os.Args[2:])
|
||||
default:
|
||||
fail("unknown command %q", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func generateKey() {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
fail("generate key: %v", err)
|
||||
}
|
||||
writeJSON(map[string]string{
|
||||
"key_type": "ed25519",
|
||||
"private_key_b64": base64.StdEncoding.EncodeToString(privateKey),
|
||||
"public_key_b64": base64.StdEncoding.EncodeToString(publicKey),
|
||||
})
|
||||
}
|
||||
|
||||
func activate(args []string) {
|
||||
fs := flag.NewFlagSet("activate", flag.ExitOnError)
|
||||
privateKeyB64 := fs.String("private-key-b64", "", "base64 Ed25519 private key")
|
||||
privateKeyFile := fs.String("private-key-file", "", "file with base64 key or generate-key JSON")
|
||||
installID := fs.String("install-id", "", "installation id; generated when empty")
|
||||
ownerEmail := fs.String("owner-email", "", "first owner email")
|
||||
role := fs.String("role", "platform_admin", "platform_admin or platform_recovery_admin")
|
||||
expiresAt := fs.String("expires-at", "", "RFC3339 expiry time")
|
||||
environment := fs.String("environment", "", "optional environment label")
|
||||
nonce := fs.String("nonce", "", "optional nonce")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
fail("parse flags: %v", err)
|
||||
}
|
||||
|
||||
keyText := strings.TrimSpace(*privateKeyB64)
|
||||
if keyText == "" && strings.TrimSpace(*privateKeyFile) != "" {
|
||||
content, err := os.ReadFile(*privateKeyFile)
|
||||
if err != nil {
|
||||
fail("read private key file: %v", err)
|
||||
}
|
||||
keyText = extractPrivateKeyText(content)
|
||||
}
|
||||
privateKey := decodePrivateKey(keyText)
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(*ownerEmail))
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
fail("owner-email is required")
|
||||
}
|
||||
normalizedRole := strings.TrimSpace(*role)
|
||||
if normalizedRole != "platform_admin" && normalizedRole != "platform_recovery_admin" {
|
||||
fail("role must be platform_admin or platform_recovery_admin")
|
||||
}
|
||||
id := strings.TrimSpace(*installID)
|
||||
if id == "" {
|
||||
id = randomID("install")
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"schema_version": activationSchemaVersion,
|
||||
"install_id": id,
|
||||
"owner_email": email,
|
||||
"platform_role": normalizedRole,
|
||||
"issued_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if strings.TrimSpace(*expiresAt) != "" {
|
||||
if _, err := time.Parse(time.RFC3339, strings.TrimSpace(*expiresAt)); err != nil {
|
||||
fail("expires-at must be RFC3339: %v", err)
|
||||
}
|
||||
payload["expires_at"] = strings.TrimSpace(*expiresAt)
|
||||
}
|
||||
if strings.TrimSpace(*environment) != "" {
|
||||
payload["environment"] = strings.TrimSpace(*environment)
|
||||
}
|
||||
if strings.TrimSpace(*nonce) != "" {
|
||||
payload["nonce"] = strings.TrimSpace(*nonce)
|
||||
}
|
||||
|
||||
canonical, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
fail("canonicalize payload: %v", err)
|
||||
}
|
||||
signature := ed25519.Sign(privateKey, canonical)
|
||||
writeJSON(map[string]any{
|
||||
"activation_payload": payload,
|
||||
"activation_signature": base64.StdEncoding.EncodeToString(signature),
|
||||
})
|
||||
}
|
||||
|
||||
func extractPrivateKeyText(content []byte) string {
|
||||
text := strings.TrimSpace(string(content))
|
||||
var generated struct {
|
||||
PrivateKey string `json:"private_key_b64"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &generated); err == nil && strings.TrimSpace(generated.PrivateKey) != "" {
|
||||
return strings.TrimSpace(generated.PrivateKey)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func decodePrivateKey(value string) ed25519.PrivateKey {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
fail("private key is required")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
if raw, rawErr := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value)); rawErr == nil {
|
||||
decoded = raw
|
||||
} else {
|
||||
fail("private key must be base64 encoded: %v", err)
|
||||
}
|
||||
}
|
||||
if len(decoded) != ed25519.PrivateKeySize {
|
||||
fail("private key must decode to %d bytes", ed25519.PrivateKeySize)
|
||||
}
|
||||
return ed25519.PrivateKey(decoded)
|
||||
}
|
||||
|
||||
func randomID(prefix string) string {
|
||||
var bytes [16]byte
|
||||
if _, err := rand.Read(bytes[:]); err != nil {
|
||||
fail("generate id: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(bytes[:]))
|
||||
}
|
||||
|
||||
func writeJSON(value any) {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
fail("write json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user