Skip to content

Commit abdc775

Browse files
authored
feat(node): blind recipient identities at rest and gate B1 by repo readability (#40)
* feat(node): blind recipient identities at rest and gate B1 by repo readability The origin no longer stores recipient DIDs. Migration v5 replaces the encrypted_blobs.recipients column with an opaque, node-keyed recipients_tag used only to detect a recipient-set change for re-seal. B1 discovery and fetch are now gated by the same repo-readability check the git read path uses, not by per-recipient matching; decryption is gated by the envelope crypto, so a non-recipient who can read the repo sees a blob's {oid, cid} but cannot open it. encrypt_and_pin keys the tag from the node seed and returns {oid, cid}; the Arweave manifest tuple drops the now-unused recipient vec. A DB compromise no longer reveals the reader set; recovering it would require brute-forcing candidate DID sets against the keyed tag with the node key. * chore: remove redundant .gitignore entry (covered by local exclude) * fix(node): gate /replicate by repo readability; harden reseal on DB error Address review on the at-rest blinding change: - The encrypted-blobs/replicate listing returned {oid, cid} with no visibility check, so a non-readable repo's blob index was reachable by an unauthenticated caller who guessed {owner}/{repo}. Gate it by the same repo-readability check discovery and fetch use. For the intended case (a public repo with withheld subtrees) the public root keeps this open to peers; only fully non-readable repos are withheld, which is the desired behavior. - encrypt_and_pin treated a recipients_tag DB read error as a cache miss and resealed, causing avoidable IPFS writes during a partial outage; skip and retry on the next push instead. - Correct the get_encrypted_blob doc comment to describe repo-readability access.
1 parent e37ea7f commit abdc775

7 files changed

Lines changed: 164 additions & 142 deletions

File tree

‎.gitignore‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,3 @@ keys/
2323
# Logs
2424
*.log
2525
.openclaude-profile.json
26-
27-
# Local planning / scratch docs (never commit)
28-
docs/superpowers/

‎crates/gitlawb-node/src/api/encrypted.rs‎

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,30 @@ use axum::Json;
66
use crate::auth::AuthenticatedDid;
77
use crate::error::{AppError, Result};
88
use crate::state::AppState;
9+
use crate::visibility::{visibility_check, Decision};
910

1011
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs
11-
/// Returns [{oid, cid}] for encrypted blobs the caller may decrypt.
12+
/// Returns [{oid, cid}] for every encrypted blob in the repo, to any caller who
13+
/// can read the repo. Not recipient-scoped: recipient identities are not stored,
14+
/// so access control here is repo readability and decryption is gated by the
15+
/// envelope crypto (only a real recipient can open an envelope).
1216
pub async fn list_encrypted_blobs(
1317
State(state): State<AppState>,
1418
auth: Option<Extension<AuthenticatedDid>>,
1519
Path((owner, repo)): Path<(String, String)>,
1620
) -> Result<Json<serde_json::Value>> {
17-
let caller = auth.as_ref().map(|e| e.0 .0.as_str()).unwrap_or("");
1821
let record = state
1922
.db
2023
.get_repo(&owner, &repo)
2124
.await?
2225
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
23-
let rows = state
24-
.db
25-
.list_encrypted_blobs_for(&record.id, caller)
26-
.await?;
26+
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
27+
let rules = state.db.list_visibility_rules(&record.id).await?;
28+
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
29+
{
30+
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
31+
}
32+
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
2733
let blobs: Vec<_> = rows
2834
.into_iter()
2935
.map(|(oid, cid)| serde_json::json!({ "oid": oid, "cid": cid }))
@@ -32,21 +38,27 @@ pub async fn list_encrypted_blobs(
3238
}
3339

3440
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blob/{oid}
35-
/// Returns raw envelope bytes if the caller is a recipient.
41+
/// Returns raw envelope bytes to callers who can read the repo; the envelope
42+
/// crypto still ensures only true recipients can decrypt.
3643
pub async fn get_encrypted_blob(
3744
State(state): State<AppState>,
3845
auth: Option<Extension<AuthenticatedDid>>,
3946
Path((owner, repo, oid)): Path<(String, String, String)>,
4047
) -> Result<Vec<u8>> {
41-
let caller = auth.as_ref().map(|e| e.0 .0.as_str()).unwrap_or("");
4248
let record = state
4349
.db
4450
.get_repo(&owner, &repo)
4551
.await?
4652
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
53+
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
54+
let rules = state.db.list_visibility_rules(&record.id).await?;
55+
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
56+
{
57+
return Err(AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")));
58+
}
4759
let cid = state
4860
.db
49-
.encrypted_blob_cid(&record.id, &oid, caller)
61+
.encrypted_blob_cid(&record.id, &oid)
5062
.await?
5163
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")))?;
5264
let bytes = crate::ipfs_pin::cat(&state.config.ipfs_api, &cid)
@@ -57,23 +69,33 @@ pub async fn get_encrypted_blob(
5769

5870
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate
5971
/// Returns [{oid, cid}] for every encrypted blob in the repo, for peer-mirror
60-
/// replication (Option B2). Recipient identities are deliberately withheld: the
61-
/// v2 envelopes no longer carry recipient public keys, so peers must not learn
62-
/// the reader set either. A mirror detects a re-seal by the CID changing (the
63-
/// OID is stable across re-seals). Ciphertext metadata only, never plaintext.
72+
/// replication (Option B2). Gated by repo readability, like discovery, so a
73+
/// non-readable repo does not expose its blob index; for the intended case (a
74+
/// public repo with withheld subtrees) the public root keeps this open to peers.
75+
/// Recipient identities are deliberately withheld: the v2 envelopes no longer
76+
/// carry recipient public keys, so peers must not learn the reader set either. A
77+
/// mirror detects a re-seal by the CID changing (the OID is stable across
78+
/// re-seals). Ciphertext metadata only, never plaintext.
6479
pub async fn replicate_encrypted_blobs(
6580
State(state): State<AppState>,
81+
auth: Option<Extension<AuthenticatedDid>>,
6682
Path((owner, repo)): Path<(String, String)>,
6783
) -> Result<Json<serde_json::Value>> {
6884
let record = state
6985
.db
7086
.get_repo(&owner, &repo)
7187
.await?
7288
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
89+
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
90+
let rules = state.db.list_visibility_rules(&record.id).await?;
91+
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
92+
{
93+
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
94+
}
7395
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
7496
let blobs: Vec<_> = rows
7597
.into_iter()
76-
.map(|(oid, cid, _recipients)| replicate_blob_json(oid, cid))
98+
.map(|(oid, cid)| replicate_blob_json(oid, cid))
7799
.collect();
78100
Ok(Json(serde_json::json!({ "blobs": blobs })))
79101
}

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,7 @@ pub async fn git_receive_pack(
689689
let irys_url = state.config.irys_url.clone();
690690
let http_client = std::sync::Arc::clone(&state.http_client);
691691
let node_did_str = state.node_did.to_string();
692+
let node_seed = state.node_keypair.seed_bytes();
692693
let repo_name = record.name.clone();
693694
tokio::spawn(async move {
694695
let pinned = crate::ipfs_pin::pin_new_objects(
@@ -722,6 +723,7 @@ pub async fn git_receive_pack(
722723
&repo_path_clone,
723724
&db_clone,
724725
&repo_id,
726+
&node_seed,
725727
&recipients,
726728
)
727729
.await;

‎crates/gitlawb-node/src/arweave.rs‎

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,15 @@ pub async fn anchor_ref_update(
104104
}
105105

106106
/// A per-push manifest of the blobs encrypted this push (Option B3). The
107-
/// `blobs` slice is `(oid, cid, recipients)` tuples; only `oid` and `cid` are
108-
/// anchored. Anchored directly to Arweave as its JSON body so the discovery
109-
/// index survives total node loss.
107+
/// `blobs` slice is `(oid, cid)` tuples. Anchored directly to Arweave as its JSON
108+
/// body so the discovery index survives total node loss. Recipient identities are
109+
/// never part of the manifest.
110110
pub struct EncryptedManifest<'a> {
111111
pub repo: &'a str,
112112
pub owner_did: &'a str,
113113
pub node_did: &'a str,
114114
pub timestamp: &'a str,
115-
pub blobs: &'a [(String, String, Vec<String>)],
115+
pub blobs: &'a [(String, String)],
116116
}
117117

118118
/// Anchor a per-push encrypted-blob manifest to Arweave via Irys. The manifest
@@ -135,7 +135,7 @@ pub async fn anchor_encrypted_manifest(
135135
let blobs_json: Vec<serde_json::Value> = manifest
136136
.blobs
137137
.iter()
138-
.map(|(oid, cid, _recipients)| manifest_blob_json(oid, cid))
138+
.map(|(oid, cid)| manifest_blob_json(oid, cid))
139139
.collect();
140140

141141
let payload = json!({
@@ -298,11 +298,7 @@ mod tests {
298298
#[tokio::test]
299299
async fn test_manifest_anchor_noop_when_url_empty() {
300300
let client = reqwest::Client::new();
301-
let blobs = vec![(
302-
"oid1".to_string(),
303-
"cid1".to_string(),
304-
vec!["did:key:zA".to_string()],
305-
)];
301+
let blobs = vec![("oid1".to_string(), "cid1".to_string())];
306302
let m = EncryptedManifest {
307303
repo: "alice/r",
308304
owner_did: "did:key:zO",
@@ -319,7 +315,7 @@ mod tests {
319315
#[tokio::test]
320316
async fn test_manifest_anchor_noop_when_no_blobs() {
321317
let client = reqwest::Client::new();
322-
let blobs: Vec<(String, String, Vec<String>)> = vec![];
318+
let blobs: Vec<(String, String)> = vec![];
323319
let m = EncryptedManifest {
324320
repo: "alice/r",
325321
owner_did: "did:key:zO",
@@ -348,11 +344,7 @@ mod tests {
348344
.await;
349345

350346
let client = reqwest::Client::new();
351-
let blobs = vec![(
352-
"oid1".to_string(),
353-
"cid1".to_string(),
354-
vec!["did:key:zA".to_string()],
355-
)];
347+
let blobs = vec![("oid1".to_string(), "cid1".to_string())];
356348
let m = EncryptedManifest {
357349
repo: "alice/r",
358350
owner_did: "did:key:zO",

‎crates/gitlawb-node/src/db/mod.rs‎

Lines changed: 41 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,17 @@ const MIGRATIONS: &[Migration] = &[
735735
"CREATE INDEX IF NOT EXISTS idx_encrypted_blobs_repo ON encrypted_blobs(repo_id)",
736736
],
737737
},
738+
Migration {
739+
version: 5,
740+
name: "encrypted_blobs_blind_recipients",
741+
stmts: &[
742+
// Replace the cleartext recipient DID list with an opaque, node-keyed
743+
// tag used only to detect a recipient-set change. Existing rows get an
744+
// empty tag and are re-sealed on the next push.
745+
"ALTER TABLE encrypted_blobs DROP COLUMN IF EXISTS recipients",
746+
"ALTER TABLE encrypted_blobs ADD COLUMN IF NOT EXISTS recipients_tag TEXT NOT NULL DEFAULT ''",
747+
],
748+
},
738749
];
739750

740751
// ── Repos ─────────────────────────────────────────────────────────────────────
@@ -1648,126 +1659,69 @@ impl Db {
16481659
repo_id: &str,
16491660
oid: &str,
16501661
cid: &str,
1651-
recipients: &[String],
1662+
recipients_tag: &str,
16521663
) -> Result<()> {
1653-
let recipients_json = serde_json::to_string(recipients)?;
16541664
sqlx::query(
1655-
"INSERT INTO encrypted_blobs (repo_id, oid, cid, recipients, created_at)
1665+
"INSERT INTO encrypted_blobs (repo_id, oid, cid, recipients_tag, created_at)
16561666
VALUES ($1, $2, $3, $4, $5)
1657-
ON CONFLICT (repo_id, oid) DO UPDATE SET cid = EXCLUDED.cid, recipients = EXCLUDED.recipients",
1667+
ON CONFLICT (repo_id, oid) DO UPDATE SET cid = EXCLUDED.cid, recipients_tag = EXCLUDED.recipients_tag",
16581668
)
16591669
.bind(repo_id)
16601670
.bind(oid)
16611671
.bind(cid)
1662-
.bind(recipients_json)
1672+
.bind(recipients_tag)
16631673
.bind(Utc::now().to_rfc3339())
16641674
.execute(&self.pool)
16651675
.await?;
16661676
Ok(())
16671677
}
16681678

1669-
/// Deserialize the stored recipients JSON. Corruption is surfaced as an
1670-
/// error rather than silently treated as an empty recipient list, which
1671-
/// would deny access to every legitimate reader and hand peers incomplete
1672-
/// replication metadata.
1673-
fn parse_recipients(repo_id: &str, oid: &str, raw: &str) -> Result<Vec<String>> {
1674-
serde_json::from_str(raw).with_context(|| {
1675-
format!("corrupt recipients JSON in encrypted_blobs (repo_id={repo_id}, oid={oid})")
1676-
})
1677-
}
1678-
1679-
/// (oid, cid) for every encrypted blob in the repo that `caller` may decrypt.
1680-
pub async fn list_encrypted_blobs_for(
1681-
&self,
1682-
repo_id: &str,
1683-
caller: &str,
1684-
) -> Result<Vec<(String, String)>> {
1685-
let rows =
1686-
sqlx::query("SELECT oid, cid, recipients FROM encrypted_blobs WHERE repo_id = $1")
1687-
.bind(repo_id)
1688-
.fetch_all(&self.pool)
1689-
.await?;
1679+
/// (oid, cid) for every encrypted blob in the repo, unscoped by caller. Used
1680+
/// by both the B2 replication view and B1 discovery. Recipient identities are
1681+
/// not stored, so authorization is the caller's repo readability, not a per
1682+
/// recipient check. Ciphertext metadata only.
1683+
pub async fn list_all_encrypted_blobs(&self, repo_id: &str) -> Result<Vec<(String, String)>> {
1684+
let rows = sqlx::query("SELECT oid, cid FROM encrypted_blobs WHERE repo_id = $1")
1685+
.bind(repo_id)
1686+
.fetch_all(&self.pool)
1687+
.await?;
16901688
let mut out = Vec::new();
16911689
for row in rows {
16921690
let oid: String = row.get("oid");
16931691
let cid: String = row.get("cid");
1694-
let recipients: String = row.get("recipients");
1695-
let recipients = Self::parse_recipients(repo_id, &oid, &recipients)?;
1696-
if recipients.iter().any(|d| d == caller) {
1697-
out.push((oid, cid));
1698-
}
1692+
out.push((oid, cid));
16991693
}
17001694
Ok(out)
17011695
}
17021696

1703-
/// (oid, cid, recipients) for every encrypted blob in the repo, unscoped by
1704-
/// caller. This is the replication view used by peer mirrors (Option B2),
1705-
/// distinct from the recipient-scoped `list_encrypted_blobs_for`. It returns
1706-
/// only ciphertext metadata; no plaintext or key material is involved.
1707-
pub async fn list_all_encrypted_blobs(
1708-
&self,
1709-
repo_id: &str,
1710-
) -> Result<Vec<(String, String, Vec<String>)>> {
1711-
let rows =
1712-
sqlx::query("SELECT oid, cid, recipients FROM encrypted_blobs WHERE repo_id = $1")
1713-
.bind(repo_id)
1714-
.fetch_all(&self.pool)
1715-
.await?;
1716-
let mut out = Vec::new();
1717-
for row in rows {
1718-
let oid: String = row.get("oid");
1719-
let cid: String = row.get("cid");
1720-
let recipients: String = row.get("recipients");
1721-
let recipients = Self::parse_recipients(repo_id, &oid, &recipients)?;
1722-
out.push((oid, cid, recipients));
1723-
}
1724-
Ok(out)
1697+
/// The CID of one encrypted blob, or None if there is no such row. Recipient
1698+
/// authorization is not enforced here: the handler checks repo readability and
1699+
/// the envelope crypto gates decryption.
1700+
pub async fn encrypted_blob_cid(&self, repo_id: &str, oid: &str) -> Result<Option<String>> {
1701+
let row = sqlx::query("SELECT cid FROM encrypted_blobs WHERE repo_id = $1 AND oid = $2")
1702+
.bind(repo_id)
1703+
.bind(oid)
1704+
.fetch_optional(&self.pool)
1705+
.await?;
1706+
Ok(row.map(|r| r.get("cid")))
17251707
}
17261708

1727-
/// The CID of one encrypted blob, only if `caller` is a recipient.
1728-
pub async fn encrypted_blob_cid(
1709+
/// The opaque recipients tag stored for an encrypted blob, or None if there is
1710+
/// no row. Used only to decide whether a re-seal is needed (the recipient set
1711+
/// changed); the tag is a node-keyed fingerprint, not the DID list.
1712+
pub async fn encrypted_blob_recipients_tag(
17291713
&self,
17301714
repo_id: &str,
17311715
oid: &str,
1732-
caller: &str,
17331716
) -> Result<Option<String>> {
17341717
let row = sqlx::query(
1735-
"SELECT cid, recipients FROM encrypted_blobs WHERE repo_id = $1 AND oid = $2",
1718+
"SELECT recipients_tag FROM encrypted_blobs WHERE repo_id = $1 AND oid = $2",
17361719
)
17371720
.bind(repo_id)
17381721
.bind(oid)
17391722
.fetch_optional(&self.pool)
17401723
.await?;
1741-
let Some(row) = row else { return Ok(None) };
1742-
let recipients: String = row.get("recipients");
1743-
let recipients = Self::parse_recipients(repo_id, oid, &recipients)?;
1744-
if recipients.iter().any(|d| d == caller) {
1745-
Ok(Some(row.get("cid")))
1746-
} else {
1747-
Ok(None)
1748-
}
1749-
}
1750-
1751-
/// The recipient DID list stored for an encrypted blob, or None if there is
1752-
/// no row. Used to decide whether a re-seal is needed (recipients changed).
1753-
pub async fn encrypted_blob_recipients(
1754-
&self,
1755-
repo_id: &str,
1756-
oid: &str,
1757-
) -> Result<Option<Vec<String>>> {
1758-
let row =
1759-
sqlx::query("SELECT recipients FROM encrypted_blobs WHERE repo_id = $1 AND oid = $2")
1760-
.bind(repo_id)
1761-
.bind(oid)
1762-
.fetch_optional(&self.pool)
1763-
.await?;
1764-
match row {
1765-
None => Ok(None),
1766-
Some(r) => {
1767-
let recipients: String = r.get("recipients");
1768-
Ok(Some(Self::parse_recipients(repo_id, oid, &recipients)?))
1769-
}
1770-
}
1724+
Ok(row.map(|r| r.get("recipients_tag")))
17711725
}
17721726

17731727
pub async fn list_pinned_cids(&self) -> Result<Vec<PinnedCidRecord>> {

0 commit comments

Comments
 (0)