diff --git a/cmd/protocol-writers/main.go b/cmd/protocol-writers/main.go new file mode 100644 index 00000000..511828b1 --- /dev/null +++ b/cmd/protocol-writers/main.go @@ -0,0 +1,330 @@ +// Copyright 2026 Certen Protocol + +// protocol-writers gives each writer to certen-protocol.acme its own key (RB4-F51). +// +// Every writer - the bridge and the seven validators' write-backs - used to sign with one key on +// certen-protocol.acme/book/1, the book's highest-priority page: one leak was authority over the whole +// identity, nothing showed which service wrote what, and the key could not be revoked without stopping +// every writer at once. The writers move to book/2, one key each, and book/2 is denied UpdateKeyPage and +// UpdateAccountAuth, so a writer key can write but cannot change who may; book/1 keeps that authority. +// +// Private keys are read from, or generated into, env files and never printed; only public keys are. +// +// protocol-writers keygen -env-file F -var V generate a key into F as V (refuses if V is set) +// protocol-writers pubkey -env-file F -var V print the public key of V in F +// protocol-writers buy-credits -env-file F -var V -to URL -credits N buy N credits for URL from V's lite token account +// protocol-writers create-page -env-file F -var V -book URL -keys H,H... create the next page of URL holding the keys +// protocol-writers restrict -env-file F -var V -page URL deny URL UpdateKeyPage and UpdateAccountAuth +// protocol-writers add-key -env-file F -var V -page URL -key H add a key to URL +// protocol-writers remove-key -env-file F -var V -page URL -key H remove a key from URL +// protocol-writers show -url URL print a key page's keys, version, credits and denials +// +// Every signing command signs with the highest-priority page (book/1) of the target's book, or with V's +// lite identity for buy-credits, and waits until the network has executed the transaction. +package main + +import ( + "bufio" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "math/big" + "os" + "strings" + "time" + + "gitlab.com/accumulatenetwork/accumulate/pkg/api/v3" + "gitlab.com/accumulatenetwork/accumulate/pkg/api/v3/jsonrpc" + "gitlab.com/accumulatenetwork/accumulate/pkg/errors" + "gitlab.com/accumulatenetwork/accumulate/pkg/types/messaging" + "gitlab.com/accumulatenetwork/accumulate/pkg/url" + "gitlab.com/accumulatenetwork/accumulate/protocol" +) + +func main() { + if len(os.Args) < 2 { + fail("usage: protocol-writers keygen|pubkey|buy-credits|create-page|restrict|add-key|remove-key|show [flags]") + } + cmd := os.Args[1] + fs := flag.NewFlagSet(cmd, flag.ExitOnError) + apiURL := fs.String("api", "https://kermit.accumulatenetwork.io/v3", "Accumulate v3 API") + envFile := fs.String("env-file", "", "env file holding the key") + envVar := fs.String("var", "", "variable holding the key (64-byte hex ed25519)") + to := fs.String("to", "", "credit recipient") + credits := fs.Uint64("credits", 0, "whole credits to buy") + book := fs.String("book", "", "key book") + keys := fs.String("keys", "", "comma-separated public keys (hex)") + page := fs.String("page", "", "key page") + key := fs.String("key", "", "public key (hex)") + target := fs.String("url", "", "key page to show") + _ = fs.Parse(os.Args[2:]) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + c := jsonrpc.NewClient(*apiURL) + + switch cmd { + case "keygen": + require(*envFile, "-env-file", *envVar, "-var") + pub, err := keygen(*envFile, *envVar) + check(err) + fmt.Printf("%s %x\n", *envVar, pub) + case "pubkey": + sk := loadKey(*envFile, *envVar) + fmt.Printf("%x\n", sk.Public()) + case "buy-credits": + require(*to, "-to") + if *credits == 0 { + fail("-credits is required") + } + check(buyCredits(ctx, c, loadKey(*envFile, *envVar), parse(*to), *credits)) + case "create-page": + require(*book, "-book", *keys, "-keys") + body := &protocol.CreateKeyPage{} + for _, h := range strings.Split(*keys, ",") { + body.Keys = append(body.Keys, &protocol.KeySpecParams{KeyHash: keyHash(h)}) + } + b := parse(*book) + check(send(ctx, c, loadKey(*envFile, *envVar), b, b.JoinPath("1"), body)) + case "restrict": + require(*page, "-page") + p := parse(*page) + check(send(ctx, c, loadKey(*envFile, *envVar), p, topPage(p), &protocol.UpdateKeyPage{Operation: []protocol.KeyPageOperation{ + &protocol.UpdateAllowedKeyPageOperation{Deny: []protocol.TransactionType{ + protocol.TransactionTypeUpdateKeyPage, protocol.TransactionTypeUpdateAccountAuth}}, + }})) + case "add-key", "remove-key": + require(*page, "-page", *key, "-key") + p := parse(*page) + var op protocol.KeyPageOperation = &protocol.AddKeyOperation{Entry: protocol.KeySpecParams{KeyHash: keyHash(*key)}} + if cmd == "remove-key" { + op = &protocol.RemoveKeyOperation{Entry: protocol.KeySpecParams{KeyHash: keyHash(*key)}} + } + check(send(ctx, c, loadKey(*envFile, *envVar), p, topPage(p), &protocol.UpdateKeyPage{Operation: []protocol.KeyPageOperation{op}})) + case "show": + require(*target, "-url") + kp, err := loadPage(ctx, c, parse(*target)) + check(err) + fmt.Printf("%v version=%d credits=%s threshold=%d denied=%v\n", kp.Url, kp.Version, + protocol.FormatAmount(kp.CreditBalance, protocol.CreditPrecisionPower), kp.AcceptThreshold, kp.TransactionBlacklist.Unpack()) + for _, e := range kp.Keys { + fmt.Printf(" key %x\n", e.PublicKeyHash) + } + default: + fail("unknown command " + cmd) + } +} + +// keygen writes a new key into envFile as envVar and returns its public key. A file that already sets +// envVar is refused: a key in use is replaced deliberately, never by a second run of this. +func keygen(envFile, envVar string) (ed25519.PublicKey, error) { + if v, err := readEnv(envFile, envVar); err == nil && v != "" { + return nil, fmt.Errorf("%s already sets %s", envFile, envVar) + } else if err != nil && !os.IsNotExist(err) { + return nil, err + } + pub, sk, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + f, err := os.OpenFile(envFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + defer f.Close() + if st, err := f.Stat(); err == nil && st.Size() > 0 { + if b, err := os.ReadFile(envFile); err == nil && b[len(b)-1] != '\n' { + if _, err := f.WriteString("\n"); err != nil { + return nil, err + } + } + } + if _, err := fmt.Fprintf(f, "%s=%x\n", envVar, []byte(sk)); err != nil { + return nil, err + } + return pub, f.Sync() +} + +func buyCredits(ctx context.Context, c *jsonrpc.Client, sk ed25519.PrivateKey, to *url.URL, credits uint64) error { + ns, err := c.NetworkStatus(ctx, api.NetworkStatusOptions{Partition: protocol.Directory}) + if err != nil { + return fmt.Errorf("network status: %w", err) + } + oracle := ns.Oracle.Price + if oracle == 0 { + return fmt.Errorf("the network reports no ACME oracle price") + } + // credits(units) = acme * CreditUnitsPerFiatUnit * oracle / (AcmeOraclePrecision * AcmePrecision) + amount := new(big.Int).SetUint64(credits * protocol.CreditPrecision) + amount.Mul(amount, big.NewInt(int64(protocol.AcmeOraclePrecision*protocol.AcmePrecision))) + amount.Div(amount, new(big.Int).SetUint64(protocol.CreditUnitsPerFiatUnit*oracle)) + lid := protocol.LiteAuthorityForKey(sk.Public().(ed25519.PublicKey), protocol.SignatureTypeED25519) + lta := lid.JoinPath(protocol.ACME) + before, _ := creditBalance(ctx, c, to) + fmt.Printf("buying %d credits for %v with %s ACME from %v (oracle %d)\n", credits, to, + protocol.FormatBigAmount(amount, protocol.AcmePrecisionPower), lta, oracle) + if err := send(ctx, c, sk, lta, lid, &protocol.AddCredits{Recipient: to, Amount: *amount, Oracle: oracle}); err != nil { + return err + } + // The credits arrive in a synthetic deposit after the purchase executes. + for ctx.Err() == nil { + if after, err := creditBalance(ctx, c, to); err == nil && after > before { + fmt.Printf("%v credits: %s -> %s\n", to, protocol.FormatAmount(before, protocol.CreditPrecisionPower), + protocol.FormatAmount(after, protocol.CreditPrecisionPower)) + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("the purchase executed but %v's credits did not arrive: %w", to, ctx.Err()) +} + +// send signs body for principal as signer and waits until the network has executed it. +func send(ctx context.Context, c *jsonrpc.Client, sk ed25519.PrivateKey, principal, signer *url.URL, body protocol.TransactionBody) error { + version := uint64(1) + if _, _, isPage := protocol.ParseKeyPageUrl(signer); isPage { + kp, err := loadPage(ctx, c, signer) + if err != nil { + return err + } + if _, _, ok := kp.EntryByKeyHash(sha256sum(sk.Public().(ed25519.PublicKey))); !ok { + return fmt.Errorf("the key %x is not on %v", sk.Public(), signer) + } + version = kp.Version + } + tx := &protocol.Transaction{Header: protocol.TransactionHeader{Principal: principal}, Body: body} + sig := &protocol.ED25519Signature{ + PublicKey: sk.Public().(ed25519.PublicKey), + Signer: signer, + SignerVersion: version, + Timestamp: uint64(time.Now().UnixMicro()), + } + init, err := sig.Initiator() + if err != nil { + return fmt.Errorf("initiator: %w", err) + } + copy(tx.Header.Initiator[:], init.MerkleHash()) + protocol.SignED25519(sig, sk, nil, tx.GetHash()) + sig.TransactionHash = *(*[32]byte)(tx.GetHash()) + env := &messaging.Envelope{Transaction: []*protocol.Transaction{tx}, Signatures: []protocol.Signature{sig}} + subs, err := c.Submit(ctx, env, api.SubmitOptions{}) + if err != nil { + return fmt.Errorf("submit %v: %w", body.Type(), err) + } + for _, s := range subs { + if !s.Success { + return fmt.Errorf("submit %v: %s", body.Type(), s.Message) + } + } + txid := tx.ID() + fmt.Printf("%v on %v signed by %v: %v\n", body.Type(), principal, signer, txid) + for ctx.Err() == nil { + r, err := api.Querier2{Querier: c}.QueryTransaction(ctx, txid, nil) + if err == nil && r.Status == errors.Delivered { + fmt.Printf(" executed\n") + return nil + } + if err == nil && r.Error != nil { + return fmt.Errorf("%v %v failed: %v", body.Type(), txid, r.Error) + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("%v %v: not executed: %w", body.Type(), txid, ctx.Err()) +} + +func loadPage(ctx context.Context, c *jsonrpc.Client, u *url.URL) (*protocol.KeyPage, error) { + kp := new(protocol.KeyPage) + if _, err := (api.Querier2{Querier: c}).QueryAccountAs(ctx, u, nil, &kp); err != nil { + return nil, fmt.Errorf("query %v: %w", u, err) + } + return kp, nil +} + +func creditBalance(ctx context.Context, c *jsonrpc.Client, u *url.URL) (uint64, error) { + r, err := api.Querier2{Querier: c}.QueryAccount(ctx, u, nil) + if err != nil { + return 0, err + } + ca, ok := r.Account.(protocol.AccountWithCredits) + if !ok { + return 0, fmt.Errorf("%v holds no credits", u) + } + return ca.GetCreditBalance(), nil +} + +// topPage is the highest-priority page of p's book, the only page allowed to restrict or re-key p. +func topPage(p *url.URL) *url.URL { + b, _, ok := protocol.ParseKeyPageUrl(p) + if !ok { + fail(fmt.Sprintf("%v is not a key page", p)) + } + return b.JoinPath("1") +} + +func loadKey(envFile, envVar string) ed25519.PrivateKey { + require(envFile, "-env-file", envVar, "-var") + v, err := readEnv(envFile, envVar) + check(err) + b, err := hex.DecodeString(v) + if err != nil || len(b) != ed25519.PrivateKeySize { + fail(fmt.Sprintf("%s in %s is not a %d-byte hex ed25519 private key", envVar, envFile, ed25519.PrivateKeySize)) + } + return ed25519.PrivateKey(b) +} + +// readEnv returns the last value envFile gives envVar, as docker compose's env_file reads it. +func readEnv(envFile, envVar string) (string, error) { + f, err := os.Open(envFile) + if err != nil { + return "", err + } + defer f.Close() + var v string + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1<<20), 1<<20) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if k, val, ok := strings.Cut(line, "="); ok && strings.TrimSpace(strings.TrimPrefix(k, "export ")) == envVar { + v = strings.Trim(strings.TrimSpace(val), `"'`) + } + } + return v, sc.Err() +} + +func keyHash(h string) []byte { + b, err := hex.DecodeString(strings.TrimSpace(h)) + if err != nil || len(b) != ed25519.PublicKeySize { + fail(fmt.Sprintf("%q is not a %d-byte hex public key", h, ed25519.PublicKeySize)) + } + return sha256sum(b) +} + +func sha256sum(b []byte) []byte { h := sha256.Sum256(b); return h[:] } + +func parse(s string) *url.URL { + u, err := url.Parse(s) + check(err) + return u +} + +func require(pairs ...string) { + for i := 0; i+1 < len(pairs); i += 2 { + if pairs[i] == "" { + fail(pairs[i+1] + " is required") + } + } +} + +func check(err error) { + if err != nil { + fail(err.Error()) + } +} + +func fail(msg string) { + fmt.Fprintln(os.Stderr, "protocol-writers:", msg) + os.Exit(1) +} diff --git a/main.go b/main.go index c51d0143..3a3a13ac 100644 --- a/main.go +++ b/main.go @@ -1381,8 +1381,6 @@ func startValidator( PrivateKey: cpKey, AccountURL: cpAccount, SignerURL: cpSigner, - KeyPageIndex: 1, - KeyIndex: 0, ConfirmationTimeout: 30 * time.Second, MaxRetries: 3, RetryDelay: 3 * time.Second, @@ -1786,8 +1784,6 @@ func startValidator( PrivateKey: wb.key, AccountURL: wb.principal, SignerURL: wb.signer, - KeyPageIndex: 1, - KeyIndex: 0, ConfirmationTimeout: 2 * time.Minute, MaxRetries: 3, RetryDelay: 5 * time.Second, diff --git a/pkg/accumulate/key_page_version_test.go b/pkg/accumulate/key_page_version_test.go new file mode 100644 index 00000000..2f4bc67b --- /dev/null +++ b/pkg/accumulate/key_page_version_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 Certen Protocol + +package accumulate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func queryAnswering(t *testing.T, account map[string]interface{}) *LiteClientAdapter { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": 1, + "result": map[string]interface{}{"recordType": "account", "account": account}}) + })) + t.Cleanup(srv.Close) + a, err := NewLiteClientAdapter(&LiteClientConfig{NetworkURL: srv.URL, RequestTimeout: 5 * time.Second}) + if err != nil { + t.Fatal(err) + } + return a +} + +// RB4-F51: a key page whose version could not be read used to sign as version 1, which the network +// rejects once keys have been added to the page. +func TestAKeyPageVersionIsReadOrAnError(t *testing.T) { + v, err := queryAnswering(t, map[string]interface{}{"type": "keyPage", "version": 9}). + GetKeyPageVersion(context.Background(), "acc://x.acme/book/2") + if err != nil || v != 9 { + t.Fatalf("got %d, %v; want 9", v, err) + } + if v, err := queryAnswering(t, map[string]interface{}{"type": "keyPage"}). + GetKeyPageVersion(context.Background(), "acc://x.acme/book/2"); err == nil { + t.Fatalf("a key page without a version was read as version %d", v) + } +} diff --git a/pkg/accumulate/liteclient_adapter.go b/pkg/accumulate/liteclient_adapter.go index 903c1228..74d1918f 100644 --- a/pkg/accumulate/liteclient_adapter.go +++ b/pkg/accumulate/liteclient_adapter.go @@ -2114,8 +2114,7 @@ func (l *LiteClientAdapter) GetKeyPageVersion(ctx context.Context, signerURL str } } - // Default to 1 if not found (most key pages start at version 1) - return 1, nil + return 0, fmt.Errorf("%s: the response carries no key page version", signerURL) } // GetSignerNonce returns the current nonce for a signer (key page or lite identity) diff --git a/pkg/execution/accumulate_submitter.go b/pkg/execution/accumulate_submitter.go index a45c7680..9f8408d8 100644 --- a/pkg/execution/accumulate_submitter.go +++ b/pkg/execution/accumulate_submitter.go @@ -41,10 +41,8 @@ type AccumulateSubmitterImpl struct { publicKey ed25519.PublicKey // Account configuration - accountURL string // Principal account for write-back (e.g., "acc://certen.acme/proof-results") - signerURL string // Key page URL for signing (e.g., "acc://certen.acme/book/1") - keyPageIndex uint64 // Key page index (signer version) - keyIndex uint64 // Key index within the page + accountURL string // Principal account for write-back (e.g., "acc://certen.acme/proof-results") + signerURL string // Key page URL for signing (e.g., "acc://certen.acme/book/1") // Nonce and credit management nonceTracker *NonceTracker @@ -68,10 +66,8 @@ type AccumulateSubmitterConfig struct { PrivateKey ed25519.PrivateKey // Account configuration - AccountURL string // Data account for write-back - SignerURL string // Key page URL - KeyPageIndex uint64 - KeyIndex uint64 + AccountURL string // Data account for write-back + SignerURL string // Key page URL // Timing configuration ConfirmationTimeout time.Duration @@ -136,8 +132,6 @@ func NewAccumulateSubmitter(cfg *AccumulateSubmitterConfig) (*AccumulateSubmitte publicKey: publicKey, accountURL: cfg.AccountURL, signerURL: cfg.SignerURL, - keyPageIndex: cfg.KeyPageIndex, - keyIndex: cfg.KeyIndex, nonceTracker: nonceTracker, creditChecker: creditChecker, confirmationTimeout: confirmationTimeout, @@ -271,8 +265,9 @@ func (s *AccumulateSubmitterImpl) getKeyPageVersion(ctx context.Context) (uint64 // Query the key page to get current version version, err := s.client.GetKeyPageVersion(ctx, s.signerURL) if err != nil { - s.logger.Printf("⚠️ Failed to query key page version, using fallback: %v", err) - return s.keyPageIndex, nil // Fall back to configured value + // A guessed version is rejected by the network once the page has changed (every key added or + // removed bumps it), so the write-back fails here, naming why, rather than there. + return 0, fmt.Errorf("key page version of %s: %w", s.signerURL, err) } s.logger.Printf("🔑 Key page version from query: %d", version) return version, nil