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
51 changes: 38 additions & 13 deletions docs/optional-trust-registry-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,18 @@ existing PVCs, or re-enroll the existing administrators.
Reuse successful Job receipts on retry.
5. Set `ADMIN_DIDS` to the existing `session.VtcDid`, start the registry, and
verify its advertised transport and write path.
6. Append a signed update to the **existing** VTC DID history through the VTA,
adding the referral while preserving the DID, keys, other services, and
history. Publish the extended log through the existing DID host. A self-hosted
VTC log, if present, must also be updated using the supported install path.
6. Append a signed update to the **existing** VTC DID history through the
**running** VTA, adding the referral while preserving the DID, keys, other
services, and history. The farm mints a did:key for this one operation, grants
it as a context-scoped admin of the VTC's context in the same stopped-VTA
window pattern as step 3 (`vta acl create --role admin --contexts <vtc>
--expires 15m`), logs in to the VTA over HTTPS as that key
(`auth/challenge/0.1`, `auth/authenticate/0.2`), reads the VTA's head of the
log (`vta/webvh/dids/get/1.0`), and sends `vta/webvh/dids/update/1.0` with the
document plus `expectedVersionId`. The VTA signs the new entry and publishes
it to the DID host itself, over TSP or DIDComm. The key is never stored; the
grant expires on its own. A self-hosted VTC log, if present, must also be
updated using the supported install path.
7. Read the current VTC runtime config immediately before writing, merge only
`[registry].did`, and restart the VTC using the existing component-config
backup/write/start/rollback pattern. Do not render a fresh setup config over
Expand All @@ -236,17 +244,34 @@ Upstream explicitly supports changing the referral after initial mint via
VTA-side DID editing ([setup schema][vtc-setup]). This requires a signed DID-log
update, not a change to a locally served JSON file alone.

The existing Farm referral update still uses offline `vta did-mgmt dids edit`.
That CLI has only REST available for publishing to an external DID host. A host
advertising only DIDComm/TSP therefore still blocks **existing-stack referral
updates**. Headless Registry provisioning does not fix this separate path;
it needs an authenticated online VTA edit or upstream offline transport support.
New stacks avoid that edit by passing `registry_did` to VTC setup. Do not report
existing-stack activation as verified until the referral publication succeeds.
**Why the edit goes through the running VTA and not the offline CLI.** The
offline `vta did-mgmt dids edit` holds no mediator session, so the only
transport it can initiate toward a DID host is REST. A host provisioned through
a VTA with a mediator, which is every Full Stack `dids` daemon, advertises only
`TSPTransport` and `DIDCommMessaging`, so the offline command stops with
`no transport in common … it advertises [tsp, didcomm] and this agent can
initiate [rest]`, after it has already committed the new entry locally. The
running VTA holds the mediator session and publishes over TSP or DIDComm; it
also re-publishes an unconfirmed local head before applying the next update,
which is how a stack left in that state by the earlier offline attempt heals.
`internal/vta` is the client; `publishRegistryReferral` in
`internal/setup/trust_registry_referral.go` is the step. It skips the grant and
the edit when the published document already carries the referral, and sends an
otherwise unchanged document when the VTA's head carries it but the host does
not, which makes the VTA reconcile and publish. New stacks avoid the edit by
passing `registry_did` to VTC setup. Do not report existing-stack activation as
verified until the referral publication succeeds.

A mediator-only host also blocks every other offline publish (`dids create`,
`dids register`). The durable fix on the host side is one more online edit, of
the daemon's own DID, adding `{"id":"<daemon did>#trust-tasks","type":"TrustTaskHTTPS","serviceEndpoint":"https://<dids host>/api"}`;
the daemon's readiness check logs that exact entry, and its control plane reloads
on publish. That is a separate change, not part of registry enablement.

**Downtime:** the VTC needs a restart for the new client/syncer. Provisioning
authorization may also need a brief VTA maintenance window: current Farm ACL
operations use `scale to 0 -> offline Job -> restart`. Prefer compatible live
authorization needs two brief VTA maintenance windows on an existing stack, the
registry context grant and the referral grant, each `scale to 0 -> offline Job ->
restart`; a retry that finds the referral already published takes neither. Prefer compatible live
APIs where a suitable credential already exists, but do not promise zero VTA
downtime or bypass the fjall lock. DID publication should use the live hosting
interface. Always restart a temporarily stopped component on error. Preserve
Expand Down
135 changes: 15 additions & 120 deletions internal/didhosting/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
Expand All @@ -14,11 +13,11 @@ import (
"strings"
"time"

"github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer"
"github.com/google/uuid"

"github.com/ic3software/vtafarm-api/internal/didkey"
"github.com/ic3software/vtafarm-api/internal/didresolver"
"github.com/ic3software/vtafarm-api/internal/trusttaskclient"
"github.com/ic3software/vtafarm-api/internal/webvh"
)

