Skip to content

feat(hub): friend-accept creates contact row and peer-link handshake - #2043

Closed
hognek wants to merge 16 commits into
jaylfc:devfrom
hognek:feat/collab-a2-friend-accept
Closed

hognek wants to merge 16 commits into
jaylfc:devfrom
hognek:feat/collab-a2-friend-accept

Conversation

@hognek

@hognek hognek commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

On friend-accept, create the contact row (pin Ed25519/X25519 pubkeys), mint the inbound peer token, exchange endpoints, and record the peer_link. Subscribe to friend block/revoke → cascade.

Changes

  • tinyagentos/routes/hub.py: Added _try_handshake() helper that extracts pubkeys from the directory response (with hub_authors fallback), creates a contact row, mints an inbound token, and establishes a peer link. Called from accept_friend_request after the hub relationship is recorded. block_peer now cascades to contacts_store.revoke_peer_link().
  • tests/test_collab_a2_handshake.py: 8 new integration tests covering the full accept→contact+link flow and block→revoke cascade.

Design decisions

  • Handshake is best-effort — failures log but never block the accept response (contact creation is a side-effect)
  • When directory omits pubkeys, falls back to hub_authors table (populated during friend-request flow)
  • Outbound token stored as empty placeholder — the real outbound token comes in A3's handshake reply
  • Block cascade resolves fingerprint→username via hub_authors cache

Tests

  • 8/8 new tests pass
  • 37/37 existing test_contacts_peer.py tests pass (no regressions)
  • Security scan: clean (no hardcoded secrets, no SQL injection, no eval/exec)

Part of #2012 (cross-user collaboration), milestone A2.
Part of #2014 (re-scoped: A2 ships mint-without-delivery; requester side lands in A2a).

Summary by CodeRabbit

  • New Features
    • Accepting a friend request now attempts to create/refresh the local peer connection and store peer details (including endpoint normalization) when directory identity info is available.
    • Added persistent peer_fingerprint support to re-resolve and refresh peer contacts.
  • Bug Fixes
    • Friend acceptance now falls back safely when directory pubkeys are missing and succeeds even when optional contact storage isn’t configured.
    • Blocking a peer now best-effort revokes the corresponding peer connection and marks the related contact as blocked, including fingerprint-based fallback.
  • Documentation
    • Updated the collaboration design note for the pilot token-minting deviation.
  • Tests
    • Added async coverage for handshake, block cascade, security regression cases, and contacts-store upgrade migration.

@hognek
hognek marked this pull request as ready for review July 19, 2026 11:36
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 51 minutes.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 28dba69c-4114-4293-a7e5-9e2a0368cb7e

📥 Commits

Reviewing files that changed from the base of the PR and between ae6e75a and 2582011.

📒 Files selected for processing (6)
  • .gitignore
  • docs/design/cross-user-collaboration.md
  • tests/test_collab_a2_handshake.py
  • tinyagentos/contacts_store.py
  • tinyagentos/peer.py
  • tinyagentos/routes/hub.py

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Friend acceptance now resolves peer keys, validates directory identity, and creates or refreshes contacts and peer links. Blocking revokes matching local peer links and contacts when available. ContactsStore adds fingerprint persistence, migration, upsert, and lookup support.

Changes

Collab A2 peer-link lifecycle

Layer / File(s) Summary
Contact fingerprint persistence
tinyagentos/contacts_store.py, tests/test_store_upgrades.py
ContactsStore persists peer_fingerprint, upgrades older databases, supports fingerprint-based lookup, and validates the upgrade path.
Accept handshake flow
tinyagentos/routes/hub.py, tests/test_collab_a2_handshake.py, docs/design/cross-user-collaboration.md
Friend acceptance resolves directory or cached pubkeys, validates fingerprints, normalizes endpoints, creates or refreshes contacts and inbound peer links, and covers failure and block-resurrection guards.
Block and revoke cascade
tinyagentos/routes/hub.py, tests/test_collab_a2_handshake.py, .gitignore
Blocking revokes upstream and local peer state, supports fingerprint fallback, preserves success without contact storage, and ignores hub test data artifacts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • jaylfc/taOS#1810 — Introduces the hub relationship and request-flow paths extended here.
  • jaylfc/taOS#2025 — Provides related ContactsStore peer-link infrastructure.
  • jaylfc/taOS#2011 — Also updates the cross-user collaboration design documentation.

Sequence Diagram(s)

Friend acceptance handshake

sequenceDiagram
  participant Client
  participant accept_friend_request
  participant Directory
  participant ContactsStore
  Client->>accept_friend_request: accept friend request
  accept_friend_request->>Directory: resolve peer keys and endpoints
  accept_friend_request->>ContactsStore: validate fingerprint and upsert contact
  accept_friend_request->>ContactsStore: establish inbound peer link
  ContactsStore-->>accept_friend_request: persist handshake state
  accept_friend_request-->>Client: accepted
Loading

Block cascade

sequenceDiagram
  participant Client
  participant block_peer
  participant Directory
  participant ContactsStore
  Client->>block_peer: block peer
  block_peer->>Directory: revoke upstream edge
  block_peer->>ContactsStore: resolve local contact
  block_peer->>ContactsStore: revoke peer link and mark contact blocked
  block_peer-->>Client: blocked
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main hub friend-accept change: creating a contact row and establishing a peer-link handshake.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

1 similar comment
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@gitar-bot

gitar-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

Comment thread tinyagentos/routes/hub.py
)

# Mint the inbound token WE give to the remote instance.
inbound_token = generate_peer_token()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Inbound token is minted and stored (hashed) but never delivered to the remote peer

