From f260b2dd580cae52770359adff61b0660ad6a7c5 Mon Sep 17 00:00:00 2001 From: Geoff Turk Date: Fri, 23 Oct 2026 11:17:30 +0200 Subject: [PATCH 1/2] feat: publish the registry referral through the running VTA The existing-stack enablement appended the TrustRegistry referral to the VTC's DID document with the offline `vta did-mgmt dids edit`, run in a Job on the VTA's PVC. That command holds no mediator session, so the only transport it can initiate toward a DID host is REST, and a Full Stack `dids` daemon, provisioned through the VTA with a mediator, advertises TSP and DIDComm alone. The Job failed with "no transport in common ... it advertises [tsp, didcomm] and this agent can initiate [rest]" after the VTA had already committed the entry locally. The referral is now published through the running VTA, which holds the mediator session and publishes over either transport, and which re-publishes an unconfirmed local head before applying the next update, so a stack left behind by the offline attempt converges too. internal/vta is the farm's first client for a VTA: a did:key minted for one call, the two login tasks (auth/challenge/0.1, auth/authenticate/0.2) over /trust-tasks for a bearer token, then vta/webvh/dids/get/1.0 for the VTA's head of the log and vta/webvh/dids/update/1.0 with the document and expectedVersionId. Refusals come back typed (TaskError, with the reason the VTA gives, "conflict" among them). vta/webvh/servers/domains/0.1, a read-only task the VTA answers only by reaching the host, is the probe that waits out the gap after a restart in which the VTA serves HTTPS but has no mediator session yet; an update in that gap would commit and then fail to publish. publishRegistryReferral (internal/setup/trust_registry_referral.go) replaces the referral Job: it skips everything when the published document already carries the referral; otherwise it grants the key as an admin of the VTC's context for an hour (`vta acl create`) in the same stopped-VTA window pattern as the registry context grant, logs in, waits for the host to be reachable, reads the head, and sends the update. When the VTA's head carries the referral but the host does not, it sends the document unchanged, which makes the VTA reconcile the host and append a version. The verify phase still reads the host. The envelope, proof and reply checks the DID hosting client carried move to internal/trusttaskclient so the two clients share one implementation of eddsa-jcs-2022; didhosting keeps its names as aliases and its tests are unchanged. docs/optional-trust-registry-design.md: step 6 and the paragraph on the offline edit now describe this path, and name the host-side change that would let offline publishes work (a TrustTaskHTTPS entry on the daemon's own DID), which is separate from registry enablement. Verified with gofmt, go vet ./... and go test on internal/vta, internal/trusttaskclient, internal/didhosting and internal/setup, all in a golang:1.26 container. EnableTrustRegistry itself still has no end-to-end test; the new step is behind the newVtaClient seam for one. Signed-off-by: Geoff Turk --- docs/optional-trust-registry-design.md | 51 ++- internal/didhosting/client.go | 135 +------ internal/setup/trust_registry.go | 21 +- internal/setup/trust_registry_referral.go | 119 ++++++ internal/trusttaskclient/trusttaskclient.go | 187 +++++++++ .../trusttaskclient/trusttaskclient_test.go | 93 +++++ internal/vta/client.go | 367 ++++++++++++++++++ internal/vta/client_test.go | 342 ++++++++++++++++ 8 files changed, 1165 insertions(+), 150 deletions(-) create mode 100644 internal/setup/trust_registry_referral.go create mode 100644 internal/trusttaskclient/trusttaskclient.go create mode 100644 internal/trusttaskclient/trusttaskclient_test.go create mode 100644 internal/vta/client.go create mode 100644 internal/vta/client_test.go diff --git a/docs/optional-trust-registry-design.md b/docs/optional-trust-registry-design.md index 5b27be0..7d73350 100644 --- a/docs/optional-trust-registry-design.md +++ b/docs/optional-trust-registry-design.md @@ -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 + --expires 1h`), 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 @@ -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":"#trust-tasks","type":"TrustTaskHTTPS","serviceEndpoint":"https:///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 diff --git a/internal/didhosting/client.go b/internal/didhosting/client.go index 7e41bde..dfe55f5 100644 --- a/internal/didhosting/client.go +++ b/internal/didhosting/client.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/ed25519" - "crypto/sha256" "encoding/base64" "encoding/json" "errors" @@ -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" ) @@ -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 @@ -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 -} diff --git a/internal/setup/trust_registry.go b/internal/setup/trust_registry.go index 7bcb9c7..c06b371 100644 --- a/internal/setup/trust_registry.go +++ b/internal/setup/trust_registry.go @@ -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 } @@ -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" diff --git a/internal/setup/trust_registry_referral.go b/internal/setup/trust_registry_referral.go new file mode 100644 index 0000000..fcb4da3 --- /dev/null +++ b/internal/setup/trust_registry_referral.go @@ -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 = "1h" + // 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 an hour 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 +} diff --git a/internal/trusttaskclient/trusttaskclient.go b/internal/trusttaskclient/trusttaskclient.go new file mode 100644 index 0000000..56c2649 --- /dev/null +++ b/internal/trusttaskclient/trusttaskclient.go @@ -0,0 +1,187 @@ +// Package trusttaskclient holds what an outbound Trust Task client needs and +// nothing a server needs: the document envelope, the eddsa-jcs-2022 proof a +// did:key makes over it, and the checks a signed reply has to pass before its +// payload is believed. The DID hosting client and the VTA client are both +// built on it, so the two never disagree about what a proof covers. +// +// The proof follows the Data Integrity eddsa-jcs-2022 cryptosuite: the signed +// message is sha256(JCS(proof options without proofValue)) followed by +// sha256(JCS(document without proof)), and proofValue is the base58btc +// multibase ("z…") of the Ed25519 signature. Both the DID hosting daemon and +// the VTA verify the JSON exactly as received, so a document is signed after +// its final marshalling shape is fixed and never re-encoded afterwards. +package trusttaskclient + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer" + + "github.com/ic3software/vtafarm-api/internal/didresolver" +) + +// MaxResponseBytes caps what a reply may carry; anything over it is refused +// unread. Both services cap their own bodies at the same size. +const MaxResponseBytes = 1 << 20 + +// Document is the Trust Task envelope as sent and received. Fields the spec +// leaves optional are omitted when empty so the signed bytes carry no nulls. +type Document 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"` +} + +// Proof is a Data Integrity proof. Only the members the cryptosuite signs are +// carried: a verifier refuses a proof with members outside this set. +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"` +} + +// Sign attaches an eddsa-jcs-2022 proof with purpose `authentication`, made +// by privateKey, naming kid as the verification method. Any proof already on +// the document is discarded first. +func Sign(doc *Document, kid string, privateKey ed25519.PrivateKey, created string) error { + doc.Proof = nil + options := Proof{ + Type: "DataIntegrityProof", + Cryptosuite: "eddsa-jcs-2022", + VerificationMethod: kid, + Created: created, + ProofPurpose: "authentication", + } + message, err := ProofMessage(doc, options) + if err != nil { + return err + } + options.ProofValue = "z" + didresolver.EncodeBase58BTC(ed25519.Sign(privateKey, message)) + doc.Proof = &options + return nil +} + +// ProofMessage is the 64 bytes the cryptosuite signs for doc under the proof +// options p (its ProofValue is ignored). doc.Proof must be nil. +func ProofMessage(doc *Document, 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 +} + +// CanonicalJSON is RFC 8785 JCS of value's JSON encoding. +func CanonicalJSON(value any) ([]byte, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + return jsoncanonicalizer.Transform(encoded) +} + +// DecodeProofValue reads a canonical base58btc multibase proofValue. +func DecodeProofValue(value string) ([]byte, error) { + if !strings.HasPrefix(value, "z") { + return nil, errors.New("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("Trust Task proofValue is not canonical base58btc") + } + return decoded, nil +} + +// Reply is the identity a signed reply must prove and whom it must address. +type Reply struct { + // Service is the DID the reply must be issued and signed by. + Service string + // Client is the DID the reply must be addressed to. + Client string + // Now is the clock the freshness checks use; nil means time.Now. + Now func() time.Time +} + +// VerifyReply checks that reply answers request and was signed by the +// service: threaded to the request id, issued by the service to the client, +// issued within the last five minutes (plus clock skew), carrying an +// eddsa-jcs-2022 `authentication` proof under a key the service's DID +// document lists for authentication, which verifies over the reply as +// received. resolver fetches that key. +func VerifyReply(ctx context.Context, resolver didresolver.AuthenticationKeyResolver, reply, request *Document, expect Reply) error { + now := time.Now + if expect.Now != nil { + now = expect.Now + } + if reply.ThreadID != request.ID { + return errors.New("Trust Task response is not threaded to the request") + } + if reply.Issuer != expect.Service { + return errors.New("Trust Task response issuer does not match the service DID") + } + if reply.Recipient != expect.Client { + return errors.New("Trust Task response recipient does not match the client DID") + } + issuedAt, err := time.Parse(time.RFC3339, reply.IssuedAt) + if err != nil || issuedAt.Before(now().Add(-5*time.Minute)) || issuedAt.After(now().Add(didresolver.DefaultClockSkew)) { + return errors.New("Trust Task response is outside the freshness window") + } + if reply.Proof == nil { + return errors.New("Trust Task response has no proof") + } + p := *reply.Proof + if p.Type != "DataIntegrityProof" || p.Cryptosuite != "eddsa-jcs-2022" || p.ProofPurpose != "authentication" { + return errors.New("Trust Task response has an unsupported proof") + } + proofDid, _, ok := strings.Cut(p.VerificationMethod, "#") + if !ok || proofDid != expect.Service { + return errors.New("Trust Task response proof does not belong to the service DID") + } + created, err := time.Parse(time.RFC3339, p.Created) + if err != nil || created.After(now().Add(didresolver.DefaultClockSkew)) { + return errors.New("Trust Task response proof has an invalid creation time") + } + signature, err := DecodeProofValue(p.ProofValue) + if err != nil { + return err + } + publicKey, err := resolver.ResolveAuthenticationKey(ctx, expect.Service, p.VerificationMethod) + if err != nil { + return fmt.Errorf("resolve Trust Task 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("Trust Task response proof signature is invalid") + } + return nil +} diff --git a/internal/trusttaskclient/trusttaskclient_test.go b/internal/trusttaskclient/trusttaskclient_test.go new file mode 100644 index 0000000..1663959 --- /dev/null +++ b/internal/trusttaskclient/trusttaskclient_test.go @@ -0,0 +1,93 @@ +package trusttaskclient + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/ic3software/vtafarm-api/internal/didkey" + "github.com/ic3software/vtafarm-api/internal/didresolver" +) + +func keyPair(t *testing.T, fill byte) (did, kid string, private ed25519.PrivateKey) { + t.Helper() + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = fill + } + private = ed25519.NewKeyFromSeed(seed) + did, err := didkey.FromPublicKey(private.Public().(ed25519.PublicKey)) + if err != nil { + t.Fatal(err) + } + return did, did + "#" + strings.TrimPrefix(did, "did:key:"), private +} + +func TestSignedReplyRoundTrip(t *testing.T) { + clientDid, _, _ := keyPair(t, 1) + serviceDid, serviceKid, servicePrivate := keyPair(t, 2) + now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.UTC) + created := now.Format(time.RFC3339) + + request := Document{ID: "urn:uuid:req", Type: "https://example.org/task/0.1", Issuer: clientDid, Recipient: serviceDid, IssuedAt: created, Payload: json.RawMessage(`{"a":1}`)} + reply := Document{ID: "urn:uuid:rep", Type: request.Type + "#response", ThreadID: request.ID, Issuer: serviceDid, Recipient: clientDid, IssuedAt: created, Payload: json.RawMessage(`{"ok":true}`)} + if err := Sign(&reply, serviceKid, servicePrivate, created); err != nil { + t.Fatal(err) + } + if reply.Proof.ProofPurpose != "authentication" || reply.Proof.Cryptosuite != "eddsa-jcs-2022" || !strings.HasPrefix(reply.Proof.ProofValue, "z") { + t.Fatalf("proof %+v", reply.Proof) + } + + // The proof survives a trip through JSON, which is how it is received. + encoded, _ := json.Marshal(reply) + var received Document + if err := json.Unmarshal(encoded, &received); err != nil { + t.Fatal(err) + } + expect := Reply{Service: serviceDid, Client: clientDid, Now: func() time.Time { return now }} + if err := VerifyReply(context.Background(), didresolver.DIDKeyResolver{}, &received, &request, expect); err != nil { + t.Fatal(err) + } + if received.Proof == nil { + t.Fatal("verification stripped the proof") + } + + // One byte of payload changed after signing fails, and so does a reply + // threaded to another request, addressed elsewhere, or stale. + tampered := received + tampered.Payload = json.RawMessage(`{"ok":false}`) + if err := VerifyReply(context.Background(), didresolver.DIDKeyResolver{}, &tampered, &request, expect); err == nil || !strings.Contains(err.Error(), "signature is invalid") { + t.Fatalf("tampered payload: %v", err) + } + other := request + other.ID = "urn:uuid:other" + if err := VerifyReply(context.Background(), didresolver.DIDKeyResolver{}, &received, &other, expect); err == nil || !strings.Contains(err.Error(), "threaded") { + t.Fatalf("wrong thread: %v", err) + } + if err := VerifyReply(context.Background(), didresolver.DIDKeyResolver{}, &received, &request, Reply{Service: serviceDid, Client: "did:key:z6MkSomeoneElse", Now: expect.Now}); err == nil || !strings.Contains(err.Error(), "recipient") { + t.Fatalf("wrong recipient: %v", err) + } + late := Reply{Service: serviceDid, Client: clientDid, Now: func() time.Time { return now.Add(6 * time.Minute) }} + if err := VerifyReply(context.Background(), didresolver.DIDKeyResolver{}, &received, &request, late); err == nil || !strings.Contains(err.Error(), "freshness") { + t.Fatalf("stale reply: %v", err) + } +} + +func TestDecodeProofValueWantsCanonicalMultibase(t *testing.T) { + if _, err := DecodeProofValue("abc"); err == nil { + t.Fatal("a value without the z prefix decoded") + } + if _, err := DecodeProofValue("z0OIl"); err == nil { + t.Fatal("non-alphabet characters decoded") + } + signature := make([]byte, ed25519.SignatureSize) + signature[0] = 7 + encoded := "z" + didresolver.EncodeBase58BTC(signature) + decoded, err := DecodeProofValue(encoded) + if err != nil || len(decoded) != ed25519.SignatureSize || decoded[0] != 7 { + t.Fatalf("round trip %v %d", err, len(decoded)) + } +} diff --git a/internal/vta/client.go b/internal/vta/client.go new file mode 100644 index 0000000..819e6e9 --- /dev/null +++ b/internal/vta/client.go @@ -0,0 +1,367 @@ +// Package vta is the farm's client for a running VTA, over HTTPS, as a +// short-lived did:key the farm minted for one operation. +// +// A VTA has one authenticated surface: Trust Task documents POSTed to +// /trust-tasks. Logging in is two of them, `auth/challenge/0.1` (unsigned) +// and `auth/authenticate/0.2` (signed by the did:key), which yields a bearer +// token. Every later document carries that token and is signed by the same +// key, because the VTA checks that the proof's signer, the document's issuer +// and the session's subject are one DID. +// +// The farm uses this where an operation has to happen inside the running VTA +// rather than against its stopped store: a did:webvh update is one. The +// offline `vta did-mgmt dids edit` holds no mediator session, so it can +// publish only to a DID host that advertises an HTTPS endpoint; a host that +// advertises TSP or DIDComm alone, which is what a host provisioned through a +// VTA with a mediator gets, is reached by the running VTA alone. +package vta + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "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" +) + +const ( + trustTasksPath = "/trust-tasks" + + challengeType = "https://trusttasks.org/spec/auth/challenge/0.1" + authenticateType = "https://trusttasks.org/spec/auth/authenticate/0.2" + didGetType = "https://trusttasks.org/spec/vta/webvh/dids/get/1.0" + didUpdateType = "https://trusttasks.org/spec/vta/webvh/dids/update/1.0" + domainsType = "https://trusttasks.org/spec/vta/webvh/servers/domains/0.1" + errorType = "https://trusttasks.org/spec/trust-task-error/0.5" + + // ReasonConflict is `details.reason` when `expectedVersionId` no longer + // names the DID's local head. + ReasonConflict = "conflict" + // ReasonNotFound is `details.reason` for a DID the VTA does not hold, or + // one the caller may not update; the VTA does not say which. + ReasonNotFound = "not_found" +) + +// Client is one did:key's session with one VTA. +type Client struct { + baseURL string + vtaDid string + did string + kid string + key ed25519.PrivateKey + hc *http.Client + resolver didresolver.AuthenticationKeyResolver + now func() time.Time + token string + // probeInterval paces WaitUntilHostReachable. + probeInterval time.Duration +} + +// NewEphemeral mints a fresh Ed25519 did:key, held in memory only, for a +// VTA reached at baseURL whose DID is vtaDid. The key has no standing until +// the VTA's ACL names it; the caller grants that, scoped and expiring, and +// discards the client when the operation is done. +func NewEphemeral(baseURL, vtaDid string) (*Client, error) { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("mint ephemeral VTA client key: %w", err) + } + return newClient(baseURL, vtaDid, key) +} + +func newClient(baseURL, vtaDid string, key ed25519.PrivateKey) (*Client, error) { + baseURL = strings.TrimRight(baseURL, "/") + if baseURL == "" { + return nil, errors.New("VTA URL is required") + } + if vtaDid == "" { + return nil, errors.New("VTA DID is required") + } + did, err := didkey.FromPublicKey(key.Public().(ed25519.PublicKey)) + if err != nil { + return nil, err + } + return &Client{ + baseURL: baseURL, + vtaDid: vtaDid, + did: did, + kid: did + "#" + strings.TrimPrefix(did, "did:key:"), + key: key, + hc: &http.Client{ + Timeout: 60 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + resolver: didresolver.MethodResolver{WebVH: webvh.NewResolver(5 * time.Second)}, + now: time.Now, + probeInterval: 5 * time.Second, + }, nil +} + +// DID is the client's did:key, the subject of the ACL entry it needs. +func (c *Client) DID() string { return c.did } + +// Login runs the challenge and authenticate tasks and keeps the access token +// for the calls that follow. The VTA hands a challenge to any DID and refuses +// the authenticate step for one its ACL does not name, so a missing or +// expired grant surfaces here as a `permissionDenied` TaskError. +func (c *Client) Login(ctx context.Context) error { + var challenge struct { + Challenge string `json:"challenge"` + SessionID string `json:"sessionId"` + } + reply, err := c.send(ctx, challengeType, map[string]any{"subject": c.did}, false, false) + if err != nil { + return fmt.Errorf("VTA challenge: %w", err) + } + if err := json.Unmarshal(reply, &challenge); err != nil || challenge.Challenge == "" || challenge.SessionID == "" { + return errors.New("VTA challenge response is missing the challenge or session id") + } + + var tokens struct { + Tokens struct { + AccessToken string `json:"accessToken"` + } `json:"tokens"` + } + reply, err = c.send(ctx, authenticateType, map[string]any{"challenge": challenge.Challenge, "sessionId": challenge.SessionID}, true, false) + if err != nil { + return fmt.Errorf("VTA authenticate: %w", err) + } + if err := json.Unmarshal(reply, &tokens); err != nil || tokens.Tokens.AccessToken == "" { + return errors.New("VTA authenticate response carries no access token") + } + c.token = tokens.Tokens.AccessToken + return nil +} + +// DIDLog returns the did.jsonl the VTA holds for did, one log entry per +// line. The last line is the local head, which may be ahead of what the DID +// host serves if an earlier publish failed; the VTA re-publishes it before +// its next update. +func (c *Client) DIDLog(ctx context.Context, did string) (string, error) { + reply, err := c.send(ctx, didGetType, map[string]any{"did": did, "includeLog": true}, true, true) + if err != nil { + return "", fmt.Errorf("read the DID log from the VTA: %w", err) + } + var got struct { + Log string `json:"log"` + } + if err := json.Unmarshal(reply, &got); err != nil || strings.TrimSpace(got.Log) == "" { + return "", errors.New("the VTA returned no DID log") + } + return got.Log, nil +} + +// UpdateDIDDocument replaces did's document and has the VTA publish the new +// log entry to the DID host over whichever transport the two share. The VTA +// rotates the DID's update key as part of any document change. With +// expectedVersionID set, the VTA refuses (ReasonConflict) if its head has +// moved since the caller read it. It returns the new versionId. +func (c *Client) UpdateDIDDocument(ctx context.Context, did string, document json.RawMessage, expectedVersionID string) (string, error) { + payload := map[string]any{"did": did, "document": document} + if expectedVersionID != "" { + payload["expectedVersionId"] = expectedVersionID + } + reply, err := c.send(ctx, didUpdateType, payload, true, true) + if err != nil { + return "", fmt.Errorf("update the DID document at the VTA: %w", err) + } + var got struct { + NewVersionID string `json:"newVersionId"` + Serverless bool `json:"serverless"` + } + if err := json.Unmarshal(reply, &got); err != nil || got.NewVersionID == "" { + return "", errors.New("the VTA reported no new version id") + } + if got.Serverless { + return "", fmt.Errorf("the VTA holds %s as a serverless DID; nothing published it", did) + } + return got.NewVersionID, nil +} + +// HostReachable asks the VTA for the hosting domains it may mint into on the +// DID host registered as serverID (`vta/webvh/servers/domains/0.1`), a +// read-only task the VTA can answer only by reaching that host over a +// transport the two share. It is the probe for the one state a caller cannot +// see from outside: right after a restart the VTA answers HTTPS before its +// mediator session is up, and in that window it can initiate REST alone, so a +// host that advertises TSP or DIDComm only is out of its reach and a document +// update would commit locally and then fail to publish. +func (c *Client) HostReachable(ctx context.Context, serverID string) error { + _, err := c.send(ctx, domainsType, map[string]any{"serverId": serverID}, true, true) + return err +} + +// WaitUntilHostReachable polls HostReachable until it succeeds, the timeout +// passes, or ctx ends. The last refusal is returned on timeout. +func (c *Client) WaitUntilHostReachable(ctx context.Context, serverID string, timeout time.Duration) error { + deadline := c.now().Add(timeout) + for { + err := c.HostReachable(ctx, serverID) + if err == nil { + return nil + } + if !c.now().Before(deadline) { + return fmt.Errorf("the VTA could not reach DID host %q within %s: %w", serverID, timeout, err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(c.probeInterval): + } + } +} + +// TaskError is a Trust Task refusal, as the VTA's `trust-task-error/0.5` +// document carries it. +type TaskError struct { + Status int + Code string + Reason string + Message string +} + +func (e *TaskError) Error() string { + text := fmt.Sprintf("the VTA refused the task: %s", e.Code) + if e.Reason != "" { + text += " (" + e.Reason + ")" + } + if e.Message != "" { + text += ": " + e.Message + } + return text +} + +// Conflict reports an `expectedVersionId` that no longer named the head. +func (e *TaskError) Conflict() bool { return e.Reason == ReasonConflict } + +// send POSTs one Trust Task and returns the verified reply's payload. signed +// attaches the did:key's proof; bearer sends the session token, which the +// login tasks must not carry. +func (c *Client) send(ctx context.Context, typeURI string, payload any, signed, bearer bool) (json.RawMessage, error) { + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("encode Trust Task payload: %w", err) + } + now := c.now().UTC().Format(time.RFC3339) + doc := trusttaskclient.Document{ + ID: "urn:uuid:" + uuid.New().String(), + Type: typeURI, + Issuer: c.did, + Recipient: c.vtaDid, + IssuedAt: now, + Payload: payloadJSON, + } + if signed { + if err := trusttaskclient.Sign(&doc, c.kid, c.key, now); err != nil { + return nil, fmt.Errorf("sign Trust Task: %w", err) + } + } + body, err := json.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("encode Trust Task: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+trustTasksPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create Trust Task request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Trust-Task", typeURI) + if bearer { + if c.token == "" { + return nil, errors.New("not logged in to the VTA") + } + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.hc.Do(req) + if err != nil { + return nil, fmt.Errorf("send Trust Task: %w", err) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, trusttaskclient.MaxResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("read Trust Task response: %w", err) + } + if len(responseBody) > trusttaskclient.MaxResponseBytes { + return nil, errors.New("VTA Trust Task response exceeds 1 MiB") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, refusal(resp.StatusCode, responseBody) + } + + var reply trusttaskclient.Document + if err := json.Unmarshal(responseBody, &reply); err != nil { + return nil, fmt.Errorf("decode Trust Task response: %w", err) + } + if err := trusttaskclient.VerifyReply(ctx, c.resolver, &reply, &doc, trusttaskclient.Reply{Service: c.vtaDid, Client: c.did, Now: c.now}); err != nil { + return nil, fmt.Errorf("VTA %w", err) + } + if reply.Type != typeURI+"#response" { + return nil, fmt.Errorf("unexpected Trust Task response type %q", reply.Type) + } + return reply.Payload, nil +} + +// refusal reads a non-2xx body: a `trust-task-error/0.5` document becomes a +// TaskError; anything else (a flat `{"error":…}`, a proxy page) is quoted. +func refusal(status int, body []byte) error { + var doc struct { + Type string `json:"type"` + Payload struct { + Code string `json:"code"` + Message string `json:"message"` + Details struct { + Reason string `json:"reason"` + } `json:"details"` + } `json:"payload"` + } + if err := json.Unmarshal(body, &doc); err == nil && doc.Type == errorType && doc.Payload.Code != "" { + return &TaskError{Status: status, Code: doc.Payload.Code, Reason: doc.Payload.Details.Reason, Message: doc.Payload.Message} + } + text := strings.TrimSpace(string(body)) + if len(text) > 512 { + text = text[:512] + "…" + } + return fmt.Errorf("VTA returned HTTP %d: %s", status, text) +} + +// LatestLogEntry reads the head of a did.jsonl: its versionId, and the DID +// document (`state`) it establishes. +func LatestLogEntry(didLog string) (versionID string, state json.RawMessage, err error) { + lines := strings.Split(didLog, "\n") + var last string + for i := len(lines) - 1; i >= 0; i-- { + if strings.TrimSpace(lines[i]) != "" { + last = lines[i] + break + } + } + if last == "" { + return "", nil, errors.New("the DID log is empty") + } + var entry struct { + VersionID string `json:"versionId"` + State json.RawMessage `json:"state"` + } + if err := json.Unmarshal([]byte(last), &entry); err != nil { + return "", nil, fmt.Errorf("parse the DID log head: %w", err) + } + if entry.VersionID == "" || len(entry.State) == 0 { + return "", nil, errors.New("the DID log head has no versionId or state") + } + return entry.VersionID, entry.State, nil +} diff --git a/internal/vta/client_test.go b/internal/vta/client_test.go new file mode 100644 index 0000000..3760434 --- /dev/null +++ b/internal/vta/client_test.go @@ -0,0 +1,342 @@ +package vta + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ic3software/vtafarm-api/internal/didkey" + "github.com/ic3software/vtafarm-api/internal/didresolver" + "github.com/ic3software/vtafarm-api/internal/trusttaskclient" +) + +type identity struct { + did string + kid string + private ed25519.PrivateKey +} + +func newIdentity(t *testing.T, fill byte) identity { + t.Helper() + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = fill + } + private := ed25519.NewKeyFromSeed(seed) + did, err := didkey.FromPublicKey(private.Public().(ed25519.PublicKey)) + if err != nil { + t.Fatal(err) + } + return identity{did: did, kid: did + "#" + strings.TrimPrefix(did, "did:key:"), private: private} +} + +// fakeVTA answers the four tasks the client sends the way a VTA does: a +// signed, threaded `#response` from the VTA's own DID (a did:key here, so the +// client's resolver needs no network), the login tasks without a bearer, and +// everything else only with the token the authenticate step handed out. +type fakeVTA struct { + t *testing.T + self identity + client identity + token string + log string + updates []map[string]json.RawMessage + refuse *TaskError + // domainsRefusals is how many domains probes to refuse before answering, + // the shape of a VTA whose mediator session is not up yet. + domainsRefusals int + domainsProbes int +} + +func (f *fakeVTA) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != trustTasksPath || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var doc trusttaskclient.Document + if err := json.NewDecoder(r.Body).Decode(&doc); err != nil { + f.t.Errorf("decode request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if doc.Recipient != f.self.did || doc.Issuer != f.client.did || !strings.HasPrefix(doc.ID, "urn:uuid:") { + f.t.Errorf("unexpected envelope: %+v", doc) + } + if r.Header.Get("Trust-Task") != doc.Type { + f.t.Errorf("Trust-Task header %q does not name the document type %q", r.Header.Get("Trust-Task"), doc.Type) + } + bearer := r.Header.Get("Authorization") + login := doc.Type == challengeType || doc.Type == authenticateType + if login && bearer != "" { + f.t.Errorf("%s carried a bearer token", doc.Type) + } + if !login && bearer != "Bearer "+f.token { + f.t.Errorf("%s carried authorization %q, want the session token", doc.Type, bearer) + } + if doc.Type != challengeType { + f.verifyProof(&doc) + } + if f.refuse != nil && doc.Type == didUpdateType { + f.writeRefusal(w, &doc, f.refuse) + return + } + if doc.Type == domainsType { + f.domainsProbes++ + if f.domainsRefusals > 0 { + f.domainsRefusals-- + f.writeRefusal(w, &doc, &TaskError{Status: http.StatusInternalServerError, Code: "internalError", Message: "no transport in common with `did:webvh:QmHost:dids.example`: it advertises [tsp, didcomm] and this agent can initiate [rest]"}) + return + } + f.writeReply(w, &doc, doc.Type+"#response", map[string]any{"domains": []any{}, "default": ""}) + return + } + var payload any + switch doc.Type { + case challengeType: + var req struct { + Subject string `json:"subject"` + } + _ = json.Unmarshal(doc.Payload, &req) + if req.Subject != f.client.did { + f.t.Errorf("challenge subject %q", req.Subject) + } + payload = map[string]any{"challenge": "6f1a" + strings.Repeat("ab", 30), "sessionId": "sess-1", "expiresAt": "2026-10-23T12:05:00Z"} + case authenticateType: + var req struct { + Challenge string `json:"challenge"` + SessionID string `json:"sessionId"` + } + _ = json.Unmarshal(doc.Payload, &req) + if req.SessionID != "sess-1" || !strings.HasPrefix(req.Challenge, "6f1a") { + f.t.Errorf("authenticate payload %s", doc.Payload) + } + f.token = "jwt-for-" + f.client.did + payload = map[string]any{"session": map[string]any{"subject": f.client.did}, "tokens": map[string]any{"accessToken": f.token, "tokenType": "Bearer", "expiresIn": 900}} + case didGetType: + payload = map[string]any{"record": map[string]any{"serverId": "dids"}, "log": f.log} + case didUpdateType: + var req map[string]json.RawMessage + _ = json.Unmarshal(doc.Payload, &req) + f.updates = append(f.updates, req) + payload = map[string]any{"did": "did:webvh:QmVtc:dids.example:vtc", "newVersionId": "3-QmNew", "serverless": false} + default: + f.t.Errorf("unexpected task %s", doc.Type) + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + f.writeReply(w, &doc, doc.Type+"#response", payload) + } +} + +func (f *fakeVTA) verifyProof(doc *trusttaskclient.Document) { + f.t.Helper() + if doc.Proof == nil { + f.t.Errorf("%s carried no proof", doc.Type) + return + } + p := *doc.Proof + if p.ProofPurpose != "authentication" || p.VerificationMethod != f.client.kid { + f.t.Errorf("proof %+v", p) + } + signature, err := trusttaskclient.DecodeProofValue(p.ProofValue) + if err != nil { + f.t.Error(err) + return + } + doc.Proof = nil + message, err := trusttaskclient.ProofMessage(doc, p) + doc.Proof = &p + if err != nil { + f.t.Error(err) + return + } + key, err := (didresolver.DIDKeyResolver{}).ResolveAuthenticationKey(context.Background(), f.client.did, f.client.kid) + if err != nil { + f.t.Error(err) + return + } + if !ed25519.Verify(key, message, signature) { + f.t.Errorf("%s proof did not verify", doc.Type) + } +} + +func (f *fakeVTA) writeReply(w http.ResponseWriter, request *trusttaskclient.Document, typeURI string, payload any) { + body, _ := json.Marshal(payload) + now := time.Now().UTC().Format(time.RFC3339) + reply := trusttaskclient.Document{ + ID: "urn:uuid:reply", + Type: typeURI, + ThreadID: request.ID, + Issuer: f.self.did, + Recipient: f.client.did, + IssuedAt: now, + Payload: body, + } + if err := trusttaskclient.Sign(&reply, f.self.kid, f.self.private, now); err != nil { + f.t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(reply) +} + +func (f *fakeVTA) writeRefusal(w http.ResponseWriter, request *trusttaskclient.Document, refusal *TaskError) { + details := map[string]any{} + if refusal.Reason != "" { + details["reason"] = refusal.Reason + } + body, _ := json.Marshal(map[string]any{ + "code": refusal.Code, + "message": refusal.Message, + "retryable": false, + "inResponseTo": map[string]any{"id": request.ID, "typeUri": request.Type}, + "details": details, + }) + reply := trusttaskclient.Document{ID: "urn:uuid:err", Type: errorType, ThreadID: request.ID, Issuer: f.self.did, Recipient: f.client.did, IssuedAt: time.Now().UTC().Format(time.RFC3339), Payload: body} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(refusal.Status) + _ = json.NewEncoder(w).Encode(reply) +} + +const testLog = `{"versionId":"1-QmOne","state":{"id":"did:webvh:QmVtc:dids.example:vtc","service":[]}} +{"versionId":"2-QmTwo","state":{"id":"did:webvh:QmVtc:dids.example:vtc","service":[{"id":"did:webvh:QmVtc:dids.example:vtc#tsp","type":"TSPTransport","serviceEndpoint":"did:webvh:QmMed:dids.example:mediator"}]}} +` + +func newTestClient(t *testing.T, fake *fakeVTA) *Client { + t.Helper() + server := httptest.NewServer(fake.handler()) + t.Cleanup(server.Close) + client, err := newClient(server.URL+"/", fake.self.did, fake.client.private) + if err != nil { + t.Fatal(err) + } + client.resolver = didresolver.DIDKeyResolver{} + return client +} + +func TestLoginReadAndUpdateOverOneEndpoint(t *testing.T) { + fake := &fakeVTA{t: t, self: newIdentity(t, 1), client: newIdentity(t, 2), log: testLog} + client := newTestClient(t, fake) + ctx := context.Background() + + if client.DID() != fake.client.did { + t.Fatalf("client DID %q", client.DID()) + } + if _, err := client.DIDLog(ctx, "did:webvh:QmVtc:dids.example:vtc"); err == nil { + t.Fatal("an authenticated task went out before login") + } + if err := client.Login(ctx); err != nil { + t.Fatal(err) + } + if client.token != "jwt-for-"+fake.client.did { + t.Fatalf("token %q", client.token) + } + + didLog, err := client.DIDLog(ctx, "did:webvh:QmVtc:dids.example:vtc") + if err != nil { + t.Fatal(err) + } + version, state, err := LatestLogEntry(didLog) + if err != nil { + t.Fatal(err) + } + if version != "2-QmTwo" || !strings.Contains(string(state), "TSPTransport") { + t.Fatalf("head %s %s", version, state) + } + + newVersion, err := client.UpdateDIDDocument(ctx, "did:webvh:QmVtc:dids.example:vtc", state, version) + if err != nil { + t.Fatal(err) + } + if newVersion != "3-QmNew" { + t.Fatalf("new version %q", newVersion) + } + if len(fake.updates) != 1 { + t.Fatalf("%d updates sent", len(fake.updates)) + } + sent := fake.updates[0] + if string(sent["did"]) != `"did:webvh:QmVtc:dids.example:vtc"` || string(sent["expectedVersionId"]) != `"2-QmTwo"` { + t.Fatalf("update payload %v", sent) + } + if !json.Valid(sent["document"]) || !strings.Contains(string(sent["document"]), "TSPTransport") { + t.Fatalf("update document %s", sent["document"]) + } + if _, has := sent["preRotationCount"]; has { + t.Fatal("update payload carried a field the caller did not set") + } +} + +func TestARefusalBecomesATaskError(t *testing.T) { + fake := &fakeVTA{t: t, self: newIdentity(t, 3), client: newIdentity(t, 4), log: testLog, + refuse: &TaskError{Status: http.StatusUnprocessableEntity, Code: "taskFailed", Reason: ReasonConflict, Message: "expectedVersionId does not match"}} + client := newTestClient(t, fake) + ctx := context.Background() + if err := client.Login(ctx); err != nil { + t.Fatal(err) + } + _, err := client.UpdateDIDDocument(ctx, "did:webvh:QmVtc:dids.example:vtc", json.RawMessage(`{"id":"did:webvh:QmVtc:dids.example:vtc"}`), "1-QmOne") + var refusal *TaskError + if !errors.As(err, &refusal) { + t.Fatalf("error %v is not a TaskError", err) + } + if !refusal.Conflict() || refusal.Status != http.StatusUnprocessableEntity || refusal.Code != "taskFailed" { + t.Fatalf("refusal %+v", refusal) + } + if !strings.Contains(err.Error(), "taskFailed (conflict): expectedVersionId does not match") { + t.Fatalf("message %q", err.Error()) + } +} + +func TestAReplyFromAnotherDIDIsRefused(t *testing.T) { + fake := &fakeVTA{t: t, self: newIdentity(t, 5), client: newIdentity(t, 6), log: testLog} + client := newTestClient(t, fake) + // The client expects the VTA to be someone else: every reply now fails + // verification before its payload is read. + client.vtaDid = newIdentity(t, 7).did + fake.self = identity{did: client.vtaDid, kid: fake.self.kid, private: fake.self.private} + err := client.Login(context.Background()) + if err == nil || !strings.Contains(err.Error(), "proof") { + t.Fatalf("login error %v", err) + } +} + +func TestWaitUntilHostReachableOutlastsTheMediatorGap(t *testing.T) { + fake := &fakeVTA{t: t, self: newIdentity(t, 8), client: newIdentity(t, 9), log: testLog, domainsRefusals: 2} + client := newTestClient(t, fake) + client.probeInterval = time.Millisecond + ctx := context.Background() + if err := client.Login(ctx); err != nil { + t.Fatal(err) + } + if err := client.WaitUntilHostReachable(ctx, "dids", time.Second); err != nil { + t.Fatal(err) + } + if fake.domainsProbes != 3 { + t.Fatalf("%d probes", fake.domainsProbes) + } + + fake.domainsRefusals = 1 << 20 + err := client.WaitUntilHostReachable(ctx, "dids", 20*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "no transport in common") { + t.Fatalf("timeout error %v", err) + } +} + +func TestLatestLogEntry(t *testing.T) { + if _, _, err := LatestLogEntry("\n\n"); err == nil { + t.Fatal("an empty log parsed") + } + if _, _, err := LatestLogEntry(`{"versionId":"1-x"}`); err == nil { + t.Fatal("an entry without state parsed") + } + version, state, err := LatestLogEntry("{\"versionId\":\"1-a\",\"state\":{\"id\":\"did:webvh:a\"}}\n{\"versionId\":\"2-b\",\"state\":{\"id\":\"did:webvh:a\",\"service\":[]}}\n\n") + if err != nil || version != "2-b" || string(state) != `{"id":"did:webvh:a","service":[]}` { + t.Fatalf("%s %s %v", version, state, err) + } +} From b84b4e67f5736b799e96271c725429ffb57e17c9 Mon Sep 17 00:00:00 2001 From: vthwang Date: Fri, 23 Oct 2026 22:55:14 -0700 Subject: [PATCH 2/2] fix: shorten referral ACL grant lifetime Reduce the exposure window for the ephemeral VTA administrator grant while preserving enough time for the bounded registry workflow. - Change the referral ACL expiry from one hour to fifteen minutes - Keep the design documentation aligned with the implementation Signed-off-by: vthwang --- docs/optional-trust-registry-design.md | 2 +- internal/setup/trust_registry_referral.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/optional-trust-registry-design.md b/docs/optional-trust-registry-design.md index 7d73350..b389102 100644 --- a/docs/optional-trust-registry-design.md +++ b/docs/optional-trust-registry-design.md @@ -223,7 +223,7 @@ existing PVCs, or re-enroll the existing administrators. 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 - --expires 1h`), logs in to the VTA over HTTPS as that key + --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 diff --git a/internal/setup/trust_registry_referral.go b/internal/setup/trust_registry_referral.go index fcb4da3..2561276 100644 --- a/internal/setup/trust_registry_referral.go +++ b/internal/setup/trust_registry_referral.go @@ -18,7 +18,7 @@ const ( 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 = "1h" + 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 @@ -45,7 +45,7 @@ var newVtaClient = vta.NewEphemeral // // 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 an hour in the same stopped-VTA window pattern the registry 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. //