From 47c091b859fc57fb7060716ec1c2cc00e519dd88 Mon Sep 17 00:00:00 2001 From: Joost van Ulden Date: Thu, 2 Jul 2026 13:00:21 -0700 Subject: [PATCH] Add registry revocations endpoint --- README.md | 4 +- core/qcap-cli/src/main.rs | 119 +++++++++++++++++++++++++++- scripts/demo.ps1 | 5 +- services/qcap-registry/README.md | 12 ++- services/qcap-registry/main.go | 118 +++++++++++++++++++++++++++ services/qcap-registry/main_test.go | 64 +++++++++++++++ 6 files changed, 316 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 557e907..110452a 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,9 @@ cargo run -p qcap-cli -- fetch demo.qcap --out /tmp/qcap-demo/fetched.qcap --reg cargo run -p qcap-cli -- grant /tmp/qcap-demo/fetched.qcap --issuer /tmp/qcap-demo/issuer.identity.json --audience --path "reports/*" --out /tmp/qcap-demo/cap.json cargo run -p qcap-cli -- open /tmp/qcap-demo/fetched.qcap --cap /tmp/qcap-demo/cap.json --identity /tmp/qcap-demo/recipient.identity.json --out /tmp/qcap-demo/exported cargo run -p qcap-cli -- revoke --cap /tmp/qcap-demo/cap.json --issuer /tmp/qcap-demo/issuer.identity.json --out /tmp/qcap-demo/revocations.json -cargo run -p qcap-cli -- open /tmp/qcap-demo/fetched.qcap --cap /tmp/qcap-demo/cap.json --identity /tmp/qcap-demo/recipient.identity.json --revocations /tmp/qcap-demo/revocations.json --out /tmp/qcap-demo/revoked-exported +cargo run -p qcap-cli -- publish-revocations /tmp/qcap-demo/revocations.json --registry http://127.0.0.1:8080 --token demo-token +cargo run -p qcap-cli -- fetch-revocations --out /tmp/qcap-demo/fetched-revocations.json --registry http://127.0.0.1:8080 +cargo run -p qcap-cli -- open /tmp/qcap-demo/fetched.qcap --cap /tmp/qcap-demo/cap.json --identity /tmp/qcap-demo/recipient.identity.json --revocations-url http://127.0.0.1:8080/revocations//revocations.json --out /tmp/qcap-demo/revoked-exported ``` This is an MVP, not a hardened security product. It uses XChaCha20-Poly1305 for file encryption, X25519-derived wrapping keys for recipients, ed25519 signatures over the Merkle root, and signed capability tokens with enforced expiry, audience, and path constraints. diff --git a/core/qcap-cli/src/main.rs b/core/qcap-cli/src/main.rs index d2b64ca..dcffc11 100644 --- a/core/qcap-cli/src/main.rs +++ b/core/qcap-cli/src/main.rs @@ -88,6 +88,8 @@ enum Commands { identity: PathBuf, #[arg(long = "revocations")] revocations: Option, + #[arg(long = "revocations-url", conflicts_with = "revocations")] + revocations_url: Option, #[arg(short = 'o', long = "out")] out: PathBuf, }, @@ -110,6 +112,16 @@ enum Commands { #[arg(long = "token", env = "QCAP_REGISTRY_TOKEN")] token: Option, }, + /// Publish a signed revocations.json to a registry issuer endpoint + PublishRevocations { + file: PathBuf, + #[arg(long = "issuer")] + issuer: Option, + #[arg(long = "registry", default_value = "http://127.0.0.1:8080")] + registry: String, + #[arg(long = "token", env = "QCAP_REGISTRY_TOKEN")] + token: Option, + }, /// Fetch a .qcap from a registry Fetch { artifact: String, @@ -118,6 +130,14 @@ enum Commands { #[arg(long = "registry", default_value = "http://127.0.0.1:8080")] registry: String, }, + /// Fetch a signed revocations.json from a registry issuer endpoint + FetchRevocations { + issuer: String, + #[arg(short = 'o', long = "out")] + out: PathBuf, + #[arg(long = "registry", default_value = "http://127.0.0.1:8080")] + registry: String, + }, /// Create a tiny valid GeoPackage fixture for MVP demos and tests SampleGeopackage { #[arg(short = 'o', long = "out")] @@ -192,8 +212,16 @@ fn run() -> Result<()> { cap, identity, revocations, + revocations_url, out, - } => open_archive(&file, &cap, &identity, revocations.as_deref(), &out)?, + } => open_archive( + &file, + &cap, + &identity, + revocations.as_deref(), + revocations_url.as_deref(), + &out, + )?, Commands::Revoke { cap, issuer, @@ -205,11 +233,22 @@ fn run() -> Result<()> { registry, token, } => publish(&file, ®istry, token.as_deref())?, + Commands::PublishRevocations { + file, + issuer, + registry, + token, + } => publish_revocations(&file, issuer.as_deref(), ®istry, token.as_deref())?, Commands::Fetch { artifact, out, registry, } => fetch(&artifact, &out, ®istry)?, + Commands::FetchRevocations { + issuer, + out, + registry, + } => fetch_revocations(&issuer, &out, ®istry)?, Commands::SampleGeopackage { out } => sample_geopackage(&out)?, } Ok(()) @@ -436,14 +475,14 @@ fn open_archive( cap_path: &Path, identity_path: &Path, revocations_path: Option<&Path>, + revocations_url: Option<&str>, out: &Path, ) -> Result<()> { let report = verify_archive(file)?; let identity: Identity = read_json(identity_path)?; let cap: CapabilityToken = read_json(cap_path)?; cap.verify()?; - if let Some(path) = revocations_path { - let revocations: RevocationList = read_json(path)?; + if let Some(revocations) = load_revocations(revocations_path, revocations_url)? { revocations.verify()?; if revocations.revokes(&cap) { return Err("capability has been revoked".into()); @@ -637,6 +676,74 @@ fn fetch(artifact: &str, out: &Path, registry: &str) -> Result<()> { Ok(()) } +fn publish_revocations( + file: &Path, + issuer: Option<&str>, + registry: &str, + token: Option<&str>, +) -> Result<()> { + let revocations: RevocationList = read_json(file)?; + revocations.verify()?; + let issuer = issuer + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| revocations.public_key.clone()); + let url = revocations_url(registry, &issuer); + let bytes = fs::read(file)?; + let mut request = ureq::post(&url).set("Content-Type", "application/qcap-revocations+json"); + if let Some(token) = token.filter(|value| !value.is_empty()) { + request = request.set("Authorization", &format!("Bearer {token}")); + } + let response: RevocationResponse = request.send_bytes(&bytes)?.into_json()?; + println!( + "Published revocations\n- issuer: {}\n- url: {}{}", + response.issuer, + registry.trim_end_matches('/'), + response.path + ); + Ok(()) +} + +fn fetch_revocations(issuer: &str, out: &Path, registry: &str) -> Result<()> { + let url = revocations_url(registry, issuer); + let revocations = fetch_revocations_from_url(&url)?; + write_json(out, &revocations)?; + println!( + "Fetched revocations\n- issuer: {}\n- out: {}", + issuer, + out.display() + ); + Ok(()) +} + +fn load_revocations( + revocations_path: Option<&Path>, + revocations_url: Option<&str>, +) -> Result> { + match (revocations_path, revocations_url) { + (Some(path), None) => Ok(Some(read_json(path)?)), + (None, Some(url)) => Ok(Some(fetch_revocations_from_url(url)?)), + (None, None) => Ok(None), + (Some(_), Some(_)) => Err("use either --revocations or --revocations-url, not both".into()), + } +} + +fn fetch_revocations_from_url(url: &str) -> Result { + let response = ureq::get(url).call()?; + let revocations: RevocationList = response.into_json()?; + revocations.verify()?; + Ok(revocations) +} + +fn revocations_url(registry: &str, issuer: &str) -> String { + let issuer = issuer.trim().trim_matches('/'); + format!( + "{}/revocations/{}/revocations.json", + registry.trim_end_matches('/'), + issuer + ) +} + fn sample_geopackage(out: &Path) -> Result<()> { if let Some(parent) = out.parent() { fs::create_dir_all(parent)?; @@ -769,6 +876,12 @@ struct PublishResponse { path: String, } +#[derive(Deserialize)] +struct RevocationResponse { + issuer: String, + path: String, +} + #[derive(Debug)] struct Access { operation: String, diff --git a/scripts/demo.ps1 b/scripts/demo.ps1 index c1eeb86..e947edf 100644 --- a/scripts/demo.ps1 +++ b/scripts/demo.ps1 @@ -68,6 +68,8 @@ try { Step "creating issuer and recipient identities" Run $QcapExe @("init", "--name", "issuer", "--out", $Issuer) Run $QcapExe @("init", "--name", "recipient", "--out", $Recipient) + $issuerIdentity = Get-Content -Raw $Issuer | ConvertFrom-Json + $issuerRevocationsUrl = "http://127.0.0.1:8080/revocations/$($issuerIdentity.signing_public_key)/revocations.json" $recipientIdentity = Get-Content -Raw $Recipient | ConvertFrom-Json $recipientAudience = $recipientIdentity.signing_public_key.Substring(0, 16) @@ -121,7 +123,8 @@ try { Step "revoking capability and proving it is blocked" Run $QcapExe @("revoke", "--cap", $Cap, "--issuer", $Issuer, "--reason", "demo-complete", "--out", $Revocations) - RunExpectFailure $QcapExe @("open", $Fetched, "--cap", $Cap, "--identity", $Recipient, "--revocations", $Revocations, "--out", $RevokedExported) "OK blocked revoked capability" + Run $QcapExe @("publish-revocations", $Revocations, "--registry", "http://127.0.0.1:8080", "--token", "demo-token") + RunExpectFailure $QcapExe @("open", $Fetched, "--cap", $Cap, "--identity", $Recipient, "--revocations-url", $issuerRevocationsUrl, "--out", $RevokedExported) "OK blocked revoked capability" Step "MVP demo complete" Write-Host "Allowed output: $(Join-Path $Exported "reports\summary.txt")" diff --git a/services/qcap-registry/README.md b/services/qcap-registry/README.md index ffaf7de..34ad520 100644 --- a/services/qcap-registry/README.md +++ b/services/qcap-registry/README.md @@ -1,6 +1,6 @@ # Q-Cap Registry (MVP) -A minimal registry that stores `.qcap` artifacts on disk, persists an `index.json`, and can require bearer-token auth for publishing. +A minimal registry that stores `.qcap` artifacts on disk, persists an `index.json`, serves signed issuer revocation lists, and can require bearer-token auth for publishing. ## Endpoints @@ -9,6 +9,8 @@ A minimal registry that stores `.qcap` artifacts on disk, persists an `index.jso - `GET /index` - HTML index listing - `POST /artifacts` - publish a `.qcap` - `GET /artifacts/` - download a `.qcap` +- `POST /revocations//revocations.json` - publish a signed revocation list +- `GET /revocations//revocations.json` - download a signed revocation list ## Configuration @@ -31,6 +33,14 @@ cargo run -p qcap-cli -- publish target/qcap-demo/demo.qcap \ --registry http://127.0.0.1:8080 \ --token demo-token +# Publish and fetch issuer revocations +cargo run -p qcap-cli -- publish-revocations target/qcap-demo/revocations.json \ + --registry http://127.0.0.1:8080 \ + --token demo-token +cargo run -p qcap-cli -- fetch-revocations \ + --out target/qcap-demo/fetched-revocations.json \ + --registry http://127.0.0.1:8080 + # Smoke test endpoints scripts/smoke-registry.sh ``` diff --git a/services/qcap-registry/main.go b/services/qcap-registry/main.go index 539dc8f..c1e1aff 100644 --- a/services/qcap-registry/main.go +++ b/services/qcap-registry/main.go @@ -25,6 +25,15 @@ type Capsule struct { CreatedAt string `json:"created_at"` } +type RevocationDocument struct { + Issuer string `json:"issuer"` + Path string `json:"path"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Digest string `json:"digest"` + CreatedAt string `json:"created_at"` +} + type Registry struct { StoreDir string IndexPath string @@ -64,6 +73,7 @@ func main() { mux.HandleFunc("/index", reg.indexHTML) mux.HandleFunc("/artifacts", reg.publish) mux.HandleFunc("/artifacts/", reg.artifact) + mux.HandleFunc("/revocations/", reg.revocations) addr := ":8080" log.Printf("registry listening on %s; store dir: %s; index: %s", addr, storeDir, indexPath) @@ -174,6 +184,7 @@ func (r *Registry) root(w http.ResponseWriter, _ *http.Request) {
  • /index.json (JSON index)
  • /index (HTML index)
  • /artifacts/ (downloads)
  • +
  • /revocations/<issuer>/revocations.json (signed revocation lists)
  • `)) } @@ -184,6 +195,7 @@ func (r *Registry) health(w http.ResponseWriter, _ *http.Request) { "status": "ok", "auth_required": r.Token != "", "artifacts": len(r.Index), + "revocations": r.revocationIssuerCount(), }) } @@ -290,6 +302,72 @@ func (r *Registry) artifact(w http.ResponseWriter, req *http.Request) { http.ServeFile(w, req, filepath.Join(r.StoreDir, name)) } +func (r *Registry) revocations(w http.ResponseWriter, req *http.Request) { + issuer := revocationIssuerFromPath(req.URL.Path) + if issuer == "" { + http.NotFound(w, req) + return + } + + switch req.Method { + case http.MethodGet: + r.serveRevocations(w, req, issuer) + case http.MethodPost: + r.publishRevocations(w, req, issuer) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (r *Registry) serveRevocations(w http.ResponseWriter, req *http.Request, issuer string) { + path := r.revocationPath(issuer) + w.Header().Set("Content-Type", "application/qcap-revocations+json") + http.ServeFile(w, req, path) +} + +func (r *Registry) publishRevocations(w http.ResponseWriter, req *http.Request, issuer string) { + if !r.authorized(req) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + dest := r.revocationPath(issuer) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + out, err := os.Create(dest) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + written, copyErr := io.Copy(out, http.MaxBytesReader(w, req.Body, 10<<20)) + closeErr := out.Close() + if copyErr != nil { + http.Error(w, copyErr.Error(), http.StatusBadRequest) + return + } + if closeErr != nil { + http.Error(w, closeErr.Error(), http.StatusInternalServerError) + return + } + digest, err := fileDigest(dest) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc := RevocationDocument{ + Issuer: issuer, + Path: "/revocations/" + issuer + "/revocations.json", + Size: written, + ContentType: "application/qcap-revocations+json", + Digest: digest, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(doc) +} + func (r *Registry) authorized(req *http.Request) bool { if r.Token == "" { return true @@ -297,6 +375,35 @@ func (r *Registry) authorized(req *http.Request) bool { return req.Header.Get("Authorization") == "Bearer "+r.Token } +func (r *Registry) revocationPath(issuer string) string { + return filepath.Join(r.StoreDir, "revocations", issuer, "revocations.json") +} + +func (r *Registry) revocationIssuerCount() int { + root := filepath.Join(r.StoreDir, "revocations") + entries, err := os.ReadDir(root) + if err != nil { + return 0 + } + count := 0 + for _, entry := range entries { + if entry.IsDir() { + count++ + } + } + return count +} + +func revocationIssuerFromPath(path string) string { + raw := strings.TrimPrefix(path, "/revocations/") + raw = strings.TrimSuffix(raw, "/revocations.json") + raw = strings.Trim(raw, "/") + if raw == "" || strings.Contains(raw, "/") { + return "" + } + return safePathSegment(raw) +} + func safeArtifactName(raw string) string { name := filepath.Base(strings.TrimSpace(raw)) name = strings.ReplaceAll(name, "\\", "_") @@ -307,6 +414,17 @@ func safeArtifactName(raw string) string { return name } +func safePathSegment(raw string) string { + name := strings.TrimSpace(raw) + name = strings.ReplaceAll(name, "\\", "_") + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "..", "_") + if name == "." || name == "" { + return "" + } + return name +} + func fileDigest(path string) (string, error) { f, err := os.Open(path) if err != nil { diff --git a/services/qcap-registry/main_test.go b/services/qcap-registry/main_test.go index aa9e5e2..bd1c2ee 100644 --- a/services/qcap-registry/main_test.go +++ b/services/qcap-registry/main_test.go @@ -70,3 +70,67 @@ func TestPublishPersistsIndex(t *testing.T) { t.Fatalf("expected digest and created_at in persisted index: %+v", persisted[0]) } } + +func TestPublishRevocationsRequiresBearerTokenWhenConfigured(t *testing.T) { + dir := t.TempDir() + reg := &Registry{ + StoreDir: dir, + IndexPath: filepath.Join(dir, "index.json"), + Token: "secret", + } + + req := httptest.NewRequest(http.MethodPost, "/revocations/issuer/revocations.json", bytes.NewReader([]byte(`{"revoked":[]}`))) + rec := httptest.NewRecorder() + reg.revocations(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized, got %d", rec.Code) + } + if _, err := os.Stat(filepath.Join(dir, "revocations", "issuer", "revocations.json")); !os.IsNotExist(err) { + t.Fatalf("revocations should not be written without auth") + } +} + +func TestPublishAndServeRevocationsByIssuer(t *testing.T) { + dir := t.TempDir() + reg := &Registry{ + StoreDir: dir, + IndexPath: filepath.Join(dir, "index.json"), + Token: "secret", + } + body := []byte(`{"schema_version":"0.1.0","revoked":[]}`) + + req := httptest.NewRequest(http.MethodPost, "/revocations/issuer-abc/revocations.json", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + reg.revocations(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected created, got %d: %s", rec.Code, rec.Body.String()) + } + + var doc RevocationDocument + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("response invalid JSON: %v", err) + } + if doc.Issuer != "issuer-abc" || doc.Path != "/revocations/issuer-abc/revocations.json" { + t.Fatalf("unexpected revocation document: %+v", doc) + } + if doc.Digest == "" || doc.CreatedAt == "" { + t.Fatalf("expected digest and created_at in response: %+v", doc) + } + + getReq := httptest.NewRequest(http.MethodGet, "/revocations/issuer-abc/revocations.json", nil) + getRec := httptest.NewRecorder() + reg.revocations(getRec, getReq) + + if getRec.Code != http.StatusOK { + t.Fatalf("expected ok, got %d: %s", getRec.Code, getRec.Body.String()) + } + if !bytes.Equal(getRec.Body.Bytes(), body) { + t.Fatalf("served revocations changed: %s", getRec.Body.String()) + } + if got := reg.revocationIssuerCount(); got != 1 { + t.Fatalf("expected one revocation issuer, got %d", got) + } +}