diff --git a/packages/dfos-cli/CLI.md b/packages/dfos-cli/CLI.md index 0125b18..ba7f94f 100644 --- a/packages/dfos-cli/CLI.md +++ b/packages/dfos-cli/CLI.md @@ -254,13 +254,13 @@ The CLI issues DFOS credentials for content access control: ```bash # grant read access -dfos content grant --read +dfos credential grant --read # grant write access (allows extending the content chain) -dfos content grant --write +dfos credential grant --write # with custom TTL -dfos content grant --read --ttl 1h +dfos credential grant --read --ttl 1h ``` Credentials are printed to stdout (or as JSON with `--json`). The recipient passes them to relay endpoints via the `X-Credential` header, or to the CLI via `--credential`: @@ -345,7 +345,8 @@ The `--auth` flag resolves the active identity, loads the auth key from the keyc | `POST` | `content delete ` | Permanently delete content chain | | `POST` | `content publish ` | Submit content chain + blob to a relay | | `GET` | `content fetch ` | Download content chain from relay | -| `POST` | `content grant ` | Issue read/write credential | +| `POST` | `credential grant ` | Issue read/write credential | +| `POST` | `credential revoke ` | Revoke a credential | | `GET` | `content verify ` | Re-verify chain integrity locally | | `GET` | `beacon show [did\|name]` | Show latest beacon | | `POST` | `beacon announce ` | Build merkle root, sign, submit | diff --git a/packages/dfos-cli/README.md b/packages/dfos-cli/README.md index 384714e..0bcdacb 100644 --- a/packages/dfos-cli/README.md +++ b/packages/dfos-cli/README.md @@ -68,7 +68,7 @@ EOF # alice grants bob read access BOB=$(dfos identity show bob --json | jq -r .did) -CRED=$(dfos --ctx alice@local content grant "$CONTENT" "$BOB" --read --json | jq -r .credential) +CRED=$(dfos --ctx alice@local credential grant "$CONTENT" "$BOB" --read --json | jq -r .credential) # bob downloads with credential dfos --ctx bob@local content download "$CONTENT" --credential "$CRED" --relay local @@ -133,7 +133,8 @@ dfos content publish --relay prod # submit when ready | `content publish` | Submit to a relay | | `content fetch` | Download from a relay | | `content log` | Show operation history | -| `content grant` | Issue read/write credential | +| `credential grant` | Issue read/write credential | +| `credential revoke` | Revoke a credential | | `content verify` | Re-verify chain integrity | | `beacon announce` | Sign merkle root over content IDs | | `beacon show` | Show latest beacon | diff --git a/packages/dfos-cli/internal/cmd/content.go b/packages/dfos-cli/internal/cmd/content.go index d48bd93..19c82fe 100644 --- a/packages/dfos-cli/internal/cmd/content.go +++ b/packages/dfos-cli/internal/cmd/content.go @@ -29,7 +29,6 @@ func newContentCmd() *cobra.Command { cmd.AddCommand(newContentPublishCmd()) cmd.AddCommand(newContentFetchCmd()) cmd.AddCommand(newContentLogCmd()) - cmd.AddCommand(newContentGrantCmd()) cmd.AddCommand(newContentUpdateCmd()) cmd.AddCommand(newContentDeleteCmd()) cmd.AddCommand(newContentVerifyCmd()) @@ -73,6 +72,13 @@ func newContentCreateCmd() *cobra.Command { return fmt.Errorf("document must be valid JSON: %w", err) } + // Re-serialize for deterministic blob storage — ensures relay CID + // verification matches regardless of original file formatting. + canonicalBytes, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("serialize document: %w", err) + } + if !noSchemaWarn { if docMap, ok := doc.(map[string]any); ok { if _, has := docMap["$schema"]; !has { @@ -108,7 +114,7 @@ func newContentCreateCmd() *cobra.Command { } // store blob in relay - lr.Store.PutBlob(relay.BlobKey{CreatorDID: chain.DID, DocumentCID: documentCID}, docBytes) + lr.Store.PutBlob(relay.BlobKey{CreatorDID: chain.DID, DocumentCID: documentCID}, canonicalBytes) // push to peer if specified var publishedTo []string @@ -141,7 +147,7 @@ func newContentCreateCmd() *cobra.Command { if err != nil { return fmt.Errorf("create auth token: %w", err) } - if err := c.UploadBlob(contentID, opCID, docBytes, authToken); err != nil { + if err := c.UploadBlob(contentID, opCID, canonicalBytes, authToken); err != nil { return fmt.Errorf("upload blob: %w", err) } @@ -303,9 +309,8 @@ func newContentDownloadCmd() *cobra.Command { // verification failed — fall through to peer } } - if !isCreator && credential == "" { - return fmt.Errorf("read credential required (you are not the content creator)") - } + // non-creator without credential — fall through to peer download + // where the relay will decide based on standing auth } // fall through to peer download @@ -418,10 +423,21 @@ func newContentPublishCmd() *cobra.Command { if blob != nil && len(idChain.State.AuthKeys) > 0 { authKeyID := idChain.State.AuthKeys[0].ID kid := idChain.DID + "#" + authKeyID - privKey, _ := keys.GetPrivateKey(idChain.DID + "#" + authKeyID) - info, _ := c.GetRelayInfo() - authToken, _ := protocol.CreateAuthToken(idChain.DID, info.DID, kid, 5*time.Minute, privKey) - c.UploadBlob(contentID, contentChain.State.HeadCID, blob, authToken) + privKey, _ := keys.GetPrivateKey(kid) + if privKey == nil { + return fmt.Errorf("auth key not in keychain for blob upload") + } + info, err := c.GetRelayInfo() + if err != nil { + return fmt.Errorf("get peer info for blob upload: %w", err) + } + authToken, err := protocol.CreateAuthToken(idChain.DID, info.DID, kid, 5*time.Minute, privKey) + if err != nil { + return fmt.Errorf("create blob upload auth token: %w", err) + } + if err := c.UploadBlob(contentID, contentChain.State.HeadCID, blob, authToken); err != nil { + return fmt.Errorf("upload blob: %w", err) + } } } @@ -587,85 +603,6 @@ func newContentLogCmd() *cobra.Command { } } -func newContentGrantCmd() *cobra.Command { - var read, write bool - var ttl string - var scopeContentID string - var noScope bool - - cmd := &cobra.Command{ - Use: "grant ", - Short: "Issue a read or write credential", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - contentID := args[0] - subjectDID := args[1] - - if !read && !write { - return fmt.Errorf("specify --read or --write") - } - - _, chain, err := requireIdentity() - if err != nil { - return err - } - - action := "read" - if write { - action = "write" - } - - dur, err := time.ParseDuration(ttl) - if err != nil { - dur = 24 * time.Hour - } - - if len(chain.State.AuthKeys) == 0 { - return fmt.Errorf("identity has no auth keys") - } - authKeyID := chain.State.AuthKeys[0].ID - kid := chain.DID + "#" + authKeyID - privKey, err := keys.GetPrivateKey(chain.DID + "#" + authKeyID) - if err != nil { - return fmt.Errorf("auth key not in keychain: %w", err) - } - - scope := contentID - if noScope { - scope = "" - } else if scopeContentID != "" { - scope = scopeContentID - } - resource := "chain:" + scope - - token, err := protocol.CreateCredential(chain.DID, subjectDID, kid, resource, action, dur, privKey) - if err != nil { - return fmt.Errorf("create credential: %w", err) - } - - if jsonFlag { - outputJSON(map[string]any{ - "credential": token, - "action": action, - "resource": resource, - "issuer": chain.DID, - "audience": subjectDID, - "expiresIn": dur.String(), - }) - } else { - fmt.Printf("Credential issued (%s %s, expires in %s):\n %s\n", action, resource, dur, token) - } - return nil - }, - } - cmd.Flags().BoolVar(&read, "read", false, "Issue DFOS read credential") - cmd.Flags().BoolVar(&write, "write", false, "Issue DFOS write credential") - cmd.Flags().StringVar(&ttl, "ttl", "24h", "Credential TTL") - cmd.Flags().StringVar(&scopeContentID, "scope", "", "Scope credential to specific content ID") - cmd.Flags().BoolVar(&noScope, "broad", false, "Issue broad credential (not scoped to any content ID)") - return cmd -} - func newContentUpdateCmd() *cobra.Command { var note string var peerName string @@ -711,6 +648,13 @@ func newContentUpdateCmd() *cobra.Command { return fmt.Errorf("document must be valid JSON: %w", err) } + // Re-serialize for deterministic blob storage — ensures relay CID + // verification matches regardless of original file formatting. + canonicalBytes, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("serialize document: %w", err) + } + documentCID, _, err := protocol.DocumentCID(doc) if err != nil { return err @@ -740,7 +684,7 @@ func newContentUpdateCmd() *cobra.Command { return fmt.Errorf("local relay rejected: %s", results[0].Error) } - lr.Store.PutBlob(relay.BlobKey{CreatorDID: contentChain.State.CreatorDID, DocumentCID: documentCID}, docBytes) + lr.Store.PutBlob(relay.BlobKey{CreatorDID: contentChain.State.CreatorDID, DocumentCID: documentCID}, canonicalBytes) // push to peer rn := peerName @@ -759,9 +703,17 @@ func newContentUpdateCmd() *cobra.Command { if len(peerResults) > 0 && peerResults[0].Status == "rejected" { return fmt.Errorf("peer rejected: %s", peerResults[0].Error) } - info, _ := c.GetRelayInfo() - authToken, _ := protocol.CreateAuthToken(idChain.DID, info.DID, kid, 5*time.Minute, privKey) - c.UploadBlob(contentID, opCID, docBytes, authToken) + info, err := c.GetRelayInfo() + if err != nil { + return fmt.Errorf("peer relay info: %w", err) + } + authToken, err := protocol.CreateAuthToken(idChain.DID, info.DID, kid, 5*time.Minute, privKey) + if err != nil { + return fmt.Errorf("peer auth token: %w", err) + } + if err := c.UploadBlob(contentID, opCID, canonicalBytes, authToken); err != nil { + return fmt.Errorf("peer blob upload: %w", err) + } } if jsonFlag { @@ -1065,7 +1017,7 @@ func verifyCredentialLocally(lr *localrelay.LocalRelay, credential, creatorDID, if err != nil { return err } - if vc.ContentID != "" && vc.ContentID != contentID { + if vc.ContentID != "" && vc.ContentID != "*" && vc.ContentID != contentID { return fmt.Errorf("credential scoped to different content") } return nil diff --git a/packages/dfos-cli/internal/cmd/credential.go b/packages/dfos-cli/internal/cmd/credential.go new file mode 100644 index 0000000..cf160f4 --- /dev/null +++ b/packages/dfos-cli/internal/cmd/credential.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "fmt" + "time" + + protocol "github.com/metalabel/dfos/packages/dfos-protocol-go" + "github.com/spf13/cobra" +) + +func newCredentialCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "credential", + Aliases: []string{"cred"}, + Short: "Manage DFOS credentials", + GroupID: "content", + } + cmd.AddCommand(newCredentialGrantCmd()) + cmd.AddCommand(newCredentialRevokeCmd()) + return cmd +} + +func newCredentialGrantCmd() *cobra.Command { + var read, write bool + var ttl string + var scopeContentID string + var noScope bool + + cmd := &cobra.Command{ + Use: "grant ", + Short: "Issue a read or write credential", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + contentID := args[0] + subjectDID := args[1] + + if !read && !write { + return fmt.Errorf("specify --read or --write") + } + + _, chain, err := requireIdentity() + if err != nil { + return err + } + + action := "read" + if write { + action = "write" + } + + dur, err := time.ParseDuration(ttl) + if err != nil { + dur = 24 * time.Hour + } + + if len(chain.State.AuthKeys) == 0 { + return fmt.Errorf("identity has no auth keys") + } + authKeyID := chain.State.AuthKeys[0].ID + kid := chain.DID + "#" + authKeyID + privKey, err := keys.GetPrivateKey(chain.DID + "#" + authKeyID) + if err != nil { + return fmt.Errorf("auth key not in keychain: %w", err) + } + + scope := contentID + if noScope { + scope = "*" + } else if scopeContentID != "" { + scope = scopeContentID + } + resource := "chain:" + scope + + token, err := protocol.CreateCredential(chain.DID, subjectDID, kid, resource, action, dur, privKey) + if err != nil { + return fmt.Errorf("create credential: %w", err) + } + + if jsonFlag { + outputJSON(map[string]any{ + "credential": token, + "action": action, + "resource": resource, + "issuer": chain.DID, + "audience": subjectDID, + "expiresIn": dur.String(), + }) + } else { + fmt.Printf("Credential issued (%s %s, expires in %s):\n %s\n", action, resource, dur, token) + } + return nil + }, + } + cmd.Flags().BoolVar(&read, "read", false, "Issue DFOS read credential") + cmd.Flags().BoolVar(&write, "write", false, "Issue DFOS write credential") + cmd.Flags().StringVar(&ttl, "ttl", "24h", "Credential TTL") + cmd.Flags().StringVar(&scopeContentID, "scope", "", "Scope credential to specific content ID") + cmd.Flags().BoolVar(&noScope, "broad", false, "Issue wildcard credential covering all content") + return cmd +} + +func newCredentialRevokeCmd() *cobra.Command { + var peerName string + + cmd := &cobra.Command{ + Use: "revoke ", + Short: "Revoke a credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + credentialCID := args[0] + + _, chain, err := requireIdentity() + if err != nil { + return err + } + + lr, err := getRelay() + if err != nil { + return err + } + + if len(chain.State.AuthKeys) == 0 { + return fmt.Errorf("identity has no auth keys") + } + authKeyID := chain.State.AuthKeys[0].ID + kid := chain.DID + "#" + authKeyID + privKey, err := keys.GetPrivateKey(chain.DID + "#" + authKeyID) + if err != nil { + return fmt.Errorf("auth key not in keychain: %w", err) + } + + jwsToken, revocationCID, err := protocol.SignRevocation(chain.DID, credentialCID, kid, privKey) + if err != nil { + return fmt.Errorf("sign revocation: %w", err) + } + + // ingest into local relay + results := lr.Relay.Ingest([]string{jwsToken}) + if len(results) > 0 && results[0].Status == "rejected" { + return fmt.Errorf("local relay rejected: %s", results[0].Error) + } + + // push to peer if specified + rn := peerName + if rn == "" { + rn = peerFlag + } + if rn != "" { + c, _, err := getPeerClient(rn) + if err != nil { + return err + } + if err := publishIdentityIfNeeded(chain, rn, c); err != nil { + return err + } + peerResults, err := c.SubmitOperations([]string{jwsToken}) + if err != nil { + return fmt.Errorf("submit: %w", err) + } + if len(peerResults) > 0 && peerResults[0].Status == "rejected" { + return fmt.Errorf("peer rejected: %s", peerResults[0].Error) + } + } + + if jsonFlag { + outputJSON(map[string]any{ + "revocationCID": revocationCID, + "credentialCID": credentialCID, + "issuerDID": chain.DID, + }) + } else { + fmt.Printf("Credential revoked:\n") + fmt.Printf(" Revocation CID: %s\n", revocationCID) + fmt.Printf(" Credential CID: %s\n", credentialCID) + fmt.Printf(" Issuer DID: %s\n", chain.DID) + } + return nil + }, + } + cmd.Flags().StringVar(&peerName, "peer", "", "Push to this peer immediately") + return cmd +} diff --git a/packages/dfos-cli/internal/cmd/root.go b/packages/dfos-cli/internal/cmd/root.go index 342ddc6..2c30f92 100644 --- a/packages/dfos-cli/internal/cmd/root.go +++ b/packages/dfos-cli/internal/cmd/root.go @@ -75,6 +75,7 @@ func NewRootCmd() *cobra.Command { root.AddCommand(newUseCmd()) root.AddCommand(newIdentityCmd()) root.AddCommand(newContentCmd()) + root.AddCommand(newCredentialCmd()) root.AddCommand(newBeaconCmd()) root.AddCommand(newWitnessCmd()) root.AddCommand(newCountersigsCmd()) diff --git a/packages/dfos-protocol-go/content.go b/packages/dfos-protocol-go/content.go index b43c670..d23442b 100644 --- a/packages/dfos-protocol-go/content.go +++ b/packages/dfos-protocol-go/content.go @@ -147,7 +147,10 @@ func SignContentDelete(did, previousCID, kid string, note string, authorization } // DocumentCID computes the dag-cbor CID of a JSON document. +// Normalizes JSON number types (float64 → int64 for whole numbers) to match +// the relay's DagCborCID verification path. func DocumentCID(doc any) (string, []byte, error) { + doc = NormalizeJSONNumbers(doc) cborBytes, err := DagCborEncode(doc) if err != nil { return "", nil, err diff --git a/packages/dfos-protocol-go/delegation.go b/packages/dfos-protocol-go/delegation.go index 825f0cf..1625e8e 100644 --- a/packages/dfos-protocol-go/delegation.go +++ b/packages/dfos-protocol-go/delegation.go @@ -100,8 +100,8 @@ func IsAttenuated(parentAtt []AttEntry, childAtt []AttEntry) bool { } // check resource coverage - if parentType == "chain" && parentID == "*" { - // chain:* covers everything: chain:X, chain:*, manifest:M + if parentType == "chain" && parentID == "*" && childType == "chain" { + // chain:* covers any chain resource: chain:X, chain:* covered = true break } else if childType == "chain" && childID == "*" { @@ -112,18 +112,8 @@ func IsAttenuated(parentAtt []AttEntry, childAtt []AttEntry) bool { covered = true break } - } else if childType == "chain" && parentType == "manifest" { - // narrowing from manifest — valid structurally - covered = true - break - } else if childType == "manifest" && parentType == "manifest" { - if childID == parentID { - covered = true - break - } } - // manifest:M NOT covered by chain:X (widening — invalid) - // chain:* NOT covered by chain:X or manifest:M (widening — invalid) + // chain:* NOT covered by chain:X (widening — invalid) } if !covered { diff --git a/packages/dfos-protocol-go/delegation_test.go b/packages/dfos-protocol-go/delegation_test.go index 571da8c..7359a49 100644 --- a/packages/dfos-protocol-go/delegation_test.go +++ b/packages/dfos-protocol-go/delegation_test.go @@ -16,7 +16,6 @@ func TestParseResource(t *testing.T) { wantOK bool }{ {"chain:abc", "chain", "abc", true}, - {"manifest:manifest1", "manifest", "manifest1", true}, {"chain:*", "chain", "*", true}, {"invalid", "", "", false}, {":", "", "", true}, // edge: empty type and id @@ -70,7 +69,7 @@ func TestParseAtt(t *testing.T) { payload := map[string]any{ "att": []any{ map[string]any{"resource": "chain:abc", "action": "read"}, - map[string]any{"resource": "manifest:m1", "action": "write"}, + map[string]any{"resource": "chain:def", "action": "write"}, }, } @@ -81,7 +80,7 @@ func TestParseAtt(t *testing.T) { if att[0].Resource != "chain:abc" || att[0].Action != "read" { t.Errorf("att[0]: got %+v", att[0]) } - if att[1].Resource != "manifest:m1" || att[1].Action != "write" { + if att[1].Resource != "chain:def" || att[1].Action != "write" { t.Errorf("att[1]: got %+v", att[1]) } } @@ -212,14 +211,6 @@ func TestIsAttenuatedChainWildcardCoversChainX(t *testing.T) { } } -func TestIsAttenuatedChainWildcardCoversManifest(t *testing.T) { - parent := []AttEntry{{Resource: "chain:*", Action: "read"}} - child := []AttEntry{{Resource: "manifest:manifest1", Action: "read"}} - if !IsAttenuated(parent, child) { - t.Fatal("chain:* should cover manifest:M") - } -} - func TestIsAttenuatedChainWildcardCoversChainWildcard(t *testing.T) { parent := []AttEntry{{Resource: "chain:*", Action: "read"}} child := []AttEntry{{Resource: "chain:*", Action: "read"}} @@ -236,46 +227,3 @@ func TestIsAttenuatedChainXCannotCoverChainWildcard(t *testing.T) { } } -func TestIsAttenuatedManifestCannotCoverChainWildcard(t *testing.T) { - parent := []AttEntry{{Resource: "manifest:manifest1", Action: "read"}} - child := []AttEntry{{Resource: "chain:*", Action: "read"}} - if IsAttenuated(parent, child) { - t.Fatal("manifest:M should NOT cover chain:* (widening)") - } -} - -// --------------------------------------------------------------------------- -// IsAttenuated — manifest / chain interactions -// --------------------------------------------------------------------------- - -func TestIsAttenuatedManifestToChainNarrowing(t *testing.T) { - parent := []AttEntry{{Resource: "manifest:manifest1", Action: "write"}} - child := []AttEntry{{Resource: "chain:content1", Action: "write"}} - if !IsAttenuated(parent, child) { - t.Fatal("manifest:M → chain:X should be valid narrowing") - } -} - -func TestIsAttenuatedChainToManifestWidening(t *testing.T) { - parent := []AttEntry{{Resource: "chain:content1", Action: "write"}} - child := []AttEntry{{Resource: "manifest:manifest1", Action: "write"}} - if IsAttenuated(parent, child) { - t.Fatal("chain:X → manifest:M should be invalid widening") - } -} - -func TestIsAttenuatedManifestExactMatch(t *testing.T) { - parent := []AttEntry{{Resource: "manifest:m1", Action: "read"}} - child := []AttEntry{{Resource: "manifest:m1", Action: "read"}} - if !IsAttenuated(parent, child) { - t.Fatal("manifest:M → manifest:M exact match should be attenuated") - } -} - -func TestIsAttenuatedManifestMismatch(t *testing.T) { - parent := []AttEntry{{Resource: "manifest:m1", Action: "read"}} - child := []AttEntry{{Resource: "manifest:m2", Action: "read"}} - if IsAttenuated(parent, child) { - t.Fatal("manifest:m1 should NOT cover manifest:m2") - } -} diff --git a/packages/dfos-protocol-go/jwt.go b/packages/dfos-protocol-go/jwt.go index 4f01a29..08c419b 100644 --- a/packages/dfos-protocol-go/jwt.go +++ b/packages/dfos-protocol-go/jwt.go @@ -57,7 +57,7 @@ func CreateAuthToken(iss, aud, kid string, ttl time.Duration, privateKey ed25519 // CreateCredential creates a DFOS credential (UCAN-style authorization token). // // The resource parameter is the full resource string (e.g., "chain:contentId", -// "chain:*", "manifest:manifestId"). The action is "read" or "write". +// "chain:*"). The action is "read" or "write". // The aud parameter is the audience DID (or "*" for public credentials). func CreateCredential(iss, aud, kid, resource, action string, ttl time.Duration, privateKey ed25519.PrivateKey) (string, error) { now := time.Now().Unix() diff --git a/packages/dfos-protocol-go/revocation.go b/packages/dfos-protocol-go/revocation.go index e394822..f0a91a6 100644 --- a/packages/dfos-protocol-go/revocation.go +++ b/packages/dfos-protocol-go/revocation.go @@ -1,10 +1,43 @@ package dfos import ( + "crypto/ed25519" "fmt" "strings" ) +// SignRevocation signs a credential revocation. +func SignRevocation(did, credentialCID, kid string, privateKey ed25519.PrivateKey) (jwsToken string, revocationCID string, err error) { + now := protocolTimestamp() + + payload := map[string]any{ + "version": int64(1), + "type": "revocation", + "did": did, + "credentialCID": credentialCID, + "createdAt": now.Format("2006-01-02T15:04:05.000Z"), + } + + _, _, cidStr, err := DagCborCID(payload) + if err != nil { + return "", "", err + } + + header := JWSHeader{ + Alg: "EdDSA", + Typ: "did:dfos:revocation", + Kid: kid, + CID: cidStr, + } + + jwsToken, err = CreateJWS(header, payload, privateKey) + if err != nil { + return "", "", err + } + + return jwsToken, cidStr, nil +} + // VerifiedRevocationResult is the result of revocation verification. type VerifiedRevocationResult struct { // The issuer DID that revoked the credential diff --git a/packages/dfos-protocol/src/credentials/dfos-credential.ts b/packages/dfos-protocol/src/credentials/dfos-credential.ts index bded6b2..61fe57e 100644 --- a/packages/dfos-protocol/src/credentials/dfos-credential.ts +++ b/packages/dfos-protocol/src/credentials/dfos-credential.ts @@ -7,9 +7,9 @@ delegation chains via embedded parent tokens (`prf`), and monotonic attenuation enforcement. - Two resource types: + Resource types: + - chain:* — wildcard covering all content chains - chain: — exact match for a specific content chain - - manifest: — transitive match covering a manifest and its entries Two audience modes: - aud: "*" — public credential, ingested into relays as standing auth @@ -354,12 +354,8 @@ const parseActions = (action: string): Set => { * * - `chain:X` covered by `chain:X` (exact match) * - `chain:X` covered by `chain:*` (narrowing from wildcard — valid) - * - `chain:X` covered by `manifest:M` (narrowing from manifest — valid structurally) - * - `manifest:M` covered by `chain:*` (narrowing from wildcard — valid) - * - `manifest:M` covered by `manifest:M` (exact match) - * - `manifest:M` NOT covered by `chain:X` (widening — invalid) * - `chain:*` covered by `chain:*` (exact match) - * - `chain:*` NOT covered by `chain:X` or `manifest:M` (widening — invalid) + * - `chain:*` NOT covered by `chain:X` (widening — invalid) * - Actions: child action set must be a subset of parent action set */ export const isAttenuated = (parentAtt: Attenuation[], childAtt: Attenuation[]): boolean => { @@ -380,28 +376,17 @@ export const isAttenuated = (parentAtt: Attenuation[], childAtt: Attenuation[]): // check resource coverage if (parentRes.type === 'chain' && parentRes.id === '*') { - // chain:* covers everything: chain:X, chain:*, manifest:M - return true; + // chain:* covers chain:X and chain:* + return childRes.type === 'chain'; } if (childRes.type === 'chain' && childRes.id === '*') { - // chain:* can only be covered by chain:* (checked above) — not by - // chain:X or manifest:M (both would be widening) + // chain:* can only be covered by chain:* (checked above) return false; } if (childRes.type === 'chain' && parentRes.type === 'chain') { // chain:X covered by chain:X (exact match) return childRes.id === parentRes.id; } - if (childRes.type === 'chain' && parentRes.type === 'manifest') { - // chain:X covered by manifest:M (narrowing — valid structurally) - return true; - } - if (childRes.type === 'manifest' && parentRes.type === 'manifest') { - // manifest:M covered by manifest:M (exact match) - return childRes.id === parentRes.id; - } - // manifest:M NOT covered by chain:X (widening) - // chain:* NOT covered by chain:X or manifest:M (widening) return false; }); }); @@ -416,18 +401,11 @@ export const isAttenuated = (parentAtt: Attenuation[], childAtt: Attenuation[]): * * Used at the relay to determine if a credential authorizes access to a * specific content chain. - * - * For `manifest:` resources, requires a `manifestLookup` callback to resolve - * which contentIds the manifest indexes. Without the callback, `manifest:` - * resources can only match exact `manifest:` requests, not `chain:` requests. */ export const matchesResource = async ( att: Attenuation[], resource: string, action: string, - options?: { - manifestLookup?: (manifestContentId: string) => Promise; - }, ): Promise => { const requestedRes = parseResource(resource); if (!requestedRes) return false; @@ -453,19 +431,10 @@ export const matchesResource = async ( return true; } - // exact resource match (chain:X == chain:X, manifest:M == manifest:M) + // exact resource match (chain:X == chain:X) if (entryRes.type === requestedRes.type && entryRes.id === requestedRes.id) { return true; } - - // manifest covers chain transitively - if (entryRes.type === 'manifest' && requestedRes.type === 'chain') { - if (options?.manifestLookup) { - const indexed = await options.manifestLookup(entryRes.id); - if (indexed.includes(requestedRes.id)) return true; - } - // without lookup, manifest: can't resolve to chain: — fall through - } } return false; diff --git a/packages/dfos-protocol/tests/credentials.spec.ts b/packages/dfos-protocol/tests/credentials.spec.ts index 4660b53..1aaa783 100644 --- a/packages/dfos-protocol/tests/credentials.spec.ts +++ b/packages/dfos-protocol/tests/credentials.spec.ts @@ -496,34 +496,6 @@ describe('dfos credential', () => { expect(await matchesResource(att, 'chain:content2', 'write')).toBe(false); }); - it('should match manifest:M with lookup', async () => { - const att = [{ resource: 'manifest:manifest1', action: 'write' }]; - const result = await matchesResource(att, 'chain:content1', 'write', { - manifestLookup: async () => ['content1', 'content2'], - }); - expect(result).toBe(true); - }); - - it('should not match manifest:M without lookup', async () => { - const att = [{ resource: 'manifest:manifest1', action: 'write' }]; - const result = await matchesResource(att, 'chain:content1', 'write'); - expect(result).toBe(false); - }); - - // --- attenuation: manifest / chain interactions --- - - it('should accept manifest:M -> chain:X as valid narrowing', () => { - const parent = [{ resource: 'manifest:manifest1', action: 'write' }]; - const child = [{ resource: 'chain:content1', action: 'write' }]; - expect(isAttenuated(parent, child)).toBe(true); - }); - - it('should reject chain:X -> manifest:M as invalid widening', () => { - const parent = [{ resource: 'chain:content1', action: 'write' }]; - const child = [{ resource: 'manifest:manifest1', action: 'write' }]; - expect(isAttenuated(parent, child)).toBe(false); - }); - // --- public credentials --- it('should create and verify public credential with aud "*"', async () => { @@ -690,12 +662,6 @@ describe('dfos credential', () => { expect(isAttenuated(parent, child)).toBe(true); }); - it('should accept manifest:M as attenuated from chain:*', () => { - const parent = [{ resource: 'chain:*', action: 'read' }]; - const child = [{ resource: 'manifest:manifest1', action: 'read' }]; - expect(isAttenuated(parent, child)).toBe(true); - }); - it('should accept chain:* as attenuated from chain:* (exact match)', () => { const parent = [{ resource: 'chain:*', action: 'read' }]; const child = [{ resource: 'chain:*', action: 'read' }]; @@ -708,12 +674,6 @@ describe('dfos credential', () => { expect(isAttenuated(parent, child)).toBe(false); }); - it('should reject chain:* as attenuated from manifest:M (widening)', () => { - const parent = [{ resource: 'manifest:manifest1', action: 'read' }]; - const child = [{ resource: 'chain:*', action: 'read' }]; - expect(isAttenuated(parent, child)).toBe(false); - }); - // --- chain:* wildcard resource matching --- it('should match chain:* with read against chain:someId with read', async () => { diff --git a/packages/dfos-web-relay-go/auth.go b/packages/dfos-web-relay-go/auth.go index a7b6f8a..7700248 100644 --- a/packages/dfos-web-relay-go/auth.go +++ b/packages/dfos-web-relay-go/auth.go @@ -1,7 +1,6 @@ package relay import ( - "encoding/json" "fmt" "strings" @@ -50,6 +49,30 @@ func AuthenticateRequest(authHeader string, relayDID string, store Store) *dfos. return result } +// --------------------------------------------------------------------------- +// public standing auth +// --------------------------------------------------------------------------- + +// hasPublicStandingAuth checks if a valid public standing credential exists +// for the given content. Verifies expiry and revocation. +func (r *Relay) hasPublicStandingAuth(contentID string, action string) bool { + resource := "chain:" + contentID + publicCreds, _ := r.readStore.GetPublicCredentials(resource) + resolveKey := CreateKeyResolver(r.store) + + chain, _ := r.readStore.GetContentChain(contentID) + if chain == nil { + return false + } + + for _, credJws := range publicCreds { + if err := r.verifyCredentialForAccess(credJws, resolveKey, resource, action, chain.State.CreatorDID, ""); err == nil { + return true + } + } + return false +} + // --------------------------------------------------------------------------- // content access verification // --------------------------------------------------------------------------- @@ -137,7 +160,7 @@ func (r *Relay) verifyCredentialForAccess(credJws string, resolveKey dfos.KeyRes // parse att from raw payload for resource matching and delegation att := dfos.ParseAtt(payload) - // check resource + action match (with manifest transitive lookup) + // check resource + action match if !r.matchesResource(att, requestedResource, action) { return fmt.Errorf("credential does not cover requested resource") } @@ -273,7 +296,6 @@ func (r *Relay) verifyDelegationChain(childJws string, prf []string, childAtt [] // --------------------------------------------------------------------------- // matchesResource checks if an att array covers a requested resource+action. -// Handles manifest transitive lookup for manifest: → chain: coverage. func (r *Relay) matchesResource(att []dfos.AttEntry, resource string, action string) bool { reqType, reqID, ok := dfos.ParseResource(resource) if !ok { @@ -310,63 +332,8 @@ func (r *Relay) matchesResource(att []dfos.AttEntry, resource string, action str return true } - // manifest covers chain transitively - if entryType == "manifest" && reqType == "chain" { - indexed := r.manifestLookup(entryID) - for _, id := range indexed { - if id == reqID { - return true - } - } - } } return false } -// manifestLookup resolves which contentIds a manifest indexes by reading its -// head document blob and extracting entries values. -func (r *Relay) manifestLookup(manifestContentID string) []string { - chain, err := r.readStore.GetContentChain(manifestContentID) - if err != nil || chain == nil { - return nil - } - if chain.State.CurrentDocumentCID == nil { - return nil - } - docCID := *chain.State.CurrentDocumentCID - - blob, _ := r.readStore.GetBlob(BlobKey{CreatorDID: chain.State.CreatorDID, DocumentCID: docCID}) - if blob == nil { - return nil - } - - var doc map[string]any - if err := json.Unmarshal(blob, &doc); err != nil { - return nil - } - - entries, ok := doc["entries"] - if !ok { - return nil - } - entriesMap, ok := entries.(map[string]any) - if !ok { - return nil - } - - var result []string - for _, v := range entriesMap { - s, ok := v.(string) - if !ok { - continue - } - // contentId references are 22-char bare hashes, not DIDs or CIDs - if strings.HasPrefix(s, "did:") || strings.HasPrefix(s, "bafyrei") { - continue - } - result = append(result, s) - } - return result -} - diff --git a/packages/dfos-web-relay-go/routes.go b/packages/dfos-web-relay-go/routes.go index 5aa8567..0039d3d 100644 --- a/packages/dfos-web-relay-go/routes.go +++ b/packages/dfos-web-relay-go/routes.go @@ -558,14 +558,27 @@ func (r *Relay) handleGetBlob(w http.ResponseWriter, req *http.Request) { r.readBlob(w, req, req.PathValue("contentId"), req.PathValue("ref")) } -func (r *Relay) readBlob(w http.ResponseWriter, req *http.Request, contentID, ref string) { - // authenticate +// authorizeRead checks if the request is authorized to read the given content. +// Returns true if authorized. Writes 401/403 error response and returns false if not. +// Allows unauthenticated access when a valid public standing credential exists. +func (r *Relay) authorizeRead(w http.ResponseWriter, req *http.Request, contentID string, creatorDID string) bool { + if r.hasPublicStandingAuth(contentID, "read") { + return true + } auth := AuthenticateRequest(req.Header.Get("Authorization"), r.did, r.store) if auth == nil { writeError(w, 401, "authentication required") - return + return false } + credHeader := req.Header.Get("X-Credential") + if errMsg := r.verifyContentAccess(auth.Iss, creatorDID, "chain:"+contentID, "read", credHeader); errMsg != "" { + writeError(w, 403, errMsg) + return false + } + return true +} +func (r *Relay) readBlob(w http.ResponseWriter, req *http.Request, contentID, ref string) { chain, err := r.readStore.GetContentChain(contentID) if storeErr(w, err) { return @@ -575,10 +588,7 @@ func (r *Relay) readBlob(w http.ResponseWriter, req *http.Request, contentID, re return } - // verify read access - credHeader := req.Header.Get("X-Credential") - if errMsg := r.verifyContentAccess(auth.Iss, chain.State.CreatorDID, "chain:"+contentID, "read", credHeader); errMsg != "" { - writeError(w, 403, errMsg) + if !r.authorizeRead(w, req, contentID, chain.State.CreatorDID) { return } @@ -636,13 +646,6 @@ func (r *Relay) handleGetDocuments(w http.ResponseWriter, req *http.Request) { } contentID := req.PathValue("contentId") - // authenticate - auth := AuthenticateRequest(req.Header.Get("Authorization"), r.did, r.store) - if auth == nil { - writeError(w, 401, "authentication required") - return - } - // verify chain exists chain, err := r.readStore.GetContentChain(contentID) if storeErr(w, err) { @@ -653,10 +656,7 @@ func (r *Relay) handleGetDocuments(w http.ResponseWriter, req *http.Request) { return } - // verify read access - credHeader := req.Header.Get("X-Credential") - if errMsg := r.verifyContentAccess(auth.Iss, chain.State.CreatorDID, "chain:"+contentID, "read", credHeader); errMsg != "" { - writeError(w, 403, errMsg) + if !r.authorizeRead(w, req, contentID, chain.State.CreatorDID) { return } diff --git a/packages/dfos-web-relay-go/store_memory.go b/packages/dfos-web-relay-go/store_memory.go index ec1dd45..9655adc 100644 --- a/packages/dfos-web-relay-go/store_memory.go +++ b/packages/dfos-web-relay-go/store_memory.go @@ -478,11 +478,6 @@ func (s *MemoryStore) GetPublicCredentials(resource string) ([]string, error) { tokens = append(tokens, cred.JWSToken) break } - // manifest-scoped credentials match chain: resources - if strings.HasPrefix(resource, "chain:") && strings.HasPrefix(att.Resource, "manifest:") { - tokens = append(tokens, cred.JWSToken) - break - } } } if tokens == nil { diff --git a/packages/dfos-web-relay-go/store_sqlite.go b/packages/dfos-web-relay-go/store_sqlite.go index 8398c82..f533a9e 100644 --- a/packages/dfos-web-relay-go/store_sqlite.go +++ b/packages/dfos-web-relay-go/store_sqlite.go @@ -851,18 +851,16 @@ func (s *SQLiteStore) IsCredentialRevoked(issuerDID string, credentialCID string func (s *SQLiteStore) GetPublicCredentials(resource string) ([]string, error) { // Build a query that unnests the att JSON array and matches on resource. - // We handle three cases: + // We handle two cases: // 1. Exact match on resource // 2. chain:* matches any chain: resource - // 3. manifest: scoped credentials match chain: resources var query string var args []any if strings.HasPrefix(resource, "chain:") { query = `SELECT DISTINCT pc.jws_token FROM public_credentials pc, json_each(pc.att) je WHERE json_extract(je.value, '$.resource') = ? - OR json_extract(je.value, '$.resource') = 'chain:*' - OR json_extract(je.value, '$.resource') LIKE 'manifest:%'` + OR json_extract(je.value, '$.resource') = 'chain:*'` args = []any{resource} } else { query = `SELECT DISTINCT pc.jws_token FROM public_credentials pc, json_each(pc.att) je diff --git a/packages/dfos-web-relay/src/auth.ts b/packages/dfos-web-relay/src/auth.ts index f239f11..33ee690 100644 --- a/packages/dfos-web-relay/src/auth.ts +++ b/packages/dfos-web-relay/src/auth.ts @@ -74,6 +74,56 @@ export interface AccessVerification { credential?: VerifiedDFOSCredential; } +/** + * Check if a valid public standing credential exists for the given content. + * + * This is used at the route level to allow unauthenticated reads when public + * credentials exist — matching the Go relay's `hasPublicStandingAuth`. + */ +export const hasPublicStandingAuth = async ( + contentId: string, + action: 'read' | 'write', + store: RelayStore, +): Promise => { + const resource = `chain:${contentId}`; + const publicCreds = await store.getPublicCredentials(resource); + if (publicCreds.length === 0) return false; + + const chain = await store.getContentChain(contentId); + if (!chain) return false; + + const resolveIdentity = createHistoricalIdentityResolver(store); + const isRevoked = async (issuerDID: string, credentialCID: string) => + store.isCredentialRevoked(issuerDID, credentialCID); + + for (const credJws of publicCreds) { + try { + const cred = await verifyDFOSCredential(credJws, { resolveIdentity }); + + // check revocation + const leafRevoked = await isRevoked(cred.iss, cred.credentialCID); + if (leafRevoked) continue; + + // check resource + action match + const covers = await matchesResource(cred.att, resource, action); + if (!covers) continue; + + // verify delegation chain roots at creator + await verifyDelegationChain(cred, { + resolveIdentity, + rootDID: chain.state.creatorDID, + isRevoked, + }); + + return true; + } catch { + continue; + } + } + + return false; +}; + /** * Verify content access for a specific resource * @@ -109,28 +159,6 @@ export const verifyContentAccess = async (options: { const isRevoked = async (issuerDID: string, credentialCID: string) => store.isCredentialRevoked(issuerDID, credentialCID); - // manifest lookup — resolves which contentIds a manifest indexes - const manifestLookup = async (manifestContentId: string): Promise => { - const chain = await store.getContentChain(manifestContentId); - if (!chain) return []; - const docCID = chain.state.currentDocumentCID; - if (!docCID) return []; - const blob = await store.getBlob({ creatorDID: chain.state.creatorDID, documentCID: docCID }); - if (!blob) return []; - try { - const doc = JSON.parse(new TextDecoder().decode(blob)) as Record; - const entries = doc['entries']; - if (!entries || typeof entries !== 'object') return []; - // extract contentId references (22-char bare hashes, not DIDs or CIDs) - return Object.values(entries as Record).filter( - (v): v is string => - typeof v === 'string' && !v.startsWith('did:') && !v.startsWith('bafyrei'), - ); - } catch { - return []; - } - }; - // 2. check stored public credentials const publicCreds = await store.getPublicCredentials(requestedResource); for (const credJws of publicCreds) { @@ -141,10 +169,8 @@ export const verifyContentAccess = async (options: { const leafRevoked = await isRevoked(cred.iss, cred.credentialCID); if (leafRevoked) continue; - // check resource + action match (with manifest transitive lookup) - const covers = await matchesResource(cred.att, requestedResource, action, { - manifestLookup, - }); + // check resource + action match + const covers = await matchesResource(cred.att, requestedResource, action); if (!covers) continue; // verify delegation chain roots at creator (with revocation at every level) @@ -177,10 +203,8 @@ export const verifyContentAccess = async (options: { } } - // check resource + action match (with manifest transitive lookup) - const covers = await matchesResource(cred.att, requestedResource, action, { - manifestLookup, - }); + // check resource + action match + const covers = await matchesResource(cred.att, requestedResource, action); if (!covers) { return { granted: false, source: 'none' }; } diff --git a/packages/dfos-web-relay/src/relay.ts b/packages/dfos-web-relay/src/relay.ts index cd40a1b..1a68818 100644 --- a/packages/dfos-web-relay/src/relay.ts +++ b/packages/dfos-web-relay/src/relay.ts @@ -15,7 +15,7 @@ import type { VerifiedAuthToken } from '@metalabel/dfos-protocol/credentials'; import { dagCborCanonicalEncode, decodeJwsUnsafe } from '@metalabel/dfos-protocol/crypto'; import { Hono } from 'hono'; import { z } from 'zod'; -import { authenticateRequest, verifyContentAccess } from './auth'; +import { authenticateRequest, hasPublicStandingAuth, verifyContentAccess } from './auth'; import { bootstrapRelayIdentity } from './bootstrap'; import { ingestOperations } from './ingest'; import { computeOpCID, sequenceOps } from './sequencer'; @@ -263,23 +263,27 @@ export const createRelay = async (options: RelayOptions): Promise if (!contentEnabled) return c.json({ error: 'content plane not available' }, 501); const contentId = c.req.param('contentId'); - // authenticate - const auth = await authenticateRequest(c.req.header('authorization'), relayDID, store); - if (!auth) return c.json({ error: 'authentication required' }, 401); - // verify chain exists const chain = await store.getContentChain(contentId); if (!chain) return c.json({ error: 'not found' }, 404); - // verify read access - const accessError = await verifyReadAccess( - auth, - chain, - contentId, - c.req.header('x-credential'), - store, - ); - if (accessError) return accessError; + // check for public standing authorization (no auth needed) + const publicAccess = await hasPublicStandingAuth(contentId, 'read', store); + if (!publicAccess) { + // require auth token + const auth = await authenticateRequest(c.req.header('authorization'), relayDID, store); + if (!auth) return c.json({ error: 'authentication required' }, 401); + + // verify read access + const accessError = await verifyReadAccess( + auth, + chain, + contentId, + c.req.header('x-credential'), + store, + ); + if (accessError) return accessError; + } const after = c.req.query('after'); const limit = Math.min(Number(c.req.query('limit') || 100), 1000); @@ -512,17 +516,21 @@ const readBlob = async (params: { }): Promise => { const { contentId, ref, authHeader, credHeader, relayDID, store } = params; - // authenticate - const auth = await authenticateRequest(authHeader, relayDID, store); - if (!auth) return jsonResponse({ error: 'authentication required' }, 401); - // look up chain const chain = await store.getContentChain(contentId); if (!chain) return jsonResponse({ error: 'content chain not found' }, 404); - // verify read credential — unless the caller is the chain creator - const credError = await verifyReadAccess(auth, chain, contentId, credHeader, store); - if (credError) return credError; + // check for public standing authorization (no auth needed) + const publicAccess = await hasPublicStandingAuth(contentId, 'read', store); + if (!publicAccess) { + // require auth token + const auth = await authenticateRequest(authHeader, relayDID, store); + if (!auth) return jsonResponse({ error: 'authentication required' }, 401); + + // verify read credential — unless the caller is the chain creator + const credError = await verifyReadAccess(auth, chain, contentId, credHeader, store); + if (credError) return credError; + } // resolve documentCID for the requested ref let documentCID: string | null = null; diff --git a/packages/dfos-web-relay/src/store.ts b/packages/dfos-web-relay/src/store.ts index 8a51989..44d8f63 100644 --- a/packages/dfos-web-relay/src/store.ts +++ b/packages/dfos-web-relay/src/store.ts @@ -145,12 +145,6 @@ export class MemoryRelayStore implements RelayStore { tokens.push(cred.jwsToken); break; } - // also include manifest-scoped credentials when requesting chain access — - // matchesResource with manifestLookup will determine actual coverage - if (isChainRequest && att.resource.startsWith('manifest:')) { - tokens.push(cred.jwsToken); - break; - } } } return tokens; diff --git a/packages/dfos-web-relay/tests/relay.spec.ts b/packages/dfos-web-relay/tests/relay.spec.ts index 92debcc..aa49ce5 100644 --- a/packages/dfos-web-relay/tests/relay.spec.ts +++ b/packages/dfos-web-relay/tests/relay.spec.ts @@ -1021,6 +1021,11 @@ describe('web relay', () => { const identity = await createIdentity(); await postOps([identity.jwsToken]); + // create a content chain BEFORE key rotation so it definitely exists + const content = await createContentOp(identity); + const ingestRes = await postOps([content.jwsToken]); + const contentId = (await json(ingestRes)).results[0].chainId; + // rotate the auth key const newAuthKey = makeKey(); const updateOp: IdentityOperation = { @@ -1044,28 +1049,19 @@ describe('web relay', () => { // create an auth token with the OLD (rotated-out) key const oldAuthToken = await createTestAuthToken(identity); // uses identity.authKey (the old one) - // create a content chain to have something to access - const content = await createContentOp(identity); - // but this content op was signed with the old auth key during createContentOp... - // actually just test the auth endpoint directly - const contentOpRes = await postOps([content.jwsToken]); - const contentBody = await json(contentOpRes); - // content op might fail since it was signed with old key — that's fine - // the important test is the auth token rejection - - // try to access content plane with old auth token - const chainLookup = await req(`/content/someid/blob`, { + // try to access content plane with old auth token — should be 401 + // because the old key is no longer in current state + const chainLookup = await req(`/content/${contentId}/blob`, { headers: { authorization: `Bearer ${oldAuthToken}` }, }); - // should be 401 because the old key is no longer in current state expect(chainLookup.status).toBe(401); // create auth token with the NEW key — should work (404 because no blob, but not 401) const newAuthToken = await createTestAuthToken(identity, newAuthKey); - const newAuthRes = await req(`/content/someid/blob`, { + const newAuthRes = await req(`/content/${contentId}/blob`, { headers: { authorization: `Bearer ${newAuthToken}` }, }); - expect(newAuthRes.status).toBe(404); // 404 = authenticated but no content, not 401 + expect(newAuthRes.status).toBe(404); // 404 = authenticated but no blob stored }); it('should accept per-request credential signed with rotated-out key', async () => { diff --git a/packages/relay-conformance/conformance_credentials_test.go b/packages/relay-conformance/conformance_credentials_test.go index 50f3fcf..85928cd 100644 --- a/packages/relay-conformance/conformance_credentials_test.go +++ b/packages/relay-conformance/conformance_credentials_test.go @@ -912,3 +912,70 @@ func TestDelegationRootMismatchRejection(t *testing.T) { } dlRes.Body.Close() } + +// =================================================================== +// public standing auth anonymous read + revocation +// =================================================================== + +func TestPublicStandingAuthAnonymousRead(t *testing.T) { + base := relayURL(t) + alice := createIdentity(t, base) + cc := createContent(t, base, alice) + + // upload blob as creator + tok := authToken(t, base, alice) + blobData, _ := json.Marshal(cc.document) + putBlob(t, base, cc.contentID, cc.genCID, tok, blobData).Body.Close() + + // issue and submit public credential (aud: "*") + kid := alice.did + "#" + alice.auth.keyID + credToken := createPublicCredential(t, alice.did, kid, "read", cc.contentID, 5*time.Minute, alice.auth.priv) + res := postOperations(t, base, []string{credToken}) + body := readBody(t, res) + if res.StatusCode != 200 { + t.Fatalf("submit public credential: status %d, body: %s", res.StatusCode, body) + } + + // anonymous blob download — no Authorization header at all + anonURL := fmt.Sprintf("%s/content/%s/blob", base, cc.contentID) + anonReq, _ := http.NewRequest("GET", anonURL, nil) + anonResp, err := http.DefaultClient.Do(anonReq) + if err != nil { + t.Fatalf("anonymous GET blob: %v", err) + } + anonBody := readBody(t, anonResp) + if anonResp.StatusCode != 200 { + t.Fatalf("expected anonymous read to succeed with standing auth: status %d, body: %s", anonResp.StatusCode, anonBody) + } + if string(anonBody) != string(blobData) { + t.Fatal("anonymous downloaded blob does not match uploaded data") + } + + // decode credential to get its CID, then revoke it + credHeader, _, err := dfos.DecodeJWSUnsafe(credToken) + if err != nil { + t.Fatal(err) + } + credCID := credHeader.CID + + revToken, _ := createRevocation(t, alice.did, credCID, alice.auth) + revRes := postOperations(t, base, []string{revToken}) + revBody := readBody(t, revRes) + if revRes.StatusCode != 200 { + t.Fatalf("revocation submit: status %d, body: %s", revRes.StatusCode, revBody) + } + + // anonymous blob download again — should be denied after revocation + anonReq2, _ := http.NewRequest("GET", anonURL, nil) + anonResp2, err := http.DefaultClient.Do(anonReq2) + if err != nil { + t.Fatalf("anonymous GET blob after revocation: %v", err) + } + anonResp2.Body.Close() + if anonResp2.StatusCode == 200 { + t.Fatal("expected anonymous read to be denied after credential revocation") + } + if anonResp2.StatusCode != 401 && anonResp2.StatusCode != 403 { + t.Fatalf("expected 401 or 403 after revocation, got %d", anonResp2.StatusCode) + } +} diff --git a/specs/CLAUDE-SKILL.md b/specs/CLAUDE-SKILL.md index 3005bb8..7dd78de 100644 --- a/specs/CLAUDE-SKILL.md +++ b/specs/CLAUDE-SKILL.md @@ -226,13 +226,13 @@ Content without a `$schema` field triggers a warning. Pass `--no-schema-warn` to ```bash # Grant read access (default 24h TTL) -dfos content grant --read +dfos credential grant --read # Grant write access -dfos content grant --write +dfos credential grant --write # Custom TTL -dfos content grant --read --ttl 1h +dfos credential grant --read --ttl 1h # Delegated write (bob updates alice's content using a write credential) dfos --ctx bob@prod content update new.json --authorization @@ -321,7 +321,7 @@ All data commands support `--json` for machine-readable output. Always use `--js ```bash dfos identity show alice --json | jq .did dfos content create post.json --peer prod --json | jq -r .contentId -dfos content grant --read --json | jq -r .credential +dfos credential grant --read --json | jq -r .credential dfos identity list --json | jq '.[].did' dfos peer list --json | jq '.[].url' ``` @@ -348,7 +348,7 @@ dfos content show "$CONTENT" ```bash BOB_DID=$(dfos identity show bob --json | jq -r .did) -CRED=$(dfos content grant "$CONTENT" "$BOB_DID" --read --json | jq -r .credential) +CRED=$(dfos credential grant "$CONTENT" "$BOB_DID" --read --json | jq -r .credential) # Bob downloads using the credential dfos --ctx bob@prod content download "$CONTENT" --credential "$CRED" @@ -385,7 +385,7 @@ dfos content publish --peer prod - **"content verify failed"**: Chain integrity issue. Re-fetch from relay: `dfos content fetch --peer `. - **"blob bytes do not match documentCID"**: Remote relay rejected the blob upload. Create content locally first (`dfos content create file.json`), then publish separately (`dfos content publish --peer `). - **"content not found on peer" / 0 operations fetched**: The content doesn't exist on that relay. Verify the content ID and check which relay it was published to with `dfos content show `. -- **"read credential required"**: You're trying to download content you don't own. The creator must issue a read credential: `dfos content grant --read`. Present it with `--credential `. +- **"read credential required"**: You're trying to download content you don't own. The creator must issue a read credential: `dfos credential grant --read`. Present it with `--credential `. - **"unknown identity" on content publish**: If content includes delegated operations (writes by non-creators via credentials), all referenced identities must be published to the relay first. Publish each delegate's identity before the content. - **"signer is not the chain creator"**: Content mutations (update, delete) must be signed by the creator identity or via a write credential. Switch to the creator's context, or use `--authorization ` with a DFOS write credential. diff --git a/specs/CREDENTIALS.md b/specs/CREDENTIALS.md index 538af6b..44990f3 100644 --- a/specs/CREDENTIALS.md +++ b/specs/CREDENTIALS.md @@ -166,13 +166,13 @@ Valid narrowing examples: - Parent grants `chain:X` and `chain:Y` -- child requests only `chain:X` (subset of resources) - Parent grants `read,write` -- child requests only `read` (subset of actions) -- Parent grants `manifest:M` -- child requests `chain:X` (type narrowing, see below) +- Parent grants `chain:*` -- child requests `chain:X` (wildcard to specific) Invalid widening: - Parent grants `chain:X` -- child requests `chain:X` and `chain:Y` (new resource) - Parent grants `read` -- child requests `read,write` (new action) -- Parent grants `chain:X` -- child requests `manifest:M` (type widening) +- Parent grants `chain:X` -- child requests `chain:*` (specific to wildcard) ### Action Coverage @@ -186,7 +186,7 @@ The child's `exp` MUST be less than or equal to every parent's `exp`. A delegate ## Resource Types -Two resource types are defined. Both use the `type:id` format. +Two resource forms are defined. Both use the `chain:` prefix. ### `chain:` -- Exact Match @@ -208,39 +208,18 @@ Grants access to all content chains owned by the credential's root authority. Th Matching: `chain:*` matches any `chain:` request for content where the delegation chain roots at the expected creator DID. -This is the broadest resource scope. Common use case: granting a collaborator access to all of a creator's content without maintaining an exhaustive manifest. +This is the broadest resource scope. Common use case: granting a collaborator access to all of a creator's content. -### `manifest:` -- Transitive Match +### Attenuation Between Forms -Grants access to a manifest and all content chains it indexes. A manifest is itself a content chain whose document lists other content IDs. +| Parent | Child | Valid? | Reason | +| --------- | --------- | ------ | ----------------------------------------- | +| `chain:*` | `chain:*` | Yes | Exact match | +| `chain:*` | `chain:X` | Yes | Narrowing from wildcard to specific chain | +| `chain:X` | `chain:X` | Yes | Exact match | +| `chain:X` | `chain:*` | No | Widening from specific to wildcard | -```json -{ "resource": "manifest:k4f9t2r6v8n3h7d2c9a4e6", "action": "write" } -``` - -Matching at the relay: - -- `manifest:M` matches a request for `manifest:M` (exact) -- `manifest:M` matches a request for `chain:X` IF a manifest lookup confirms that content ID `X` is indexed by manifest `M` -- Without a manifest lookup callback, `manifest:` resources can only match exact `manifest:` requests - -### Attenuation Between Types - -| Parent | Child | Valid? | Reason | -| ------------ | ------------ | ------ | ----------------------------------------- | -| `chain:*` | `chain:*` | Yes | Exact match | -| `chain:*` | `chain:X` | Yes | Narrowing from wildcard to specific chain | -| `chain:*` | `manifest:M` | Yes | Narrowing from wildcard to manifest | -| `chain:X` | `chain:X` | Yes | Exact match | -| `manifest:M` | `manifest:M` | Yes | Exact match | -| `manifest:M` | `chain:X` | Yes | Narrowing from manifest to specific chain | -| `chain:X` | `chain:*` | No | Widening from specific to wildcard | -| `chain:X` | `manifest:M` | No | Widening from chain to manifest | -| `manifest:M` | `chain:*` | No | Widening from manifest to wildcard | - -The resource hierarchy from broadest to narrowest is: `chain:*` > `manifest:M` > `chain:X`. Each delegation hop can only move down this hierarchy, never up. - -The `manifest -> chain` narrowing is valid structurally during delegation chain verification. The actual membership check (does manifest M contain chain X?) happens at the relay during resource matching, not during chain verification. +The resource hierarchy from broadest to narrowest is: `chain:*` > `chain:X`. Each delegation hop can only move down this hierarchy, never up. ---