Expand All @@ -30,28 +29,21 @@ const (
aclGrantType = "https://trusttasks.org/spec/acl/grant/0.1"
aclRevokeType = "https://trusttasks.org/spec/acl/revoke/0.1"

maxTrustTaskResponseBytes = 1 << 20
maxTrustTaskResponseBytes = trusttaskclient.MaxResponseBytes
)

type trustTask struct {
ID string `json:"id"`
Type string `json:"type"`
ThreadID string `json:"threadId,omitempty"`
Issuer string `json:"issuer,omitempty"`
Recipient string `json:"recipient,omitempty"`
IssuedAt string `json:"issuedAt,omitempty"`
Payload json.RawMessage `json:"payload"`
Proof *proof `json:"proof,omitempty"`
}
// The envelope, the proof and the cryptosuite steps are the shared outbound
// client's; the names below keep this package's own vocabulary.
type (
trustTask = trusttaskclient.Document
proof = trusttaskclient.Proof
)

type proof struct {
Type string `json:"type"`
Cryptosuite string `json:"cryptosuite"`
VerificationMethod string `json:"verificationMethod"`
Created string `json:"created"`
ProofPurpose string `json:"proofPurpose"`
ProofValue string `json:"proofValue,omitempty"`
}
var (
signTrustTask = trusttaskclient.Sign
proofMessage = trusttaskclient.ProofMessage
decodeProofValue = trusttaskclient.DecodeProofValue
)

// Client talks to one DID Hosting control plane exclusively through signed
// Trust Tasks. serverDid is supplied by provisioning and is both the request
Expand Down Expand Up @@ -222,106 +214,9 @@ func (c *Client) call(ctx context.Context, typeURI string, payload any) (json.Ra
return reply.Payload, nil
}

func signTrustTask(doc *trustTask, kid string, privateKey ed25519.PrivateKey, created string) error {
doc.Proof = nil
proofConfig := proof{
Type: "DataIntegrityProof",
Cryptosuite: "eddsa-jcs-2022",
VerificationMethod: kid,
Created: created,
ProofPurpose: "authentication",
}
message, err := proofMessage(doc, proofConfig)
if err != nil {
return err
}
proofConfig.ProofValue = "z" + didresolver.EncodeBase58BTC(ed25519.Sign(privateKey, message))
doc.Proof = &proofConfig
return nil
}

func (c *Client) verifyReply(ctx context.Context, reply, request *trustTask) error {
if reply.ThreadID != request.ID {
return errors.New("DID hosting Trust Task response is not threaded to the request")
}
if reply.Issuer != c.serverDid {
return errors.New("DID hosting Trust Task response issuer does not match the service DID")
}
if reply.Recipient != c.clientDid {
return errors.New("DID hosting Trust Task response recipient does not match DID_HOSTING_DID")
}
issuedAt, err := time.Parse(time.RFC3339, reply.IssuedAt)
if err != nil || issuedAt.Before(time.Now().Add(-5*time.Minute)) || issuedAt.After(time.Now().Add(didresolver.DefaultClockSkew)) {
return errors.New("DID hosting Trust Task response is outside the freshness window")
}
if reply.Proof == nil {
return errors.New("DID hosting Trust Task response has no proof")
}
p := *reply.Proof
if p.Type != "DataIntegrityProof" || p.Cryptosuite != "eddsa-jcs-2022" || p.ProofPurpose != "authentication" {
return errors.New("DID hosting Trust Task response has an unsupported proof")
}
proofDid, _, ok := strings.Cut(p.VerificationMethod, "#")
if !ok || proofDid != c.serverDid {
return errors.New("DID hosting Trust Task response proof does not belong to the service DID")
}
created, err := time.Parse(time.RFC3339, p.Created)
if err != nil || created.After(time.Now().Add(didresolver.DefaultClockSkew)) {
return errors.New("DID hosting Trust Task response proof has an invalid creation time")
}
signature, err := decodeProofValue(p.ProofValue)
if err != nil {
return err
}
publicKey, err := c.resolver.ResolveAuthenticationKey(ctx, c.serverDid, p.VerificationMethod)
if err != nil {
return fmt.Errorf("resolve DID hosting response key: %w", err)
}
reply.Proof = nil
message, err := proofMessage(reply, p)
reply.Proof = &p
if err != nil {
return fmt.Errorf("canonicalize Trust Task response: %w", err)
}
if len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, message, signature) {
return errors.New("DID hosting Trust Task response proof signature is invalid")
if err := trusttaskclient.VerifyReply(ctx, c.resolver, reply, request, trusttaskclient.Reply{Service: c.serverDid, Client: c.clientDid}); err != nil {
return fmt.Errorf("DID hosting %w", err)
}
return nil
}