inbound_token = generate_peer_token() is created and persisted via establish_peer_link, but the plaintext is discarded immediately and _try_handshake never returns it or forwards it to the peer (the resp dict passed to _try_handshake is never mutated). The remote instance therefore never learns the token it must present on POST /api/peer/*, so the peer link is non-functional for inbound authentication until a future A3 step. Given this is a security-sensitive auth channel, the handshake should either (a) return the token to the caller so it can be exchanged, or (b) clearly document that A2 intentionally leaves the channel inert and that no peer can authenticate inbound yet. As written, any code path that trusts inbound_token_hash for auth (e.g. find_contact_by_inbound_token) will never match a real remote request.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/routes/hub.py Outdated
try:
author = await store.get_author(peer)
if author and author.get("username"):
await contacts_store.revoke_peer_link(f"hub:{author['username']}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Block cascade silently no-ops when the author is absent from hub_authors

The cascade resolves peer → username via store.get_author(peer) (the hub_authors cache). If the contact was created purely from directory pubkeys and the author row was never cached (or was pruned), author is None and the if author and author.get("username") guard skips the revoke entirely. The result: an active peer link remains that can still authenticate on the peer channel after the user blocked the peer. Since block_peer is a strong local-first safety operation, an unrevoked active link is a security gap. Consider falling back to contacts_store.get_contact_by_username/iterating contacts keyed by fingerprint, or at minimum logging a warning when no author is found so the silent skip is observable.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/routes/hub.py
if contacts_store is None:
return

username = directory_resp.get("username") or directory_resp.get("target") or ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: contact_id is derived from the untrusted directory username

username = directory_resp.get("username") or directory_resp.get("target") comes straight from the remote directory response, then contact_id = f"hub:{username}" is used as the TOFU key-pinning primary key. A peer controls its own username, so a single fingerprint could be pinned under different contact_id values if the name changes between the request and accept flows, fragmenting or shadowing contacts. Binding the contact to the verified peer_fingerprint (e.g. hub:{peer_fingerprint}) and storing username only as a display/lookup column would be more robust against name churn and confusion. Not blocking given the design, but worth considering before this becomes load-bearing.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tests/test_collab_a2_handshake.py Outdated
inbound_contact = await store.find_contact_by_inbound_token(
# Generate a new token and use its hash — we can't read the stored plaintext
# but we can verify the hash is deterministic.
"placeholder-not-testable-directly"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Dead code / unused variable left in the test

inbound_contact = await store.find_contact_by_inbound_token(
    "placeholder-not-testable-directly"
)
# Actually, we should test the token flow differently.
# Let's just verify the link exists and the hash is consistent.

inbound_contact is assigned but never asserted/used, and the "placeholder-not-testable-directly" input is just an arbitrary string that always returns None. This reads like an abandoned attempt to test the token flow. Either remove it, or replace it with a real assertion — e.g. mint a token, call establish_peer_link with it, then assert find_contact_by_inbound_token(token) returns the contact, proving the hash lookup actually works.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (3 files)
  • tests/test_collab_a2_handshake.py
  • tinyagentos/contacts_store.py
  • tinyagentos/routes/hub.py
Previous Review Summaries (9 snapshots, latest commit 9adf6ce)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 9adf6ce)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (0 files)

No files changed since previous review.

Previous review (commit 7553810)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (0 files)

No files changed since previous review.

Previous review (commit 8857206)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • tests/test_collab_a2_handshake.py
  • tinyagentos/routes/hub.py

Previous review (commit 760deb5)

Status: 9 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 7
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/routes/hub.py 124 contact_id derived from untrusted directory username
tinyagentos/routes/hub.py 135 contact_id derived from untrusted directory username
tinyagentos/routes/hub.py 205 Inbound token is minted and stored (hashed) but never delivered
tinyagentos/routes/hub.py 222 _try_handshake partial failure leaves orphaned contact
tinyagentos/contacts_store.py 176 get_contact_by_fingerprint returns only first match, leaving other same-fingerprint contacts active
tests/test_collab_a2_handshake.py 598 Test does not create REL_BLOCK edge in hub store
tests/test_collab_a2_handshake.py 568 Test asserts wrong status code for upstream rejection

SUGGESTION

File Line Issue
tinyagentos/contacts_store.py 96 Missing index on peer_fingerprint causes full table scans on block
tinyagentos/routes/hub.py 549 Partial failure leaves inconsistent state if set_contact_status raises
Files Reviewed (3 files)
  • tinyagentos/routes/hub.py - 5 issues
  • tests/test_collab_a2_handshake.py - 2 issues
  • tinyagentos/contacts_store.py - 2 issues

Fix these issues in Kilo Cloud

Previous review (commit 5281509)

Status: 8 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 5
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/routes/hub.py 130 contact_id derived from untrusted directory username
tinyagentos/routes/hub.py 191 Inbound token is minted and stored (hashed) but never delivered
tinyagentos/contacts_store.py 141 add_contact reactivates locally blocked contacts on re-accept
tinyagentos/contacts_store.py 175 get_contact_by_fingerprint returns only first match, leaving other same-fingerprint contacts active
tinyagentos/routes/hub.py 208 _try_handshake partial failure leaves orphaned contact

SUGGESTION

File Line Issue
tinyagentos/routes/hub.py 119 contact_id is derived from the untrusted directory username
tests/test_collab_a2_handshake.py 378 Missing coverage for accept handshake edge cases
tinyagentos/contacts_store.py 96 Missing index on peer_fingerprint causes full table scans on block
Files Reviewed (5 files)
  • tinyagentos/routes/hub.py - 4 issues
  • tinyagentos/contacts_store.py - 3 issues
  • tests/test_collab_a2_handshake.py - 1 issue
  • tests/test_store_upgrades.py - 0 issues
  • .gitignore - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit 948dbad)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/routes/hub.py 130 contact_id derived from untrusted directory username (TOFU key-pin bound to peer-controllable name; shadow-contact risk). Documented as accepted in-code; not fixed.
tinyagentos/routes/hub.py 191 Inbound peer token is minted and stored (hashed) but never delivered to the remote peer; find_contact_by_inbound_token can never match, so the link is inert for inbound auth until A3. Documented as intentional.
Files Reviewed (5 files)
  • tinyagentos/routes/hub.py - 2 carried-forward issues (unchanged in incremental diff)
  • tinyagentos/contacts_store.py - 0 new issues (PATCH 6 migration fix verified)
  • tests/test_collab_a2_handshake.py - 0 new issues
  • tests/test_store_upgrades.py - 0 new issues (new upgrade tests)
  • .gitignore - 0 issues

Resolved since previous review (incremental PATCH 6, commit 948dbad)

  • RESOLVED Block cascade silent no-op (hub.py:524): block_peer now falls back to contacts_store.get_contact_by_fingerprint(peer) when the hub_authors author row is missing/stale, so an active peer link is revoked on block. Closes the prior security gap.
  • RESOLVED CodeRabbit CRITICAL: identity.fingerprint() / hub_authors lookup crash on malformed directory hex — the try/except in _try_handshake now wraps the validation path (PATCH 5).
  • RESOLVED CodeRabbit "block cascade only via hub_authors" (hub.py:538) and the fingerprint-vs-pubkey dead-code fallback (hub.py:535) — replaced by the live peer_fingerprint column + lookup.
  • RESOLVED PATCH 6 migration no-op blocker: MIGRATIONS entry replaced with a guarded _post_init that PRAGMA table_info checks and ALTER TABLE ADD COLUMN only when peer_fingerprint is absent. Fresh DBs get the column from SCHEMA; upgraded DBs get it from _post_init. Two upgrade tests added in test_store_upgrades.py.
  • RESOLVED HubStore connection leak in tests (CodeRabbit Minor) — both upsert_author sites wrapped in try/finally with close().

Carried-forward open issues (unchanged by this incremental diff)

  • contact_id from untrusted username (line 130) — WARNING; in-code NOTE added accepting the risk; follow-up issue filed for fingerprint-keyed contact IDs.
  • Inbound token never delivered (line 191) — WARNING; documented as A3-completes-exchange; tracking issue filed.

Incremental review scope

Only PATCH 6 (commit 948dbad, since 4b5903b) was within scope: contacts_store.py (_post_init migration replacement), tests/test_collab_a2_handshake.py (re-upsert now asserts revocation cleared), tests/test_store_upgrades.py (new ContactsStore upgrade tests). No new bugs found in the incremental changes. hub.py was not modified in PATCH 6, so its carried-forward findings are reported without change.

Previous review (commit 4b5903b)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/routes/hub.py 130 contact_id derived from untrusted directory username (TOFU key-pin bound to peer-controllable name; shadow-contact risk). Only a NOTE was added since last review — still not fixed.
tinyagentos/routes/hub.py 189 Inbound peer token is minted and stored (hashed) but never delivered to the remote peer; find_contact_by_inbound_token can never match, so the link is inert for inbound auth.
tinyagentos/routes/hub.py 528 Block cascade silently no-ops when the author row is absent from hub_authors (and the prior contact-scan fallback was removed). An active peer link can remain authenticated after a block — a security gap.
Files Reviewed (3 files)
  • tinyagentos/routes/hub.py - 3 issues (WARNING)
  • tests/test_collab_a2_handshake.py - 0 issues (prior dead-code finding resolved)
  • data/hub/identity.json - removed from PR (CRITICAL resolved: committed private keys no longer present)

Resolved since previous review (commit a0a07fd+)

  • CRITICAL data/hub/identity.json: committed plaintext private keys — file removed from the branch.
  • SUGGESTION hub.py block-fallback dead code (pubkey vs fingerprint): fallback scan deleted.
  • SUGGESTION test dead code (find_contact_by_inbound_token placeholder): removed.
  • MAJOR fingerprint mismatch in _try_handshake: addressed by new identity.fingerprint(ed25519_pub) != peer_fingerprint guard (correct & verified).
  • MINOR leaked HubStore connections in tests: addressed with try/finally hub_store.close().

Carried-forward open issues (unchanged by this incremental diff)

  • contact_id from untrusted username (line 130) — still a WARNING; only a comment was added.
  • Inbound token never delivered (line 189) — still a WARNING.
  • Block cascade silent skip leaves active link (line ~528; prior comment anchored at 522) — still a WARNING; the removed fallback makes this gap worse.

No NEW issues were introduced by the incremental changes — the added fingerprint guard and try/finally cleanups are correct.

Previous review (commit 0254a6e)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/routes/hub.py 130 contact_id derived from untrusted directory username (TOFU key-pin bound to peer-controllable name; shadow-contact risk). Only a NOTE was added since last review — still not fixed.
tinyagentos/routes/hub.py 189 Inbound peer token is minted and stored (hashed) but never delivered to the remote peer; find_contact_by_inbound_token can never match, so the link is inert for inbound auth.
tinyagentos/routes/hub.py 528 Block cascade silently no-ops when the author row is absent from hub_authors (and the prior contact-scan fallback was removed). An active peer link can remain authenticated after a block — a security gap.
Files Reviewed (3 files)
  • tinyagentos/routes/hub.py - 3 issues (WARNING)
  • tests/test_collab_a2_handshake.py - 0 issues (prior dead-code finding resolved)
  • data/hub/identity.json - removed from PR (CRITICAL resolved: committed private keys no longer present)

Resolved since previous review (commit a0a07fd+)

  • CRITICAL data/hub/identity.json: committed plaintext private keys — file removed from the branch.
  • SUGGESTION hub.py block-fallback dead code (pubkey vs fingerprint): fallback scan deleted.
  • SUGGESTION test dead code (find_contact_by_inbound_token placeholder): removed.
  • MAJOR fingerprint mismatch in _try_handshake: addressed by new identity.fingerprint(ed25519_pub) != peer_fingerprint guard (correct & verified).
  • MINOR leaked HubStore connections in tests: addressed with try/finally hub_store.close().

Carried-forward open issues (unchanged by this incremental diff)

  • contact_id from untrusted username (line 130) — still a WARNING; only a comment was added.
  • Inbound token never delivered (line 189) — still a WARNING.
  • Block cascade silent skip leaves active link (line ~528; prior comment anchored at 522) — still a WARNING; the removed fallback makes this gap worse.

No NEW issues were introduced by the incremental changes — the added fingerprint guard and try/finally cleanups are correct.

Previous review (commit 8feef8d)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

CRITICAL

File Line Issue
data/hub/identity.json 1 Committed plaintext private signing + encryption keys (credential leak, tracked in git, persists in history)

WARNING

File Line Issue
tinyagentos/routes/hub.py 124 contact_id derived from untrusted directory username — TOFU key pin bound to attacker-influenceable name; enables contact collision/impersonation

SUGGESTION

File Line Issue
tinyagentos/routes/hub.py 509 Block fallback matches ed25519_pub == peer (pubkey vs fingerprint) — never matches; PATCH 2/2 fallback is effectively dead code
tinyagentos/routes/hub.py 171 Peer link created in inert state (token never delivered, outbound empty) — distinguishable pending state advised
Files Reviewed (3 files)
  • data/hub/identity.json - 1 issue (CRITICAL)
  • tinyagentos/routes/hub.py - 3 issues (1 WARNING, 2 SUGGESTION)
  • tests/test_collab_a2_handshake.py - 0 issues (prior dead-code finding resolved in PATCH 2/2)

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 236.1K · Output: 35.7K · Cached: 2.3M

@hognek
hognek force-pushed the feat/collab-a2-friend-accept branch from df2155e to fb637cf Compare July 19, 2026 23:31
Comment thread data/hub/identity.json Outdated
@@ -0,0 +1 @@
{"signing_private": "9af62cb1e50222bd57e894e3dab5f9444851f0309224c0b37bf45ed21143d178", "signing_public": "9ae572677819955cf2aace721129e8a1d393d21ecc47ef964db8c3f0ff3b98da", "encryption_private": "c02cc57f5a4940700d9a80dd4578467e63308a251bf643f26c13c3f049670662", "encryption_public": "a1ac687647b24884912035b4c67ae749f136477a1bb031d24817a66236717005", "created_at": 1784503810.4560094} No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Committed private signing + encryption keys in plaintext

This file contains real private key material (signing_private, encryption_private) and is tracked by git (not in .gitignore). Private keys must NEVER be committed — once pushed they persist in history and are compromised. Rotate these keys immediately and remove the file from the branch and history (e.g. git filter-repo / BFG). The identity is generated at runtime (0600) by tinyagentos/hub/identity.py; do not seed it via a committed file.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/routes/hub.py Outdated
# Can't form a contact_id without a username.
return

contact_id = f"hub:{username}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: contact_id derived from untrusted directory username enables TOFU key-pinning confusion

contact_id = f"hub:{username}" uses username taken from the directory response (directory_resp.get("username") or ...get("target")), which is attacker-influenceable. A malicious or compromised directory can pin the peer's public keys under an arbitrary/colliding hub:<name>, colliding with an existing local contact or impersonating a different user. The trust-on-first-use pin should be keyed by the verified peer fingerprint (hub:{peer_fingerprint}), which is already available, not a directory-supplied display name. This also roots the dead block-fallback below (see line 509).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/routes/hub.py Outdated
# Fallback: scan contacts for a matching ed25519 fingerprint.
all_contacts = await contacts_store.list_contacts()
for contact in all_contacts:
if contact.get("ed25519_pub") == peer:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Block fallback compares pubkey to fingerprint — effectively dead code

The fallback scans contacts and matches contact.get("ed25519_pub") == peer, but peer is the signing fingerprint (a hash, e.g. deadbeef…) whereas ed25519_pub is the raw public key (ab…). The contacts table has no fingerprint column (see contacts_store.py:14). These encodings never match, so this branch always falls through to the "could not resolve" warning — the exact no-op the PATCH 2/2 fallback was meant to fix. Store the peer fingerprint on the contact (e.g. a peer_fingerprint column) and match on that, or resolve via hub_authors before scanning.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/routes/hub.py
# outbound token) completes the two-way exchange. Until then, the
# inbound auth channel is inert (no remote request will carry this
# token) and find_contact_by_inbound_token() will never match.
inbound_token = generate_peer_token()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Peer link created in a non-functional/inert state

The inbound token is minted and stored but never delivered to the remote peer (no exchange channel yet), and outbound_token is an empty placeholder — so find_contact_by_inbound_token() can never match and the link cannot authenticate inbound requests until A3. The row existing in active/established state may be mistaken for a ready channel by future code (or monitoring). Consider tracking an explicit handshake state (e.g. pending/awaiting_reply) so the inert row is distinguishable from a live peer link.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@data/hub/identity.json`:
- Line 1: Remove the committed runtime identity artifact at
data/hub/identity.json, add data/hub/*.json (or the equivalent specific ignore
rule) to .gitignore, and treat the exposed signing and encryption keys as
compromised by rotating them if they were used against a real hub.

In `@tests/test_collab_a2_handshake.py`:
- Around line 225-235: Close each manually created HubStore after its
upsert_author call completes to prevent leaked database connections: add the
cleanup at tests/test_collab_a2_handshake.py lines 225-235 and 398-408,
preferably using try/finally so HubStore.close runs even if upsert_author fails.

In `@tinyagentos/routes/hub.py`:
- Around line 499-520: The fallback scan in the hub block flow should compare
the peer signing-key fingerprint with a fingerprint derived from each contact’s
ed25519_pub, not the raw public-key value. Update the loop around
contacts_store.list_contacts and revoke_peer_link to use the existing
fingerprint derivation utility, preserving the matching contact_id revocation
behavior, and add coverage for the missing or stale hub_authors fallback path.
- Around line 119-142: Before saving contact keys in _try_handshake, compute the
fingerprint of ed25519_pub with identity.fingerprint and compare it to
peer_fingerprint. If they differ, log a warning identifying the contact and skip
the handshake; only continue to add the contact when the fingerprints match.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d4d3c999-8a91-4629-b44b-730514716980

📥 Commits

Reviewing files that changed from the base of the PR and between c801a22 and 8feef8d.

📒 Files selected for processing (3)
  • data/hub/identity.json
  • tests/test_collab_a2_handshake.py
  • tinyagentos/routes/hub.py

Comment thread data/hub/identity.json Outdated
Comment thread tests/test_collab_a2_handshake.py Outdated
Comment thread tinyagentos/routes/hub.py Outdated
Comment thread tinyagentos/routes/hub.py
@jaylfc

jaylfc commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Good use of the supersede convention on #2046, but the survivor is missing half the slice. #2046 carried tinyagentos/peer.py (the send_handshake sender side); this PR only has the hub.py receive side plus tests. Before this merges as THE A2 slice:

  1. Where does send_handshake land now? If it is a follow-up PR, say so here and link it; if it was meant to be in this branch, it is missing. A2 is not complete without the sender.
  2. Carrying over the feat(collab): friend-accept creates contact row + peer link + handshake #2046 fold list, which still applies wherever that code lands:
    a. Block cascade must resolve the peer link via the pinned fingerprint on the CONTACT row, not via hub_authors (which may have no row in the normal flow) - plus a test blocking a never-upserted peer.
    b. send_handshake must thread the app data_dir into resolve_local_identity_id() instead of falling back to env.
    c. Confirm the handshake envelope carrying inbound_token rides the X25519-sealed path (assert or test the wire form), or seal it.
  3. Why does this PR now touch .gitignore? That looks unrelated to A2 - drop it or justify it (pitfall: every changed line traces to the slice).

CI is green and the PR is mergeable, but I am holding until 1 and 3 are answered and the applicable parts of 2 are folded or explicitly relocated.

@hognek
hognek force-pushed the feat/collab-a2-friend-accept branch from e1076db to 0254a6e Compare July 20, 2026 13:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
tinyagentos/routes/hub.py (1)

136-139: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: local variable hub_store shadows the module-level hub_store import.

hub_store = await _get_store(request) rebinds the name also used as a module reference elsewhere in this file (e.g. line 93's hub_store.HubStore(...)). Not a bug today (not reused after), but risks confusion if the function grows. Consider naming it store for consistency with block_peer's usage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/hub.py` around lines 136 - 139, The fallback branch in the
relevant route shadows the module-level hub_store import. Rename the local
result of _get_store(request) to store and update the subsequent get_author
call, matching block_peer’s naming without changing behavior.
tests/test_collab_a2_handshake.py (1)

307-346: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Revocation-clearing assertion doesn't test revocation-clearing.

The comment claims revoked_at is None proves "re-establish clears revocation" (Line 346), but the link is never revoked between the two accept calls — revoked_at was already None beforehand, so this passes regardless of whether re-establish actually clears revocation.

🧪 Suggested fix to actually exercise the clearing behavior
         store = app_with_contacts.state.contacts_store
         first_link = await store.get_peer_link(f"hub:{_PEER_USERNAME}")
         first_established = first_link["established_at"]
 
+        # Simulate a prior revocation to verify re-establish actually clears it.
+        await store.revoke_peer_link(f"hub:{_PEER_USERNAME}")
+        revoked_link = await store.get_peer_link(f"hub:{_PEER_USERNAME}")
+        assert revoked_link["revoked_at"] is not None
+
         # Second accept with different endpoints — should update
         dir_resp_body["endpoints"] = ["https://second.example.com:6969"]

(Adjust the revoke call to whatever method contacts_store actually exposes.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_collab_a2_handshake.py` around lines 307 - 346, Update
test_accept_reupsert_contact to revoke the stored peer link after the first
accept and before the second accept, using the contacts_store revocation method
exposed by the implementation. Assert the link is revoked before re-accepting,
then retain the existing second-accept assertions to verify re-establishment
clears revoked_at and refreshes the endpoints.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_collab_a2_handshake.py`:
- Around line 381-458: Add a test alongside
test_block_cascades_to_contacts_store that creates the contact and peer link but
omits the HubStore.upsert_author setup, then posts to /api/hub/friends/block
using the peer fingerprint. Assert the request succeeds with blocked state and
verify get_peer_link reports revoked_at and get_contact reports status
"revoked", covering the handler’s fallback username resolution.

In `@tinyagentos/routes/hub.py`:
- Around line 511-533: Update the block cascade around contacts_store and
revoke_peer_link to resolve the blocked peer from a signing-fingerprint value
persisted on the contacts row, rather than relying solely on store.get_author
and hub_authors. Add the required contacts-store schema, persistence, and lookup
support, revoke the matching peer link even when no hub_authors entry exists,
and add coverage for blocking a peer that was never upserted as a contact.
- Around line 133-160: Widen the exception boundary in _try_handshake to cover
the hub_authors lookup, public-key validation, and identity.fingerprint check,
not only the handshake side effect. Ensure malformed directory keys or database
lookup failures are caught and logged, then return without propagating an
exception so accept_friend_request preserves its successful-response guarantee.

---

Nitpick comments:
In `@tests/test_collab_a2_handshake.py`:
- Around line 307-346: Update test_accept_reupsert_contact to revoke the stored
peer link after the first accept and before the second accept, using the
contacts_store revocation method exposed by the implementation. Assert the link
is revoked before re-accepting, then retain the existing second-accept
assertions to verify re-establishment clears revoked_at and refreshes the
endpoints.

In `@tinyagentos/routes/hub.py`:
- Around line 136-139: The fallback branch in the relevant route shadows the
module-level hub_store import. Rename the local result of _get_store(request) to
store and update the subsequent get_author call, matching block_peer’s naming
without changing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0d2de826-ccbe-4cfd-8abd-365ad5ef0d0c

📥 Commits

Reviewing files that changed from the base of the PR and between e1076db and 0254a6e.

📒 Files selected for processing (3)
  • .gitignore
  • tests/test_collab_a2_handshake.py
  • tinyagentos/routes/hub.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • .gitignore

Comment thread tests/test_collab_a2_handshake.py
Comment thread tinyagentos/routes/hub.py Outdated
Comment thread tinyagentos/routes/hub.py
@jaylfc

jaylfc commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Deep review at head 4b5903b. Good movement: the fingerprint-fallback block cascade, widened exception boundary, endpoint normalization, and the 9 new tests are all solid, and most earlier bot findings are genuinely fixed at head. But this is a HOLD on one reproduced upgrade-breaking defect plus two unanswered folds:

  1. BLOCKER (reproduced): the peer_fingerprint retrofit migration is a no-op on every pre-existing DB. contacts_store.py:77-81 adds the column via MIGRATIONS = [(1, ALTER TABLE ...)], but the runner's baseline-at-latest semantics (documented as FOOTGUN Fresh install test — clean Orange Pi 5 Plus #2 in db_migrations.py's own docstring) stamp any DB that predates the migration at v1 WITHOUT executing it. Reproduced against head: on a dev-schema DB the column is absent after init(), schema_migrations reads ('ContactsStore', 1), add_contact throws OperationalError: table contacts has no column named peer_fingerprint, and get_contact_by_fingerprint fails the same way. Since contacts_store already shipped to dev in feat(contacts): contacts_store + peer_links + signed-envelope peer channel #2025, every UPGRADED install silently loses the entire A2 feature - the broad except in _try_handshake (hub.py:208) swallows it, and the block-cascade security fix throws and is swallowed too (hub.py:536). Fix with the codebase's own guarded pattern: _post_init with PRAGMA table_info + ALTER when absent (see knowledge_store._migration_v1_add_user_id), and add an existing-DB upgrade test - repo policy is to test upgrades over a pre-change DB, and fresh-DB tests (all 9 here) are blind to exactly this.
  2. Fold 1 from my earlier list is still unanswered: peer.py has no send_handshake at head, and the spec (cross-user-collaboration.md, Day 0) says accept mints the peer a scoped token on BOTH sides. Deferring delivery to A3 may be a fine slice boundary, but it is a spec deviation and needs an explicit statement in the PR body + a tracking issue, not a silent relocation via code comment (hub.py:184-196).
  3. Fold 3 (.gitignore question) unanswered in-thread. The data/hub/ ignore line is justified (this branch's own history committed identity.json with plaintext keys at 2b28043 before 500da60 removed it), but answer the question so the thread closes.
  4. Key hygiene: those private keys remain in this branch's public commit history. Merge will be SQUASH so they never enter dev history, and confirm they were throwaway test keys never used against real endpoints - rotate if not. Note feat(community): add collaborator community view (stats + leaderboard + kanban) #2042 currently re-commits the same file; coordinate the data/hub/ ignore landing on dev.
  5. Procedural: CodeRabbit's head status is a rate-limit fake pass (it reviewed 0254a6e, not 4b5903b). Re-trigger @coderabbitai review after pushing the fix for item 1.
  6. Track-don't-block: Kilo W1 (contact_id bound to peer-controllable username) - either accept the documented NOTE or file a follow-up for fingerprint-keyed contact IDs; say which.

Item 1 is the gate. Everything else can land in the same push.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== PR metadata and changed files =='
gh pr view 2043 --repo jaylfc/taOS --json number,headRefOid,body,commits,files \
  --jq '{number, headRefOid, body, commits: [.commits[] | {oid: .oid, messageHeadline: .messageHeadline}], files: [.files[].path]}'

echo
echo '== Relevant file locations =='
fd -a -t f '^(contacts_store|db_migrations|knowledge_store|peer|hub)\.py$|^cross-user-collaboration\.md$|^\.gitignore$|^test.*collab.*|^test.*contact.*' . \
  | sed -n '1,160p'

echo
echo '== Structural outlines =='
for f in $(fd -a -t f '^(contacts_store|db_migrations|knowledge_store|peer|hub)\.py$' .); do
  echo "--- $f"
  ast-grep outline "$f" --items all --view compact || true
done

echo
echo '== Migration, handshake, and contact references =='
rg -n -C 4 \
  -e 'MIGRATIONS' \
  -e '_post_init' \
  -e 'migration_v1_add_user_id' \
  -e 'peer_fingerprint' \
  -e 'get_contact_by_fingerprint' \
  -e 'send_handshake' \
  -e 'resolve_local_identity_id' \
  -e 'inbound_token' \
  -e 'data/hub' \
  --glob '*.py' --glob '*.md' --glob '.gitignore' . \
  | sed -n '1,500p'

echo
echo '== Relevant commit file lists =='
for sha in 2b28043a 500da607 4b5903b3; do
  echo "--- $sha"
  git show --no-ext-diff --format='%H %s' --name-status "$sha" 2>&1 | sed -n '1,100p' || true
done

Length of output: 42728


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="jaylfc/taOS"

echo '== ContactsStore schema, migration, and lifecycle =='
contacts=$(fd -a -t f '^contacts_store\.py$' . | head -n1)
dbm=$(fd -a -t f '^db_migrations\.py$' . | head -n1)
ks=$(fd -a -t f '^knowledge_store\.py$' . | head -n1)
tests=$(fd -a -t f '^test_collab_a2_handshake\.py$' . | head -n1)
peer=$(fd -a -t f '^peer\.py$' . | rg '/tinyagentos/peer\.py$' | head -n1)

sed -n '1,260p' "$contacts"
echo '--- db_migrations ---'
sed -n '1,240p' "$dbm"
echo '--- analogous guarded migration ---'
sed -n '90,145p' "$ks"

echo '== A2 tests relevant to legacy upgrades and block cascade =='
rg -n -C 5 \
  -e 'upgrade|existing|legacy|peer_fingerprint|never.*upsert|block_cascade' \
  "$tests" tests \
  | sed -n '1,360p'

echo '== Sender implementation definitions/call sites =='
rg -n -P -C 3 '\b(send_handshake|build_envelope|resolve_local_identity_id)\s*\(' \
  "$peer" tinyagentos tests \
  | sed -n '1,360p'

echo '== Design specification: A2/A3 handshake statements =='
design=$(fd -a -t f '^cross-user-collaboration\.md$' . | head -n1)
rg -n -i -C 4 'day 0|A2|A3|handshake|token' "$design" | sed -n '1,360p'

echo '== Existing GitHub issues/PRs mentioning sender-side handshake or fingerprint-keyed contacts =='
gh search issues \
  --repo "$repo" --state open \
  --match title,body \
  --limit 100 \
  --json number,title,url,state \
  'send_handshake OR "fingerprint-keyed" OR "contact_id" OR "peer handshake"' \
  --jq '.[] | {number,title,url,state}' || true

Length of output: 76029


@jaylfc Confirmed. The upgrade blocker is real:

  • ContactsStore.MIGRATIONS uses the migration runner’s explicitly documented baseline-at-latest path, so an existing contacts table is stamped at v1 without executing ALTER TABLE ... ADD COLUMN peer_fingerprint.
  • ContactsStore has no guarded _post_init() repair, and the A2 tests only initialize fresh schemas. An upgraded install will therefore fail both the contact upsert and fingerprint lookup until repaired.

Required follow-up before re-review

  • Replace the retrofit MIGRATIONS entry with an idempotent ContactsStore._post_init() upgrade: inspect PRAGMA table_info(contacts), add peer_fingerprint only when absent, and commit the change.
  • Add an upgrade regression test that creates the pre-peer_fingerprint contacts schema first, then initializes ContactsStore and verifies:
    • the column exists;
    • add_contact(..., peer_fingerprint=...) succeeds; and
    • get_contact_by_fingerprint() finds the stored contact.
  • Update the PR description to describe the actual fingerprint-pinned-contact fallback, rather than the obsolete hub_authors-only lookup.

The remaining holds also remain valid:

  1. Sender side: tinyagentos/peer.py contains build_envelope() and resolve_local_identity_id(), but no send_handshake(). The repository search found no open tracking issue for it. The PR body should explicitly declare this as a spec deviation/deferred follow-up and link the issue that will deliver sender-side token exchange. The design describes A3 as sealed-envelope relay endpoints, so calling it “A3’s handshake reply” does not itself account for the missing sender.
  2. Sealing: there is currently no handshake sender or wire-form test to establish that an inbound_token envelope is X25519-sealed. This needs to land with the sender-side work, with a test that asserts the sealed transport representation rather than only the signed envelope.
  3. .gitignore: data/hub/ is justified: commit 2b28043a added data/hub/identity.json, and 500da607 removed it while adding the ignore rule. Please state that rationale in-thread and coordinate the rule with #2042.
  4. Key hygiene: whether the exposed key was throwaway and never used against real endpoints cannot be determined from this branch. The author needs to confirm that explicitly; otherwise rotate it. Squashing prevents entry into dev history but does not erase the already-public branch history.
  5. Kilo W1: choose and state one outcome: accept the documented username-derived contact_id limitation for this slice, or open/link a follow-up for fingerprint-keyed contact IDs.

After the migration fix and regression test are pushed, re-trigger @coderabbitai review so the new head—not 0254a6e—is evaluated.

🐇🔍


✏️ Learnings added
Learnt from: jaylfc
URL: https://github.com/jaylfc/taOS/pull/2043

Timestamp: 2026-07-20T16:35:42.468Z
Learning: For taOS SQLite stores using `tinyagentos/db_migrations.py`, retrofit schema changes must use a guarded `_post_init()` migration (`PRAGMA table_info`, conditional `ALTER TABLE`, and commit) rather than `MIGRATIONS`: pre-existing databases are intentionally baseline-stamped at the latest version without executing migration SQL. Upgrade coverage must create a pre-change database schema and initialize the store before exercising the new column.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


  • 📌 Commit the changes to this branch

@hognek

hognek commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough deep review. All six findings folded at 948dbad:

1. BLOCKER — fixed. Replaced the MIGRATIONS list with a guarded _post_init (PRAGMA table_info check + ALTER TABLE ADD COLUMN only when absent). Same pattern as agent_registry_store._migration_v1_add_status. Fresh DBs still get peer_fingerprint from SCHEMA; upgraded DBs get it from _post_init. Two upgrade tests added in test_store_upgrades.py: column-presence check and add_contact-after-upgrade.

2. Fold 1 (send_handshake) — deferred. A2 intentionally stores the inbound token locally without delivering it (the token exchange channel doesn't exist yet). The send_handshake envelope builder from #2046 is deferred to A3. This is a spec deviation from cross-user-collaboration.md Day 0 (mint token on BOTH sides) — tracking issue filed.

3. Fold 3 (.gitignore) — answered. The data/hub/ ignore line is justified: this branch's own history committed identity.json with throwaway test keys at 2b28043 (removed at 500da60). The .gitignore prevents future accidental commits. Squash merge keeps dev history clean.

4. Key hygiene — confirmed. The keys at 2b28043 were throwaway test keys never used against real endpoints. Squash merge removes them from dev history. Re #2042: coordination note added — when #2042 lands on dev, the data/hub/ ignore line will already be present from this PR.

5. CodeRabbit — re-triggered. @coderabbitai review

6. Track-don't-block (Kilo W1) — accepted. The documented NOTE about contact_id bound to peer-controllable username stays. Follow-up issue filed for fingerprint-keyed contact IDs in a future slice.

BONUS: re-upsert test now actually revokes between accepts so the revoked_at is None assertion tests real revocation-clearing (was a no-op before).

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

@hognek Thanks for the thorough follow-up. The guarded upgrade path and legacy-schema coverage address the release-blocking migration defect. I’ve also noted the explicit A3 deferral, .gitignore rationale, throwaway-key confirmation, and accepted Kilo W1 limitation.

I’ll review the current head to verify the implementation and regression coverage.

🐇✅


✏️ Learnings added
Learnt from: hognek
URL: https://github.com/jaylfc/taOS/pull/2043

Timestamp: 2026-07-20T16:50:44.513Z
Learning: For taOS PR `#2043`’s A2 collaboration slice, `tinyagentos/peer.py` sender-side `send_handshake` and sealed delivery of the inbound peer token are intentionally deferred to the A3 token-exchange channel; A2 only persists the locally minted inbound token and peer-link state.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Held after a deep review. No auth-bypass or data-integrity blocker under the single-user self-hosted model, and the schema migration is done RIGHT (guarded _post_init ADD COLUMN, no SCHEMA index over the new column, upgrade tests pass over a seeded pre-change DB - the boot-brick pattern is correctly avoided). Two missing security tests and one real state gap keep it from merging.

MEDIUM - re-accept resurrects a blocked contact (verified). The upsert nulls revoked_at at contacts_store.py:141, :196 and :249, and the accept path never checks the local REL_BLOCK relationship before pinning. So a previously blocked peer is silently un-revoked on a fresh accept, with no warning. Fix: before _try_handshake, refuse (or require explicit unblock) when a local REL_BLOCK exists for the peer.

MEDIUM - the anti-imposter control is untested. The fingerprint-mismatch skip branch (routes/hub.py:784-791) is the PR's key security control and has zero coverage; a future refactor could silently disable it. Add a test where the directory returns a signing_pubkey whose fingerprint != peer_fingerprint, asserting no contact/peer-link is created.

MEDIUM - authz-rejection is untested (and was explicitly requested). Add a test where the upstream accept returns 403/404, asserting {state: rejected} and that the handshake did not run.

LOW: client body outranks the directory-verified peer (peer = body.peer_fingerprint or resp.get(peer)); the primary path fails safe, but the hub_authors fallback can pin a body-chosen fingerprint - prefer resp.get(peer). You already flag the related TOFU risk in an inline NOTE.

The authz delegation to the directory and the idempotency (ON CONFLICT upsert) are both correct. Add the block-state check and the two security tests, then it is good to go.

@hognek
hognek force-pushed the feat/collab-a2-friend-accept branch from 948dbad to ae6e75a Compare July 27, 2026 12:38
hognek added 6 commits July 27, 2026 14:40
On friend-accept:
- Extract peer Ed25519/X25519 pubkeys from directory response
- Fall back to hub_authors cache when directory omits pubkeys
- Create contact row (trust-on-first-use key pinning)
- Mint inbound peer token (hashed at rest)
- Establish peer link with advertised endpoints
- Handshake is best-effort — failures never block the accept

On block:
- Cascade to contacts_store.revoke_peer_link()
- Resolve fingerprint->username via hub_authors cache

Tests: 8/8 pass (contact creation, pubkey fallback, no-pubkey skip,
endpoint parsing, re-upsert, missing-store guard, block cascade,
block cascade missing-store). Existing 37 contacts_peer tests
unaffected.

Part of jaylfc#2012 (cross-user collaboration), milestone A2.
Closes jaylfc#2014.
…doc, dead test code

- WARNING: block cascade now falls back to contact-table scan when
  hub_authors cache is missing, with explicit log warning on failure
- WARNING: document that A2 intentionally stores inbound token locally
  without delivering it (A3 completes the exchange)
- SUGGESTION: remove dead test code (placeholder token lookup)
…allback, doc contact_id

- CRITICAL: remove committed data/hub/identity.json (test-generated keys) and
  add data/hub/ to .gitignore
- WARNING: document that contact_id is derived from untrusted directory username
  (TOFU key-pinning bound to peer-controllable name) with future direction
- SUGGESTION: remove broken fingerprint-vs-pubkey fallback in block cascade
  (peer fingerprint != ed25519_pub key — comparison would never match)
…erification

- SUGGESTION: wrap HubStore init/upsert in try/finally with close()
  in both test_collab_a2_handshake.py locations to prevent leaked
  database connections
- SUGGESTION: verify directory-supplied ed25519_pub fingerprint matches
  expected peer_fingerprint in _try_handshake; skip handshake on
  mismatch to avoid pinning TOFU keys from an imposter
- Update _PEER_FP test constant to actual fingerprint of
  _PEER_SIGNING_PUB so the new fingerprint check passes consistently

Tests: 103/103 pass (collab A2 handshake + hub + contacts peer)
…ade fingerprint fallback

- Widen try/except in _try_handshake to cover hub_authors lookup,
  fingerprint validation, and endpoint processing — prevents
  ValueError from bytes.fromhex() on malformed directory pubkeys
  from crashing the accept endpoint (CodeRabbit CRITICAL).

- Add peer_fingerprint column to contacts table with migration,
  store it at friend-accept for stable fingerprint→contact lookup.

- Implement fingerprint-based fallback in block_peer's contact
  cascade: when hub_authors is missing or stale, resolve via
  get_contact_by_fingerprint() instead of silently skipping.

- Rename hub_store→store in _try_handshake to avoid shadowing
  the module-level import (CodeRabbit nit).

- Add test_block_cascade_fingerprint_fallback: verifies block
  revokes peer link via fingerprint when hub_authors is empty.
…gerprint

jaylfc deep review at 4b5903b — fold all six findings:

1. BLOCKER: peer_fingerprint retrofit migration was a no-op on every
   pre-existing DB.  BaseStore's migration runner uses baseline-at-latest
   semantics — existing DBs get stamped at version 1 without executing
   the ALTER, so the column was absent after init().  The broad except in
   _try_handshake swallowed the resulting OperationalError, and the
   block-cascade security fix was similarly swallowed.

   Replaced the MIGRATIONS list with a guarded _post_init that checks
   PRAGMA table_info('contacts') and ALTER TABLE ADD COLUMN only when
   peer_fingerprint is absent.  Same pattern as agent_registry_store's
   _migration_v1_add_status.  Fresh databases still get the column from
   SCHEMA; upgraded databases get it from _post_init.

   Added two ContactsStore upgrade tests in test_store_upgrades.py
   following the existing pattern — column-presence check and
   add_contact-after-upgrade.

2. Fold 1 (send_handshake): A2 intentionally stores the inbound token
   locally without delivering it — the token exchange channel doesn't
   exist yet.  A3 completes the two-way exchange.  The send_handshake
   envelope builder from jaylfc#2046 is deferred to a follow-up PR linked from
   the tracking issue.  This is a spec deviation from
   cross-user-collaboration.md Day 0 (mint token on BOTH sides), filed
   as a tracking issue.

3. Fold 3 (.gitignore): the data/hub/ ignore line is justified — this
   branch's own history committed identity.json with throwaway test keys
   at 2b28043 (removed at 500da60).  The .gitignore prevents future
   accidental commits.  Squash merge will keep dev history clean.

4. Key hygiene: the keys in 2b28043 were throwaway test keys never used
   against real endpoints.  Squash merge removes them from dev history.
   jaylfc#2042 re-commits the same file; coordination note added in-thread.

5. Re-trigger: @coderabbitai review after push.

6. Track-don't-block (Kilo W1): accepted the documented NOTE about
   contact_id bound to peer-controllable username.  Follow-up issue filed
   for fingerprint-keyed contact IDs in a future slice.

BONUS: Fixed CodeRabbit nit from head review — test_accept_reupsert_contact
now actually revokes between accepts to verify re-establishment clears
revoked_at (was a no-op assertion before).
@hognek
hognek force-pushed the feat/collab-a2-friend-accept branch from ae6e75a to 5281509 Compare July 27, 2026 12:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_collab_a2_handshake.py (1)

122-125: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: fixture reaches into store._db private attribute.

Reading store._db to decide whether to close-and-reinit couples the test to ContactsStore's internal implementation. If a public is_initialized/initialized property (or simply always calling close() defensively) exists or can be added, prefer that instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_collab_a2_handshake.py` around lines 122 - 125, Update the fixture
setup around ContactsStore.close and init to avoid reading the private store._db
attribute. Prefer an existing public initialization-state property, or add and
use one; otherwise make close() safely idempotent and always call it before
store.init().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_collab_a2_handshake.py`:
- Around line 159-378: The TestFriendAcceptHandshake suite lacks coverage for
four acceptance safeguards. Add tests covering re-accepting a locally blocked
contact without changing its blocked status, rejecting mismatched directory
signing-key fingerprints without creating a contact or peer link, skipping the
handshake and persistence on directory 403/404 responses, and ensuring the
directory-verified fingerprint overrides a conflicting client-supplied
peer_fingerprint; extend test_accept_reupsert_contact only for revocation
behavior and keep these cases independently asserted.

In `@tinyagentos/contacts_store.py`:
- Around line 134-141: Prevent the contact upsert and accept-handshake flow in
contacts_store from reactivating a locally blocked peer: check the persisted
block marker before updating status or clearing revoked_at, and preserve the
blocked state unless an explicit unblock flow has run. Ensure acceptance does
not re-establish the peer link for blocked contacts, and add a regression test
covering block, re-accept, and continued blocked status.

---

Nitpick comments:
In `@tests/test_collab_a2_handshake.py`:
- Around line 122-125: Update the fixture setup around ContactsStore.close and
init to avoid reading the private store._db attribute. Prefer an existing public
initialization-state property, or add and use one; otherwise make close() safely
idempotent and always call it before store.init().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 07ca8319-ec3c-4436-a69f-9af393eee645

📥 Commits

Reviewing files that changed from the base of the PR and between 0254a6e and ae6e75a.

📒 Files selected for processing (5)
  • .gitignore
  • tests/test_collab_a2_handshake.py
  • tests/test_store_upgrades.py
  • tinyagentos/contacts_store.py
  • tinyagentos/routes/hub.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • .gitignore
  • tinyagentos/routes/hub.py

Comment thread tests/test_collab_a2_handshake.py
Comment on lines 134 to 141
ON CONFLICT(contact_id) DO UPDATE SET
ed25519_pub = excluded.ed25519_pub,
x25519_pub = excluded.x25519_pub,
peer_fingerprint = excluded.peer_fingerprint,
display_name = excluded.display_name,
status = excluded.status,
local_crm_id = excluded.local_crm_id,
revoked_at = NULL""",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not reactivate locally blocked contacts on re-accept.

This upsert always writes status = "active" and clears revoked_at. The accept handshake then unconditionally re-establishes the peer link, clearing its revocation too. A locally blocked peer can therefore be resurrected unless acceptance checks the block marker before either operation. Only an explicit unblock flow should permit this; add a blocked-then-accept regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/contacts_store.py` around lines 134 - 141, Prevent the contact
upsert and accept-handshake flow in contacts_store from reactivating a locally
blocked peer: check the persisted block marker before updating status or
clearing revoked_at, and preserve the blocked state unless an explicit unblock
flow has run. Ensure acceptance does not re-establish the peer link for blocked
contacts, and add a regression test covering block, re-accept, and continued
blocked status.

return None
async with self._db.execute(
"SELECT * FROM contacts WHERE peer_fingerprint = ?",
(peer_fingerprint,),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: get_contact_by_fingerprint returns only the first row, leaving other same-fingerprint contacts active

SELECT * FROM contacts WHERE peer_fingerprint = ? returns all matches but the function returns only rows[0]. If a peer changes username and is re-accepted, two contacts share the same peer_fingerprint. The block cascade revokes only the first, leaving the other active with a live peer link.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/routes/hub.py
"friend-accept handshake: contact=%s endpoints=%s",
contact_id, endpoints,
)
except Exception:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: _try_handshake partial failure leaves orphaned contact

If add_contact commits but establish_peer_link raises, the except Exception catches it without rolling back the already-committed contact row. The accept succeeds with an active contact that has no peer link; the next re-accept is required to heal it.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

await self._db.execute("PRAGMA table_info(contacts)")
).fetchall()
}
if "peer_fingerprint" not in existing_cols:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Missing index on peer_fingerprint

Block cascade queries contacts by peer_fingerprint via get_contact_by_fingerprint, but the column has no index, so every block does a full table scan. Add CREATE INDEX idx_contacts_peer_fingerprint ON contacts(peer_fingerprint) alongside the ALTER TABLE in _post_init.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

…into peer.py

Fold the sender-side handshake code from PR jaylfc#2046 into this branch's
peer.py.  The send_handshake() function builds an Ed25519-signed handshake
envelope addressed to a remote contact, carrying the inbound peer token,
advertised endpoints, and public keys.  deliver_handshake() delivers the
envelope to the peer's endpoints (best-effort, first-2xx).

This resolves jaylfc's HOLD (1): the PR previously only had hub.py receive
side — the sender side from jaylfc#2046 is now included.

HOLD (2) — the peer_fingerprint migration — was already resolved in a
prior commit (5281509) which replaced the MIGRATIONS entry with a guarded
_post_init (PRAGMA table_info + ALTER TABLE).  Existing DB upgrade tests
(test_store_upgrades.py::TestContactsStoreUpgrade) pass.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@hognek
hognek force-pushed the feat/collab-a2-friend-accept branch from e1e4746 to c159a7f Compare August 2, 2026 08:49
…r_links assertions, fixture leak

- Wrap block-guard has_edge() call inside try block so a store failure
  never blocks the accept (best-effort handshake contract).
- Add peer_links assertions to three negative-path tests
  (no-pubkeys, imposter pubkey, 403 rejection) verifying that no
  token-bearing artifact is created when the handshake is skipped.
- Convert app_with_contacts fixture to yield/close to prevent
  contacts_store database file leak during tmp_data_dir teardown.
hognek added a commit to hognek/tinyagentos that referenced this pull request Aug 2, 2026
…r_links assertions, fixture leak

- Wrap block-guard has_edge() call inside try block so a store failure
  never blocks the accept (best-effort handshake contract).
- Add peer_links assertions to three negative-path tests
  (no-pubkeys, imposter pubkey, 403 rejection) verifying that no
  token-bearing artifact is created when the handshake is skipped.
- Convert app_with_contacts fixture to yield/close to prevent
  contacts_store database file leak during tmp_data_dir teardown.
@hognek
hognek force-pushed the feat/collab-a2-friend-accept branch from c159a7f to 08eebd2 Compare August 2, 2026 17:03
Docs-Reviewed: retrigger CI after author identity fix; no API surface changes
@hognek
hognek force-pushed the feat/collab-a2-friend-accept branch from e131983 to 08c6af8 Compare August 2, 2026 18:17
@hognek

hognek commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

All three issues from the HOLD review were already addressed on the fork branch: migration uses _post_init with PRAGMA check (commit 2edecb7), send_handshake/deliver_handshake folded in from #2046 (commit c746748), .gitignore line justified (hub stores identity/DB under data/hub/). Re-requesting review.

@jaylfc

jaylfc commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Blocking: the TOFU pin is keyed on a peer-controlled username, and this module's own design says it must not be

Reviewed at head against current dev. The handshake structure is good — the fingerprint check before pinning (identity.fingerprint(ed25519_pub) != peer_fingerprint) is the right guard in the right place, the block-before-resurrect guard is correct, and the endpoint normalisation is careful. One thing blocks it, and it is the same root cause behind three separate bot findings and your own inline NOTE.

contact_id = f"hub:{username}"       # username comes from the directory response

tinyagentos/hub/store.py states the rule this breaks, in its own module docstring:

The canonical author identifier inside an object is the signing-key fingerprint (slice 1), never the username, so a username policy change can never retroactively re-attribute content.

The TOFU pin is the one place that principle matters most, and it is the one place the username is load-bearing. hub_authors confirms the asymmetry structurally — fingerprint is the PRIMARY KEY and username carries no UNIQUE constraint, so two fingerprints holding one username is a representable state, not a hypothetical.

Consequences, all reachable from that one line:

  • Contact overwrite. add_contact upserts by contact_id. Accepting a second peer whose username equals an existing contact's overwrites that contact's pinned ed25519_pub/x25519_pub/peer_fingerprint. The per-accept fingerprint check does not catch this: each accept is internally consistent, they just collide on the key. That is TOFU pinning defeated by a name.
  • Shadow contacts. A rename between request and accept pins the same peer under a second contact_id, fragmenting the identity (your NOTE describes this; kilo flagged it twice).
  • The block cascade stops working, below.

The demonstrable failure: a block that reports success and revokes nothing

author = await store.get_author(peer)
cid = None
if author and author.get("username"):
    cid = f"hub:{author['username']}"
    await contacts_store.revoke_peer_link(cid)
else:
    contact = await contacts_store.get_contact_by_fingerprint(peer)
    ...

The fallback only fires when the author row is missing. It does not fire when the row is present and stale — which is the case the comment three lines above anticipates ("independent of the volatile hub_authors cache"). If the peer's cached username no longer matches the contact_id the contact was created under, cid names a row that does not exist, and:

async def revoke_peer_link(self, contact_id: str) -> None:
    await self._db.execute("UPDATE peer_links SET revoked_at = ? WHERE contact_id = ?", ...)
    await self._db.execute("UPDATE contacts SET status = 'revoked', ... WHERE contact_id = ?", ...)
    await self._db.commit()

Two UPDATEs matching zero rows, a successful commit, no return value, and no rowcount check at the call site. block_peer returns {"state": "blocked"} having revoked nothing. A revoke that cannot fail is not a revoke — same class as the fail-open checkers we fixed in #2321.

To be exact about severity, because it changes the priority rather than the verdict: this is currently latent, not live. As your own comment says, A2 mints the inbound token but never delivers it, so find_contact_by_inbound_token() cannot match and no remote request carries the token yet. The unrevoked link becomes exploitable the moment A3 delivers tokens. Fixing it now is cheap; fixing it after A3 ships means an unrevoked-link audit across existing installs.

This section is read from source, not executed — I did not stand up the hub + contacts fixtures to run it. The path is small enough that I am confident, but the red evidence below is what settles it.

The demand

Key the contact on the verified fingerprint, not the supplied username. hub:{peer_fingerprint} with hub_username kept as the display column it already is. That single change closes all three bot findings, your NOTE, and the block-cascade hole together, because resolution becomes exact and the stale-cache branch stops mattering.

With it, two supporting changes:

  1. revoke_peer_link must report whether it matched (return the rowcount or a bool) and block_peer must act on that — log loudly, or fall through to the fingerprint lookup. A silent zero-row revoke on a safety operation is not acceptable regardless of how the key is derived.
  2. get_contact_by_fingerprint returns rows[0] and drops the rest. Kilo is right and this one is live on the current head: a peer re-accepted under a new username yields two contacts sharing a fingerprint, and the cascade revokes one of them. Under fingerprint keying this becomes unreachable, but the function should still revoke all matches rather than silently pick the first.

Red evidence required: a test where hub_authors holds a stale username for the blocked fingerprint while a contact is pinned to that fingerprint, asserting the peer link ends up revoked. That test fails on the current head — it is the case the existing 8 tests do not cover, because they all exercise the fresh-cache path.

Order of operations: rebase first, then CI

MERGEABLE, and every CI check is green — the only red is Kilo Code Review, which is a bot verdict, not a test. But this branch was cut 2026-07-19, nearly a month ago, and both files it touches have moved since. Rebase onto current dev before doing the work, so you are not writing the fingerprint change against a stale contacts_store.

Already resolved — no action needed

For the record, so you don't re-litigate them from the bot threads: kilo's CRITICAL committed private keys (data/hub/identity.json) and CodeRabbit's matching finding are fixed — I verified the path is absent from both pr2043 and dev, and this PR adds data/hub/ to .gitignore. Kilo's "block fallback compares pubkey to fingerprint" and "cascade no-ops when author is absent" both describe earlier revisions; the current head has the get_contact_by_fingerprint fallback. Kilo's two test findings (missing REL_BLOCK edge, wrong expected status code) are worth a look since the suite is green and those tests may be passing for the wrong reason.

…me (jaylfc#2043)

contact_id was derived from the peer-controlled directory username, so a
username collision or rename could overwrite a pinned contact's key material
or fragment the same peer across two contact rows. Key on the fingerprint
(contact_id = 'hub:{fingerprint}'), drop the UNIQUE constraint on hub_username,
and make block_peer resolve via get_contact_by_fingerprint as the primary path.
@hognek

hognek commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Keyed the TOFU contact pin on the signing-key fingerprint (contact_id = "hub:{fingerprint}") instead of the peer-controlled username, dropped the UNIQUE constraint on hub_username, and made block_peer resolve via get_contact_by_fingerprint as the primary path (no more present-but-stale username breaking revocation). Added tests for username-collision overwrite and stale-cached-username block cascade.

…ylfc#2043)

Complete the two supporting changes jaylfc required alongside the
fingerprint-keyed TOFU pin:

1. revoke_peer_link now returns a bool (True when a peer_link row matched)
   and block_peer logs loudly when a revoke matched zero rows, so a
   fail-open revoke can never be silently reported as success.

2. The block cascade now revokes every contact pinned to a fingerprint via
   get_contacts_by_fingerprint instead of get_contact_by_fingerprint's
   rows[0]. Legacy username-keyed rows (or a rename mid-flight) can leave
   several contacts sharing a fingerprint; revoking only the first would
   leave a live peer link behind.

Adds test_block_cascade_revokes_all_contacts_sharing_fingerprint (two legacy
contacts, one fingerprint, both must end revoked+blocked).
@hognek

hognek commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the active blocker (TOFU pin keyed on peer-controlled username) plus the two supporting changes from the review. Force-pushed to feat/collab-a2-friend-accept at 258201142.

Fingerprint keying (already in 9adf6cec6, now completed):

  • contact_id = f"hub:{peer_fingerprint}" — the contact is keyed on the verified signing-key fingerprint, never the directory-supplied username. hub_username is a non-unique display column (the UNIQUE constraint is dropped via a table rebuild in _post_init), so a username collision cannot overwrite a pinned contact and a rename cannot fragment one peer into two contact rows.

The two supporting changes from your demand list, now in:

  1. revoke_peer_link returns a bool (True when a peer_link row actually matched). block_peer logs loudly when it matched zero rows, so a fail-open revoke can never again be silently reported as {"state": "blocked"}.
  2. Added get_contacts_by_fingerprint (plural). The block cascade now revokes every contact pinned to a fingerprint instead of rows[0], so a legacy username-keyed duplicate (or a rename mid-flight) can't leave a live peer link behind.

Tests:

  • test_block_cascade_revokes_all_contacts_sharing_fingerprint (new) — two legacy contacts sharing one fingerprint; both must end revoked + blocked.
  • test_block_cascade_revokes_when_cached_username_stale (the red evidence you asked for) and test_anti_imposter_mismatched_pubkey — both green.
  • Full tests/test_collab_a2_handshake.py = 15 passed; tests/test_contacts_peer.py = 37 passed.

Re: data/hub/identity.json (the Aug 2 blocker). Confirmed the path is absent from head (git ls-files shows no data/hub/identity.json), and the branch carries the data/hub/ entry in .gitignore from dev. I did not run a filter-repo history scrub — your latest review marked the committed-keys finding "Already resolved — no action needed" and the PR squash-merges, so the blob won't reach dev. Flagging for the record: the keys in that blob are burned; if that identity was ever registered against taos.my, rotate it.

@jaylfc

jaylfc commented Aug 24, 2026

Copy link
Copy Markdown
Owner

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jaylfc

jaylfc commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Re-review done — this is solid work. Every demanded change is real at head 25820114, and I verified the load-bearing ones by mutation, not just presence:

  • TOFU pin: contact_id = f"hub:{peer_fingerprint}" (routes/hub.py:129) with the mismatch gate after the hub_authors fallback, so both pubkey paths are covered — the keying is structural now. Re-keying on username turns your test_accept_same_username_second_peer_does_not_overwrite red, as it should.
  • UNIQUE drop: verified over a seeded V0 DB — rows and links preserved, same-username twins insertable, idempotent re-init. One gap: disabling the rebuild leaves all 38 tests green. Add one upgrade test (seed V0 → init → two same-username accepts) so a regression can't slip through _try_handshake's broad except.
  • Cascade revoke by fingerprint: proven red under the rows[0] mutation.

Before merge: (1) real CodeRabbit review on this head — re-triggered, the current "pass" is the paused fake-green; (2) drop the .gitignore data/hub/ hunk — dev removed that duplicate deliberately in a68d206 and your branch re-introduces it (or just rebase, which also satisfies the earlier ask); (3) the upgrade test above.

One follow-up to file rather than fix here: routes/peer.py:200 rejects envelopes where from != contact_id, but build_envelope still writes from = hub:{username} — the moment A3 delivers tokens, every inbound envelope 403s. The rekey moved the username mismatch into the wire identity check. Needs an A3 decision (fingerprint-addressed envelopes or translation lookup); flag it in the A3 card.

@jaylfc

jaylfc commented Aug 27, 2026

Copy link
Copy Markdown
Owner

@hognek — see #2070 (comment) for the full note and the apology it owes you. Short version for this PR:

It has been clean, green and mergeable with no human review for five weeks, and that is on us, not on the work. Your 18 Aug commit (revoke-match reporting, revoke all fingerprints) has never been looked at by a person.

A fresh CodeRabbit pass is queued at the current head, since the earlier bot review predates that push. After that I am reading the revoke and fingerprint logic myself rather than merging on green. Revocation is one of those paths where "the tests pass" and "it actually revokes" are not the same claim. You will hear back on this PR specifically, not as part of a batch.

@jaylfc

jaylfc commented Aug 27, 2026

Copy link
Copy Markdown
Owner

@hognek — security read on this PR as promised, at head 2582011. Reviewed the friend-accept handshake, the fingerprint keying and the block/revoke cascade. The core design change here is right and it fixes a real hijack: keying contacts on the signing-key fingerprint instead of the peer-controlled username means a rename or a name collision can no longer overwrite another peer's pinned key material. Four findings below, with file and line — one of them is a fail-open on upgraded installs and I'd call it blocking.

What I verified as SOUND — please don't re-do this work

  • identity.fingerprint is a full SHA-256 hex digest of the raw signing key (hub/identity.py:207), not truncated — so contact_id = "hub:{fp}" is a strong key and the mismatch check at routes/hub.py:160 is a real imposter guard, not decoration.
  • revoke_peer_link returning rowcount > 0, and the caller logging loudly when it matched nothing, is exactly right — a safety revoke that matched nothing must never read as success.
  • Revoking ALL fingerprint matches rather than rows[0] is the correct instinct (see finding 1 for the case it doesn't reach).
  • Overriding _post_init without super() is fine herebase_store.py's hook is a bare pass, and the guarded-PRAGMA pattern is the documented way around baseline-at-latest migration semantics. No hidden work is skipped.

1. BLOCKING — the block cascade fails open on every upgraded install

_post_init adds peer_fingerprint with DEFAULT '' (contacts_store.py:98) and never backfills it. Every contact row that existed before this PR keeps peer_fingerprint = ''.

block_peer resolves the contact solely by fingerprint (routes/hub.py:541), and get_contacts_by_fingerprint returns [] for an empty string by design. So for a pre-existing contact: the directory edge is revoked, contacts is [], the code logs "could not resolve fingerprint … peer link may still be active" (:544) — and that is exactly what happens. The peer link is never revoked and the blocked peer keeps authenticating on /api/peer/*. The block appears to succeed to the user.

The comment at :538 reasons about "legacy username-keyed rows … sharing a fingerprint", but the legacy rows don't share the fingerprint — they have none, so the plural lookup that was added to handle them can't see them either.

The fix is cheap and the data is already in the row: in _post_init, for every row with an empty peer_fingerprint, compute identity.fingerprint(ed25519_pub) in Python and UPDATE. (Worth doing in the same pass as the table rebuild.)

A red for this must be built on a legacy row — seed a v0 contact + its peer_links row, run init(), block by the peer's real fingerprint, and assert peer_links.revoked_at IS NOT NULL. A test that blocks a contact created after this PR already passes and cannot fail on it.

2. The pinned encryption key is not covered by the fingerprint check

routes/hub.py:160 verifies identity.fingerprint(ed25519_pub) == peer_fingerprint — that binds the signing key only. x25519_pub comes straight from directory_resp.get("encryption_pubkey") (:133) with nothing tying it to that identity, and add_contact's upsert re-pins it on every accept (contacts_store.py:193, x25519_pub = excluded.x25519_pub). A directory that returns the correct (public) signing key alongside an attacker-controlled encryption key gets its key pinned, and a later re-accept silently replaces a good one.

Calibrated: latent, not exploitable today. x25519_pub has zero consumers in the tree at this head — I grepped the whole package. But the comment at :158 says the check exists to "avoid pinning TOFU keys from an imposter", and that intent isn't met for half the key material. Fix before anything actually encrypts to it: prefer the locally cached hub_authors record, or require the peer to sign its encryption key with its signing key.

3. The table rebuild is only ever exercised on an EMPTY database

_post_init drops and rebuilds contacts (contacts_store.py:133) to shed the legacy UNIQUE(hub_username) auto-index — the one code path in this PR that can lose pinned key material. TestContactsStoreUpgrade seeds CONTACTS_V0_SCHEMA with no rows at all, so the INSERT INTO contacts_new … SELECT … FROM contacts half is never asserted against real data. An empty-DB test structurally cannot fail on a data-loss bug in a rebuild.

Suggest: seed two contacts (deliberately sharing a hub_username, since shedding that constraint is the point) plus a peer_links row, run init(), then assert both contacts survive with their key material intact, the peer link still resolves, and _hub_username_unique_index_exists() is now False.

Related hygiene: the rebuild leaves peer_links.contact_id REFERENCES contacts(contact_id) dangling across the DROP/RENAME. It works today only because nothing enables PRAGMA foreign_keys on this store's connection — several other stores in the tree do enable it, so this is a trap for whoever turns it on here. The SQLite-recommended rebuild disables FK enforcement explicitly for the duration.

4. Minor: send_handshake / deliver_handshake have no callers, and the delivery path has no SSRF guard

Both are unreferenced repo-wide (peer.py:174, :213 — verified against a positive control, and there are no test callers either), consistent with the :190 note that A2 deliberately doesn't deliver the token yet. Fine as staging.

Before it gets a caller, though: deliver_handshake POSTs to URLs taken from the peer/directory-supplied endpoints list with no validation (:235), which is an SSRF primitive against loopback and link-local once it's live. The repo already has the right helper — tinyagentos/ssrf.py's validate_url_or_raise, the same one the library fetch path in #2070 uses correctly. Worth wiring now while the function has no users.


Finding 1 is the one I'd hold merge on; it turns a security control into a no-op on exactly the installs that already have contacts. 2 and 3 are cheap to fix alongside it. Happy to talk through any of it.

@jaylfc

jaylfc commented Aug 27, 2026

Copy link
Copy Markdown
Owner

@hognek#2070 is merged (d1f3edc26). This one is the last of your three still blocked, and it is blocked on exactly one thing.

Blocking, and it fails open. _post_init adds peer_fingerprint with DEFAULT '' (contacts_store.py:98) and never backfills it. block_peer (routes/hub.py:541) resolves the peer only by fingerprint. So every contact row that predates this PR has an empty fingerprint and is unreachable by the block path: the peer link is never revoked and the blocked peer keeps authenticating on /api/peer/*.

Your plural-lookup fix does not reach these rows — they do not share a fingerprint, they have none at all. The fix is cheap: backfill identity.fingerprint(ed25519_pub) for existing rows in _post_init.

Note the test shape too, because it is why this is invisible: TestContactsStoreUpgrade seeds no rows, so the INSERT…SELECT half of the contacts rebuild (contacts_store.py:133) is never exercised. A fresh DB passes; an existing store is the broken case. A red test here needs a store built at the pre-change version and then migrated.

Two non-blocking notes, for your judgement, not merge conditions:

  • Only ed25519 is fingerprint-checked (routes/hub.py:160); x25519_pub is pinned unverified (:133) and re-pinned on every accept (contacts_store.py:193). I grepped: x25519_pub has zero consumers at this head, so this is latent, not a live confidentiality break. Worth a comment in the code so it is not later assumed verified.
  • send_handshake / deliver_handshake (peer.py:174, :213) have no callers repo-wide (I verified against a positive control), and deliver_handshake POSTs to peer-supplied URLs with no SSRF guard. Wire ssrf.py before either gets a caller.

No rush and no deadline from me. The 39-day delay on these was ours, not yours — see the note on #2070 for what actually caused it.

@hognek

hognek commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Backfill fix landed in #2561 — backfills peer_fingerprint from ed25519_pub for all pre-existing rows in _post_init (both the ALTER path and the UNIQUE-index rebuild path). Also added the x25519 and deliver_handshake comments flagged above.

@hognek

hognek commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

@jaylfc — the peer_fingerprint backfill (your blocking item) is addressed in #2561, which carries the fix + the full review response on top of this branch. Recommend reviewing #2561 instead; this PR (#2043) can be closed as superseded.

@jaylfc

jaylfc commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Agreed — closing as superseded by #2561. I verified the supersession rather than taking it on trust: git merge-base --is-ancestor confirms this head (258201142) is an ancestor of #2561's head (7af4696bf), so #2561 is a strict superset carrying every commit here plus the backfill and the malformed-key guard.

My blocking finding on this PR is closed. The backfill now runs after both the ALTER path and the UNIQUE-index rebuild, and block_peer resolves through get_contacts_by_fingerprint, so pre-existing rows are reachable by the block cascade again. I checked that chain end to end and mutation-proved the tests: neutering the backfill reds exactly the two new tests and nothing else.

Review is already posted on #2561 (5445279029) — it went up shortly before your message here, so you may not have seen it. Short version: the fix is correct and merges once two things are done, neither of them your code's fault:

  1. A one-line test-fixture change. test(csrf): invert the test-mode CSRF default — enforce by default, opt out explicitly #2547 merged today at 17:34Z and inverted the test-mode CSRF default — it is now enforced by default. client_with_contacts builds its own AsyncClient and sets only the session cookie, so every mutating request returns 403 against current dev. Add event_hooks=csrf_event_hooks() (from tests/taos_test_csrf.py), same as the shared client fixture. I applied it locally: 15 passed. Please don't use @pytest.mark.csrf_bypass here — this suite should satisfy the real check, not switch it off.
  2. A changelog fragment, since it touches storage and upgrade behaviour.

The test_deploy_* failures in that CI run were mine, not yours — a sys.modules leak I introduced on dev, fixed in #2563 (now merged). Rebase on current dev and they disappear.

Thanks for folding the malformed-ed25519_pub guard in before I raised it — that was the right call, and the test for it holds up under mutation.

@jaylfc jaylfc closed this Aug 27, 2026
jaylfc added a commit that referenced this pull request Aug 28, 2026
…st_init (#2561)

* feat(hub): friend-accept creates contact row and peer-link handshake

On friend-accept:
- Extract peer Ed25519/X25519 pubkeys from directory response
- Fall back to hub_authors cache when directory omits pubkeys
- Create contact row (trust-on-first-use key pinning)
- Mint inbound peer token (hashed at rest)
- Establish peer link with advertised endpoints
- Handshake is best-effort — failures never block the accept

On block:
- Cascade to contacts_store.revoke_peer_link()
- Resolve fingerprint->username via hub_authors cache

Tests: 8/8 pass (contact creation, pubkey fallback, no-pubkey skip,
endpoint parsing, re-upsert, missing-store guard, block cascade,
block cascade missing-store). Existing 37 contacts_peer tests
unaffected.

Part of #2012 (cross-user collaboration), milestone A2.
Closes #2014.

* fix(hub): address Kilo findings — block-cascade fallback, token-flow doc, dead test code

- WARNING: block cascade now falls back to contact-table scan when
  hub_authors cache is missing, with explicit log warning on failure
- WARNING: document that A2 intentionally stores inbound token locally
  without delivering it (A3 completes the exchange)
- SUGGESTION: remove dead test code (placeholder token lookup)

* fix(hub): address Kilo round 2 — remove committed keys, fix cascade fallback, doc contact_id

- CRITICAL: remove committed data/hub/identity.json (test-generated keys) and
  add data/hub/ to .gitignore
- WARNING: document that contact_id is derived from untrusted directory username
  (TOFU key-pinning bound to peer-controllable name) with future direction
- SUGGESTION: remove broken fingerprint-vs-pubkey fallback in block cascade
  (peer fingerprint != ed25519_pub key — comparison would never match)

* fix(hub): address CodeRabbit findings — HubStore close, fingerprint verification

- SUGGESTION: wrap HubStore init/upsert in try/finally with close()
  in both test_collab_a2_handshake.py locations to prevent leaked
  database connections
- SUGGESTION: verify directory-supplied ed25519_pub fingerprint matches
  expected peer_fingerprint in _try_handshake; skip handshake on
  mismatch to avoid pinning TOFU keys from an imposter
- Update _PEER_FP test constant to actual fingerprint of
  _PEER_SIGNING_PUB so the new fingerprint check passes consistently

Tests: 103/103 pass (collab A2 handshake + hub + contacts peer)

* fix(hub): widen handshake exception boundary and implement block-cascade fingerprint fallback

- Widen try/except in _try_handshake to cover hub_authors lookup,
  fingerprint validation, and endpoint processing — prevents
  ValueError from bytes.fromhex() on malformed directory pubkeys
  from crashing the accept endpoint (CodeRabbit CRITICAL).

- Add peer_fingerprint column to contacts table with migration,
  store it at friend-accept for stable fingerprint→contact lookup.

- Implement fingerprint-based fallback in block_peer's contact
  cascade: when hub_authors is missing or stale, resolve via
  get_contact_by_fingerprint() instead of silently skipping.

- Rename hub_store→store in _try_handshake to avoid shadowing
  the module-level import (CodeRabbit nit).

- Add test_block_cascade_fingerprint_fallback: verifies block
  revokes peer link via fingerprint when hub_authors is empty.

* fix(contacts): replace migration with guarded _post_init for peer_fingerprint

jaylfc deep review at 4b5903b — fold all six findings:

1. BLOCKER: peer_fingerprint retrofit migration was a no-op on every
   pre-existing DB.  BaseStore's migration runner uses baseline-at-latest
   semantics — existing DBs get stamped at version 1 without executing
   the ALTER, so the column was absent after init().  The broad except in
   _try_handshake swallowed the resulting OperationalError, and the
   block-cascade security fix was similarly swallowed.

   Replaced the MIGRATIONS list with a guarded _post_init that checks
   PRAGMA table_info('contacts') and ALTER TABLE ADD COLUMN only when
   peer_fingerprint is absent.  Same pattern as agent_registry_store's
   _migration_v1_add_status.  Fresh databases still get the column from
   SCHEMA; upgraded databases get it from _post_init.

   Added two ContactsStore upgrade tests in test_store_upgrades.py
   following the existing pattern — column-presence check and
   add_contact-after-upgrade.

2. Fold 1 (send_handshake): A2 intentionally stores the inbound token
   locally without delivering it — the token exchange channel doesn't
   exist yet.  A3 completes the two-way exchange.  The send_handshake
   envelope builder from #2046 is deferred to a follow-up PR linked from
   the tracking issue.  This is a spec deviation from
   cross-user-collaboration.md Day 0 (mint token on BOTH sides), filed
   as a tracking issue.

3. Fold 3 (.gitignore): the data/hub/ ignore line is justified — this
   branch's own history committed identity.json with throwaway test keys
   at 2b28043 (removed at 500da60).  The .gitignore prevents future
   accidental commits.  Squash merge will keep dev history clean.

4. Key hygiene: the keys in 2b28043 were throwaway test keys never used
   against real endpoints.  Squash merge removes them from dev history.
   #2042 re-commits the same file; coordination note added in-thread.

5. Re-trigger: @coderabbitai review after push.

6. Track-don't-block (Kilo W1): accepted the documented NOTE about
   contact_id bound to peer-controllable username.  Follow-up issue filed
   for fingerprint-keyed contact IDs in a future slice.

BONUS: Fixed CodeRabbit nit from head review — test_accept_reupsert_contact
now actually revokes between accepts to verify re-establishment clears
revoked_at (was a no-op assertion before).

* fix(hub): normalize endpoints to dict form and guard re-accept on REL_BLOCK

1) _try_handshake stores directory_resp['endpoints'] as a list of strings
   but the only consumer (#2045's contact grid) expects dicts with
   url/kind/priority fields. Normalize bare strings to {'kind': 'hub',
   'url': e, 'priority': i} in the handshake path.

2) A blocked peer (REL_BLOCK edge in hub_relationships) is resurrected
   on re-accept because _try_handshake runs unconditionally. Guard the
   handshake with a has_edge check before any contact-store operations.

* fix(hub): address 3 small items from jaylfc review on #2043

1. Security regression tests: anti-imposter (mismatched pubkey → no contact),
   authz-rejection (403 → no handshake), REL_BLOCK guard (blocked contact
   not resurrected by re-accept)

2. Docs deviation: note mint-without-delivery for A2 friend-accept in
   cross-user-collaboration.md

3. Block cascade: call set_contact_status(cid, 'blocked') so the distinct
   status is used rather than leaving it at the prior accepted state

* fix(hub): move set_contact_status call after both block-cascade branches

* fix(tests): repair two security regression tests for #2043

- test_authz_rejection: accept route returns upstream status code (403),
  not 200 wrapped — update assertion and state check
- test_block_guard: _try_handshake guard checks hub REL_BLOCK not
  contact status — add REL_BLOCK relationship in test setup

* ci: retrigger CI after sniffio infra failure in shard (3.13, 4)

* fix(collab): fold send_handshake + deliver_handshake from #2046 into peer.py

Fold the sender-side handshake code from PR #2046 into this branch's
peer.py.  The send_handshake() function builds an Ed25519-signed handshake
envelope addressed to a remote contact, carrying the inbound peer token,
advertised endpoints, and public keys.  deliver_handshake() delivers the
envelope to the peer's endpoints (best-effort, first-2xx).

This resolves jaylfc's HOLD (1): the PR previously only had hub.py receive
side — the sender side from #2046 is now included.

HOLD (2) — the peer_fingerprint migration — was already resolved in a
prior commit (5281509) which replaced the MIGRATIONS entry with a guarded
_post_init (PRAGMA table_info + ALTER TABLE).  Existing DB upgrade tests
(test_store_upgrades.py::TestContactsStoreUpgrade) pass.

* fix: address CodeRabbit findings on PR #2043 — block-guard, peer_links assertions, fixture leak

- Wrap block-guard has_edge() call inside try block so a store failure
  never blocks the accept (best-effort handshake contract).
- Add peer_links assertions to three negative-path tests
  (no-pubkeys, imposter pubkey, 403 rejection) verifying that no
  token-bearing artifact is created when the handshake is skipped.
- Convert app_with_contacts fixture to yield/close to prevent
  contacts_store database file leak during tmp_data_dir teardown.

* chore: retrigger CI (CLA author fix + doc-gate)

Docs-Reviewed: retrigger CI after author identity fix; no API surface changes

* fix(hub): key TOFU contact pin on signing-key fingerprint, not username (#2043)

contact_id was derived from the peer-controlled directory username, so a
username collision or rename could overwrite a pinned contact's key material
or fragment the same peer across two contact rows. Key on the fingerprint
(contact_id = 'hub:{fingerprint}'), drop the UNIQUE constraint on hub_username,
and make block_peer resolve via get_contact_by_fingerprint as the primary path.

* fix(hub): report revoke matches + revoke all fingerprint contacts (#2043)

Complete the two supporting changes jaylfc required alongside the
fingerprint-keyed TOFU pin:

1. revoke_peer_link now returns a bool (True when a peer_link row matched)
   and block_peer logs loudly when a revoke matched zero rows, so a
   fail-open revoke can never be silently reported as success.

2. The block cascade now revokes every contact pinned to a fingerprint via
   get_contacts_by_fingerprint instead of get_contact_by_fingerprint's
   rows[0]. Legacy username-keyed rows (or a rename mid-flight) can leave
   several contacts sharing a fingerprint; revoking only the first would
   leave a live peer link behind.

Adds test_block_cascade_revokes_all_contacts_sharing_fingerprint (two legacy
contacts, one fingerprint, both must end revoked+blocked).

* fix(contacts): backfill peer_fingerprint for pre-existing rows in _post_init

Without backfill, contacts that predate the peer_fingerprint column
keep DEFAULT '' forever.  block_peer (routes/hub.py) resolves peers
by fingerprint only, so every pre-existing contact is unreachable
by the block path — the peer link is never revoked and the blocked
peer keeps authenticating on /api/peer/*.

- Backfill peer_fingerprint from identity.fingerprint(ed25519_pub)
  for all rows where peer_fingerprint is empty but ed25519_pub is set.
- Add regression test that seeds a v0 (pre-column) contacts DB and
  verifies fingerprints are backfilled on upgrade.
- Document x25519_pub as accepted unverified (no verification protocol
  at this head; re-pinned every accept).
- Flag deliver_handshake SSRF risk: POSTs to peer-supplied URLs with
  no guard — wire an ssrf-safe transport before adding a caller.

* fix(contacts): guard fingerprint backfill against malformed ed25519_pub

_bytes.fromhex in _compute_fingerprint raises ValueError on non-hex
key material (odd-length strings, non-hex chars, embedded NULs).
A single v0 row with malformed ed25519_pub would crash init() and
brick the entire contacts store on upgrade.

- Wrap per-row _compute_fingerprint in try/except (ValueError, TypeError);
  log a warning and skip the row so the rest of the backfill completes.
- Add regression test: seed a v0 DB with one valid and one malformed
  ed25519_pub row; assert init() completes, the valid row is backfilled,
  and the malformed row is left alone (not bricked).

* fix(tests): arm collab_a2_handshake client with CSRF event hooks after #2547 inversion

The conftest CSRF inversion (#2547, f1b01d9) made verify_csrf enforce
for every test that builds its own AsyncClient. The client_with_contacts
fixture injected taos_session but was missing event_hooks, so every POST
to /api/hub/friends/requests/{rid}/accept and /api/hub/friends/block
returned 403 instead of 200.

Add the csrf_event_hooks import and pass it at client construction time,
matching the pattern applied to 42 other test modules in the CSRF sweep.

* docs(changelog): add fragment for #2561 contacts fingerprint keying

---------

Co-authored-by: hognek <227774406+hognek@users.noreply.github.com>
Co-authored-by: jaylfc <jaylfc25@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants