Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions packages/dfos-cli/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,13 @@ The CLI issues DFOS credentials for content access control:

```bash
# grant read access
dfos content grant <contentId> <did> --read
dfos credential grant <contentId> <did> --read

# grant write access (allows extending the content chain)
dfos content grant <contentId> <did> --write
dfos credential grant <contentId> <did> --write

# with custom TTL
dfos content grant <contentId> <did> --read --ttl 1h
dfos credential grant <contentId> <did> --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`:
Expand Down Expand Up @@ -345,7 +345,8 @@ The `--auth` flag resolves the active identity, loads the auth key from the keyc
| `POST` | `content delete <id>` | Permanently delete content chain |
| `POST` | `content publish <id>` | Submit content chain + blob to a relay |
| `GET` | `content fetch <id>` | Download content chain from relay |
| `POST` | `content grant <id> <did>` | Issue read/write credential |
| `POST` | `credential grant <id> <did>` | Issue read/write credential |
| `POST` | `credential revoke <cid>` | Revoke a credential |
| `GET` | `content verify <id>` | Re-verify chain integrity locally |
| `GET` | `beacon show [did\|name]` | Show latest beacon |
| `POST` | `beacon announce <id...>` | Build merkle root, sign, submit |
Expand Down
5 changes: 3 additions & 2 deletions packages/dfos-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -133,7 +133,8 @@ dfos content publish <id> --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 |
Expand Down
140 changes: 46 additions & 94 deletions packages/dfos-cli/internal/cmd/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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 <contentId> <did>",
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading