Home / Keyring format v1

Keyring format, version 1

Status, from /shipped/: 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/<credentialId>")  →  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-<credentialId base64url>", "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" }
}

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.

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

CallParameters
createrp: { 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.
getrpId; 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.