Skip to content

next: cloud-copy collections — service-created, owner-device, and device join (cloud-copy bootstrap C2) - #616

Merged
callumalpass merged 16 commits into
mainfrom
next/cloud-copy-create
Oct 6, 2026
Merged

callumalpass merged 16 commits into
mainfrom
next/cloud-copy-create

Conversation

@callumalpass

Copy link
Copy Markdown
Contributor

Cloud-copy bootstrap, PR C2. It is stacked on #615 (C1), so review only the last commit until #615 merges. It follows the coordinator's sealed-envelope §7.1 decision: only the owner's device keys the hosted replica. On create, the owner's desktop performs the initial rekey with wraps for desktop + hosted + escrow, and there is no automatic escrow→hosted wrap. Control owns these paths and ACKed the direction; security-2 review requested.

Route

POST /v1/next/collections/cloud-copy {collection_id, device_id, challenge, sig}

  • Connector auth plus a device signature over H("mdbase/v1/cloud-copy-create", cbor[challenge, connector, device, collection]), using a challenge from /v1/next/devices/challenge, consumed once.
  • Mounted only with MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1. That setting requires:
    • MDBASE_NEXT_{HOSTED,ESCROW}_SERVICE_URL (https) and MDBASE_NEXT_{HOSTED,ESCROW}_SERVICE_TOKEN;
    • both inbound internal tokens;
    • all four tokens distinct.
  • Rate limit 6/min; cache-control: no-store.

Flow

  1. Transaction with a per-collection advisory lock: verify the proof and consume the challenge. The collection must be free, or already be this owner's cloud copy. A local collection with that id owned by someone else is refused.
  2. With no lock held, the hosted and escrow deployments each generate their device through the C1 client (bounded, HTTPS, kind-checked, no weak keys, escrow Noise key zero).
  3. Transaction: lock again and recheck (a concurrent create gets 503 and the retry path handles it). Register the genesis with ops genesis(cloud-copy), member-set(owner), device-enrol(owner device), then device-enrol(hosted, zero account) and device-enrol(escrow, zero account). Store both records in the same transaction.
  4. Drain the emitter. Success is returned only once the log returns the exact genesis bytes at seq 1.

The response carries:

  • head, root, cert and genesis;
  • rekey_recipients = [owner device, hosted, escrow];
  • the service devices' public keys (no wrapped keys, no ARN);
  • a role-0, single-collection, 15-minute log token for the owner device to append the rekey.

A retry must come from the enrolled device: its device-enrol, with the same sign key, must be in the genesis outbox row. It reuses the stored records and does not call the deployments. Other devices, other owners and existing private collections get 409. A failed or mismatched generation registers nothing and returns 503, so the client retries with a fresh proof.

Not here

  • Converting an existing private collection to a cloud copy.
  • Log quotas: the default applies.
  • Readiness: hosted Ready still needs the replica observer (fresh authenticated head + verified membership + clean cache). The directory's standard here is only bootstrap eligibility.

Tests

cloud-copy-bootstrap.postgres.test.ts runs against a real Postgres, a fake log and fake deployments:

  • config gating;
  • genesis ops, kinds and accounts decoded from the signed item;
  • token claims;
  • no wrapped keys in the response;
  • idempotent retry without regeneration;
  • sibling device, stranger, private and foreign-local refusals;
  • proof replay and wrong collection;
  • deployment down or wrong kind registers nothing, then a retry succeeds.

Results: server pnpm test 786 passed; src/features/next with PG16, serial: 160 passed. Typecheck, architecture and changelog checks pass.

…lections

Add next_service_devices (0048): hosted/escrow members of cloud_copy
collections only, via the (collection_id, sync) composite key. The
control plane stores public keys and the deployment's KMS-wrapped keys
and never unwraps them.

- service-devices.ts: strict record parsing (canonical 32-byte hex keys,
  canonical base64 wrapped keys <= 64 KiB, arn), idempotent put-first
  store that refuses a different device, lookup that requires a current
  cloud copy under a share lock, and a bounded HTTPS-only generate client
  (no redirects, timeout, 128 KiB response cap) for the C2 bootstrap.
- hosted-routes.ts: GET collections/:id/service-devices/:kind and
  POST service-devices/:device/log-token, kind-matched to the caller's
  internal token, 409 unless standard. Tokens are role 0, name the
  device, its sign key and one collection, and last 15 minutes.
…OOTSTRAP)