func proofMessage(doc *trustTask, p proof) ([]byte, error) {
p.ProofValue = ""
proofCanonical, err := canonicalJSON(p)
if err != nil {
return nil, fmt.Errorf("canonicalize proof options: %w", err)
}
documentCanonical, err := canonicalJSON(doc)
if err != nil {
return nil, fmt.Errorf("canonicalize document: %w", err)
}
proofHash := sha256.Sum256(proofCanonical)
documentHash := sha256.Sum256(documentCanonical)
message := make([]byte, 0, len(proofHash)+len(documentHash))
message = append(message, proofHash[:]...)
message = append(message, documentHash[:]...)
return message, nil
}

func canonicalJSON(value any) ([]byte, error) {
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
return jsoncanonicalizer.Transform(encoded)
}

func decodeProofValue(value string) ([]byte, error) {
if !strings.HasPrefix(value, "z") {
return nil, errors.New("DID hosting Trust Task proofValue is not base58btc multibase")
}
decoded, err := didresolver.DecodeBase58BTC(value[1:])
if err != nil || didresolver.EncodeBase58BTC(decoded) != value[1:] {
return nil, errors.New("DID hosting Trust Task proofValue is not canonical base58btc")
}
return decoded, nil
}
21 changes: 4 additions & 17 deletions internal/setup/trust_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,24 +209,11 @@ func (o *Orchestrator) EnableTrustRegistry(ctx context.Context, s *model.SetupSe
if err = o.RegistryPhase(s, "publish_referral"); err != nil {
return err
}
// Resolve again immediately before editing; never apply the preflight snapshot.
document, err = webvh.NewResolver(webvh.DefaultTimeout).ResolveDocument(ctx, s.VtcDid)
if err != nil {
return err
}
updated, changed, err := RegistryReferral(document, s.VtcDid, s.TrustRegistryDid)
if err != nil {
// Through the running VTA, which reads its own head immediately before
// editing; the preflight snapshot above is never applied.
if err = o.publishRegistryReferral(ctx, ns, s); err != nil {
return err
}
if changed {
err = o.registryStopped(ctx, ns, s, "vta", func() error {
_, e := o.registryJob(ctx, ns, s, "referral", s.VtaImage, "/work/vta", []string{"vta", "did-mgmt", "dids", "edit", "--did", s.VtcDid, "--document", "/config/document.json", "--no-confirm"}, []k8s.PVCMount{{Name: "vta-data", ClaimName: k8s.FSVtaName(s.ID), MountPath: "/work/vta"}}, map[string][]byte{"document.json": updated})
return e
})
if err != nil {
return err
}
}
if err = o.RegistryPhase(s, "configure_vtc"); err != nil {
return err
}
Expand Down Expand Up @@ -258,7 +245,7 @@ mv config.toml.vtafarm-registry-next config.toml`}, []k8s.PVCMount{{Name: "vtc-d
if err != nil {
return err
}
if _, changed, err = RegistryReferral(document, s.VtcDid, s.TrustRegistryDid); err != nil || changed {
if _, changed, err := RegistryReferral(document, s.VtcDid, s.TrustRegistryDid); err != nil || changed {
return errors.New("published VTC referral has not converged; retry after DID hosting is updated")
}
s.TrustRegistryState = "active"
Expand Down
119 changes: 119 additions & 0 deletions internal/setup/trust_registry_referral.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package setup

import (
"context"
"errors"
"fmt"
"log"
"time"

"github.com/ic3software/vtafarm-api/internal/k8s"
"github.com/ic3software/vtafarm-api/internal/model"
"github.com/ic3software/vtafarm-api/internal/vta"
"github.com/ic3software/vtafarm-api/internal/webvh"
)

const (
// referralGrantLabel names the ACL entry the referral edit runs under.
referralGrantLabel = "trust-registry-referral"
// referralGrantTTL bounds that entry; the VTA's sweeper removes it. The
// key itself lives only in this process, for the length of one call.
referralGrantTTL = "15m"
// referralHostWait is how long to give the restarted VTA to bring its
// mediator session up before the edit, whose publish needs it.
referralHostWait = 3 * time.Minute
// referralDidsServer is the id the session's DID hosting daemon is
// registered under at the VTA (`vta did-mgmt servers add --id dids`).
referralDidsServer = "dids"
)

// newVtaClient is the seam a test replaces to stand in a VTA.
var newVtaClient = vta.NewEphemeral

// publishRegistryReferral adds the TrustRegistry referral to the VTC's
// published DID document, through the running VTA.
//
// Why the running VTA: the offline `vta did-mgmt dids edit` this step used to
// run holds no mediator session, so it can publish only to a DID host that
// advertises an HTTPS endpoint. A host provisioned through a VTA with a
// mediator, which is every Full Stack `dids` daemon, advertises TSP and
// DIDComm alone, and the offline edit stopped with `no transport in common`
// after committing the entry locally. The running VTA holds the mediator
// session, publishes over either, and re-publishes an unconfirmed local head
// before applying the next update, which is also how a stack left behind by
// that offline attempt converges.
//
// The farm has no standing credential at a user's VTA, and does not want one.
// It mints a did:key for this call, grants it as an admin of the VTC's context
// for fifteen minutes in the same stopped-VTA window pattern the registry context
// grant uses, logs in over HTTPS, and sends the update signed by that key. On
// return the key is garbage; the entry expires on its own.
//
// Idempotent by observation, not by record: a published document that
// already carries the referral means nothing to do, with no grant and no
// restart; a VTA head that carries it while the host does not gets an update
// with an unchanged document, which the VTA answers by reconciling the host
// and then appending a version. Either way the verify phase that follows reads
// the host, not this function's belief.
func (o *Orchestrator) publishRegistryReferral(ctx context.Context, ns string, s *model.SetupSession) error {
published, err := webvh.NewResolver(webvh.DefaultTimeout).ResolveDocument(ctx, s.VtcDid)
if err != nil {
return fmt.Errorf("resolve the VTC DID before publishing the referral: %w", err)
}
if _, changed, err := RegistryReferral(published, s.VtcDid, s.TrustRegistryDid); err != nil {
return err
} else if !changed {
log.Printf("[orchestrator] fs session %d: %s already refers to the registry; nothing to publish", s.ID, s.VtcDid)
return nil
}

client, err := newVtaClient(s.PublicURL(), s.VtaDid)
if err != nil {
return err
}
grant := []string{"vta", "acl", "create", "--did", client.DID(), "--role", "admin", "--contexts", s.VtcName, "--expires", referralGrantTTL, "--label", referralGrantLabel}
err = o.registryStopped(ctx, ns, s, "vta", func() error {
_, e := o.registryJob(ctx, ns, s, "referral", s.VtaImage, "/work/vta", grant, []k8s.PVCMount{{Name: "vta-data", ClaimName: k8s.FSVtaName(s.ID), MountPath: "/work/vta"}}, nil)
return e
})
if err != nil {
return err
}

if err = client.Login(ctx); err != nil {
return fmt.Errorf("log in to the VTA as the referral grant %s: %w", client.DID(), err)
}
if err = client.WaitUntilHostReachable(ctx, referralDidsServer, referralHostWait); err != nil {
return fmt.Errorf("the restarted VTA has no route to the DID host, so it could not publish: %w", err)
}
didLog, err := client.DIDLog(ctx, s.VtcDid)
if err != nil {
return err
}
head, state, err := vta.LatestLogEntry(didLog)
if err != nil {
return fmt.Errorf("read the VTA's head of %s: %w", s.VtcDid, err)
}
updated, changed, err := RegistryReferral(state, s.VtcDid, s.TrustRegistryDid)
if err != nil {
return err
}
if !changed {
// The VTA's head has the referral and the host does not: an earlier
// publish failed after the local commit. An update that changes
// nothing in the document still makes the VTA re-publish that head
// first, then append a version the host takes.
log.Printf("[orchestrator] fs session %d: the VTA's head of %s (%s) carries the referral but the host does not; publishing through a new version", s.ID, s.VtcDid, head)
updated = state
}
newVersion, err := client.UpdateDIDDocument(ctx, s.VtcDid, updated, head)
if err != nil {
var refusal *vta.TaskError
if errors.As(err, &refusal) && refusal.Conflict() {
return fmt.Errorf("the VTC DID advanced past %s while the referral was being published; retry registry enablement: %w", head, err)
}
return err
}
log.Printf("[orchestrator] fs session %d: TrustRegistry referral published; %s is now version %s", s.ID, s.VtcDid, newVersion)
return nil
}
Loading
Loading