# secrets.sgit.ai > A zero-knowledge secrets manager that runs entirely in the browser: passkey unlock, ciphertext in a GCP bucket, readable by no one else. Site version: v0.1.2 (2026-10-05). Content CC BY 4.0, code Apache-2.0. Source: https://github.com/SGit-AI/SGit-AI__Website__Secrets Every HTML page has a markdown twin at the same path with the extension swapped; links inside the markdown point at markdown, so an agent never has to parse HTML. What is shipped, proposed or absent is in /docs/reality.md, generated from data/features.json on every release: if it is not listed there, it does not exist. Nothing on this site is described in the present tense before that file says shipped. Notes for agents: - Everything in the repository is public and nothing in it is secret; a leak tripwire in the release gate scans every file. - The passkey RP ID is secrets.sgit.ai, never the apex sgit.ai. - There is no server-side code: the browser does every cryptographic operation. - If your tooling cannot follow links, fetch /llms-full.txt: every page and document in one request. ============================================================================== source: /index.md ============================================================================== # secrets.sgit.ai > A password-manager-shaped app for anything small and secret, unlocked by a passkey, stored as ciphertext in a GCP bucket, readable by no one else. Static site, no server. The pipeline and the content pages exist; nothing of the app is built yet. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- A zero-knowledge secrets manager that will run entirely in the browser. The site is static on GitHub Pages. The only cloud is one GCP project per environment, holding Identity Platform for login and a Cloud Storage bucket for ciphertext. The browser does every cryptographic operation. A full compromise of the GCP project, the Identity Platform admin or the bucket yields ciphertext and login metadata, never a secret. **What exists at this version.** The pipeline, the live site, and the content pages that describe the design. No app, no admin, no probe page is built. Every row below marked *proposed* is a design, not a thing. The design is in [the brief](/docs/design/secrets-sgit-ai__mvp-build-brief.md); what is real is in [/shipped/](/shipped/index.md) and [docs/reality.md](/docs/reality.md), generated from the same data as the table on this page. ## The three-step demo, when it exists Status, from [/shipped/](/shipped/index.md): proposed Sign in and out with Google and email/password against the chosen environment · proposed Passkey with WebAuthn PRF derives the keyring wrapping key; RP ID secrets.sgit.ai · proposed Entries: six kinds kept apart, vault list, entry page, copy and reveal, lock timers 1. **Sign in** with Google or an email address, against the environment shown in the header. 2. **Touch your passkey.** The authenticator returns a secret bound to this origin; the browser derives the key that opens your keyring. 3. **See your secret.** It was ciphertext in a bucket a second ago and it is plaintext only in this tab, until you lock, sign out or leave. None of the three steps is built. [How it works](/how-it-works/index.md) draws the flows; [the keyring page](/keyring/index.md) is the file format; [security](/security/index.md) is what each party gets and what the design cannot withhold. ## What it will be A password-manager-shaped app where the "passwords" can be anything small and secret: passwords, API keys, sgit vault keys and read keys, PKI private keys, short notes. Unlocked by a passkey using the WebAuthn PRF extension, with a recovery code as the second unlock method. Stored as an encrypted keyring in a bucket the user's login can reach. Readable by no one else, including the people who run the bucket. Three principles are not negotiable: plaintext exists only in the browser, briefly, after a passkey gesture; the login decides which paths you may touch and the passkey decides whether the bytes mean anything; and nothing in the repository is secret, so the whole configuration of every environment is public. ## What a compromised party would get The design's threat table, from [section 3.4 of the brief](/docs/design/secrets-sgit-ai__mvp-build-brief.md), in full on [the security page](/security/index.md). It describes the design, not a shipped system; the acceptance test that checks it is listed as proposed below. | Party compromised | Gets | Does not get | |---|---|---| | GCP project or Identity Platform admin | User emails, login metadata, ciphertext, the ability to delete or roll back, the ability to log in as anyone | Any plaintext: the PRF output is bound to the origin and the user's authenticator | | Bucket reader | Ciphertext | Plaintext | | Terraform pipeline | Can change rules, delete the bucket | Plaintext | | This repository or the DNS | **Everything, for users who load the malicious page** | Nothing is withheld | The last row is why the repository protections in [docs/ops/branch-protection.md](/docs/ops/branch-protection.md) exist. The code served to the browser is the boundary. ## Shipped, proposed, absent Every claim this site makes, with its status, from `data/features.json`. *shipped* exists and runs at the version shown; *proposed* is designed and not built; *absent* is deliberately not in the MVP. | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | pipeline | One version in admin/build/version.txt, repeated in every badge, twin, index and config | shipped v0.1.0 | admin/build/version.txt | Check 1 of the gate fails on any disagreement. | | pipeline | Chrome (head, nav, footer, version badge, CSP, canonical) generated into every page from one definition | shipped v0.1.0 | admin/build/chrome.py | gen_chrome.py --check is part of the gate. | | pipeline | Markdown twin of every HTML page, links pointing at markdown | shipped v0.1.0 | admin/build/gen_twins.py | index.html has index.md beside it; the twin carries the site version. | | pipeline | llms.txt, llms-full.txt and sitemap.xml generated on every release | shipped v0.1.0 | admin/build/gen_llms.py | llms-full.txt concatenates every twin and every design document. | | pipeline | docs/reality.md and the status tables generated from data/features.json | shipped v0.1.0 | admin/build/gen_features.py | If the reality document does not list it, it does not exist. | | pipeline | The eight-check release gate, no dependencies | shipped v0.1.0 | admin/build/validate.js | Version agreement, internal links, canonical host, leak tripwire, vendor manifest, rules in sync, reality, storage keys. | | pipeline | The same gate locally and in CI, one command | shipped v0.1.0 | admin/build/gate.py | The CI validate job runs gate.py; a release that fails locally fails the same way in CI. | | pipeline | Every push to dev is a release: version.txt and the commit subject agree, CI tags it | shipped v0.1.0 | admin/build/tag_release.py | Anchors on the newest commit whose subject is 'site vX.Y.Z : ...', asserts the next minor or patch or major .0, backfills missing tags. | | pipeline | Deploy to GitHub Pages from the validated tree | shipped v0.1.0 | .github/workflows/deploy-pages.yml | Excludes .git, .github, infra, tests/unit and node_modules. Actions pinned by commit SHA. | | pipeline | verify-live: the run is red until the live site serves the released version | shipped v0.1.0 | admin/build/verify_live.py | Green does not mean live. Polls version.txt and the homepage badge for up to ten minutes. | | pipeline | Every third-party file vendored and hashed in vendor/MANIFEST.json; no runtime script from another origin | shipped v0.1.0 | vendor/MANIFEST.json | Check 5 of the gate. Today the only vendored file is the family design tokens. | | site | secrets.sgit.ai served by GitHub Pages over HTTPS | shipped v0.1.0 | CNAME, docs/ops/dns.md | verify-live passed on the v0.1.0 run (attempt 2, 2026-10-05) after the DNS record and Pages settings were made. | | site | Content pages: how it works, security, keyring spec, sharing, environments | shipped v0.1.1 | how-it-works/, security/, keyring/, sharing/, environments/ | Every page opens with a status line generated from this file and describes only designs in the future tense. | | site | /shipped/ generated from this file, one table per status, beside docs/reality.md | shipped v0.1.1 | shipped/index.html | The same generator writes both, so the page and the reality document cannot disagree. | | site | The five design documents published verbatim | shipped v0.1.0 | docs/design/ | The markdown is the source of truth; since v0.2.0 each is also rendered to an HTML page beside it. | | site | Every markdown document under docs/ rendered to HTML on each release, with index pages | shipped v0.1.1 | admin/build/gen_docs.py, admin/build/md_to_html.py | Standard-library renderer; the markdown stays the twin. gen_docs --check is part of the gate. | | site | The family nav: grouped menus with dropdowns, part-of-sgit.ai link, stage pill, phone menu, breadcrumbs | shipped v0.1.1 | admin/build/chrome.py, assets/nav.js | The shape sgit.ai, nfrs.sgit.ai and pki.sgit.ai run; works with no JavaScript because every group label is a link. | | admin | Comms page: the asks back to the project lead and the nine build steps with status, from data/steps.json | shipped v0.1.1 | admin/comms.html | gen_versions.py renders the step tracker; a done step must name a release that exists. | | site | Branch protection, hardware-key 2FA, verified domain and the Actions policy in place and dated on /security/ | proposed | docs/ops/branch-protection.md | Asked for in docs/ops/needs.md. Each row on /security/ flips to a dated yes when confirmed. | | site | brief-corrections.md: what the brief got wrong, dated, beside it | shipped v0.1.0 | docs/design/brief-corrections.md | Appended to as the build finds out. | | site | docs/ops/needs.md: exactly what only a human can do | shipped v0.1.0 | docs/ops/needs.md | DNS, Pages, branch protection, GCP bootstrap, OAuth client secret. | | infra | Bootstrap script for the tfstate project, env projects, Terraform service account and WIF pool | proposed | infra/bootstrap/bootstrap.sh | Step 3. Run once by a human; idempotent; --dry-run. | | infra | Terraform module secrets-env and the dev environment root | proposed | infra/terraform/ | Step 3. Identity Platform, Firebase web app, bucket, rules release, IAM, WIF. | | infra | infra.yml: plan on PR, apply on dispatch and on push to dev for dev | proposed | .github/workflows/infra.yml | Step 3. Workload Identity Federation, no JSON keys. | | infra | Storage Security Rules deployed per environment | proposed | infra/rules/storage.rules | The rules text is in the repository and hashed; nothing is deployed yet. Syntax to verify on a real project. | | infra | config/environments.json with real dev values from Terraform outputs | proposed | config/environments.json | Step 3. Today every environment is a placeholder with _source null. | | app | Sign in and out with Google and email/password against the chosen environment | proposed | app/index.html | Step 3. Popup sign-in by default; vendored Firebase SDK. | | app | Environment page: pick a built-in environment, enter a custom one, import, export, reset | proposed | app/environment.html | Step 3. The active environment is shown in the header at all times. | | app | Passkey with WebAuthn PRF derives the keyring wrapping key; RP ID secrets.sgit.ai | proposed | app/setup.html, app/unlock.html | Step 4. Not before the dev project exists and the probe pages are green. | | app | Keyring v1 format: wraps per unlock method, AES-256-GCM body, known-answer tests | proposed | section 8 of the brief | Step 4. Published at /keyring/ as a specification. | | app | Recovery code: 26 characters base32, 128 bits, shown once | proposed | app/setup.html | Step 4. Lose every passkey and the code and the data is gone; the site will say so. | | app | Entries: six kinds kept apart, vault list, entry page, copy and reveal, lock timers | proposed | app/vault.html, app/entry.html | Step 5. Kinds: password, api-key, sgit-vault-key, sgit-read-key, pki-private-key, note. | | app | Optimistic concurrency on keyring writes with a three-way merge | proposed | section 8.5 of the brief | Step 5. rev in the AAD so a stale body fails to decrypt. | | app | Devices page: add and remove passkeys, regenerate the recovery code | proposed | app/devices.html | Step 6. | | app | Account page: export and import the encrypted keyring, sign out, wipe memory | proposed | app/account.html | Step 6. | | app | Key pair per user generated at first run; public bundle written to directory/ | proposed | section 8.3 of the brief | Step 4 generates the keys; step 9 writes the public bundle. Phase 1 data model for phase 2 sharing. | | app | Sharing an entry with another user through their inbox | absent | /sharing/ (step 2 describes it) | Phase 2. The data model ships first so it is not rewritten later. | | app | A document kind in the keyring | absent | section 8.4 of the brief | Documents will be a pointer to an sgit vault plus that vault's key, not bytes in the keyring. | | app | Browser extension or autofill | absent | section 12 of the brief | A later site or a later version. | | app | An API for agents | absent | section 12 of the brief | A later version. | | app | Any server-side code: Cloud Functions, proxies, a small API | absent | everywhere | Forbidden by design. A need for one is a proposal in brief-corrections.md, not code. | | admin | Admin sign-in with the visitor's own Google account, token in memory only | proposed | admin/oauth.js | Step 7. Implicit flow to verify first; PKCE fallback. | | admin | Setup checklist: every per-project resource as a row, with Fix where fixable client-side | proposed | admin/setup-checklist.html | Step 3 read-only, step 7 with fixes. | | admin | Auth config, storage, rules diff and deploy, users, environment export | proposed | admin/*.html | Step 7. GCP IAM is the role; the pages are a client for GCP's own APIs. | | admin | Release history page generated from data/versions.json | shipped v0.1.0 | admin/versions.html | One row per release; the newest row must equal version.txt. | | tests | Browser probe pages: webauthn-prf, crypto, config, auth, storage, keyring-roundtrip, offline, leak-check, matrix | proposed | tests/*.html | Steps 3 to 6. Each prints PASS/FAIL/SKIP with the raw values; together they are the compatibility matrix. | | tests | Unit tests under node --test, real WebCrypto, no mocks | shipped v0.1.0 | tests/unit/ | At this version: the gate's own checks against fake fixtures. Keyring tests come with step 4. | | tests | Build tests: the generators run on the real tree, chrome in every page, twins exist, features schema | shipped v0.1.0 | tests/build/ | pytest, TestCase classes, no mocks. | | tests | Playwright end to end against the real dev project with a virtual authenticator | proposed | tests/e2e/ | Whether the virtual authenticator does PRF is an open question. | | tests | Acceptance: an Owner of the dev project is handed a uid and asked to produce one plaintext field | proposed | docs/acceptance.md | Before 1.0. The write-up is published whatever the result. | ## Where to read next - [How it works](/how-it-works/index.md), [Security](/security/index.md), [Keyring format](/keyring/index.md), [Sharing](/sharing/index.md), [Environments](/environments/index.md): the design, every page marked with its status. - [The MVP build brief](/docs/design/secrets-sgit-ai__mvp-build-brief.md), the instruction set this site is built from, and [what it got wrong](/docs/design/brief-corrections.md); the four design documents are listed at [docs/design/](/docs/design/index.md). - [How a release works](/docs/ops/release.md): the version gate, the commit subject, the tag, and why green does not mean live. - [What only a human can do](/docs/ops/needs.md) before the next step. - [Release history](/admin/versions.md) and [llms.txt](/llms.txt) for agents. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/)* ============================================================================== source: /admin/comms.md ============================================================================== # Comms: asks and steps > The state of play on this site, kept here rather than in a chat message: the asks back to the project lead, numbered, and the nine build steps of the brief with their status and the release that delivered each. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- The state of play, kept on the site rather than in a chat message, in the manner of the sibling sites' comms pages. Numbered so a reply can refer to an item without quoting it. ## Asks back to the project lead The exact list, with who and which step waits on each, is [docs/ops/needs.md](/docs/ops/needs.md); it is the source and this page points at it rather than copying it. In one line each, as of v0.1.1 (2026-10-05): | # | Ask | Blocks | Status | |---|---|---|---| | N1 | Branch protection on `dev` and `main` with `validate` required | section 9.5; every release from here on arrives by pull request | Open | | N2 | Hardware-key 2FA for the organisation; `sgit.ai` verified as an organisation domain; the Actions policy | section 9.5; the rows on [/security/](/security/index.md) say "unconfirmed" | Open | | N3 | A billing account, confirmed project ids, and the bootstrap run once the script exists | step 3 | Open | | N4 | The Google OAuth client secret for sign-in, as the GitHub environment secret for `dev` | step 3 | Open | | N5 | Confirm the versioning decision: every release bumps the third digit; the second digit is reserved for a milestone you name (brief-corrections C15) | nothing; recorded as decided on 2026-10-05 | Confirm | | N6 | Confirm that documents are rendered at build time with the raw markdown one click away, rather than a client-side markdown viewer (C16) | nothing | Confirm | | N0 | DNS, Pages with the custom domain and HTTPS, and the re-run of the v0.1.0 workflow | step 1 | Done, 2026-10-05 | ## The build order, as it stands Section 11 of the brief, one row per step, from `data/steps.json`. The planned version is what the brief wrote; releases bump the third digit, so the delivered version differs. | # | Step | Planned as | Delivered as | Status | Note | |---|---|---|---|---|---| | T1 | The pipeline before the site | `v0.1.0` | `v0.1.0` | done | Live at secrets.sgit.ai on 2026-10-05; verify-live green. | | T2 | Content pages, everything marked proposed; /shipped/; twins; llms.txt; sitemap | `v0.2.0` | `v0.1.1` | done | The documents are rendered to HTML as well; the family nav with grouped menus. | | T3 | Bootstrap, Terraform, infra.yml, rules, environments.json with dev real; sign in against dev | `v0.3.0` | | open | Blocked on the GCP items in needs.md (billing, project ids, bootstrap run, OAuth client secret). | | T4 | PRF probe page; keyring v1 library with KATs; setup and unlock; recovery code | `v0.4.0` | | open | Not before step 3's probe pages are green on a real dev project. | | T5 | Entries: kinds, vault list, entry page, copy and reveal, lock timers, merge | `v0.5.0` | | open | | | T6 | Devices page; account export and import; meta.json; matrix page | `v0.6.0` | | open | | | T7 | Admin pages with fixes; rules.yml; new-environment.md timed on a fresh main project | `v0.7.0` | | open | Not before step 6: the users page needs meta.json. | | T8 | prod live; homepage demo real; security page final; acceptance test published | `v0.8.0` | | open | | | T9 | Phase 2 data only: public bundle in directory/, inbox rules live | `v0.9.0` | | open | sgit pki import of the published bundle to verify. | ## Open questions carried from the brief Listed and dated at the end of [brief-corrections.md](/docs/design/brief-corrections.md): the Playwright virtual authenticator and PRF, Google's implicit flow, the Security Rules syntax, where Identity Platform stores user records, and whether `sgit pki import` reads a browser-generated bundle. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/admin/comms.html)* ============================================================================== source: /admin/index.md ============================================================================== # Admin > How the site is built and gated, and the admin pages that are proposed: a client for GCP's own APIs, working only for a Google account with IAM on the chosen project. At this version only the build pipeline exists. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- Two things live under `/admin/`: how this site is built and released, which exists, and the environment admin pages, which are proposed. ## How the site is built There is no build step and no bundler. The committed tree is the deployed tree. Five generators under `admin/build/` write the parts that must not drift (the chrome, the status tables, the release table, the markdown twins, the machine indexes), each with a `--check` mode, and an eight-check gate in `admin/build/validate.js` refuses a release that disagrees with itself. The same command, `python3 admin/build/gate.py`, runs locally and in CI. Every push to `dev` is a release: the version in `admin/build/version.txt` is repeated in the commit subject as `site vX.Y.Z : what`, CI tags it, deploys it, and then polls the live site until it serves that version. The whole procedure is in [docs/ops/release.md](/docs/ops/release.md). ### The eight checks 1. **Version agreement.** `version.txt` equals every page badge, the newest row of the versions table, `llms.txt`, `llms-full.txt`, `index.md` and `config/environments.json#siteVersion`; no version is listed twice. 2. **Internal links and fragments** resolve, in every HTML and markdown file. 3. **Canonical host.** Every page has `rel=canonical` and `og:url` on the host in `CNAME`. 4. **Leak tripwire** on every file in the tree: sgit vault and read keys, AWS and GitHub tokens, API keys, PEM private key blocks, Slack tokens, Google OAuth client secrets, service-account JSON, bare twelve-digit numbers. The fix for a trip is always a redaction, never a wider pattern. 5. **Vendor manifest.** Every file under `vendor/` matches its SHA-256; no script is loaded from another origin anywhere under `app/`, `components/`, `admin/` or `tests/`. 6. **Storage rules in sync.** The hash of `infra/rules/storage.rules` equals the hash recorded in `data/features.json`, so a rules change without a release note fails. 7. **Reality.** `docs/reality.md` is what `data/features.json` generates. 8. **No storage of secrets in code.** Every `localStorage.setItem` and `sessionStorage.setItem` uses a key listed in `app/config/storage-keys.js`. ## The admin pages, proposed The admin pages will work only when the visitor signs in with a Google account that has IAM on the selected environment's GCP project. There will be no admin role in the app: the project's IAM is the role, and the pages are a client for GCP's own APIs, calling them with the visitor's own token held in memory. Every write action will show the exact request before sending it. None of this exists yet. | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | admin | Comms page: the asks back to the project lead and the nine build steps with status, from data/steps.json | shipped v0.1.1 | admin/comms.html | gen_versions.py renders the step tracker; a done step must name a release that exists. | | admin | Admin sign-in with the visitor's own Google account, token in memory only | proposed | admin/oauth.js | Step 7. Implicit flow to verify first; PKCE fallback. | | admin | Setup checklist: every per-project resource as a row, with Fix where fixable client-side | proposed | admin/setup-checklist.html | Step 3 read-only, step 7 with fixes. | | admin | Auth config, storage, rules diff and deploy, users, environment export | proposed | admin/*.html | Step 7. GCP IAM is the role; the pages are a client for GCP's own APIs. | | admin | Release history page generated from data/versions.json | shipped v0.1.0 | admin/versions.html | One row per release; the newest row must equal version.txt. | ## The pipeline, as it stands | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | pipeline | One version in admin/build/version.txt, repeated in every badge, twin, index and config | shipped v0.1.0 | admin/build/version.txt | Check 1 of the gate fails on any disagreement. | | pipeline | Chrome (head, nav, footer, version badge, CSP, canonical) generated into every page from one definition | shipped v0.1.0 | admin/build/chrome.py | gen_chrome.py --check is part of the gate. | | pipeline | Markdown twin of every HTML page, links pointing at markdown | shipped v0.1.0 | admin/build/gen_twins.py | index.html has index.md beside it; the twin carries the site version. | | pipeline | llms.txt, llms-full.txt and sitemap.xml generated on every release | shipped v0.1.0 | admin/build/gen_llms.py | llms-full.txt concatenates every twin and every design document. | | pipeline | docs/reality.md and the status tables generated from data/features.json | shipped v0.1.0 | admin/build/gen_features.py | If the reality document does not list it, it does not exist. | | pipeline | The eight-check release gate, no dependencies | shipped v0.1.0 | admin/build/validate.js | Version agreement, internal links, canonical host, leak tripwire, vendor manifest, rules in sync, reality, storage keys. | | pipeline | The same gate locally and in CI, one command | shipped v0.1.0 | admin/build/gate.py | The CI validate job runs gate.py; a release that fails locally fails the same way in CI. | | pipeline | Every push to dev is a release: version.txt and the commit subject agree, CI tags it | shipped v0.1.0 | admin/build/tag_release.py | Anchors on the newest commit whose subject is 'site vX.Y.Z : ...', asserts the next minor or patch or major .0, backfills missing tags. | | pipeline | Deploy to GitHub Pages from the validated tree | shipped v0.1.0 | .github/workflows/deploy-pages.yml | Excludes .git, .github, infra, tests/unit and node_modules. Actions pinned by commit SHA. | | pipeline | verify-live: the run is red until the live site serves the released version | shipped v0.1.0 | admin/build/verify_live.py | Green does not mean live. Polls version.txt and the homepage badge for up to ten minutes. | | pipeline | Every third-party file vendored and hashed in vendor/MANIFEST.json; no runtime script from another origin | shipped v0.1.0 | vendor/MANIFEST.json | Check 5 of the gate. Today the only vendored file is the family design tokens. | ## The admin section - [How the site is built](/admin/index.md): this page. - [Release history](/admin/versions.md): one row per release, generated from `data/versions.json`. - [Comms](/admin/comms.md): the asks back to the project lead and the nine build steps with their status, generated from `data/steps.json`. - [What needs a human](/docs/ops/needs.md), exactly, and the rest of the [documents](/docs/index.md), each rendered from its markdown with the raw file one click away. - The build itself, served as plain files: [chrome.py](/admin/build/chrome.py) (the nav, footer, badges and CSP), [validate.js](/admin/build/validate.js) (the eight checks), [version.txt](/admin/build/version.txt), [nav.js](/assets/nav.js) (the menu interaction, the only script on the site at this version). --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/admin/)* ============================================================================== source: /admin/versions.md ============================================================================== # Release history > Every release of secrets.sgit.ai: version, date, what changed. Generated from data/versions.json; the newest row is the version in admin/build/version.txt and the tag CI pushed. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- One row per release. The version increments on every push to `dev`: a minor bump for a release, a patch for a same-day fix. Each row's version is also a git tag, pushed by CI from the commit whose subject reads `site vX.Y.Z : what`. The table is generated from `data/versions.json`; the gate fails if the newest row disagrees with `admin/build/version.txt` or a version appears twice. | Version | Date | Release | What changed | |---|---|---|---| | `v0.1.2` | 2026-10-05 | the GCP bootstrap, from the command line | Step 3 begins. infra/bootstrap/bootstrap.sh: the one-time, idempotent gcloud bootstrap with --dry-run and --check, creating the state project and bucket, one project per environment with billing and the APIs, the Terraform service account, and the Workload Identity Federation pool and provider, then printing the public values and the gh commands that store them. docs/ops/bootstrap.md maps exactly what a human does, in order, as commands, and names the one part Google keeps in the console (the OAuth consent screen and the two Web clients) and the two organisation settings with no API. The script is dry-run tested against a stand-in gcloud; its first real run is the project lead's. | | `v0.1.1` | 2026-10-05 | the content pages and the family chrome | Six content pages: how it works, security, keyring format v1, sharing, environments, and /shipped/ generated from data/features.json with one table per status. Every content page opens with a status line generated from the same file. Every markdown document under docs/ is now rendered to an HTML page beside it (the design documents, the ops notes, reality.md) by a standard-library markdown renderer, with index pages for docs/design/ and docs/ops/; the markdown stays the source and the twin. The homepage links the new pages and carries the three-step demo as proposed. The chrome now has the family nav (grouped menus with dropdowns, the part-of-sgit.ai link, the stage pill, a phone menu) with breadcrumbs, and the admin section gains a comms page with the asks and a tracker of the nine build steps. Versions now bump the third digit per release (brief-corrections C15). v0.1.0 went live at secrets.sgit.ai (DNS, Pages and the re-run done by Dinis), so the live-domain claim is shipped; needs.md items 1 to 3 are closed. | | `v0.1.0` | 2026-10-05 | the pipeline, before the site | The repository layout, version.txt as the one source of the version, the chrome generator, markdown twins, llms.txt and llms-full.txt, docs/reality.md generated from data/features.json, the eight-check gate in admin/build/validate.js, deploy-pages.yml with validate, tag-release, deploy and verify-live, CNAME, the DNS and branch-protection notes, docs/ops/needs.md, and the five design documents copied in verbatim. A placeholder homepage with the version badge and the status table. Nothing of the app, admin or test pages exists yet; every one of those claims is marked proposed. | How a release works, step by step: [docs/ops/release.md](/docs/ops/release.md). --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/admin/versions.html)* ============================================================================== source: /docs/design/index.md ============================================================================== # Design documents > The brief that this site is built from, what it got wrong, and the four documents that carry the reasoning. Copied in verbatim; the markdown is the source of truth and the HTML is rendered from it on every release. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- The brief that this site is built from, what it got wrong, and the four documents that carry the reasoning. Copied in verbatim; the markdown is the source of truth and the HTML is rendered from it on every release. - [Brief corrections](/docs/design/brief-corrections.md): What the brief got wrong or left open, found while building, dated, beside it. [(markdown)](/docs/design/brief-corrections.md) - [Risk Mandate — AWS Cognito Client-Side Architecture](/docs/design/riskmandate-aws-cognito-architecture.md): The AWS variant; why no secret can live inside an identity provider; the attack table. [(markdown)](/docs/design/riskmandate-aws-cognito-architecture.md) - [Risk Mandate — GCP Key Vault Architecture & Password Manager MVP](/docs/design/riskmandate-gcp-key-vault-password-manager-mvp.md): The primary design: all-GCP stack, keyring, PRF unlock, sharing scheme, storage layout, threat summary, password-manager MVP scope. [(markdown)](/docs/design/riskmandate-gcp-key-vault-password-manager-mvp.md) - [Risk Mandate — User Onboarding & Account Experience](/docs/design/riskmandate-user-onboarding-account-experience.md): The Workspace-based onboarding design, the Google terms research, and the five-tier model that led here. [(markdown)](/docs/design/riskmandate-user-onboarding-account-experience.md) - [Risk Mandate — Google Workspace as Identity, Storage and Deployment Substrate](/docs/design/riskmandate-workspace-architecture-briefing.md): The earlier Workspace architecture briefing. [(markdown)](/docs/design/riskmandate-workspace-architecture-briefing.md) - [secrets.sgit.ai — MVP build brief](/docs/design/secrets-sgit-ai__mvp-build-brief.md): The instruction set: architecture, environments, the site, the keyring specification, the pipeline, the build order. [(markdown)](/docs/design/secrets-sgit-ai__mvp-build-brief.md) --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/docs/design/)* ============================================================================== source: /docs/index.md ============================================================================== # Documents > Every document this site carries, readable as a rendered page with the raw markdown one click away: the brief and its corrections, the four design documents, the operations notes, and the reality document generated on each release. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- The documents this site was built from and the notes it is run by, published whole. Each is a markdown file in the repository, which is the source of truth, and a page rendered from it on every release, which is presentation. The rendered page links the raw file at the top; the raw file is what a reader checks the site against. - [Design documents](/docs/design/index.md): [the MVP build brief](/docs/design/secrets-sgit-ai__mvp-build-brief.md) (the instruction set), [what it got wrong](/docs/design/brief-corrections.md), and the four documents that carry the reasoning. - [Operations](/docs/ops/index.md): [how a release works](/docs/ops/release.md), [what only a human can do](/docs/ops/needs.md), [the GCP bootstrap as commands](/docs/ops/bootstrap.md), [the DNS record](/docs/ops/dns.md), [the repository protections](/docs/ops/branch-protection.md). - [Reality](/docs/reality.md): what is built, by status, generated from `data/features.json`. The same data renders [/shipped/](/shipped/index.md). ## Why publish the brief at all A reader who wants to check whether this site is faithful to what it was asked to build should not have to reconstruct the brief from the site. Publishing the commission, and the corrections beside it, makes the site checkable against something other than its own claims. That is the same argument the reality document makes, pointed at this site. ## For agents [llms.txt](/llms.txt) lists every page and document with a one-line description; [llms-full.txt](/llms-full.txt) is all of them in one fetch. Every HTML page has a markdown twin at the same path with the extension swapped, and links inside the markdown point at markdown. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/docs/)* ============================================================================== source: /docs/ops/index.md ============================================================================== # Operations > How a release works, what only a human can do, the DNS record and the repository protections. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- How a release works, what only a human can do, the DNS record and the repository protections. - [GCP bootstrap: exactly what a human does, from the command line](/docs/ops/bootstrap.md): Exactly what a human does on the GCP and GitHub side, in order, as commands; the one console-only part named. [(markdown)](/docs/ops/bootstrap.md) - [Repository protections](/docs/ops/branch-protection.md): The settings for dev and main, the organisation, Actions and environments. [(markdown)](/docs/ops/branch-protection.md) - [DNS for secrets.sgit.ai](/docs/ops/dns.md): The CNAME record and the Pages settings for secrets.sgit.ai. [(markdown)](/docs/ops/dns.md) - [What only a human can do](/docs/ops/needs.md): Exactly what only a human can do, who, and which step waits on it. [(markdown)](/docs/ops/needs.md) - [How a release works](/docs/ops/release.md): The version, the commit subject, the gate, the four CI jobs, and why green does not mean live. [(markdown)](/docs/ops/release.md) --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/docs/ops/)* ============================================================================== source: /environments/index.md ============================================================================== # Environments > One site, one GCP project per environment: dev, main, prod, and a customer's own. How the browser picks an environment, what config/environments.json holds and why none of it is secret, and the setup guide for running your own project. Proposed. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- Status, from [/shipped/](/shipped/index.md): proposed Bootstrap script for the tfstate project, env projects, Terraform service account and WIF pool · proposed Terraform module secrets-env and the dev environment root · proposed config/environments.json with real dev values from Terraform outputs · proposed Environment page: pick a built-in environment, enter a custom one, import, export, reset One site serves every environment. An environment is one GCP project holding an Identity Platform configuration and one bucket; the project is the unit that is created and destroyed. The browser picks the environment at runtime, with `prod` as the default on `secrets.sgit.ai`, and shows which one is active in the app header at all times, so nobody enters a real secret into `dev` by mistake. ## The environments | Environment | Purpose | GCP project id (proposed) | Who uses it | |---|---|---|---| | `dev` | Daily development, disposable | `sgit-secrets-dev` | Builders, and the end-to-end tests | | `main` | Staging; what the `main` branch is tested against | `sgit-secrets-main` | Review | | `prod` | The public default | `sgit-secrets-prod` | Everyone | | `` | A customer's own project, in their organisation and billing | Theirs | Them | The project ids are proposed until the bootstrap confirms they are available. None exists yet. ## What a project contains Terraform in `infra/terraform/` will create, per project: the services; the Firebase project link and web app registration; Identity Platform with email/password and Google sign-in, authorised domains `secrets.sgit.ai` and `localhost`; a second OAuth client for the admin pages; the bucket with uniform access, versioning, thirty days of soft-delete retention and CORS for this origin; the Security Rules release; IAM for the Terraform service account and the admins group; and the Workload Identity Federation pool that lets GitHub Actions apply all of it without a key file. The first project and the pool are created once by a human with a short script, `infra/bootstrap/bootstrap.sh`, after which everything is Terraform. The exact procedure, as commands, is [docs/ops/bootstrap.md](/docs/ops/bootstrap.md). ## config/environments.json The site ships one file naming every built-in environment. Every value in it is a **public identifier**: a Firebase web API key is not a secret, it is restricted by HTTP referrer, and the project id, auth domain, bucket name, app id and admin OAuth client id are all visible to any user of the app anyway. The file is generated from Terraform outputs by the pipeline and validated by the gate; a block whose values do not match the Terraform state it came from will fail the build. The leak tripwire allows the `AIza…` shape in this file only, so a key pasted anywhere else is caught. ``` { "version": 1, "siteVersion": "…", "default": "prod", "environments": { "prod": { "label": "secrets.sgit.ai (production)", "projectId": "sgit-secrets-prod", "apiKey": "AIza… (public web API key, restricted by referrer)", "authDomain": "sgit-secrets-prod.firebaseapp.com", "storageBucket": "sgit-secrets-prod.firebasestorage.app", "appId": "1:…:web:…", "adminOauthClientId": "….apps.googleusercontent.com", "tenantId": null, "region": "europe-west2", "signInMethod": "popup" } } } ``` The current file is [live on this site](/config/environments.json); at this version every environment in it is a placeholder. ## How the browser chooses - The app reads the active configuration from `localStorage['sgit.secrets.config.v1']` if present, else from the file's `default`. - `?env=dev` in the URL selects a built-in environment for that load and persists it. - The Environment page will let a user pick a built-in environment, enter a custom one (every field above), export it as JSON, import one, and reset. - Changing environment signs the user out and clears every key from memory. - Nothing secret is ever stored in localStorage; the only other keys are user-interface conveniences, and the gate checks every write against the allow-list in `app/config/storage-keys.js`. ## Running your own The target, from the brief: a customer with a GCP organisation and a billing account goes from nothing to a green setup checklist in under thirty minutes with no support. The guide, `docs/ops/new-environment.md`, arrives with step 7 and will say: 1. Run the bootstrap for one project. 2. Add your domain to the authorised domains. 3. Run Terraform. 4. Paste the printed configuration into the site's Environment page, or into your fork's `config/environments.json`. The admin pages' setup checklist will verify each step against the live project. A customer can use the public site pointed at their own project, or fork the repository; either way the site never holds anything of theirs but public identifiers. ## Notes to verify - Where Identity Platform stores user records, for customers with data-residency requirements. The bucket's location is chosen per project; Identity Platform is global. - Whether the tenant claim is available to Storage Security Rules, for projects that turn on multi-tenancy. - Pricing reference: Identity Platform basic sign-in is free to 50,000 monthly active users; SAML and OIDC federation are free only to 50. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/environments/)* ============================================================================== source: /how-it-works/index.md ============================================================================== # How it works > The four flows of the design, drawn: first run, returning, new device, admin. What each party can and cannot see at every step. All of it is proposed; nothing on this page is built yet. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- Status, from [/shipped/](/shipped/index.md): proposed Sign in and out with Google and email/password against the chosen environment · proposed Passkey with WebAuthn PRF derives the keyring wrapping key; RP ID secrets.sgit.ai · proposed Keyring v1 format: wraps per unlock method, AES-256-GCM body, known-answer tests · proposed Devices page: add and remove passkeys, regenerate the recovery code Two things decide what you can do. The **login** decides which paths in the bucket you may touch. The **passkey** decides whether the bytes there mean anything. They are deliberately separate: an administrator of the login can fake the first and can never fake the second. This page describes the design in section 3 of [the brief](/docs/design/secrets-sgit-ai__mvp-build-brief.md). Every flow on it is *proposed*. The status line above is generated from `data/features.json` and changes when the code ships. ## The parts | Layer | Component | Where it runs | What it is trusted with | |---|---|---|---| | Site and app | Static HTML, JS and CSS from this repository, on GitHub Pages at `secrets.sgit.ai` | Your browser | **The boundary.** Whoever controls this repository or the DNS controls the app | | Login | Identity Platform, through the vendored Firebase Auth SDK | Google | Can impersonate; cannot decrypt | | Storage | A Cloud Storage for Firebase bucket with Security Rules | Google | Holds ciphertext; can delete | | Unlock | A WebAuthn passkey with the PRF extension, RP ID `secrets.sgit.ai` | Your authenticator: Google Password Manager, iCloud Keychain or a hardware key | The only thing that can decrypt | | Admin | The same static pages, calling GCP's own REST APIs with your Google account's token | Your browser | Works only if your Google account has IAM on the project | | Infrastructure | Terraform in this repository, applied by GitHub Actions through Workload Identity Federation | GitHub Actions | Can reconfigure or delete; cannot read | ## First run ``` sign in ──▶ no keyring at users/{uid}/keyring.json ──▶ create a passkey with the PRF extension (your authenticator asks for a gesture) ──▶ generate: KEK (32 random bytes), a key pair, a recovery code ──▶ wrap the KEK under the passkey's PRF output and under the recovery code ──▶ encrypt the body under the KEK; write keyring.json and meta.json to the bucket ──▶ show the recovery code once, behind "I have written it down" ``` What leaves the browser: ciphertext, two wrapped copies of the KEK, a 32-byte PRF salt, and `meta.json` with the passkey's credential id and public key. What never leaves: the PRF output, the KEK, the recovery code, the private keys, any entry. ## Returning ``` sign in ──▶ fetch keyring.json (and remember its generation number) ──▶ navigator.credentials.get with prf.eval.first = prfSalt (one gesture) ──▶ HKDF-SHA256(prf output, salt, "sgit-secrets/v1/wrap/") → wrapping key ──▶ unwrap the KEK; decrypt the body into memory ──▶ the vault list ``` Nothing decrypted is written anywhere: not to localStorage, sessionStorage, IndexedDB, the URL or a log. The gate checks the code for that on every release (check 8 on [the admin page](/admin/index.md)). Keys are cleared from memory on sign-out, on an environment change, when the tab has been hidden for five minutes, and on `beforeunload`. ## New device ``` sign in ──▶ fetch keyring.json ──▶ unlock with a synced passkey, a cross-device passkey (QR), or the recovery code ──▶ register a new passkey on this device ──▶ add a wrapped-KEK entry for it; bump rev; write with an if-generation-match precondition ``` Lose every passkey and the recovery code, and the data is gone. There is no reset, because nothing that could reset it exists anywhere but your authenticator and your note of the code. The site will say this on the setup page in the same words. ## Admin ``` open /admin/ ──▶ choose an environment ──▶ "Sign in with Google for admin" (OAuth, scope cloud-platform, token held in memory only) ──▶ each check calls a GCP API: exists / enabled / configured / Fix ``` There is no admin role in the app. The GCP project's IAM is the role; the pages are a client for GCP's APIs. An admin page can list users, deploy rules and change CORS. It cannot open a keyring, because a keyring is ciphertext and the admin's token unlocks nothing. ## What each party can see | Step | Google (Identity Platform, bucket) | GitHub (the site) | Your authenticator | Your browser | |---|---|---|---|---| | Sign in | Your email, the sign-in event, your uid | Nothing (static files) | Nothing | An ID token | | Fetch keyring | That uid read that object | Nothing | Nothing | Ciphertext | | Passkey gesture | Nothing | Nothing | The PRF secret for this credential and this origin | 32 bytes of PRF output, briefly | | Unlock | Nothing | Nothing | Nothing | The KEK and the plaintext body, in memory | | Write | New ciphertext, the object's size and time | Nothing | Nothing | Everything it already had | The one party absent from that table is whoever serves the JavaScript. The code your browser runs is the boundary of the whole design, which is why the [security page](/security/index.md) is mostly about this repository. ## Read next - [The keyring format](/keyring/index.md): what is in the file, what is encrypted under what, and why the recovery code is long. - [Security](/security/index.md): the threat model and the RP ID decision. - [Environments](/environments/index.md): one site, one GCP project per environment, and how a customer runs their own. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/how-it-works/)* ============================================================================== source: /keyring/index.md ============================================================================== # Keyring format, version 1 > The specification of the encrypted keyring: objects in the bucket, the key hierarchy, keyring.json, the decrypted body, entry kinds and limits, concurrency, and the passkey parameters. Version 1, proposed, with known-answer fixtures to come. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- Status, from [/shipped/](/shipped/index.md): proposed Keyring v1 format: wraps per unlock method, AES-256-GCM body, known-answer tests · proposed Recovery code: 26 characters base32, 128 bits, shown once · proposed Optimistic concurrency on keyring writes with a three-way merge · proposed Entries: six kinds kept apart, vault list, entry page, copy and reveal, lock timers The keyring is one encrypted file in the user's own prefix of a Cloud Storage bucket. Google stores it and cannot read it. This page is the file format, published as a specification so that a reader can check the code against it and so that another implementation could open the same file. It is version 1 and it is proposed: the known-answer fixtures in `tests/fixtures/keyring-v1/` and the crypto probe page will make it checkable when step 4 ships. ## Objects in the bucket ``` users/{uid}/keyring.json the encrypted keyring (one object, versioned by GCS) users/{uid}/meta.json { "v":1, "createdAt", "keyringRev", "devices":[{"id","name","createdAt","lastUsedAt"}] } not secret directory/{uid}.pub.json the user's public-key bundle, signed (phase 1 writes it; phase 2 reads it) inbox/{uid}/{shareId}.json keys encrypted to this user (phase 2) ``` ## Key hierarchy ``` passkey PRF output (32 bytes, from the authenticator, never stored) └─ HKDF-SHA256(ikm = prf, salt = keyring.prfSalt, info = "sgit-secrets/v1/wrap/") → WK_passkey (AES-256-GCM key) recovery code (26 characters of base32, 128 bits, shown once) └─ HKDF-SHA256(ikm = code bytes, salt = keyring.prfSalt, info = "sgit-secrets/v1/wrap/recovery") → WK_recovery KEK (32 random bytes) wrapped once per unlock method, under a WK_* └─ decrypts the body body (AES-256-GCM under the KEK) { keys, entries[] } ``` **The PRF salt.** `keyring.prfSalt` is 32 random bytes, fixed for the keyring's life, passed to every passkey as `prf.eval.first`. One salt serves all passkeys; the per-credential HKDF `info` keeps the wrapping keys apart. The authenticator's PRF is already per-credential, so the info string is belt and braces. **The recovery code and why it is long.** HKDF alone is fast, so an attacker who holds the keyring could brute-force a weak code. A code of 128 bits of entropy makes that irrelevant, which is why it is long and machine-generated and never chosen by the user. The design deliberately does not add PBKDF2 "for safety": it would only slow a legitimate recovery. ## keyring.json ``` { "v": 1, "keyringId": "uuid", "rev": 7, "prfSalt": "base64(32 bytes)", "wraps": [ { "id": "pk-", "kind": "passkey", "name": "MacBook Chrome", "createdAt": "…", "iv": "b64", "ct": "b64" }, { "id": "recovery-1", "kind": "recovery", "createdAt": "…", "iv": "b64", "ct": "b64" } ], "body": { "iv": "b64", "ct": "b64", "aad": "keyringId|rev" } } ``` - `wraps[].ct` is AES-256-GCM of the 32-byte KEK under the wrap's WK, with additional authenticated data `keyringId|wrap.id`. - `body.ct` is AES-256-GCM of the UTF-8 JSON body under the KEK, with additional authenticated data `keyringId|rev`, so a body cannot be transplanted between keyrings or revisions: a stale body fails to decrypt rather than silently winning. - Every `iv` is 12 random bytes, fresh per encryption. ## The decrypted body ``` { "v": 1, "keys": { "encrypt": { "alg": "RSA-OAEP-4096", "jwk": { "…private…" } }, "sign": { "alg": "ECDSA-P256", "jwk": { "…private…" } } }, "entries": [ { "id": "uuid", "kind": "password", "title": "…", "createdAt": "…", "updatedAt": "…", "fields": { "username": "…", "password": "…", "url": "…", "notes": "…" }, "tags": [] } ] } ``` The key pair is generated at first run and lives in the body from the first keyring, even though nothing uses it until phase 2. Its algorithms match `sgit pki` (RSA-OAEP 4096 for encryption, ECDSA P-256 for signing) so that a phase-2 share envelope can be the sgit hybrid envelope and `sgit pki decrypt` can open what a browser sealed. Whether `sgit pki import` accepts a browser-generated bundle is an open question recorded in [brief-corrections.md](/docs/design/brief-corrections.md). ## Entry kinds Six kinds, a closed list, kept apart so that a vault key is never mistaken for a password: `password`, `api-key`, `sgit-vault-key`, `sgit-read-key`, `pki-private-key`, `note`. Each has its own fields and its own reveal behaviour. An `sgit-vault-key` entry shows its prefix (`sgit_private_vault_…`) and will refuse to enter a shareable set without a confirmation. Entries are small: a soft limit of 16 KB each and 1 MB for the keyring. A `document` kind is **absent**: documents will be a pointer to an sgit vault plus that vault's key, never bytes in the keyring. ## Concurrency Writes carry a GCS precondition, `x-goog-if-generation-match`, against the generation read at unlock. On a mismatch the page re-fetches, re-unlocks with the KEK already in memory (no new gesture), merges entries three ways by `id` and `updatedAt`, bumps `rev`, and retries once; after that it shows a conflict page rather than guessing. ## Passkey parameters | Call | Parameters | |---|---| | `create` | `rp: { id: 'secrets.sgit.ai', name: 'secrets.sgit.ai' }`; `user.id` is 32 random bytes stored in `meta.json`, not the Firebase uid; `pubKeyCredParams` ES256 then RS256; `authenticatorSelection: { residentKey: 'required', userVerification: 'required' }`; `extensions: { prf: {} }`. If `prf.enabled` comes back false, the page says this authenticator cannot unlock and offers the recovery code or another authenticator. | | `get` | `rpId`; `allowCredentials` from `meta.json`; `userVerification: 'required'`; `extensions: { prf: { eval: { first: prfSalt } } }`; read `getClientExtensionResults().prf.results.first`. | Known support, dated 2026-10-05 and to be re-checked on the probe page: Chrome and Edge on macOS, Windows and Android with Google Password Manager; Safari 18 and later with iCloud Keychain; hardware keys with hmac-secret. The matrix page will be built before anything more is promised. ## Versioning this specification The format carries `"v": 1` at the top and in the body. A later version will be a new section on this page, and a keyring will say which version it is before any key is derived. Nothing in version 1 is frozen until the known-answer fixtures exist and the crypto probe page checks them; until then this is the brief's section 8, restated. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/keyring/)* ============================================================================== source: /security/index.md ============================================================================== # Security > The threat model: what each compromised party gets and does not get. Why the passkey RP ID is exactly secrets.sgit.ai and never sgit.ai. Why the code served to your browser is the boundary, and what we ask you to trust. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- Status, from [/shipped/](/shipped/index.md): proposed Branch protection, hardware-key 2FA, verified domain and the Actions policy in place and dated on /security/ · proposed Acceptance: an Owner of the dev project is handed a uid and asked to produce one plaintext field · proposed Passkey with WebAuthn PRF derives the keyring wrapping key; RP ID secrets.sgit.ai This page says what the design withholds from whom, and then says plainly what it cannot withhold. It describes a design. The acceptance test that will check it, an Owner of the GCP project being handed a uid and asked to produce one plaintext field, is listed as proposed above and its write-up will be published whatever the result. ## What a compromised party gets | Party compromised | Gets | Does not get | |---|---|---| | GCP project, or the Identity Platform admin | User emails, login metadata, ciphertext, the ability to delete or roll back, the ability to log in as anyone | Any plaintext. The PRF output is bound to the origin and to the user's authenticator; logging in as the user fetches ciphertext and nothing to open it with | | Bucket reader | Ciphertext, object sizes and times | Plaintext | | Terraform pipeline | Can change the rules, delete the bucket | Plaintext | | Public-key directory tamperer (phase 2) | Future shares, unless fingerprints or signatures are checked | Existing keys | | **This repository, or the DNS for sgit.ai** | **Everything, for the users who load the malicious page while it is served** | Nothing is withheld | ## The code is the boundary Client-side cryptography is exactly as trustworthy as the code delivered to the browser. If this repository, the GitHub organisation that owns it, or the DNS zone for `sgit.ai` is compromised, the attacker can serve a page that asks for your passkey gesture and sends the plaintext wherever they like. No amount of cloud hardening changes that, and the design does not pretend otherwise. What it does instead is keep the site on a host that is separate from the GCP project, so that a cloud compromise does not reach the code, and put every guard it can on the repository: | Guard | What it stops | In place? | |---|---|---| | `dev` and `main` protected: pull request required, one review, `validate` required, linear history, no force-push, no bypass for administrators | A single account pushing code to users | Unconfirmed as of 2026-10-05; asked for in [needs.md](/docs/ops/needs.md) | | Hardware-key two-factor authentication for every organisation member | A phished password becoming a push | Unconfirmed as of 2026-10-05 | | `sgit.ai` verified as an organisation domain | Another account claiming a dangling `*.sgit.ai` subdomain on Pages | Unconfirmed as of 2026-10-05 | | Every third-party action pinned by commit SHA; minimal `permissions` per job; Actions may not approve pull requests | A compromised action or token widening its own reach | Pins and permissions: yes, since v0.1.0. Organisation policy: unconfirmed | | No build step; every dependency vendored and hashed; the gate refuses any script from another origin | A supply-chain change arriving at runtime without a reviewed diff | Yes, since v0.1.0 (check 5 of the gate) | | A leak tripwire over every file on every release | A credential entering the public tree | Yes, since v0.1.0 (check 4) | | Registrar lock, DNSSEC and a CAA record on `sgit.ai` | The zone being moved or a rogue certificate issued | The DNS owner's decision; unconfirmed as of 2026-10-05 | | A Content-Security-Policy on every page: `script-src 'self'`, `style-src 'self'`, `object-src 'none'`, `base-uri 'none'`, `form-action 'none'` | An injected script or form, if some other flaw let one in | Yes, since v0.1.0, on every page; `frame-ancestors` cannot be set in a meta tag, so app pages will add a frame-busting check | Each "unconfirmed" row flips to a dated "yes" when the person who can check it has. The repository's own copy of these settings is [docs/ops/branch-protection.md](/docs/ops/branch-protection.md). ## The RP ID is secrets.sgit.ai, never sgit.ai A WebAuthn passkey is scoped to a relying-party identifier, and the PRF secret it returns is derived per credential and per RP ID. The RP ID for the unlock passkey is exactly `secrets.sgit.ai` (and `localhost` when testing locally). It is never the apex `sgit.ai`, and this is the single most important decision in the design. A passkey scoped to the apex can be asserted by any page on any subdomain: there are more than twenty-seven sibling sites under `sgit.ai`, each in its own repository, and a compromise of any one of them would then be able to ask for the gesture that unlocks every user's secrets here. Scoping to this host means a sibling's compromise is a sibling's problem. Consumers such as riskmandate.ai will use their own passkeys, or WebAuthn Related Origin Requests, later. Neither is in the MVP. ## What the passkey is, and is not, used for The passkey is a plain WebAuthn credential registered by this site's own page. It is **not** an Identity Platform passkey and it is not a login. The page never verifies the assertion signature, because there is no server to verify it for: the proof that matters is that the PRF output unwraps the keyring key, and a wrong authenticator produces bytes that unwrap nothing. Login is a separate step, through Identity Platform, and decides only which bucket paths the browser may read and write. The credential's id and public key are stored in `meta.json` so the page can list devices and build `allowCredentials`. The WebAuthn user handle is 32 random bytes, not the Firebase uid. ## What we ask you to trust - **Your authenticator.** Google Password Manager, iCloud Keychain or a hardware key holds the PRF secret and syncs it through your personal account, which no administrator of this service can reach. - **Your browser's WebCrypto.** AES-256-GCM, HKDF-SHA256, RSA-OAEP and ECDSA come from the browser; the site vendors no cryptographic library. - **This repository, as served.** Everything above this heading is about keeping that trust narrow and visible. You can read every line that will run, and the gate publishes what it checks. - **Google, for availability and metadata only.** Google can see who signed in and when, can delete or roll back ciphertext, and can refuse service. Bucket versioning and soft-delete retention are the design's answer to deletion; export of the encrypted keyring from the account page is yours. ## What this page does not claim - That the system exists. See [/shipped/](/shipped/index.md). - That the Security Rules are correct. The rules text is in the repository and hashed; it is deployed to nothing yet, and its syntax is marked "verify first" in the brief. - That the compatibility of PRF across authenticators is known. The matrix page (proposed) will say which browsers and authenticators returned PRF output, dated. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/security/)* ============================================================================== source: /sharing/index.md ============================================================================== # Sharing > The sharing scheme: a key pair per user, a public-key directory, an inbox of keys encrypted to the recipient. Phase 2 for the user interface; the data model ships in phase 1 so it is never rewritten. Proposed. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- Status, from [/shipped/](/shipped/index.md): proposed Key pair per user generated at first run; public bundle written to directory/ · absent Sharing an entry with another user through their inbox Single-user wrapping works until a secret must be readable by a second person. You cannot wrap it with their passkey, because their PRF secret never leaves their device, and you cannot send it through the server in plaintext. The answer is a key pair per user. The user interface for sharing is **phase 2 and absent from the MVP**; the data model is phase 1, because adding it later would mean rewriting every keyring. ## The scheme 1. At first run each user's browser generates a key pair (RSA-OAEP 4096 for encryption, ECDSA P-256 for signing). The private keys go into the keyring body, protected by the passkey like everything else. 2. The public keys are published as a signed bundle at `directory/{uid}.pub.json`, readable by any signed-in user of the same environment. 3. To share, your browser fetches the colleague's bundle, encrypts the entry's key to their public key, and drops the result in `inbox/{uid}/{shareId}.json`. Any signed-in user may create an inbox object; only the owner may read or delete one. 4. On their next unlock, their browser decrypts the inbox item with their private key and adds it to their own keyring. The public key shares *keys*, not data. The server carries the package and can never read it. ## What it drags in, designed up front | Concern | Why it matters | Approach | |---|---|---| | Revocation | A removed member already holds the key | Rotate the key, re-encrypt, re-share to the remaining members | | Directory trust | A compromised admin could swap a public key and intercept the next share | Signed bundles, and fingerprints users can compare out of band before a sensitive share | | Group membership | Who can open what is real metadata, visible to the bucket | Membership stored per shared set; updated on add and remove; accepted as metadata exposure | | Kinds kept apart | A vault key shared by mistake opens a whole vault | An `sgit-vault-key` entry refuses to enter a shareable set without a confirmation | ## Fit with sgit sgit already supports public and private keys (`sgit pki`) and is designed to store encrypted data on top of its own encrypted data. The keyring, the per-user key pairs and the inbox are applications of that capability, not new machinery. The public bundle will use sgit's JSON bundle shape so that `sgit pki import` can read what a browser published and `sgit pki decrypt` can open what a browser sealed. Both are marked "verify first" and recorded as open in [brief-corrections.md](/docs/design/brief-corrections.md). ## What ships when - **Step 4**: the key pair is generated and stored in the first keyring. - **Step 9**: the public bundle is written to `directory/` and the inbox rules go live, with the sharing interface still proposed. - **Phase 2**: the interface, revocation and fingerprints. A later version, not the MVP. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/sharing/)* ============================================================================== source: /shipped/index.md ============================================================================== # Shipped, proposed, absent > Every claim this site makes, by status: shipped (exists and runs, with the version), proposed (designed, not built), absent (deliberately not in the MVP). Generated from data/features.json on every release; the same data produces docs/reality.md. *Source: · site v0.1.2 (2026-10-05) · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.* --- This site publishes its argument before the thing is finished. To keep that honest, every claim it makes carries one of three statuses, and this page is the list. It is generated from `data/features.json` on every release, and so is [docs/reality.md](/docs/reality.md), so the two cannot disagree. Nothing is described in the present tense anywhere on the site before this page says shipped. A *shipped* row names the version that shipped it and where in the repository it lives. A *proposed* row points at the section of the brief that designs it. An *absent* row says why it is not in the MVP and, where there is one, what comes instead. ## Shipped (23) exists in the repository, runs, and was exercised at the version shown. | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | pipeline | One version in admin/build/version.txt, repeated in every badge, twin, index and config | shipped v0.1.0 | admin/build/version.txt | Check 1 of the gate fails on any disagreement. | | pipeline | Chrome (head, nav, footer, version badge, CSP, canonical) generated into every page from one definition | shipped v0.1.0 | admin/build/chrome.py | gen_chrome.py --check is part of the gate. | | pipeline | Markdown twin of every HTML page, links pointing at markdown | shipped v0.1.0 | admin/build/gen_twins.py | index.html has index.md beside it; the twin carries the site version. | | pipeline | llms.txt, llms-full.txt and sitemap.xml generated on every release | shipped v0.1.0 | admin/build/gen_llms.py | llms-full.txt concatenates every twin and every design document. | | pipeline | docs/reality.md and the status tables generated from data/features.json | shipped v0.1.0 | admin/build/gen_features.py | If the reality document does not list it, it does not exist. | | pipeline | The eight-check release gate, no dependencies | shipped v0.1.0 | admin/build/validate.js | Version agreement, internal links, canonical host, leak tripwire, vendor manifest, rules in sync, reality, storage keys. | | pipeline | The same gate locally and in CI, one command | shipped v0.1.0 | admin/build/gate.py | The CI validate job runs gate.py; a release that fails locally fails the same way in CI. | | pipeline | Every push to dev is a release: version.txt and the commit subject agree, CI tags it | shipped v0.1.0 | admin/build/tag_release.py | Anchors on the newest commit whose subject is 'site vX.Y.Z : ...', asserts the next minor or patch or major .0, backfills missing tags. | | pipeline | Deploy to GitHub Pages from the validated tree | shipped v0.1.0 | .github/workflows/deploy-pages.yml | Excludes .git, .github, infra, tests/unit and node_modules. Actions pinned by commit SHA. | | pipeline | verify-live: the run is red until the live site serves the released version | shipped v0.1.0 | admin/build/verify_live.py | Green does not mean live. Polls version.txt and the homepage badge for up to ten minutes. | | pipeline | Every third-party file vendored and hashed in vendor/MANIFEST.json; no runtime script from another origin | shipped v0.1.0 | vendor/MANIFEST.json | Check 5 of the gate. Today the only vendored file is the family design tokens. | | site | secrets.sgit.ai served by GitHub Pages over HTTPS | shipped v0.1.0 | CNAME, docs/ops/dns.md | verify-live passed on the v0.1.0 run (attempt 2, 2026-10-05) after the DNS record and Pages settings were made. | | site | Content pages: how it works, security, keyring spec, sharing, environments | shipped v0.1.1 | how-it-works/, security/, keyring/, sharing/, environments/ | Every page opens with a status line generated from this file and describes only designs in the future tense. | | site | /shipped/ generated from this file, one table per status, beside docs/reality.md | shipped v0.1.1 | shipped/index.html | The same generator writes both, so the page and the reality document cannot disagree. | | site | The five design documents published verbatim | shipped v0.1.0 | docs/design/ | The markdown is the source of truth; since v0.2.0 each is also rendered to an HTML page beside it. | | site | Every markdown document under docs/ rendered to HTML on each release, with index pages | shipped v0.1.1 | admin/build/gen_docs.py, admin/build/md_to_html.py | Standard-library renderer; the markdown stays the twin. gen_docs --check is part of the gate. | | site | The family nav: grouped menus with dropdowns, part-of-sgit.ai link, stage pill, phone menu, breadcrumbs | shipped v0.1.1 | admin/build/chrome.py, assets/nav.js | The shape sgit.ai, nfrs.sgit.ai and pki.sgit.ai run; works with no JavaScript because every group label is a link. | | admin | Comms page: the asks back to the project lead and the nine build steps with status, from data/steps.json | shipped v0.1.1 | admin/comms.html | gen_versions.py renders the step tracker; a done step must name a release that exists. | | site | brief-corrections.md: what the brief got wrong, dated, beside it | shipped v0.1.0 | docs/design/brief-corrections.md | Appended to as the build finds out. | | site | docs/ops/needs.md: exactly what only a human can do | shipped v0.1.0 | docs/ops/needs.md | DNS, Pages, branch protection, GCP bootstrap, OAuth client secret. | | admin | Release history page generated from data/versions.json | shipped v0.1.0 | admin/versions.html | One row per release; the newest row must equal version.txt. | | tests | Unit tests under node --test, real WebCrypto, no mocks | shipped v0.1.0 | tests/unit/ | At this version: the gate's own checks against fake fixtures. Keyring tests come with step 4. | | tests | Build tests: the generators run on the real tree, chrome in every page, twins exist, features schema | shipped v0.1.0 | tests/build/ | pytest, TestCase classes, no mocks. | ## Proposed (22) designed in the brief, not built; described only in the future tense. | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | site | Branch protection, hardware-key 2FA, verified domain and the Actions policy in place and dated on /security/ | proposed | docs/ops/branch-protection.md | Asked for in docs/ops/needs.md. Each row on /security/ flips to a dated yes when confirmed. | | infra | Bootstrap script for the tfstate project, env projects, Terraform service account and WIF pool | proposed | infra/bootstrap/bootstrap.sh | Step 3. Run once by a human; idempotent; --dry-run. | | infra | Terraform module secrets-env and the dev environment root | proposed | infra/terraform/ | Step 3. Identity Platform, Firebase web app, bucket, rules release, IAM, WIF. | | infra | infra.yml: plan on PR, apply on dispatch and on push to dev for dev | proposed | .github/workflows/infra.yml | Step 3. Workload Identity Federation, no JSON keys. | | infra | Storage Security Rules deployed per environment | proposed | infra/rules/storage.rules | The rules text is in the repository and hashed; nothing is deployed yet. Syntax to verify on a real project. | | infra | config/environments.json with real dev values from Terraform outputs | proposed | config/environments.json | Step 3. Today every environment is a placeholder with _source null. | | app | Sign in and out with Google and email/password against the chosen environment | proposed | app/index.html | Step 3. Popup sign-in by default; vendored Firebase SDK. | | app | Environment page: pick a built-in environment, enter a custom one, import, export, reset | proposed | app/environment.html | Step 3. The active environment is shown in the header at all times. | | app | Passkey with WebAuthn PRF derives the keyring wrapping key; RP ID secrets.sgit.ai | proposed | app/setup.html, app/unlock.html | Step 4. Not before the dev project exists and the probe pages are green. | | app | Keyring v1 format: wraps per unlock method, AES-256-GCM body, known-answer tests | proposed | section 8 of the brief | Step 4. Published at /keyring/ as a specification. | | app | Recovery code: 26 characters base32, 128 bits, shown once | proposed | app/setup.html | Step 4. Lose every passkey and the code and the data is gone; the site will say so. | | app | Entries: six kinds kept apart, vault list, entry page, copy and reveal, lock timers | proposed | app/vault.html, app/entry.html | Step 5. Kinds: password, api-key, sgit-vault-key, sgit-read-key, pki-private-key, note. | | app | Optimistic concurrency on keyring writes with a three-way merge | proposed | section 8.5 of the brief | Step 5. rev in the AAD so a stale body fails to decrypt. | | app | Devices page: add and remove passkeys, regenerate the recovery code | proposed | app/devices.html | Step 6. | | app | Account page: export and import the encrypted keyring, sign out, wipe memory | proposed | app/account.html | Step 6. | | app | Key pair per user generated at first run; public bundle written to directory/ | proposed | section 8.3 of the brief | Step 4 generates the keys; step 9 writes the public bundle. Phase 1 data model for phase 2 sharing. | | admin | Admin sign-in with the visitor's own Google account, token in memory only | proposed | admin/oauth.js | Step 7. Implicit flow to verify first; PKCE fallback. | | admin | Setup checklist: every per-project resource as a row, with Fix where fixable client-side | proposed | admin/setup-checklist.html | Step 3 read-only, step 7 with fixes. | | admin | Auth config, storage, rules diff and deploy, users, environment export | proposed | admin/*.html | Step 7. GCP IAM is the role; the pages are a client for GCP's own APIs. | | tests | Browser probe pages: webauthn-prf, crypto, config, auth, storage, keyring-roundtrip, offline, leak-check, matrix | proposed | tests/*.html | Steps 3 to 6. Each prints PASS/FAIL/SKIP with the raw values; together they are the compatibility matrix. | | tests | Playwright end to end against the real dev project with a virtual authenticator | proposed | tests/e2e/ | Whether the virtual authenticator does PRF is an open question. | | tests | Acceptance: an Owner of the dev project is handed a uid and asked to produce one plaintext field | proposed | docs/acceptance.md | Before 1.0. The write-up is published whatever the result. | ## Absent (5) deliberately not in the MVP; comes as a later version or a later site, or never. | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | app | Sharing an entry with another user through their inbox | absent | /sharing/ (step 2 describes it) | Phase 2. The data model ships first so it is not rewritten later. | | app | A document kind in the keyring | absent | section 8.4 of the brief | Documents will be a pointer to an sgit vault plus that vault's key, not bytes in the keyring. | | app | Browser extension or autofill | absent | section 12 of the brief | A later site or a later version. | | app | An API for agents | absent | section 12 of the brief | A later version. | | app | Any server-side code: Cloud Functions, proxies, a small API | absent | everywhere | Forbidden by design. A need for one is a proposal in brief-corrections.md, not code. | ## How a row changes status A feature becomes shipped in the release that makes it true, by editing one row of `data/features.json` in the same commit as the code. The gate refuses a release whose generated pages disagree with that file, and the build tests check that every shipped row carries a version no newer than the release. There is no other way to change a status. --- *[Site index for agents](/llms.txt) · [What is real](/docs/reality.md) · [HTML version](https://secrets.sgit.ai/shipped/)* ============================================================================== source: /docs/reality.md ============================================================================== # secrets.sgit.ai — reality *Generated from `data/features.json` by `admin/build/gen_features.py` at site v0.1.2 (2026-10-05). If the reality document does not list it, it does not exist. Briefs are aspirations; this file is the fact.* 50 claims: 23 shipped, 22 proposed, 5 absent. | Status | Meaning | |---|---| | shipped | exists in the repository, runs, and was exercised at the version shown | | proposed | designed in the brief, not built; described only in the future tense | | absent | deliberately not in the MVP; comes as a later version or a later site, or never | ## Shipped (23) | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | pipeline | One version in admin/build/version.txt, repeated in every badge, twin, index and config | shipped v0.1.0 | admin/build/version.txt | Check 1 of the gate fails on any disagreement. | | pipeline | Chrome (head, nav, footer, version badge, CSP, canonical) generated into every page from one definition | shipped v0.1.0 | admin/build/chrome.py | gen_chrome.py --check is part of the gate. | | pipeline | Markdown twin of every HTML page, links pointing at markdown | shipped v0.1.0 | admin/build/gen_twins.py | index.html has index.md beside it; the twin carries the site version. | | pipeline | llms.txt, llms-full.txt and sitemap.xml generated on every release | shipped v0.1.0 | admin/build/gen_llms.py | llms-full.txt concatenates every twin and every design document. | | pipeline | docs/reality.md and the status tables generated from data/features.json | shipped v0.1.0 | admin/build/gen_features.py | If the reality document does not list it, it does not exist. | | pipeline | The eight-check release gate, no dependencies | shipped v0.1.0 | admin/build/validate.js | Version agreement, internal links, canonical host, leak tripwire, vendor manifest, rules in sync, reality, storage keys. | | pipeline | The same gate locally and in CI, one command | shipped v0.1.0 | admin/build/gate.py | The CI validate job runs gate.py; a release that fails locally fails the same way in CI. | | pipeline | Every push to dev is a release: version.txt and the commit subject agree, CI tags it | shipped v0.1.0 | admin/build/tag_release.py | Anchors on the newest commit whose subject is 'site vX.Y.Z : ...', asserts the next minor or patch or major .0, backfills missing tags. | | pipeline | Deploy to GitHub Pages from the validated tree | shipped v0.1.0 | .github/workflows/deploy-pages.yml | Excludes .git, .github, infra, tests/unit and node_modules. Actions pinned by commit SHA. | | pipeline | verify-live: the run is red until the live site serves the released version | shipped v0.1.0 | admin/build/verify_live.py | Green does not mean live. Polls version.txt and the homepage badge for up to ten minutes. | | pipeline | Every third-party file vendored and hashed in vendor/MANIFEST.json; no runtime script from another origin | shipped v0.1.0 | vendor/MANIFEST.json | Check 5 of the gate. Today the only vendored file is the family design tokens. | | site | secrets.sgit.ai served by GitHub Pages over HTTPS | shipped v0.1.0 | CNAME, docs/ops/dns.md | verify-live passed on the v0.1.0 run (attempt 2, 2026-10-05) after the DNS record and Pages settings were made. | | site | Content pages: how it works, security, keyring spec, sharing, environments | shipped v0.1.1 | how-it-works/, security/, keyring/, sharing/, environments/ | Every page opens with a status line generated from this file and describes only designs in the future tense. | | site | /shipped/ generated from this file, one table per status, beside docs/reality.md | shipped v0.1.1 | shipped/index.html | The same generator writes both, so the page and the reality document cannot disagree. | | site | The five design documents published verbatim | shipped v0.1.0 | docs/design/ | The markdown is the source of truth; since v0.2.0 each is also rendered to an HTML page beside it. | | site | Every markdown document under docs/ rendered to HTML on each release, with index pages | shipped v0.1.1 | admin/build/gen_docs.py, admin/build/md_to_html.py | Standard-library renderer; the markdown stays the twin. gen_docs --check is part of the gate. | | site | The family nav: grouped menus with dropdowns, part-of-sgit.ai link, stage pill, phone menu, breadcrumbs | shipped v0.1.1 | admin/build/chrome.py, assets/nav.js | The shape sgit.ai, nfrs.sgit.ai and pki.sgit.ai run; works with no JavaScript because every group label is a link. | | admin | Comms page: the asks back to the project lead and the nine build steps with status, from data/steps.json | shipped v0.1.1 | admin/comms.html | gen_versions.py renders the step tracker; a done step must name a release that exists. | | site | brief-corrections.md: what the brief got wrong, dated, beside it | shipped v0.1.0 | docs/design/brief-corrections.md | Appended to as the build finds out. | | site | docs/ops/needs.md: exactly what only a human can do | shipped v0.1.0 | docs/ops/needs.md | DNS, Pages, branch protection, GCP bootstrap, OAuth client secret. | | admin | Release history page generated from data/versions.json | shipped v0.1.0 | admin/versions.html | One row per release; the newest row must equal version.txt. | | tests | Unit tests under node --test, real WebCrypto, no mocks | shipped v0.1.0 | tests/unit/ | At this version: the gate's own checks against fake fixtures. Keyring tests come with step 4. | | tests | Build tests: the generators run on the real tree, chrome in every page, twins exist, features schema | shipped v0.1.0 | tests/build/ | pytest, TestCase classes, no mocks. | ## Proposed (22) | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | site | Branch protection, hardware-key 2FA, verified domain and the Actions policy in place and dated on /security/ | proposed | docs/ops/branch-protection.md | Asked for in docs/ops/needs.md. Each row on /security/ flips to a dated yes when confirmed. | | infra | Bootstrap script for the tfstate project, env projects, Terraform service account and WIF pool | proposed | infra/bootstrap/bootstrap.sh | Step 3. Run once by a human; idempotent; --dry-run. | | infra | Terraform module secrets-env and the dev environment root | proposed | infra/terraform/ | Step 3. Identity Platform, Firebase web app, bucket, rules release, IAM, WIF. | | infra | infra.yml: plan on PR, apply on dispatch and on push to dev for dev | proposed | .github/workflows/infra.yml | Step 3. Workload Identity Federation, no JSON keys. | | infra | Storage Security Rules deployed per environment | proposed | infra/rules/storage.rules | The rules text is in the repository and hashed; nothing is deployed yet. Syntax to verify on a real project. | | infra | config/environments.json with real dev values from Terraform outputs | proposed | config/environments.json | Step 3. Today every environment is a placeholder with _source null. | | app | Sign in and out with Google and email/password against the chosen environment | proposed | app/index.html | Step 3. Popup sign-in by default; vendored Firebase SDK. | | app | Environment page: pick a built-in environment, enter a custom one, import, export, reset | proposed | app/environment.html | Step 3. The active environment is shown in the header at all times. | | app | Passkey with WebAuthn PRF derives the keyring wrapping key; RP ID secrets.sgit.ai | proposed | app/setup.html, app/unlock.html | Step 4. Not before the dev project exists and the probe pages are green. | | app | Keyring v1 format: wraps per unlock method, AES-256-GCM body, known-answer tests | proposed | section 8 of the brief | Step 4. Published at /keyring/ as a specification. | | app | Recovery code: 26 characters base32, 128 bits, shown once | proposed | app/setup.html | Step 4. Lose every passkey and the code and the data is gone; the site will say so. | | app | Entries: six kinds kept apart, vault list, entry page, copy and reveal, lock timers | proposed | app/vault.html, app/entry.html | Step 5. Kinds: password, api-key, sgit-vault-key, sgit-read-key, pki-private-key, note. | | app | Optimistic concurrency on keyring writes with a three-way merge | proposed | section 8.5 of the brief | Step 5. rev in the AAD so a stale body fails to decrypt. | | app | Devices page: add and remove passkeys, regenerate the recovery code | proposed | app/devices.html | Step 6. | | app | Account page: export and import the encrypted keyring, sign out, wipe memory | proposed | app/account.html | Step 6. | | app | Key pair per user generated at first run; public bundle written to directory/ | proposed | section 8.3 of the brief | Step 4 generates the keys; step 9 writes the public bundle. Phase 1 data model for phase 2 sharing. | | admin | Admin sign-in with the visitor's own Google account, token in memory only | proposed | admin/oauth.js | Step 7. Implicit flow to verify first; PKCE fallback. | | admin | Setup checklist: every per-project resource as a row, with Fix where fixable client-side | proposed | admin/setup-checklist.html | Step 3 read-only, step 7 with fixes. | | admin | Auth config, storage, rules diff and deploy, users, environment export | proposed | admin/*.html | Step 7. GCP IAM is the role; the pages are a client for GCP's own APIs. | | tests | Browser probe pages: webauthn-prf, crypto, config, auth, storage, keyring-roundtrip, offline, leak-check, matrix | proposed | tests/*.html | Steps 3 to 6. Each prints PASS/FAIL/SKIP with the raw values; together they are the compatibility matrix. | | tests | Playwright end to end against the real dev project with a virtual authenticator | proposed | tests/e2e/ | Whether the virtual authenticator does PRF is an open question. | | tests | Acceptance: an Owner of the dev project is handed a uid and asked to produce one plaintext field | proposed | docs/acceptance.md | Before 1.0. The write-up is published whatever the result. | ## Absent (5) | Area | Feature | Status | Where | Notes | |---|---|---|---|---| | app | Sharing an entry with another user through their inbox | absent | /sharing/ (step 2 describes it) | Phase 2. The data model ships first so it is not rewritten later. | | app | A document kind in the keyring | absent | section 8.4 of the brief | Documents will be a pointer to an sgit vault plus that vault's key, not bytes in the keyring. | | app | Browser extension or autofill | absent | section 12 of the brief | A later site or a later version. | | app | An API for agents | absent | section 12 of the brief | A later version. | | app | Any server-side code: Cloud Functions, proxies, a small API | absent | everywhere | Forbidden by design. A need for one is a proposal in brief-corrections.md, not code. | Storage Security Rules source: `infra/rules/storage.rules`, `sha256:6caa0c5280ef2a5429e832ccef6dddc5e6cae5625ec284f895a516d221dae250` (the gate fails when the file and this hash disagree, so a rules change needs a release note). *Source: [https://github.com/SGit-AI/SGit-AI__Website__Secrets](https://github.com/SGit-AI/SGit-AI__Website__Secrets) · CC BY 4.0* ============================================================================== source: /docs/design/brief-corrections.md ============================================================================== # Brief corrections *secrets.sgit.ai · what the MVP build brief got wrong or left open, found while building, dated · beside the brief it corrects · CC BY 4.0* The brief (`secrets-sgit-ai__mvp-build-brief.md`) is the instruction set. Where building it showed a statement to be wrong, incomplete or impossible as written, the finding is recorded here with the date and the version, and the build carried on with the correction. Sections 1 to 4 of the brief hold closed decisions; nothing here reopens one. The design documents beside this file are copied verbatim and are never edited. ## v0.1.0 (2026-10-05): the pipeline before the site ### C1. "Type_Safe style" for the generators, without the library Section 7.2 asks for Python in Type_Safe style. `Type_Safe` is a runtime type system from `osbot-utils`, a dependency the gate would have to `pip install` before it could run. The brief also requires that "a release that fails the gate locally fails the same way in CI" and that `validate.js` has no dependencies. The generators therefore use the standard library only and keep the shape of the style (one class per file, `═══` banners instead of docstrings, aligned assignments, trailing comments carrying the reasoning, double-underscore class names such as `Site__Pages`, `Gen__Chrome`, `Tag__Release`), without the `Type_Safe` base class. The one Python dependency of the whole gate is `pytest`, which the brief itself names. If the house later publishes a stdlib-only `Type_Safe`, the base class can be added without changing a call site. ### C2. The generator filenames are the brief's, not the house's The house rule is "filename equals class name". The brief names the files `admin/build/chrome.py` and `admin/build/gen_*.py`, and the CI table runs `gen_*.py --check`. The brief wins on the filenames; the class inside each file follows the house naming. `admin/build/version.txt` sits beside them as the brief says. ### C3. Check 1 cannot mean "every `vX.Y.Z` string in these files" Section 9.3's first check says the version must equal `llms.txt`, `llms-full.txt` and `index.md`, and "no version listed twice". `llms-full.txt` concatenates every design document, and the brief itself lists `v0.1.0` to `v0.9.0` in its build-order table, so a literal reading fails forever. Check 1 reads each file's declared version at a fixed anchor (`Site version: vX.Y.Z` in the two indexes, `· site vX.Y.Z` in a twin's source line, `data-version="X.Y.Z"` on a badge, `"siteVersion"` in the config, the newest row of `data/versions.json`) and "listed twice" applies to the versions table. ### C4. The leak tripwire's patterns must not trip on the brief The brief lists its own tripwire shapes in section 9.3 (`sgit_private_vault_`, `GOCSPX-`, `"private_key_id"`, and so on), and it is copied in verbatim. Each pattern therefore requires the shape of a real value, not the bare prefix: a key body after `sgit_private_vault_`, ten or more characters after `GOCSPX-`, a colon after `"private_key_id"` (the JSON shape), and so on. This is a sharper pattern, not a wider one, and the brief's rule stands: the fix for a trip is a redaction. Firebase `AIza…` keys are not in the brief's list; the gate adds them as a shape that may appear only in `config/environments.json`, so a web API key pasted into a page is caught and sent to the one file where it belongs. ### C5. `infra/` is excluded from the deploy but `admin/rules.html` reads `infra/rules/storage.rules` Section 9.1 excludes `infra` from the Pages artifact. Section 6.3's `rules.html` diffs the deployed rules against "`infra/rules/storage.rules` in the repo". A page served from the site cannot read a file the deploy left out. Open until step 7; the likely resolution is that `gen_features.py` (which already hashes the rules file) also writes the rules text into `data/storage-rules.json` so the page reads a deployed copy whose hash the gate has already checked. Recorded here so the exclude list is not changed silently. ### C6. One Pages site, two deploying branches Section 9.1 deploys both `dev` and `main` to Pages. A repository has one Pages site, so a push to `main` replaces what `dev` deployed. This is consistent with `main` being a fallback promoted from `dev`, and `docs/ops/release.md` says so; it is not a separate `main` site. The `main` environment in `config/environments.json` is a GCP project the `main` branch is tested against, not a second hostname. ### C7. Branch protection could not precede the first release Section 9.5 says the protections are set on day one. The required status check is `validate`, which did not exist before the commit that created it, and the brief asks for that commit to be pushed to `dev` as release v0.1.0. v0.1.0 was therefore pushed directly to `dev`; the rules in `docs/ops/branch-protection.md` apply from the next release on, and `docs/ops/needs.md` asks for them. ### C8. `enablement` of Pages is not the workflow's to do `actions/configure-pages` can try to create the Pages site, but that needs a token with administration rights the workflow does not and should not hold. Enabling Pages with the custom domain is a human step (`docs/ops/needs.md`, item 2). Until it is done the `deploy` job fails at `configure-pages`, which is the intended, visible failure. ### C9. Check 8 covers `sessionStorage` and IndexedDB too Section 9.3 lists `localStorage.setItem(` only. The rules the session must never break name `sessionStorage` and IndexedDB as well. Check 8 also requires every `sessionStorage.setItem` key to be in `app/config/storage-keys.js` (the admin OAuth `state` is the only one listed, section 6.3) and fails on any use of `indexedDB` under `app/`, `components/` or `admin/`. `tests/leak-check.html` may scan IndexedDB, so `tests/` is exempt from that one rule. ### C10. Pinning actions by SHA from inside a scoped session The six actions in `deploy-pages.yml` are pinned to the commit SHAs of `actions/checkout@v4.2.2`, `actions/setup-node@v4.1.0`, `actions/setup-python@v5.3.0`, `actions/configure-pages@v5.0.0`, `actions/upload-pages-artifact@v3.0.1` and `actions/deploy-pages@v4.0.5`, resolved with `git ls-remote` against the public repositories on 2026-10-05. A reviewer can re-check each with `git ls-remote https://github.com/ refs/tags/`. ### C11. `node --test tests/unit/` is not a valid Node 22 invocation Section 9.1's validate job runs `node --test tests/unit/`. Node 22 treats a positional argument as a glob pattern for test files, not a directory, and fails with "Cannot find module .../tests/unit". The gate runs `node --test tests/unit/**/*.test.js`, which is the same intent in the form Node accepts. ## v0.1.1 (2026-10-05): the content pages and the family chrome ### C12. "The four design docs, rendered" became every document under docs/, rendered Section 6.1 lists `/docs/design/` as the design documents rendered. Rendering needs a markdown-to-HTML step, and the house has no build step and no dependency the gate could rely on, so `admin/build/md_to_html.py` is a standard-library renderer for the constructs these documents use (headings with GitHub-style ids, nested and task lists, tables with alignment, fenced code, blockquotes, inline emphasis, links). Once it existed, rendering only the design folder would have left the ops notes and `docs/reality.md` as raw markdown on the site, so `gen_docs.py` renders every document under `docs/` and writes an index page for `docs/design/` and `docs/ops/`. The markdown stays the source of truth and is the markdown twin of the rendered page (`` names it), so the twin rule holds without a second copy. ### C13. The 404 page is not in the sitemap or llms.txt Section 6.1 does not mention a not-found page. One exists (`404.html`, which GitHub Pages serves for a missing path) with the full chrome so the version badge and nav are there; it is excluded from `sitemap.xml` and `llms.txt` because it is not a page anyone navigates to. ### C14. The three guard rows on /security/ that this build cannot confirm Section 9.5 says the protections are "listed on `/security/`". Four of them (branch protection, organisation 2FA, the verified domain, the Actions policy) are organisation settings this session cannot read, so each row says "unconfirmed as of 2026-10-05" rather than yes or no, and `needs.md` asks for them. A reviewer who sets them changes the row to a dated yes in the same pull request. ### C15. Releases bump the third digit, not the second Section 9.1 says every push to `dev` is a minor bump and section 11 numbers the steps v0.1.0 to v0.9.0. Dinis decided on 2026-10-05 that each release bumps the third digit (v0.1.0, v0.1.1, v0.1.2, …), as the sibling sites do (sgit.ai is at v0.6.59 after hundreds of releases), and that the second digit is reserved for a milestone he names. The step numbers in section 11 therefore name the deliverable, not the release; `data/steps.json` records which release delivered each step. `tag_release.py` already accepted a patch bump, so nothing in the pipeline changed. ### C16. The admin section and the markdown viewer follow the siblings Section 6.1 names `/admin/index.html` and `/admin/versions.html` only. The sibling sites (sgit.ai, nfrs.sgit.ai, pki.sgit.ai) share one nav shape, grouped dropdown menus with a parent link, a stage pill and the version, and an admin section of three pages: how the site is built, the release history, and a comms page of numbered asks and tasks. This site now follows that shape (`admin/build/chrome.py`, `assets/nav.js`, `admin/comms.html`). For documents, the family's own brief ("Markdown and file viewers in a vault: what not to build") says not to write a client-side viewer, and the sibling websites publish documents as pages rendered at build time with the raw markdown one click away as the source of truth; `gen_docs.py` does exactly that, so no viewer script runs in the browser and the CSP stays at `script-src 'self'` with one small nav script. ## Open questions carried from section 12 (unanswered at this version) - Does the Playwright virtual authenticator support PRF? (step 4) - Does Google still issue implicit-flow tokens for a Web client with our origins? (step 7) - Exact Security Rules syntax for the size and null checks; is `request.auth.token.firebase.tenant` available in Storage rules? (step 3) - Where does Identity Platform store user records? (step 3, for `/environments/`) - Does `sgit pki import` accept a browser-generated bundle? (step 9) ============================================================================== source: /docs/design/riskmandate-aws-cognito-architecture.md ============================================================================== # Risk Mandate — AWS Cognito Client-Side Architecture 2026-10-05 · Dinis Cruz ## Summary Cognito handles login (Google, other social or OIDC/SAML providers, and Cognito-managed username/password or passkeys). A Cognito identity pool swaps the login token for short-lived AWS credentials in the browser, and the browser talks to S3 directly, limited to its own prefix. SGit vaults are already encrypted client-side, so S3 only ever holds ciphertext. The one new piece is a per-user **keyring**: an encrypted file in the user's S3 prefix listing the vault keys that user can open, like a password manager's vault. The keyring is unlocked by a key the browser derives from the user's passkey (WebAuthn PRF). That key never exists in Cognito, KMS or any AWS service. **Answer to the core question:** nothing stored *inside* Cognito or KMS can be kept from someone holding the AWS admin account. But the design doesn't need that. If the key that opens the keyring is derived on the user's authenticator, a full compromise of the Cognito and AWS admin accounts yields ciphertext only. This keeps SGit's existing property: server compromise causes no data disclosure. **Update:** the primary build will be all-GCP (Identity Platform + Cloud Storage for Firebase, one project per client) so each deployment lives in one cloud and can be created and destroyed as a unit. This Cognito design remains the AWS variant for AWS-native clients. See *Risk Mandate — GCP Key Vault Architecture & Password Manager MVP*. ## Why secrets cannot live in Cognito or KMS - **Cognito user attributes** (including custom attributes) are readable by any principal with `cognito-idp:AdminGetUser` or `ListUsers`. Encrypting them first just moves the question to where that key lives. - **Whoever controls authentication can impersonate.** A Cognito admin can reset a password (`AdminSetUserPassword`), sign in as the user, obtain identity-pool credentials and do anything the user can do. So any secret that is released *because a user logged in* is reachable by the admin. - **KMS** decrypts server-side for whoever holds IAM permission. A key policy can try to exclude admins, but the account owner can usually regain control, and an impersonated user's credentials would pass anyway. - **AWS Private CA and KMS asymmetric keys** keep private keys inside AWS, usable by IAM principals. Same problem. **CloudHSM** keeps keys behind HSM user credentials that AWS admins don't have, but it's server-side, priced per HSM-hour and not per-user, so it doesn't fit. Conclusion: the authority to decrypt must come from something the AWS account cannot reach — the user's authenticator, or a passphrase only the user knows. ## Architecture | Layer | Component | Role | What a compromised admin gets | |---|---|---|---| | Web app | Static site on GitHub Pages (outside AWS) | All crypto and UI run here | Nothing, if the code host is separate | | Login | Cognito user pool: Google federation, other IdPs, native username/password, passkeys | Proves who the user is | Ability to impersonate users and alter config | | AWS access | Cognito identity pool, authenticated role with policy variables | Short-lived credentials scoped to `vaults/${cognito-identity.amazonaws.com:sub}/*` | Access to any user's prefix | | Storage | S3: user keyring + SGit vault objects | Holds ciphertext only | Ciphertext; ability to delete or roll back | | Keys | Passkey PRF on the user's device, synced via iCloud Keychain or personal Google Password Manager | Derives the key that unwraps the keyring | Nothing | ## Login options - **Google sign-in** through Cognito's federated identity providers. Cognito also supports Apple, Facebook, Amazon, any OIDC provider and SAML (for customer IdPs like Entra or Okta). - **Cognito-managed accounts**: username/password with MFA, or passwordless with passkeys or email OTP (passwordless needs the Essentials feature plan). - Use the Amplify Auth library or the Cognito API from the custom UI, rather than the Cognito-hosted login pages, so every page the user sees comes from the code host outside AWS. Login and key unlock are deliberately separate. Login decides *which S3 prefix you may touch*; the passkey PRF decides *whether you can read what's there*. An admin can fake the first, never the second. ## Keyring design Path: `vaults/{identityId}/keyring.json` (versioned). Contents, conceptually: - **Wrapped keyring key (KEK) entries**, one per unlock method: each registered passkey (KEK wrapped with that passkey's PRF output via HKDF + AES-GCM), and one recovery code generated at signup and shown once. - **Encrypted body** (AES-GCM under the KEK): a list of vaults the user can open, each with vault id, S3 location, SGit vault key and label. Unlock flow: 1. User signs in via Cognito; browser gets identity-pool credentials. 2. Browser downloads `keyring.json`. 3. Browser runs `navigator.credentials.get` with the PRF extension and the salt stored in the keyring; the authenticator returns a 32-byte secret. 4. HKDF turns it into a wrapping key; browser unwraps the KEK, decrypts the body, holds vault keys in memory only. 5. SGit reads and writes vault objects directly in S3. Adding a device: sign in, unlock with an existing passkey or the recovery code, register the new passkey, add a new wrapped-KEK entry. Losing every passkey and the recovery code means the data is gone; say so in the terms. ## Sharing a vault between users Each user has an X25519 key pair; the private key lives in their keyring, the public key in a readable directory (`directory/{identityId}.pub`). To share a vault, the owner encrypts the vault key to the recipient's public key and drops it in the recipient's inbox prefix; the recipient's browser moves it into their keyring on next unlock. Risk: a compromised admin could replace a public key in the directory and receive the next share. Mitigate with key fingerprints users can compare, or signed directory entries, before any sensitive sharing. Revocation means rotating the vault key and re-sharing to remaining members. ## What a full admin compromise can and cannot do | Attack | Result | Mitigation | |---|---|---| | Read S3 | Ciphertext only | — | | Read Cognito users | Emails, names, login metadata | Keep the user pool minimal; accept metadata exposure | | Impersonate a user | Gets their ciphertext, cannot pass the PRF step | Passkey bound to the riskmandate.ai origin | | Point Cognito at a phishing site | PRF output is bound to the site's origin, so a phishing site cannot get it | Code host outside AWS | | Delete or encrypt-for-ransom S3 data | Availability loss | S3 versioning + Object Lock, replication to a separate AWS account, SGit clones on devices | | Roll back a vault to an older version | Stale data | SGit commit hashes; client remembers last-seen head | | Swap a public key in the directory | Intercepts future shares | Fingerprint check or signed directory | | **Serve malicious JavaScript** | **Full compromise of any user who loads it** | Code hosting outside the AWS account, protected separately; SRI on pinned libraries; reproducible builds | The last row is the real boundary: client-side crypto is only as trustworthy as the code delivered to the browser. Keeping the site on GitHub Pages, outside the AWS account, means an AWS or Cognito compromise does not reach it. ## Compared with the Google designs | | Cognito + S3 | Identity Platform + Cloud Storage | Workspace | |---|---|---|---| | Terms issue for multi-customer use | None | None | Needs Google's written agreement | | Native passkeys | Yes | Limited | Yes (Google account) | | Multi-tenancy | Pool per customer or tenant attributes | Built in | Tenant per customer | | Browser-direct storage | Identity pool + S3 | Firebase Storage rules | Drive/GCS via OAuth | | Mail, Drive, Calendar | No | No | Yes | | Familiarity | High (already used) | Lower | Medium | ## Open questions and next steps - Test whether Cognito's own passkey flow can also return a PRF result, so one passkey does login and unlock. If not, register a separate passkey with RP ID riskmandate.ai for unlock only. - Decide multi-tenancy: one user pool per customer, or one pool with a tenant attribute and per-tenant S3 prefixes. - Check Cognito feature-plan pricing for the passkey and passwordless features. - Build a PRF compatibility matrix (older Android, managed Chrome, Firefox). - Design the keyring file format and versioning, including concurrent writes from two devices. - Decide on the public-key directory trust model before multi-user sharing ships. - Set up S3 Object Lock and cross-account replication; test restore. - Prototype: sign in with Google via Cognito, get identity-pool credentials, write an encrypted keyring to S3, unlock it on a second device. ============================================================================== source: /docs/design/riskmandate-gcp-key-vault-password-manager-mvp.md ============================================================================== # Risk Mandate — GCP Key Vault Architecture & Password Manager MVP 2026-10-05 · Dinis Cruz ## Summary Risk Mandate needs two things from a cloud: an **identity** and a **place to store encrypted keys**. Everything else (SGit vault encryption, key unlock, sharing) happens in the browser. Decision: build it **all in GCP**, one project per client deployment, so a deployment can be created and destroyed as one unit. The stack is Identity Platform for login, Cloud Storage for Firebase for the encrypted keyring and SGit vaults, Storage Security Rules as the per-user guardrail, and a passkey with the WebAuthn PRF extension as the only thing that can decrypt. The first build is a **password manager**. It contains every risky piece of the vault-management design: login, browser-direct storage, passkey unlock, keyring format, device enrolment, recovery and multi-user sharing. Once it works, vault key management and user preferences are a layer on top: the same keyring holds vault keys instead of passwords. The server side never holds plaintext. A full compromise of the GCP project, Identity Platform or the admin account yields ciphertext only. This matches SGit's existing principle, and SGit's existing public/private key support and encrypted-data-on-top-of-encrypted-data design already cover most of what's needed. ## Why all-GCP rather than Cognito The Cognito + S3 design works (see the Cognito architecture doc) and Cognito is familiar. GCP wins on deployment shape: - **One cloud per deployment.** Google sign-in needs a Google OAuth client, which lives in a GCP project. With Cognito, a client wanting Google sign-in would run AWS *and* a GCP project. All-GCP keeps it to one. - **Clean create/destroy.** A GCP project is a complete boundary: identity config, bucket, IAM, billing. Deleting the project wipes the deployment. - **Familiarity is no longer a deciding factor.** GCP is well known to the team. What GCP does not give in this design: mailboxes, Drive or Calendar. Those are Workspace, which carries the resale terms issue documented in the onboarding doc. This design needs none of them. ## Client-owned deployments In a client deployment the client controls everything. The GCP project sits in the client's own Google Cloud organisation and billing account; Risk Mandate deploys the architecture into it. The client creates (or authorises Risk Mandate to create) the Google OAuth client for Google sign-in, so that credential is theirs too. Risk Mandate holds no standing access it doesn't need. ## The GCP stack | Layer | GCP component | Role | Cognito equivalent | |---|---|---|---| | Web app | Static site (GitHub Pages or Firebase Hosting) | All crypto and UI run in the browser | Same | | Login | Identity Platform: Google sign-in, email/password, OIDC/SAML federation for a client's own IdP | Proves who the user is | Cognito user pool | | Browser-direct storage | Firebase SDK talking to Cloud Storage for Firebase | No server in the data path | Identity pool + temporary AWS credentials | | Access guardrail | Storage Security Rules keyed on the user's UID (and tenant if used) | Each user touches only their own paths | IAM policy variables on S3 prefixes | | Encrypted data | Keyring files, public-key directory, inboxes, SGit vault objects in the bucket | Ciphertext only | S3 | | Unlock | Passkey + WebAuthn PRF, held in the user's Google Password Manager or iCloud Keychain | Derives the key that opens the keyring | Same | Pricing reference: Identity Platform basic sign-in is free to 50,000 monthly active users, then roughly $0.0055 per MAU; SAML/OIDC federation is free only to 50 MAU. ## Deployment units: project, tenant, region - **Project per client (recommended).** Own Identity Platform config, own bucket, own IAM, own billing. Destroying the project removes everything. Best fit for create/destroy and client ownership. - **Tenants within one project (option).** Identity Platform has built-in multi-tenancy: separate user pools under one project. Useful for a shared Risk Mandate environment hosting several small or pilot clients. Security Rules then key on both UID and tenant. - **Region.** Set per client by the bucket location, for data residency. Identity Platform itself is global; check where its user records are held if a client has strict residency needs. - **Automation.** Script project creation end to end (Terraform or gcloud): create project, enable Identity Platform, add Firebase, create bucket in the chosen region, deploy Security Rules, configure providers. Watch per-organisation and per-billing-account project quotas if spinning many up. ## Where the passkey lives The passkey is not stored in Identity Platform, the bucket or anything Risk Mandate administers. - It lives in the user's **authenticator**: in plain Chrome that is Google Password Manager, on Apple devices iCloud Keychain, or a hardware security key. - It syncs through the user's own **personal** account (their personal Google or Apple account), which no Risk Mandate or client admin can reach. - It is a WebAuthn credential registered by Risk Mandate's own page against the riskmandate.ai origin. It needs no Google app, no OAuth client and no Identity Platform setting. The user meets Google in two unrelated roles: once as a **login provider** (Google sign-in through Identity Platform, which needs an OAuth client) and once as the **home of the passkey** (Google Password Manager, which needs no configuration). Neither role creates a lock-in: login can be any provider, and the passkey would sit in the same place under any cloud. ## First-run and returning-user flow You, in Chrome, at riskmandate.ai: 1. **Sign in.** Click "Sign in with Google" (or email/password). Identity Platform returns an ID token; the Firebase SDK can now reach your paths in the bucket. Everything there is ciphertext, and you have no keyring yet. 2. **Create a passkey.** The page sees no keyring and calls `navigator.credentials.create` with the PRF extension. Chrome shows its own dialog and offers to save the passkey in Google Password Manager. You approve with fingerprint, PIN or face. 3. **Create the keyring.** The browser takes the PRF output, derives a wrapping key, generates your key pair and a keyring key, encrypts the keyring and writes it to the bucket. A recovery code is shown once. 4. **Returning visit** (same Chrome, or any Chrome signed into the same Google account so the passkey has synced): sign in, the page fetches the keyring, calls `navigator.credentials.get` with PRF, you confirm, the keyring unlocks in memory. 5. **New device without the passkey** (e.g. Safari on an iPhone using iCloud Keychain): sign in, unlock with the passkey from another device via cross-device sign-in, or with the recovery code, then register a new passkey on this device; a new wrapped-key entry is added to the keyring. ## Keyring design The keyring is an encrypted file in Cloud Storage. Google stores it but cannot read it. - **Wrapped keyring-key entries**, one per unlock method: each registered passkey (keyring key wrapped with that passkey's PRF output via HKDF + AES-GCM) and one recovery code. - **Encrypted body**, holding the user's private key, and the entries they can open: passwords in the MVP; SGit vault keys and preferences later. One unlock opens everything: passkey unlocks the keyring; the keyring holds the private key and all the keys/secrets. Plaintext exists only in the browser, briefly. Losing every passkey and the recovery code means the data is unrecoverable. This must be stated in the terms and in onboarding copy. ## Multi-user sharing scheme **The problem.** Single-user wrapping works until a secret must be readable by a second person. You can't wrap it with their passkey (their PRF secret never leaves their device), and you can't send it through the server in plaintext. **The solution: a key pair per user.** At setup each user's browser generates a public/private key pair. - The **private key** goes into the user's keyring, protected by their passkey like everything else. - The **public key** is published in a readable directory: "this user, this public key". **Sharing flow.** 1. Your browser fetches the colleague's public key. 2. It encrypts the vault key (or password entry key) to that public key. 3. It drops the result in the colleague's inbox in the bucket. 4. On their next unlock, their browser decrypts it with their private key and adds it to their own keyring. The public key shares *keys*, not data. The data stays where it is, encrypted under its vault key; sharing hands over the ability to open it. The server carries the package but can never read it. **What it drags in (design up front, not later):** | Concern | Why it matters | Approach | |---|---|---| | Revocation | A removed member already holds the key | Rotate the vault key, re-encrypt, re-share to remaining members | | Directory trust | A compromised admin could swap a public key and intercept the next share | Key fingerprints users can compare, or signed directory entries | | Group membership | Who can open what is real metadata | Store membership per vault; update on add/remove | If the MVP stores only single-user wrapped keys and sharing is added later, the core data model gets rewritten. Build the key pair and inbox in from the start, even if version one only shares with one person. **Fit with SGit.** SGit already supports public/private keys and is designed to store encrypted data on top of its own encrypted data. The keyring, the per-user key pairs and the inbox are applications of existing SGit capability, not new machinery. Reuse SGit's key formats and primitives rather than introducing a parallel scheme. ## Storage layout | Path | Contents | Who can read/write (Security Rules) | |---|---|---| | `users/{uid}/keyring` | Encrypted keyring | Owner only | | `directory/{uid}.pub` | Public key (signed) | Owner writes; signed-in users read | | `inbox/{uid}/{shareId}` | Keys encrypted to that user | Any signed-in user writes; owner reads and deletes | | `vaults/{vaultId}/...` | SGit vault objects | Members, per membership record | With tenants, prefix every path with `tenants/{tenantId}/` and add the tenant check to every rule. ## Password manager MVP Why it's the right first build: if an admin with full GCP access cannot read a stored password, the security model holds for vault keys too. It is easy to test, easy to demo, and has every hard piece. Scope: - Sign in (Google and email/password via Identity Platform) - First-run passkey creation, keyring creation, recovery code - Add, view, edit, delete password entries - Add a second device; recover with the recovery code - Share an entry with another user (key pair + inbox) - Revoke a share (rotation) - Fingerprint display for verifying another user's public key Then vault management is the same keyring holding SGit vault keys, and user preferences become more encrypted fields. ## Threat summary | Attacker | Gets | Cannot get | |---|---|---| | GCP project or Identity Platform admin | Ciphertext, user emails and login metadata, ability to impersonate a login, delete data | Plaintext; the PRF secret is bound to the riskmandate.ai origin and the user's device | | Bucket read access | Ciphertext | Plaintext | | Directory tampering | Future shares, unless fingerprints or signatures are checked | Existing keys | | Whoever controls the served JavaScript | Everything for users who load it | — | The served code is the real boundary. Host the site separately from the GCP project and protect that host independently. Use bucket versioning and retention to defend availability against deletion. ## Open questions and next steps - Confirm Storage Security Rules can check the Identity Platform tenant claim, if tenants are used. - Confirm where Identity Platform stores user records, for clients with residency requirements. - Map SGit's existing key pair and layered-encryption support onto the keyring, directory and inbox; list any gaps. - Choose directory trust: fingerprints, signed entries, or both. - Define the keyring format, versioning and conflict handling for two devices writing at once. - Build the PRF compatibility matrix (older Android, managed Chrome, Firefox, Safari). - Write the project-creation script and test create/destroy end to end. - Build the password manager MVP; test by giving someone full GCP admin and asking them to read a password. ============================================================================== source: /docs/design/riskmandate-user-onboarding-account-experience.md ============================================================================== # Risk Mandate — User Onboarding & Account Experience Oct 4, 2026 · @Dinis Cruz ## Summary Risk Mandate provisions each user a Google Workspace account and runs the whole product on top of it, while the user sees Google only once: on the sign-in screen. First login is a password the browser generates; the user then enrols a passkey and never types that password again. From then on one biometric gesture both signs them into Google and unlocks their SGit data. This is the companion to the architecture briefing (tiers, encryption, Cloud substrate). It covers only the user-facing account lifecycle: provisioning, first run, return visits, and how Gmail and Drive are used without ever showing their UI. **Update after terms research:** Google's terms do not allow one Workspace tenant to hold several customers' staff without Google's written agreement. The shared tier therefore defaults to Google Identity Platform with Cloud Storage, and the Workspace flows in this document apply to tenants the customer owns, or to the shared tier once Google signs off. See *Tier model and options*. ## Decision: password once, then passkey Google will not let an admin enrol a passkey on a user's behalf, so a Workspace account's first sign-in always needs a password typed once. We accept that and make it painless: the browser's password manager generates and stores it, and the very next screen enrols a passkey. What this buys: - No identity provider to run. Google remains the only IdP. - 2FA from day one: the passkey is phishing-resistant and bound to the device. - One gesture for everything: the same passkey carries the PRF secret that unwraps the SGit data key. - Recovery rides on the user's personal Google or Apple keychain, not on Risk Mandate. | Option | Password ever typed? | Who runs an IdP? | Verdict | | --- | --- | --- | --- | | Password once, then passkey (chosen) | Once | Google | Simple; accepted | | Third-party SSO with passkey-only IdP | Never | Risk Mandate (Keycloak, Authentik, Descope, Auth0) | Rejected: operating an IdP | | Personal Google as IdP for the tenant | Never | Nobody | Not possible: Google cannot be a SAML IdP for another Google tenant | | Domain-wide delegation | Once | Google | Rejected for the client-side experience: server-side credential | ## Account provisioning Risk Mandate creates the account; the user never sees the initial password. Two variants, both using the Admin SDK Directory API (`users.insert`). **Variant A: random password, delivered out of band** 1. Create the user with a random password and `changePasswordAtNextLogin: true`. Discard the random value after delivery. 2. Send a pre-filled sign-in link carrying `login_hint=` so Google skips the account picker. Optionally as a QR code for phone onboarding. 3. Deliver the password separately (SMS, voice, in person). Google will not accept a one-time code minted by Risk Mandate; the random password is the one-time credential. **Variant B: Google-sent invite** 1. Create the user with a recovery email set to an address the person already reads. 2. Trigger Google's password-reset/invite email. Google delivers the link; Risk Mandate never holds a credential. Variant B needs a reachable external email; Variant A does not. Hand-provision both while proving the model; move to the reseller provisioning APIs once volume justifies it. No initial password can be avoided entirely with Google as the IdP. See the Rejected alternatives section for what it would take. ## First-run flow Target: under two minutes, one password screen, one biometric prompt. 1. User opens the onboarding link (or scans the QR) on their phone or laptop. Google shows the password screen for the pre-filled address. 2. User enters the one-time password (Variant A) or the invite link's set-password screen appears (Variant B). 3. Google forces a new password. The browser's password manager offers a generated one; the user accepts it and never needs to remember it. 4. Google's "Protect your account" step offers a passkey. Risk Mandate's onboarding copy tells the user to accept: Face ID, fingerprint or hardware key. This is the 2FA. 5. Redirect lands on `riskmandate.ai`. OIDC sign-in passes silently against the fresh Google session. 6. The page requests Drive and Gmail tokens via Google Identity Services; the Marketplace domain install means no consent screen. 7. The page runs one WebAuthn assertion with PRF on the passkey just enrolled, derives the wrapping key, generates the SGit data key locally, wraps it and stores the wrapped blob in the customer's Cloud project. 8. Onboarding prompts a second passkey (hardware key or second device) as backup. This is the moment to pitch it; later prompts are ignored. If Google's own passkey prompt is skipped in step 4, step 7 registers the passkey at `riskmandate.ai` instead and the user is asked to add it to their Google account from the Risk Mandate settings page. Both passkeys can live in the same Google Password Manager or iCloud Keychain. ## Returning-user experience After first run the user never types a password again. | Situation | What the user does | Mechanism | | --- | --- | --- | | Same browser, Google session alive | Opens riskmandate.ai; one biometric tap | OIDC silent sign-in; passkey + PRF assertion unlocks the data key | | Same browser, Google session expired | One tap on the One Tap bubble, then one biometric | Sign in with Google One Tap; Workspace passkey-as-primary login skips the password | | Clean device, new browser | Picks the account, one biometric, one biometric again | Workspace passkey login (synced via personal keychain); then PRF assertion | | Lost device | Recovers personal Google or Apple account on a new device; passkeys sync back | Keychain recovery; no Risk Mandate involvement | | All authenticators and personal account lost | Data unrecoverable | Accepted and stated in terms | Access tokens expire hourly. Google Identity Services re-requests them silently while the Google session lives, so the user is never interrupted mid-session. For the clean-device case to be passwordless, the tenant must have passkeys enabled as a primary sign-in method (Admin console, Security, Passkeys, "Skip passwords when possible"). ## Risk Mandate as the only UI Once the Marketplace domain install is in place, Gmail, Drive and Calendar are APIs the Risk Mandate page calls in the browser with the user's own permissions. The user can run on the full power of Workspace without opening a single Google page after sign-in. What the user sees from Google, and only from Google: - Sign-in, 2FA and passkey prompts - Password reset and account recovery - Security notifications (new device, suspicious sign-in) What Risk Mandate renders instead: - Inbox: `gmail.readonly` to read; a real mailbox exists, so Risk Mandate can receive mail on the user's behalf (evidence, notifications, approvals) - Files: `drive.file` plus the Google Picker, or `drive.readonly` if browsing is needed; "Open with Risk Mandate" appears in Drive - Calendar: `calendar.readonly` for deadlines and review dates To keep users inside Risk Mandate, hide the Google apps from the launcher and, if wanted, turn the Gmail and Drive web UIs off for the organisational unit. The services keep working through the API; only the Google front ends disappear. If a user types mail.google.com with the UI still on, they will land in Gmail, so decide this per tier. All Drive and Gmail reads happen client-side. Tokens stay in the tab; Risk Mandate's servers never see them. ## Admin configuration checklist One-time setup per tenant. Same list applies on the Private tier, done by the customer's admin. - [ ] Google Cloud Console: create the OAuth client; set Authorised JavaScript origins to `https://riskmandate.ai`, `https://sgit.ai` and any other Risk Mandate-owned origin. One client with several origins, or one client per site under the same project so each can be revoked alone. - [ ] Google Cloud Console: configure the OAuth consent screen with the scopes `openid email profile`, `drive.file`, `gmail.readonly`, `calendar.readonly`. Add `drive.readonly` only if the Picker proves insufficient. - [ ] Workspace Marketplace SDK: publish Risk Mandate as a private app for the tenant. - [ ] Admin console, Apps, Marketplace apps: domain-install Risk Mandate for the organisational unit. This pre-grants the scopes for every user. - [ ] Admin console, Security, API controls, App access control: mark Risk Mandate as Trusted; set unconfigured third-party apps to Restricted. - [ ] Admin console, Security, Passkeys: allow users to skip passwords when possible. - [ ] Admin console, Security, 2-Step Verification: enforce, with passkeys and security keys permitted; enrolment grace period of 1 day. - [ ] Admin console, Apps, Google Workspace: hide Gmail, Drive, Calendar from the app launcher for the OU; decide per tier whether to turn their web UIs off. - [ ] Admin console, Account, Recovery: set the recovery email policy (required for Variant B invites). - [ ] Admin console, Reporting, Audit: confirm Admin audit log and OAuth token audit log retention; export to the customer's Cloud project on Tier 2 and 3. ## Rejected alternatives Recorded so the reasoning survives. **Third-party SSO with a passkey-only IdP.** Point the tenant at an external SAML IdP (Keycloak, Authentik self-hosted; Descope, Auth0 hosted). Google then never shows a password screen; the IdP enrols a passkey on first visit via QR or push. The Google password still exists, random and unknown, but is never used. Rejected because Risk Mandate does not want to operate an identity provider. Revisit if a Tier 3 customer already runs one and wants Risk Mandate behind it. **Keycloak brokering a personal Google account.** Keycloak accepts the user's personal Google sign-in via OIDC and re-issues it as a SAML assertion the tenant trusts. Elegant, but still an IdP to run. Same verdict. **Personal Google account as the tenant's IdP.** Not possible: Workspace federates outward only to non-Google SAML providers. The personal account's real role is the recovery chain for the passkey, which already works. **Domain-wide delegation for data access.** A service account impersonating users removes consent prompts but moves Drive and Gmail access server-side and creates the most powerful credential in the design. Reserved for Tier 2 and 3 features that must run without the user present; never for the client-side experience. **Google Client-Side Encryption.** The right model, but Enterprise Plus only, not available on the seven-pound tier. SGit's own encryption gives the same guarantee at any tier. ## Tier model and options (updated after terms research) Two rules now drive the design: - **The terms decide who owns a Workspace tenant.** A Workspace tenant may only hold one organisation's people, unless Google agrees otherwise in writing. Data sensitivity does not change this. - **Sensitivity decides who holds the keys and the infrastructure.** Non-sensitive work can share Risk Mandate infrastructure; sensitive work moves to infrastructure the customer owns. What stays constant on every tier: SGit encrypts client-side, the data key is wrapped by a passkey PRF secret derived in the browser, and no Risk Mandate server ever holds plaintext or long-lived user tokens. The identity provider and the bucket rules decide *who can touch which path*; SGit decides *whether the bytes mean anything*. ### Tiers | Tier | Identity | Storage | Who owns what | Terms status | For | | --- | --- | --- | --- | --- | --- | | **Shared (default)** | Google Identity Platform, one IdP tenant per customer | Cloud Storage for Firebase on Risk Mandate's bucket, Security Rules scoped to user and tenant | Risk Mandate owns the Cloud project; no Workspace involved | Within terms: Identity Platform is Google's customer-identity product, built for an app's own users | Pilots, non-sensitive work | | **Shared on Workspace (pending)** | Workspace accounts in Risk Mandate's tenant | Cloud Storage; Gmail and Drive client-side | Risk Mandate owns tenant holding several customers' staff | **Needs Google's written agreement** (ToS §2.6, AUP). Do not launch before it is signed | Same as Shared, plus mailbox and Drive | | **Customer Workspace** | Customer's own Workspace tenant; Risk Mandate is a domain-installed Marketplace app | Per-customer Cloud project; Gmail and Drive client-side | Customer owns tenant; buys direct or through Risk Mandate as reseller/distributor; Risk Mandate optionally delegated admin | Within terms. Restricted-scope app verification applies; no CASA while data stays client-side | Sensitive work, customers wanting Gmail/Drive in the product | | **Private** | Customer's Workspace or own IdP | Customer's Cloud org and buckets; optional customer KMS or Google CSE | Customer owns everything; Risk Mandate sells setup and managed ops | Within terms | Regulated and enterprise | | **Federated (add-on to any tier)** | Customer's existing Workspace, Entra or other SAML/OIDC IdP federated into Identity Platform | As the tier it attaches to | Customer keeps its own accounts | Within terms | Customers who already have an IdP | Migration path: Shared to Customer Workspace or Private moves the customer's SGit bucket into their own Cloud project. Nothing is re-encrypted, because the keys were never Risk Mandate's. ### Shared tier: zero servers Risk Mandate runs 1. Browser signs in with the Identity Platform (Firebase Auth) JS SDK into the customer's IdP tenant. 2. Browser runs its own WebAuthn assertion with PRF to unwrap the SGit data key. This works whatever the IdP, so Identity Platform's limited native passkey support does not block the design. 3. Browser reads and writes Cloud Storage for Firebase directly; Security Rules restrict each user to `tenants/{tenantId}/users/{uid}/` and shared customer paths. 4. No Risk Mandate backend sits in the data path. **Cost:** basic sign-in free up to 50,000 MAU, then about $0.0055 per MAU; SAML/OIDC federation free only to 50 MAU. Far below £7 per seat. **Gives up:** mailbox, Drive, Calendar and Google-run account recovery. If the product needs inbound email, add a mail service separately. ### AWS variant Same architecture for an AWS-hosted private tier: Cognito user pool for sign-in, Cognito identity pool to swap the token for temporary AWS credentials in the browser, S3 with an IAM policy limiting each user to their own identity-ID prefix. Cognito has native passkey support. Multi-tenancy is a pool per customer or tenant attributes. SGit encryption and passkey PRF are unchanged. ### Request to Google for the Shared-on-Workspace tier The Terms allow the restrictions to be waived if Google specifically agrees in writing. Ask for: - Permission to provision End User Accounts in Risk Mandate's tenant for staff of multiple customer organisations, embedded in the Risk Mandate product, with Google's UI hidden. - Permission for customers to offer the same onward to their own clients, if wanted. - Clarity that a risk-workflow interface over Gmail and Drive is not a "substitute or similar service" (ToS §2.6(c)). - The commercial vehicle: amendment to Risk Mandate's Workspace agreement, or reseller/ISV partner agreement. Get it signed by someone with contracting authority, not an email from a contact. Until then the Shared tier runs on Identity Platform, and switches to Workspace only once the agreement lands. ### Decision summary | Question | Decision | | --- | --- | | Default substrate for shared, non-sensitive use | Identity Platform + Cloud Storage for Firebase | | When Workspace is used | Customer owns the tenant, or Google has signed off on the shared Workspace tier | | How Risk Mandate earns on Workspace | Reseller/distributor margin plus setup and managed services | | What never changes | Client-side SGit encryption, passkey PRF key wrapping, no server-side plaintext or long-lived tokens | | AWS | Cognito + identity pool + S3 as a drop-in private-tier option | ## Research A: Google commercial and terms restrictions The Shared tier as designed breaks Google's terms. Risk Mandate holding one tenant and handing accounts in it to other companies' staff is exactly what the Acceptable Use Policy forbids. The Delegated and Private tiers are fine, and the compliant way to keep the "we pass on the £7 seat" model is to resell a separate tenant per customer, through Google's reseller programme or a distributor. | Restriction | Source | Impact on Risk Mandate | | --- | --- | --- | | Customer may not sell, resell or lease the Services to a third party unless the agreement authorises it | Workspace Terms of Service §2.6(a) | Tier 1 (one Risk Mandate tenant, many customers) is resale without authorisation. Blocking. | | May not resell End User Accounts, or parts of them, as part of a commercial product offered to third parties | Workspace / Cloud Acceptable Use Policy | Bundling seats inside the Risk Mandate product from our own tenant is named explicitly. Blocking for Tier 1. | | May not "attempt to create a substitute or similar service" using the Services | Workspace ToS §2.6(c) | Building a Gmail-like inbox UI is a grey zone. Keep the UI risk-workflow-shaped, not a general mail client. Ask counsel. | | One human per account; no shared accounts outside delegation; no function accounts used to share files | Acceptable Use Policy | Each seat is a real person; agent and service identities must be service accounts, not users. | | Admins may access End User data; customer must obtain consents for that | Workspace ToS | Already planned (audit log + DPA). In a per-customer tenant the customer, not Risk Mandate, is the controller. | | Reseller must have each customer accept Google's ToS itself; cannot accept on their behalf | Reseller agreements | Onboarding includes a Google ToS click-through per customer tenant. | | Reseller cannot resell to someone who will resell onward | Reseller agreements | Customers cannot sub-resell their Risk Mandate seats. | | Direct Google partner status: \~100 provisioned seats, business plan, credit check | Google partner programme (as reported by distributors Vendasta, Sherweb) | Not reachable at pilot scale. Use a distributor (Sherweb, Vendasta: no minimums) until volume justifies direct. | | Max 2 reseller transfers per calendar year per subscription; licence count cannot drop below commitment on transfer | Workspace transfer rules | Plan the Tier 1-to-Tier 3 move once; prefer Flexible plans. | | Gmail read scopes and `drive` / `drive.readonly` are restricted scopes | Google OAuth policy | Internal app in the same org: exempt. Third-party app into customer tenants: needs Google app verification even when domain-installed; annual CASA security assessment if restricted data passes through our servers. Keep reads client-side and prefer `drive.file`. | **SLA.** Workspace guarantees 99.9% monthly uptime, with credits capped at 15 days of service per month, as extra days or, for monthly billing, an invoice credit. Bought through a reseller, the credit flows through that reseller. There is no money-back remedy, so Risk Mandate should not promise customers an SLA stronger than Google's on Workspace-dependent functions; Cloud Storage has its own SLA. **What this changes in the design:** - The shared tier no longer uses a Risk Mandate Workspace tenant; it runs on Identity Platform and Cloud Storage until Google agrees otherwise in writing. See *Tier model and options*. - Workspace is used where the customer owns the tenant, bought direct or through Risk Mandate as reseller. - The only users in Risk Mandate's own Workspace tenant are Risk Mandate staff. - The client-side architecture already minimises the verification burden: no server-side Gmail or Drive data means verification without CASA. Sources: [Workspace Terms of Service](https://workspace.google.com/terms/standard_terms/) · [Acceptable Use Policy](https://cloud.google.com/cloud/terms/aup) · [Workspace SLA](https://workspace.google.com/terms/sla/?hl=es) · [EDU reseller terms](https://workspace.google.com/terms/reseller/amendment_edu_reselling) · [Transfer to resellers](https://knowledge.workspace.google.com/admin/billing/transfer-subscriptions-between-google-and-resellers) · [Restricted scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification) · [Gmail API scopes](https://developers.google.com/gmail/api/auth/scopes?authuser=2) · [Vendasta on partner requirements](https://www.vendasta.com/blog/google-workspace-reseller/) · [Sherweb reseller](https://www.sherweb.com/productivity/google-workspace/resell/) Not legal advice; confirm the Tier 1 restructuring and the "substitute service" question with counsel before launch. ## Research B: who else has done this Each half of the design has well-known precedents; the full combination does not show up in public sources. Website builders and payment companies resell a per-customer Workspace tenant bundled with their product, and email startups put their own UI over Gmail. No company surfaced that resells Workspace as the identity, storage and mail substrate for a non-email product with client-side encryption on top. That gap is either Risk Mandate's opening or a sign the economics or terms bite; the absence of results is not proof nobody has. | Who | What they do | Which half of the pattern | Lesson for Risk Mandate | | --- | --- | --- | --- | | [Squarespace](https://leadsmonky.com/google-workspace-vs-squarespace/) | Authorised reseller; sells real Workspace inside its accounts, billed by Squarespace, often a free first year of Business Starter | Reseller bundle, per-customer tenant | Proves the bundle model at scale. Only works for new tenants; an existing Workspace cannot be linked. | | [Square](https://community.squareup.com/t5/Troubleshooting/Where-do-I-pay-for-Google-Workspace/m-p/754859) | Resells Workspace to merchants; Google tells users to "contact reseller" for billing | Reseller bundle | Support burden lands on the reseller; users get confused about who to pay. Budget for first-line support. | | [Google Domains to Squarespace](https://www.searchenginejournal.com/google-domains-agrees-to-be-acquired-by-squarespace/) | Google moved its own domain-plus-Workspace customers to Squarespace billing | Reseller at scale | Google itself relies on resellers for the bundled small-business segment. | | [Sherweb](https://www.sherweb.com/productivity/google-workspace/resell/), [Vendasta](https://www.vendasta.com/blog/google-workspace-reseller/), [AppXite](https://www.appxite.com/google) | Distributors and platforms letting smaller firms resell Workspace without Google's direct-partner minimums; provisioning and billing automation | Reseller plumbing | The practical route for Risk Mandate at pilot scale. | | [WHMCS Workspace module](https://www.modulesgarden.com/products/whmcs/google-workspace) | Hosting companies provision Workspace seats from their billing system | Automated provisioning | Reseller API provisioning is a solved, off-the-shelf problem. | | [Superhuman](https://sacra.com/chat/h/4c327cd9-4a73-4c8e-bdc3-8527575402c0/) | Workflow and UI layer on a user's existing Gmail or Outlook via OAuth; Google keeps storage, spam and deliverability | Own UI over Gmail | Owning the interface but not the rails; exposed to Google's API quotas and policy. | | [Shortwave](https://sacra.com/chat/h/5e7868c3-fa5f-4145-a8d9-a8b9f7f15384/) | Gmail-only client that indexes mail server-side for AI search | Own UI over Gmail, server-side | The server-side path means restricted-scope verification and CASA. Risk Mandate's client-side design avoids the heaviest part. | | [MailMate](https://lists.freron.com/mailmate/2025-April/018273.html) | Small desktop mail client that had to pass CASA Tier 2 to keep Gmail OAuth | Restricted scope cost | Even small vendors face the audit when acting as a third-party app; plan for it on Tier 2/3. | **Where Risk Mandate differs from all of them:** the customer does not bring an account or buy email; the account is provisioned as the product's identity and data home, the product's data is encrypted before Google sees it, and the Google UI is hidden. Closest analogy: Squarespace's bundle plus Superhuman's interface plus end-to-end encryption. One honest risk the precedents show: platform dependence. Superhuman and Shortwave live inside Google's quotas and policy; a reseller relationship adds Google's terms on top. Keep SGit's data portable (it already is, in Cloud Storage) so a Google policy change costs a migration, not the company. ## Open questions and next steps Open questions: - Does Google's post-password "add a passkey" prompt appear reliably on Business Starter, or does it need an admin nudge? Test on a fresh tenant. - Can the Google-account passkey and the riskmandate.ai passkey be the same credential for PRF purposes, or do users hold two? Two is fine, but the onboarding copy depends on the answer. - PRF support on older Android and on managed Chrome with enterprise policies: build the compatibility matrix. - Which tiers turn the Gmail and Drive web UIs off entirely versus only hiding them from the launcher? - Multi-user customers: how is the customer data key shared across seats and rotated on offboarding? Not yet designed. Next steps: - [ ] Stand up a throwaway tenant and run the full first-run flow on a clean phone and a clean laptop; time it. - [ ] Implement OIDC sign-in with no refresh tokens and the GIS token client for Drive, Gmail, Calendar. - [ ] Implement passkey registration with PRF and data-key wrapping; test recovery by wiping a device. - [ ] Write the onboarding copy for the passkey and backup-passkey prompts. - [ ] Draft the terms language on unrecoverable data. - [ ] Design the multi-user key-sharing scheme before the second seat on any customer. ============================================================================== source: /docs/design/riskmandate-workspace-architecture-briefing.md ============================================================================== # Risk Mandate — Google Workspace as Identity, Storage and Deployment Substrate **Technical briefing** · Draft v0.1 · 4 October 2026 **Scope:** Using Google Workspace accounts as the identity provider, per-customer storage home and deployment substrate for Risk Mandate (riskmandate.ai), with SGit (sgit.ai) as the encrypted data layer. --- ## 1. Summary Risk Mandate will provision each customer a Google Workspace account (≈ £7/user/month, cost passed through to the customer). That account does three jobs at once: 1. **Identity provider** — OIDC/SAML login for Risk Mandate and SGit. 2. **Data home** — the customer's policies, mandates and evidence live under an account that is recognisably *theirs*. 3. **Cloud substrate** — every Workspace tenant is also a Google Cloud organisation, so per-customer Cloud projects (storage, KMS, compute) fall out for free. All customer data is stored **encrypted by SGit before it touches Google**. Google, and any Workspace admin, only ever hold ciphertext. The decryption key is derived from the user's own authenticator (passkey + PRF) and rides on the user's *personal* recovery chain, never on Risk Mandate infrastructure. The result is a **three-tier ladder** — Shared → Delegated → Private — where the customer moves up as their governance requirements grow, and the move is a Google domain transfer rather than a data migration. This mirrors Risk Mandate's own thesis: a named business owner and a time-bound mandate, applied to our own access. --- ## 2. Design goals and constraints | Goal | Implication | |---|---| | Minimise Risk Mandate's contact with user data | Never hold plaintext; avoid holding standing OAuth credentials where possible | | Pass infrastructure cost to the customer | Resell Workspace seats; Cloud billing attached per customer | | Scale security from "simple" to "enterprise-ready" | Tiered model, same code, different tenancy and key custody | | Prove the model before automating | Hand-provision early customers; reseller APIs later | | Business model = helping customers deploy at their end | Private tier is the upsell, shared tier is the on-ramp | **Hard constraint discovered:** standard Workspace storage has *no admin-proof zone*. A super admin can reach any user's Drive via Vault, data transfer, Takeout or domain-wide delegation. Google's Client-Side Encryption (CSE) solves this but is Enterprise Plus / Education only and not available on the £7 tier. Therefore encryption must happen **in SGit, before Google**, not inside Google. --- ## 3. Architecture overview ``` ┌──────────────────────────────────────────────────────────────┐ │ User's PERSONAL identity (own Google / Apple account) │ │ └─ Passkey for riskmandate.ai, stored in Google Password │ │ Manager or iCloud Keychain → synced, recoverable by user │ │ └─ PRF extension → deterministic secret → unwraps data key │ └───────────────┬──────────────────────────────────────────────┘ │ login (WebAuthn + OIDC) ┌───────────────▼──────────────────────────────────────────────┐ │ Risk Mandate app (browser / agent) │ │ - Verifies Workspace ID token, discards it │ │ - Holds data key IN MEMORY ONLY for the session │ │ - SGit client encrypts/decrypts locally │ └───────────────┬──────────────────────────────────────────────┘ │ ciphertext only ┌───────────────▼──────────────────────────────────────────────┐ │ Customer's Workspace tenant + Google Cloud org node │ │ - Workspace user(s): identity, Groups, Drive (light use) │ │ - Cloud project(s): Cloud Storage (SGit object store), KMS, │ │ Cloud Run (SGit services), audit logs │ └──────────────────────────────────────────────────────────────┘ ``` Key separation: **identity + ciphertext** live in the tenant (which Risk Mandate may administer on the shared tier); **key material** lives on the user's personal recovery chain (which Risk Mandate never touches). --- ## 4. Identity ### 4.1 Workspace as IdP - Google Workspace supports OIDC and SAML out of the box. Risk Mandate registers once as an OAuth client in Google Cloud. - Use **pure OpenID Connect** for login: request only `openid email profile`, verify the ID token, and discard it. No `offline_access`, no refresh token, nothing for Risk Mandate to custody. - Groups API drives roles (e.g. `risk-owners@`, `auditors@`), so authorisation can be delegated to the customer's own admins on higher tiers. ### 4.2 Passkeys + PRF for key derivation - On first login the user registers a **passkey** at `riskmandate.ai`. The passkey is bound to the site, but *stored* wherever the user chooses — Google Password Manager, iCloud Keychain, a hardware key. - The **PRF extension** (WebAuthn) returns a deterministic 32-byte secret per credential per site on each assertion. Risk Mandate uses it to derive a wrapping key and unwrap the customer's SGit data key. - Nothing key-related is stored server-side in plaintext. A stolen database yields wrapped keys only. - **Recovery:** because the passkey syncs via the user's personal Google/Apple account, losing a device is survivable — recover the personal account, the passkey returns, the PRF secret returns. Losing *all* authenticators and the personal account = data gone. This is accepted and should be stated plainly in the terms. - Browser support: Chrome and Safari both ship PRF; iCloud Keychain supports it from iOS 18. Test older Android builds before relying on it there. ### 4.3 Why not the alternatives | Option | Verdict | |---|---| | Drive appDataFolder | Admin-reachable; stores the key next to the lock. No. | | Google CSE | Correct model, but Enterprise Plus only and operationally heavy. Revisit for Private tier if a customer already has it. | | Password managers (1Password, LastPass) | 1Password has a usable SDK; LastPass does not. Either way adds a third party. Optional, not default. | | Customer KMS (GCP KMS / Azure Key Vault / AWS KMS) | Right answer for Private tier where the customer already audits a KMS. Supported as an alternative wrapping backend. | --- ## 5. Storage ### 5.1 SGit as the encrypted data layer - SGit encrypts every object client-side before upload. Google only ever sees ciphertext. - Google Drive is **not** the primary store — SGit is too chatty for the Drive API. Drive may hold human-readable exports (e.g. a published policy PDF) but the SGit object store is **Google Cloud Storage** in a per-customer project. - Each Workspace user can create Cloud projects immediately (Workspace tenant = Cloud Identity org), so no separate account system is needed. ### 5.2 Per-customer Cloud layout ``` Organisation: .com └── Folder: riskmandate-customers (shared tier) / root (private tier) └── Project: rm- ├── Cloud Storage bucket – SGit object store (ciphertext) ├── Cloud KMS (optional) – alternative key wrapping backend ├── Cloud Run – SGit services, if hosted └── Cloud Audit Logs – retained, exportable to customer ``` - Billing: Workspace and Cloud are billed separately. Each project needs a billing account — Risk Mandate's with labels for chargeback (Shared), or the customer's own (Private). - IAM: Cloud org admin is a **different role** from Workspace super admin. Split identity administration from infrastructure administration deliberately from day one. --- ## 6. Deployment tiers | | **Tier 1 — Shared** | **Tier 2 — Delegated** | **Tier 3 — Private** | |---|---|---|---| | Tenant owner | Risk Mandate | Customer | Customer | | Domain | `.riskmandate.ai` or similar | Customer's own | Customer's own | | Workspace admin | Risk Mandate | Customer; Risk Mandate holds a scoped, revocable delegated-admin role | Customer only | | Cloud org / billing | Risk Mandate (chargeback) | Customer | Customer | | Who could read plaintext | Nobody — ciphertext + user-held keys | Same | Same | | Who could *see ciphertext / metadata* | Risk Mandate admins (audit-logged) | Customer + Risk Mandate (scoped) | Customer | | Provisioning | Hand-provisioned initially → reseller API | Guided setup, sold as a service | Full setup + managed ops, sold as a service | | Target | Startups, pilots, proving the model | Mid-market wanting ownership without ops burden | Regulated / enterprise | **Trust story on Tier 1:** "We technically can access the tenant, here is the Admin audit log proving we didn't, and all we could see is ciphertext anyway." Back it with a DPA. This is stronger than most SaaS trust claims and should be stated rather than hidden. **Migration path:** Tier 1 → 2/3 is a Google **domain transfer** of the tenant plus a Cloud project move under the customer's org node. No data export, no re-encryption — keys never belonged to Risk Mandate in the first place. --- ## 7. Federated login (customer brings their own Google) Both tiers can optionally accept logins from a customer's *existing* Workspace instead of a provisioned account. | Depth | What's needed on the customer side | |---|---| | OIDC sign-in only | Nothing, unless the admin has locked third-party apps — then a one-line allow-list of Risk Mandate's OAuth client | | Per-user Drive access | User consent at OAuth time; no admin involvement | | Domain-wide delegation | Super admin configures it in Admin console; triggers security review. **Avoid unless essential.** | | SAML into Risk Mandate's tenant | One-time admin setup on their side; routine for any IT team | **Credential-custody rule:** Risk Mandate holds no refresh tokens by default. If Drive access is ever needed, keep the token in the browser and call Drive client-side so the grant dies with the session. Anything that must run server-side without the user present is a Tier 2/3 feature, with tokens encrypted under a key the user's PRF secret unwraps and scopes requested incrementally. --- ## 8. Session and unlock flow (end-to-end) 1. User visits `riskmandate.ai`, chooses "Sign in with Google" → OIDC against their Workspace tenant. 2. ID token verified; session established; token discarded. 3. App triggers WebAuthn assertion with PRF for the registered passkey. 4. PRF output → HKDF → wrapping key. 5. App fetches the user's **wrapped** SGit data key from the Cloud project; unwraps in memory. 6. SGit client decrypts/encrypts objects locally against the per-customer Cloud Storage bucket. 7. On logout or tab close the data key is dropped; nothing persists in plaintext. First-run differs only in step 3–5: generate the data key locally, wrap it with the PRF-derived key, store the wrapped blob. Optionally register a second passkey (hardware key) and a KMS-wrapped copy for Tier 3 customers as recovery. --- ## 9. Open questions and risks - **PRF on older Android / enterprise-managed Chrome** — needs a compatibility matrix before launch. - **Reseller status** — joining Google's partner programme unlocks provisioning/billing APIs; needed before Tier 1 can be self-serve. Not needed for hand-provisioned pilots. - **Legal access obligations** — on Tier 1, Risk Mandate is data controller for the tenant and may receive lawful-access requests. Ciphertext-only storage limits what can be produced; document this position with counsel. - **Multi-user customers** — sharing a data key across a customer's users needs a key-sharing scheme (per-user wrapped copies of the customer key, rotated on offboarding). Design before Tier 1 onboards its second seat. - **Drive quota and API rate limits** — not a concern once Cloud Storage is the store, but confirm Drive isn't accidentally on any hot path. - **Cost model** — £7/seat Workspace + Cloud consumption per project; validate margin on the shared tier with real pilot usage. --- ## 10. Next steps 1. Hand-provision a Workspace tenant + Cloud project for one pilot customer (Tier 1). 2. Implement OIDC login (no refresh tokens) and passkey + PRF key wrapping in the Risk Mandate app. 3. Point SGit at a per-customer Cloud Storage bucket; confirm Drive is out of the data path. 4. Write the Tier 1 trust statement (admin audit log + ciphertext + DPA). 5. Design the multi-user key-sharing scheme. 6. Prototype a Tier 1 → Tier 3 domain transfer on a throwaway tenant to prove the migration story. 7. Evaluate Google partner/reseller onboarding once pilot volume justifies automation. --- ## 11. Client-side data access — Marketplace domain install **Goal:** a user signed into their Workspace account opens `riskmandate.ai` (or `sgit.ai`) and the page can read their Drive and Gmail **in the browser**, limited to what they can already access, with no OAuth consent prompts and no tokens ever touching Risk Mandate's servers. ### 11.1 Mechanism - Risk Mandate is published as a **Google Workspace Marketplace app**, listed privately to the tenant. - The tenant admin performs a **domain install**. This pre-grants the app's scopes for every user in the domain — the admin's consent replaces per-user consent. - The web page uses **Google Identity Services** (token client) to request an access token silently against the user's existing Google session. Because the grant is already on file, no consent screen appears. - The token lives only in the tab. Drive and Gmail enforce the user's own ACLs, so the page can only reach what the user could already open. - Tokens expire hourly; a silent re-request renews them without a prompt while the Google session is alive. This is **not** a browser extension. Nothing is installed in Chrome. The grant is recorded against the tenant. ### 11.2 Scopes | Need | Scope | Notes | |---|---|---| | Open specific Drive files | `drive.file` + Google Picker | User picks a file; app sees only that file. Also enables "Open with Risk Mandate" in Drive. | | Browse Drive broadly | `drive.readonly` | Only if Picker is insufficient. Broader; prefer `drive.file`. | | Read Gmail | `gmail.readonly` | No picker equivalent; filter client-side. | Keep every scope read-only unless a write path is explicitly designed. ### 11.3 Allow-listing which sites may invoke the app Two controls, both required: 1. **Google Cloud Console → OAuth client → Authorised JavaScript origins.** List `https://riskmandate.ai`, `https://sgit.ai` and any other Risk Mandate-owned origin. Google issues tokens only to pages served from these origins. Either one client with multiple origins, or one client per site under the same Cloud project. 2. **Admin console → Security → API controls → App access control.** Mark the Risk Mandate app as *Trusted*; set unconfigured third-party apps to *Restricted* (or *Limited*). No other app can then request Workspace data from users in the tenant, even with user consent. Together these mean: only Risk Mandate-owned origins can mint tokens, and only Risk Mandate's app is permitted to hold Workspace scopes in the tenant. ### 11.4 Clean-browser login walkthrough 1. User opens a clean Chrome and signs into `accounts.google.com` with their Workspace username, password and 2FA. 2. Google session established. The domain install is already on file; the app appears in the waffle launcher and in Drive's "Open with" menu. 3. User navigates to `riskmandate.ai`. The page performs OIDC sign-in — passes silently against the existing Google session. 4. The page requests a Drive/Gmail access token via Google Identity Services — passes silently because the admin grant covers these scopes. Token held in the tab only. 5. The page performs one **passkey assertion with PRF** (the only user-visible step on a clean device) to derive the wrapping key and unwrap the SGit data key in memory. 6. User works. Drive/Gmail reads happen client-side with the user's own permissions; SGit encrypt/decrypt happens client-side; ciphertext goes to the per-customer Cloud Storage bucket. 7. On tab close, the access token and the data key are gone. Nothing persists in plaintext anywhere. ### 11.5 Why not domain-wide delegation for this Domain-wide delegation (service account impersonating users) also removes consent prompts, but it moves data access **server-side** and creates the most powerful credential in the design. It is reserved for Tier 2/3 features that must run without the user present, scoped narrowly, run on Cloud Run with Workload Identity (no key file), and with every impersonation logged. For the client-side experience above it is unnecessary. ============================================================================== source: /docs/design/secrets-sgit-ai__mvp-build-brief.md ============================================================================== # secrets.sgit.ai — MVP build brief Status: **brief, written to be executed** · 5 October 2026 · for the Claude Code session working in `SGit-AI/SGit-AI__Website__Secrets` · CC BY 4.0 > A zero-knowledge secrets manager that runs entirely in the browser. The site is static on GitHub Pages. The only cloud is one GCP project per environment, holding Identity Platform (login) and a Cloud Storage bucket (ciphertext). The browser does every cryptographic operation. A full compromise of the GCP project, the Identity Platform admin or the bucket yields ciphertext and login metadata, never a secret. Everything in this repository is public, including the configuration of every environment, because nothing in it is secret. --- ## 0. How to use this brief 1. Read sections 1–4 before writing anything. They hold the decisions that are already made; do not reopen them. 2. The build order in section 11 is the plan. Each step has acceptance criteria. Ship the pipeline first, then the content, then the app, then admin and tests. Every push to `dev` is a release. 3. Copy the four design documents listed in section 2 into `docs/design/` on your first commit, unchanged. They are the reasoning; this brief is the instruction. 4. Where this brief says PROPOSED or VERIFY FIRST, test before building on it. Where you find this brief wrong, correct it in `docs/design/brief-corrections.md` with what you found, and carry on. 5. The house rules are at coding.sgit.ai and nfrs.sgit.ai. Section 7 extracts what applies here. When in doubt, read `https://coding.sgit.ai/llms-full.txt` and `https://nfrs.sgit.ai/llms.txt`. What exists today: the repository, with README, Apache-2.0 LICENSE and a Python .gitignore, on branches `dev` and `main`. Nothing else. --- ## 1. What this is **One sentence.** A password-manager-shaped app where the "passwords" can be anything small and secret — passwords, API keys, sgit vault keys and read keys, PKI private keys, short notes — unlocked by a passkey, stored as ciphertext in a GCP bucket the user's login can reach, and readable by no one else, including the people who run the bucket. **Why it exists.** It is the MVP for the key-vault layer under Risk Mandate and sgit: identity plus key storage, with every hard piece (login, browser-direct storage, passkey PRF unlock, keyring format, device enrolment, recovery, sharing) in one small, testable product. If an admin with full GCP access cannot read a stored password, the same model holds for vault keys. sgit's own docs say sgit is *not* a secrets manager and its partnerships page asks for exactly this: browser first, key kinds kept apart, release only on approval, end-to-end sharing, revocation, keys for agents. This site answers that call from inside the family. **Principles (non-negotiable).** | # | Principle | What it forbids | |---|---|---| | P1 | Plaintext exists only in the browser, briefly, after a passkey gesture | Any server-side decryption; any plaintext in storage, logs or URLs | | P2 | The login decides *which paths you may touch*; the passkey decides *whether the bytes mean anything* | Deriving a key from the login; storing a key in Identity Platform | | P3 | Nothing in the repo is secret | Any credential, account id or private key in the tree, ever; test fixtures use obviously fake values | | P4 | No build step, no bundler, no runtime CDN in the app origin | `npm run build`; `