diff --git a/docs/optional-trust-registry-design.md b/docs/optional-trust-registry-design.md index 5b27be0..b389102 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 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 @@ -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..2561276 --- /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 = "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 +} 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) + } +}