Per the coordinator's sealed-envelope 7.1 decision: the owner's device
signs a fresh challenge; the control plane asks the hosted and escrow
deployments to generate their service devices (outbound tokens distinct
from inbound), then registers a cloud-copy genesis that enrols the
owner's device and both service devices (zero account) and stores the
records. The desktop performs the initial rekey with wraps for desktop,
hosted and escrow; the CP never holds a collection key and makes no
escrow-to-hosted wrap.

Retries must come from the enrolled device and reuse stored records;
other owners, other devices and existing private collections get 409.
A failed generation registers nothing. Success is reported only after
the log returns the exact genesis bytes.
…mint; CHECK key and wrapped sizes; validate every writer

Review (control): fetch and mint now run in one transaction under FOR
SHARE of the collection row, so a concurrent leave waits for the mint
and a later fetch/mint sees it. 0048 CHECKs 32-byte keys and 1..64 KiB
wrapped keys (pg-mem gains octet_length); storeServiceDevice validates
records from any writer. Deployments need not be idempotent: the CP's
first stored record wins.
@callumalpass

Copy link
Copy Markdown
Contributor Author

Merged C1's review fixes (now 4537913). Changes:

  • The fake deployments are stateless (a new device on every call); the CP's first stored record still wins.
  • New test: a retry after leaving sync gets 409. existing() refuses left_sync_at rows before any anyState read.
  • There is no cleanup or delete on any failure path. The log and genesis are always preserved (per migration's 14:39 note).

Results: server 786 passed; next/* 164 passed against PG16.

…await; refuse nil IDs

Review (control): after generation and after the log read-back, the
owner's connector (not revoked), account (not suspended) and device
(same keys) are re-read under FOR SHARE, and the final transaction
checks the collection is still this owner's current cloud copy and the
device the genesis enrolled before minting. PG tests for revoke,
suspend and device removal during generation, and leave/revoke during
the read-back. Nil collection/device IDs are refused.
…on answers busy

Review (control): every transaction sets lock_timeout 5s like C1; a
timeout answers 503 busy with no driver detail, and registers nothing.
PG regression holds the collection lock from another session.
@callumalpass

Copy link
Copy Markdown
Contributor Author

Control touched-path/source owner ACK at exact C2 head 2e1a32b, stacked on C1 f3076cd. Independently reviewed production bootstrap/app/config and the currentness/nil/lock-bound deltas plus regression source. Owner findings are closed in source: after remote generation and log read-back, current connector/account/device and immutable tuple are rechecked and locked; final current collection/enrolment/record lookup and token mint occur under transaction locks; nil IDs fail; all bootstrap transactions set 5s lock_timeout, with no network inside them. Exact persisted genesis/log read-back equality and first-record retention remain; escrow has a genuine unused Noise key, no validator change or endpoint. This is identity/genesis provisioning only, not legal epoch keying or Ready; rekey_recipients is advisory historical genesis identity data, and the consumer derives legal/current recipients and owner authority from the verified log. Source review only: no independent author-test rerun, IAM/private-material verification, actual policy-validator/LAB/epoch/Ready acceptance. Initial lock failure returns busy without registration; later post-commit uncertainty remains not_ready with retained state, not a no-side-effects claim. Scoped security and actual CI qualification remain required.

…oin, per Callum's 2026-10-06 decision

- POST /v1/next/collections/cloud-copy/service: account session; genesis
  [genesis cloud-copy, member-set owner, enrol hosted, enrol escrow];
  hosted is the first member and keys the collection. Same phases as the
  owner path (generate with no lock held, recheck account and collection
  under lock, exact genesis read-back), no token.
- POST /v1/next/collections/:id/devices: a device registered under the
  owner's connector, with a join-domain proof, is enrolled via
  queueNextPolicy only into a current cloud copy (private, foreign or
  left collections refuse before any op); idempotent for the same keys;
  the token is minted after the enrol batch reads back exactly.
- The owner-device create path stays.
Tests cover genesis ops, retries, session/suspension/foreign refusals,
private exclusion (no outbox row), join proof binding and revocation.
@callumalpass callumalpass changed the title next: owner creates a cloud-copy collection (cloud-copy bootstrap C2) next: cloud-copy collections — service-created, owner-device, and device join (cloud-copy bootstrap C2) Oct 5, 2026
@callumalpass

Copy link
Copy Markdown
Contributor Author

Reworked at 336b474 for Callum's 2026-10-06 decision, which replaces §7.1 owner-only keying for cloud copy only. Private collections are unchanged.

New routes

  • POST /v1/next/collections/cloud-copy/service {collection_id}
    • Auth: account session (requireUser, under the global origin gate). No device needed.
    • Genesis is [genesis cloud-copy, member-set owner, device-enrol hosted, device-enrol escrow]. Hosted is the first member and generates/wraps the key (hosted plus replica/policy work).
    • Phases are the same as the owner path. Generation runs with no lock held; the account and collection are rechecked under a 5 s lock; the exact genesis is read back from the log. No token is minted.
  • POST /v1/next/collections/:id/devices {device_id, challenge, sig}
    • Auth: connector plus a join-domain device proof, H("mdbase/v1/cloud-copy-join", cbor[challenge, connector, device, collection]).
    • The device is enrolled only into this owner's current cloud copy. Private, foreign or left collections get 409 before any policy op is queued.
    • Idempotent for the same keys; 409 for a different enrolment.
    • A role-0 token is minted only after the enrol batch reads back exactly, under a final identity and collection recheck. Hosted (or escrow) then wraps the epoch key to the device.

Unchanged

  • The owner-device create path stays.

Tests

  • Genesis ops and accounts for service-created.
  • Idempotent retry with no new generation.
  • No session (401), a foreign collection (409), suspension during generation (403, nothing registered).
  • Join: enrolment op in the log at seq 2, token claims, idempotent re-join.
  • Private exclusion: no outbox row is ever written for a private collection (this fails if the cloud-copy check is removed).
  • Foreign and left collections refused; proof bound to the collection and the join domain; revocation during the read-back mints nothing.

Results: next/* 175 passed against PG16; server suite 786 passed; architecture and changelog checks pass.

… rechecked after awaits; full enrolment tuple; revoked devices never revive

Review (control, security-2) on 336b474:
- join: currentIdentity (connector/account/device, exact keys) under
  FOR SHARE before the enrolment is queued and through its commit; PG
  barrier: a revocation racing the request wins and nothing is queued.
- service-created: the session itself (not revoked/expired, same session
  epoch, account active) is rechecked under lock after every await
  (Tailscale identities: the account).
- idempotent re-join and the owner-path check compare the whole
  immutable tuple (device, account, kind, sign/KEM/Noise keys).
- a device with a later device-revoke is never accepted again.
@callumalpass

Copy link
Copy Markdown
Contributor Author

Control touched-path/source owner ACK at exact new-rule head 0e523eb. Reviewed 2e→336 service/account-join production changes and 336→0e fixes, app/config/docs and new regression source; also checked existing session/authentication helper semantics. Source findings are closed: join locks current connector/account/device/full tuple before enqueue or idempotent acceptance; cookie sessions are rechecked with revocation/expiry/account-session epoch under lock after remote work; whole enrollment tuple is compared; recorded device revocation denies retry/final mint. Tailscale-auth mode uses its existing actual Tailscale identity path (not a cookie-session fallback). Existing bounded transactions, no network within them, strict cloud-copy/private rejection, exact batch read-back, nil checks and retained unknown outcomes carry. This is component/source review ONLY: author 178/786 are not my test executions; no actual policy-validator/service keying or wrapping, IAM, LAB/app-serving or Ready qualification. Response metadata/first-member labels are not authority; legal current mode, membership, signer/recipient and epoch at delivery remain verified-log/consumer obligations. Private→cloud conversion is NOT implemented. Scoped security and actual CI remain required; ordinary device log-token refresh is a separate follow-up, not private-mode expansion here.

@callumalpass

Copy link
Copy Markdown
Contributor Author

security: no blocking findings at 0e523eb, base f3076cd (scoped CloudCopy creation/device-join source review under the revised cloud-copy rule). Reviewed account-session revalidation after awaits and locked current connector/account/full-device identity before enrollment publication, exact genesis/enrollment readback, canonical namespace and domain-separated single-use proof, full-tuple idempotence and later-revoke refusal. Earlier scoped source concerns are corrected here. This is source review; author PG/barrier/suite results, owner ACK and CI metadata are separate—not my independently executed PG or actual genesis-to-Replica interoperability. No private-create/private-join or private-to-cloud conversion authorization, mutable epoch-key delivery, KMS custody, Ready, live acceptance or deployment qualification is conveyed.

Base automatically changed from next/cloud-copy-bootstrap to main October 6, 2026 00:04
@callumalpass
callumalpass enabled auto-merge October 6, 2026 00:08
@callumalpass
callumalpass added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 6041b80 Oct 6, 2026
14 checks passed
@callumalpass
callumalpass deleted the next/cloud-copy-create branch October 6, 2026 00:28
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.

1 participant