Skip to content

next: private collection create and device enrol - #621

Merged
callumalpass merged 19 commits into
mainfrom
next/private-collections
Oct 6, 2026
Merged

callumalpass merged 19 commits into
mainfrom
next/private-collections

Conversation

@callumalpass

@callumalpass callumalpass commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #616 (next/cloud-copy-create). Coordinator 2026-10-06: build the private create and private device-enrol routes; control reviews.

Behind MDBASE_NEXT_PRIVATE_BOOTSTRAP=1 (0/1; anything else is refused at startup).

POST /v1/next/collections/private

  • Who: an owner's registered device (connector auth).
  • Proof: H("mdbase/v1/private-create", cbor[challenge, connector, device, collection]) over a fresh single-use challenge.
  • One transaction holds the lock, the proof, the current identity (FOR SHARE) and the ownership/existence check, then registers the genesis: genesis(e2e), member-set owner, and device-enrol of that device only. No network call runs inside it, and no hosted or escrow device is ever enrolled (assertPrivateOps also guards this).
  • After the drain, the collection counts as created only if the log returns the exact genesis bytes at seq 1. Identity, sync='private' AND runtime='next' AND left_sync_at IS NULL, no revoke and the exact enrolment in the first outbox row are then rechecked under locks before the token is minted.
  • Answer: the genesis, head, root key, policy cert, log URL, rekey_recipients: [device] (advisory; that device signs the initial rekey wrapped to itself) and a log token.
  • A retry from the creating device answers the same genesis. Other devices, other owners, cloud copies, shadow-runtime and left collections are refused.

POST /v1/next/collections/:id/private/devices

  • Who: a registered device of a current member account, carrying sas_commit (32 bytes), with its connector.
  • Proof: H("mdbase/v1/private-device-enrol", cbor[challenge, connector, device, collection, sas_commit]), so the commitment is bound to the proof.
  • Checks, before queueing and again after the append, all under locks:
    • identity is current;
    • the collection is a current private one on the next runtime;
    • the account is a current member: the latest effective op, projected in SQL (member-remove counts even while pending, member-set only once appended; LIMIT 1, the same projection as next: device log-token refresh for synced collections #618);
    • the device is not revoked.
  • Queues device-enrol with key 7 = sas_commit, drains, and verifies the exact appended bytes.
  • Answer: {enrolled_at, approval: "pending", device token}. The device holds no key until an existing keyed device approves it by SAS (chore: move public packages to mdbase-dev scope #144 in mdbase-next) and appends a key_grant.
  • The same tuple and commitment is idempotent. A different commitment gets device_enrolled_differently; re-committing is the device's own approval-request, never this route.
  • The control plane never approves, grants or holds a key.

Refactor. The shared helpers (authenticate, currentIdentity, currentSession, currentAccount, inTransaction, lock, the enrolment lookups, refuseRevoked and refuse) move unchanged from cloud-copy-bootstrap.ts to bootstrap-common.ts, plus currentMember. The cloud-copy routes are behaviourally unchanged; its 23 PG tests still pass.

Tests (private-collections.postgres.test.ts, 9):

  • configuration;
  • e2e genesis with only the owner device;
  • an idempotent retry;
  • refusals: other devices or owners, a cloud copy, a left collection, a shadow runtime;
  • proof freshness and domain separation (an enrol proof never creates; a swapped sas_commit is refused);
  • a device removed before the proof;
  • second-device enrol with sas_commit on the log, idempotent, and a different commitment refused;
  • a member account (not_member while the member-set is pending, then enrolled), and a removed member refused;
  • a revoked device, a cloud copy and a left collection refused.

Six mutations were checked (membership, sas_commit in the digest, the runtime check, the sync check, the commitment-exact idempotency and the revoke check), and each fails a test.

Validation:

  • src/features/next + app: 236 passed, 1 skipped.
  • tsc is clean.
  • The architecture check passes (justified in architecture.d/next-private-collections.json) and the changelog check passes.

Review: control (owner), security.

…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.
…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.
…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.
… 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.
…hind MDBASE_NEXT_PRIVATE_BOOTSTRAP=1

Create: owner's registered device; e2e genesis enrolling only that device.
Enrol: a current member's registered device with its sas_commit (key 7); no key
until an existing keyed device approves and grants. Shared proof/identity/
membership helpers move from cloud-copy-bootstrap.ts to bootstrap-common.ts.
…; bootstrap transactions bound every statement

Per control/security-2 on #621: P1's final locked transaction adds currentMember
(a pending member-remove refuses); inTransaction sets statement_timeout 5s and
57014 answers 503 busy like lock_timeout (shared by the cloud-copy routes).
@callumalpass

Copy link
Copy Markdown
Contributor Author

Control SOURCE OWNER ACK exact 0940c0c (base #616 0e523eb). Read private route/common helpers, app/config/extraction delta and PostgreSQL test source; final locked P1 membership check after log awaits and 5s transaction statement/lock bounds with timeout refusal correct the source findings. Current whole identity, private/NEXT/not-left mode, exact enrollment/SAS commitment, effective membership and revoke guards remain required before queue/mint. Private genesis/enrollment remain service-free and credential-only; existing keyed user-device SAS approval/key_grant delivers the key. This is independent source review, not independent PG execution or a PG after-await barrier result. Author tests, S2 model execution/security and actual keying/Ready/runtime acceptance are separate. Coordinator main-merge hold until beta129 tag remains in force.

@callumalpass

Copy link
Copy Markdown
Contributor Author

security: no blocking findings at 0940c0c, base 0e523eb (scoped private-create/SAS-enrollment CP delta). Reviewed private-only owner/self genesis, registered full device identity, SAS-commitment-bound single-use proof, locked current membership before enrollment and after awaits, exact batch readback, idempotence/revoke refusal, and shared-helper extraction. The final create membership recheck and 5s statement/lock bounds with retryable timeout refusal correct the scoped source concerns. Independently executed 15 hermetic actual-digest/HTTP/helper and SQL-query-model checks, including removal during the head await: PASS. These are not independent PostgreSQL, actual log/genesis-to-Replica, SAS approval/key-grant or native execution; author PG and S1 structural/user-keying review are separate evidence. CP issues credentials/enrollment only, not epoch keys or approval. No service actor/recipient, conversion, Ready, custody/live/whole-consumer or approval-UI ship clearance is conveyed.

Base automatically changed from next/cloud-copy-create to main October 6, 2026 00:28
@callumalpass
callumalpass enabled auto-merge October 6, 2026 03:26
@callumalpass
callumalpass added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit ff969ce Oct 6, 2026
14 checks passed
@callumalpass
callumalpass deleted the next/private-collections branch October 6, 2026 05:36
callumalpass added a commit that referenced this pull request Oct 6, 2026
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