diff --git a/docs.json b/docs.json index e6b4e14b..fa89fd29 100644 --- a/docs.json +++ b/docs.json @@ -181,6 +181,7 @@ "pages": [ "vaults/overview", "vaults/credentials", + "vaults/passkeys", "vaults/existing-credential-vault", "vaults/fill", "vaults/1password" diff --git a/vaults/credentials.mdx b/vaults/credentials.mdx index ff75cc5b..205a5094 100644 --- a/vaults/credentials.mdx +++ b/vaults/credentials.mdx @@ -3,7 +3,7 @@ title: "Credential Items" description: "Collect and update encrypted credentials, then use them in a vault-attached browser session" --- -use a `credential` item for usernames, passwords, totp generators, and other non-payment credentials. it belongs directly to a [vault](/vaults/overview); you don't need a wallet or an external credential provider. credential items power the [Fill from Vault](/auth/fill-from-vault) path under Auth. +use a `credential` item for usernames, passwords, totp generators, [passkeys](/vaults/passkeys), and other non-payment credentials. it belongs directly to a [vault](/vaults/overview); you don't need a wallet or an external credential provider. credential items power the [Fill from Vault](/auth/fill-from-vault) path under Auth. use `wallet` and `card` items for credit card numbers, security codes, and expiration dates. don't store, collect, or fill payment-card data through credential items. @@ -96,12 +96,12 @@ set `provider: "kernel"` for **KERNEL-hosted collection**: the user enters value | --- | --- | | `name` | required stable machine key for item state, value updates, and browser fill | | `label` | optional human-readable form text; falls back to `name` when omitted | -| `type` | `text`, `email`, `password`, or `totp` | +| `type` | `text`, `email`, `password`, `totp`, or `passkey` | | `required` | defaults to `true`; all required fields must have values for the item to become ready | | `sensitive` | defaults to `true`; leave it `true` for write-only values, or set it `false` only when your backend or collection form needs to read and prefill the value | | `value` | optional initial value from trusted code; omit it to leave the field unset | -password and totp fields must be sensitive. usernames and email addresses can also remain sensitive; `fill` works either way. fields marked `sensitive: false` return their stored values in item responses. +password, totp, and passkey fields must be sensitive. usernames and email addresses can also remain sensitive; `fill` works either way. fields marked `sensitive: false` return their stored values in item responses. Vault `totp` fields currently generate SHA1, six-digit codes every 30 seconds. The TOTP settings you can configure on a Managed Auth credential don't change this. For vault fill, those settings apply only when you [fill from Managed Auth](/auth/fill-from-managed-auth), where `fill` generates codes with that credential's algorithm, digit count, and period. For a non-default algorithm, digit count, or period, don't use a vault `totp` field for fill until that API supports those settings. @@ -109,7 +109,7 @@ Vault `totp` fields currently generate SHA1, six-digit codes every 30 seconds. T an item accepts 1–32 named fields. field names must start with an ascii letter, contain only ascii letters, numbers, and underscores, and be at most 64 characters. `email` values must be bare valid addresses such as `user@example.com`; use `text` for usernames that aren't valid email addresses. initial values must be nonempty strings and fit within 16 kib of utf-8 data each; `null` and empty strings aren't accepted on creation. -field names, labels, types, required flags, and sensitivity are immutable. repeating the same creation request retrieves the current item without overwriting later edits. use an update for value changes; use a new item key for a different field definition. +repeating the same creation request retrieves the current item without overwriting later edits. use an update for value changes, and [add or remove fields](#add-or-remove-fields) to change the field list. ### Copy values from an existing vault @@ -256,7 +256,7 @@ poll the item with a bounded wait until its status is `ready`. readiness means r invoke the advertised `collect` operation to reopen collection for a ready item. this doesn't clear its values. record its `version` before opening the form, then retrieve without `wait` to observe edits: `wait` waits for readiness, not changes to an already-ready item. a version change can also come from an api update, so it doesn't identify a particular form submission. -the form includes text, email, and password fields in declaration order, not totp. it renders `label` when present and falls back to `name`. non-sensitive values can be prefilled; stored secrets aren't revealed. required visible inputs must be populated on submission. +the form includes text, email, and password fields in declaration order, not totp or passkey fields. it renders `label` when present and falls back to `name`. non-sensitive values can be prefilled; stored secrets aren't revealed. required visible inputs must be populated on submission. ## Use a credential with WebMCP @@ -319,16 +319,85 @@ this replaces the password without returning either value. public updates can in a successful update increments the version and invalidates outstanding hosted collection sessions. on a `409` conflict, reload and reconcile with the user rather than automatically resubmitting stale edits. +## Add or remove fields + +an existing field's name, label, type, required flag, and sensitivity can't be edited. to change the field list, send `add_fields` or `remove_fields` in an update with the latest `version`: + +- `add_fields` appends field definitions, with the same defaults as creation. it takes definitions only; set a new field's value in `fields` in the same update. +- `remove_fields` deletes the named fields and their values. an unknown name returns `400`, and at least one field must remain. +- removals apply before additions, so removing a field and adding one with the same name in one update replaces its definition and deletes the old value. +- an item still holds at most 32 fields, and names must be unique. a required totp or passkey field needs its value in the same update, because a collection form can't supply it. + + + +```typescript TypeScript +const current = await kernel.vaults.items.retrieve(item.key, { id_or_name: vault.id }); +if (current.type !== "credential" || current.id !== item.id) { + throw new Error("credential item was replaced"); +} +const updated = await kernel.vaults.items.update(item.key, { + id_or_name: vault.id, + type: "credential", + version: current.version, + expected_item_id: item.id, + spec: { + add_fields: [ + { + name: "account_number", + label: "Account number", + type: "text", + required: true, + sensitive: false, + }, + ], + fields: { account_number: { value: "4417-2231" } }, + }, +}); +``` + +```python Python +current = kernel.vaults.items.retrieve(item.key, id_or_name=vault.id) +if current.type != "credential" or current.id != item.id: + raise RuntimeError("credential item was replaced") +updated = kernel.vaults.items.update( + item.key, + id_or_name=vault.id, + type="credential", + version=current.version, + expected_item_id=item.id, + spec={ + "add_fields": [ + { + "name": "account_number", + "label": "Account number", + "type": "text", + "required": True, + "sensitive": False, + } + ], + "fields": {"account_number": {"value": "4417-2231"}}, + }, +) +``` + + + +adding a required field without a value returns the item to `pending_collection` when the field is form-supported, so the user can supply it. + ## Use totp supply a base32 generator seed through trusted backend code, using `value` at creation or an authenticated update. a required totp field needs its seed at creation; the collection form can't supply it. an optional totp field can receive its seed later. KERNEL generates a fresh six-digit code immediately before filling, using hmac-sha1 and a 30-second period. neither the seed nor a generated code is returned by item reads. the seed never enters the browser; the generated code does. custom algorithms, periods, and digit counts aren't supported. +## Use passkeys + +a `passkey` field holds a passkey that a vault-attached browser can register on a website or sign in with. KERNEL signs on its servers, so the private key never enters the browser. passkeys aren't filled; use the `register_passkey`, `arm_passkey`, and `disarm_passkey` operations instead. see [passkeys](/vaults/passkeys). + ## Build your own collection surface your authenticated backend can retrieve safe item state and submit value updates using the ordinary item api. authenticate your own users, authorize their access to the vault and immutable item id, and apply the version precondition. keep broad KERNEL api credentials out of the frontend. -render every form-supported field in declaration order, display `label` with a `name` fallback, keep controls and updates keyed by `name`, omit totp, prefill only non-sensitive values, and don't log submitted values. for an unchanged form, use your own completion callback rather than an empty update. KERNEL doesn't store or authenticate your custom collection url. +render every form-supported field in declaration order, display `label` with a `name` fallback, keep controls and updates keyed by `name`, omit totp and passkey fields, prefill only non-sensitive values, and don't log submitted values. for an unchanged form, use your own completion callback rather than an empty update. KERNEL doesn't store or authenticate your custom collection url. continue with [fill browser fields](/vaults/fill) or the [browser agent cookbook](/browsers/use-vault-credentials-in-browser-agent). diff --git a/vaults/overview.mdx b/vaults/overview.mdx index cb61dc1a..eb789765 100644 --- a/vaults/overview.mdx +++ b/vaults/overview.mdx @@ -13,6 +13,8 @@ payments. sensitive values aren't returned by the vault api. use KERNEL's [`fill` api](/vaults/fill) to safely inject stored values from supported items into browser fields without passing the values through your application's injection code or model context. your controller supplies references and selectors; KERNEL writes the values and returns outcomes, not the values. invoke `fill` when the item advertises it in `available_operations`. +credential items can also hold [passkeys](/vaults/passkeys). an attached browser registers and signs in with them while KERNEL signs on its servers, so the private key never enters the browser. + for authentication workflows where your application or agent controls navigation and submission, start with [Fill from Vault](/auth/fill-from-vault). diff --git a/vaults/passkeys.mdx b/vaults/passkeys.mdx new file mode 100644 index 00000000..ba30f387 --- /dev/null +++ b/vaults/passkeys.mdx @@ -0,0 +1,272 @@ +--- +title: "Passkeys" +description: "Register, import, and sign in with passkeys stored in a credential item, without the private key entering the browser" +--- + +a `credential` item can hold passkeys in `passkey` fields. a browser attached to the item's vault can then create a passkey on a website and sign in with it. KERNEL answers the page's WebAuthn requests (`navigator.credentials.create` and `navigator.credentials.get`) and signs on its servers, so the private key never enters the browser, your application, or the model's context. + +passkeys aren't filled into pages. instead of `fill`, use three item operations: + +| operation | what it does | +| --- | --- | +| `register_passkey` | the next passkey a page creates in the browser is generated by KERNEL and stored in an empty passkey field | +| `arm_passkey` | the browser answers sign-in requests for the passkey's website with a stored passkey | +| `disarm_passkey` | the browser stops using the item's passkeys | + +each operation returns immediately and has no time limit. your agent drives the website with its own browser tools; KERNEL only answers the page's passkey requests. + + + while a passkey is armed, any script running on that website's pages can obtain a sign-in without user interaction, as with a password manager that fills automatically. disarm as soon as the task is done. + + +## Requirements + +- a `credential` item with `provider: "kernel"`. see [credential items](/vaults/credentials). +- a browser created with the item's vault attached. use the browser's session id in `browser_id`, not its name. +- invoke an operation only when the item advertises it in `available_operations`. KERNEL rechecks eligibility when you invoke it. +- SDK 0.131.0 or later for Node, Python, and Go, or CLI 0.50.0 or later. + +## Add a passkey field + +a passkey field must be sensitive. add an optional, empty passkey field to an existing item with an update. a required passkey field needs a value when you add it, so use an optional field when KERNEL will create the passkey. + + + +```typescript TypeScript +const current = await kernel.vaults.items.retrieve("portal-login", { + id_or_name: vault.id, +}); +if (current.type !== "credential") throw new Error("not a credential item"); +const item = await kernel.vaults.items.update(current.key, { + id_or_name: vault.id, + type: "credential", + version: current.version, + expected_item_id: current.id, + spec: { + add_fields: [ + { + name: "passkey", + label: "Passkey", + type: "passkey", + required: false, + sensitive: true, + }, + ], + }, +}); +``` + +```python Python +current = kernel.vaults.items.retrieve("portal-login", id_or_name=vault.id) +if current.type != "credential": + raise RuntimeError("not a credential item") +item = kernel.vaults.items.update( + current.key, + id_or_name=vault.id, + type="credential", + version=current.version, + expected_item_id=current.id, + spec={ + "add_fields": [ + { + "name": "passkey", + "label": "Passkey", + "type": "passkey", + "required": False, + "sensitive": True, + } + ] + }, +) +``` + + + +you can also declare the passkey field when you create the item. collection forms leave passkey fields out, so the item becomes ready once its other required fields have values. + +## Register a passkey on a website + +invoke `register_passkey` with the browser's session id, then have your agent open the website's "add a passkey" flow in that browser. when the page calls `navigator.credentials.create`, KERNEL generates the passkey, stores it in the field, and only then returns it to the page. the website never receives a passkey that KERNEL failed to store. + + + +```typescript TypeScript +const registration = await kernel.vaults.items.performOperation("portal-login", { + id_or_name: vault.id, + type: "register_passkey", + browser_id: browser.session_id, + field: "passkey", +}); +// drive the website's "add a passkey" flow with your browser tools, then confirm: +const registered = await kernel.vaults.items.retrieve("portal-login", { + id_or_name: vault.id, +}); +if ( + registered.type !== "credential" || + registered.state.provider !== "kernel" || + !registered.state.fields.passkey?.has_value +) { + throw new Error("the website didn't create a passkey"); +} +``` + +```python Python +registration = kernel.vaults.items.perform_operation( + "portal-login", + id_or_name=vault.id, + type="register_passkey", + browser_id=browser.session_id, + field="passkey", +) +# drive the website's "add a passkey" flow with your browser tools, then confirm: +registered = kernel.vaults.items.retrieve("portal-login", id_or_name=vault.id) +if (registered.type != "credential" or registered.state.provider != "kernel" + or not registered.state.fields["passkey"].has_value): + raise RuntimeError("the website didn't create a passkey") +``` + + + +`field` names an empty passkey field. omit it when the item has exactly one empty passkey field. the registration waits for the next passkey any page in that browser creates, so start it right before the website's flow. each browser has one pending registration; a later `register_passkey` replaces it. + +after the website confirms the new passkey, the field reports `has_value` and the vault records a `credential_passkey_registered` event. sign in once with `arm_passkey` to confirm the website accepts it. KERNEL creates ES256 passkeys only; a website that doesn't accept ES256 gets a `NotSupportedError`. + +to replace a stored passkey, clear the field or add another empty passkey field first. `register_passkey` only writes into an empty field. + +## Import an existing passkey + +if you already hold a passkey, for example exported from a password manager, write it as the field's value from your trusted backend. the value is a JSON-encoded passkey object from the FIDO Credential Exchange Format (CXF): + +| member | value | +| --- | --- | +| `credentialId` | unpadded base64url credential id | +| `userHandle` | unpadded base64url user handle, at most 64 bytes | +| `key` | unpadded base64url PKCS#8 ECDSA P-256 private key | +| `rpId` | lowercase relying party domain, such as `example.com` | +| `username`, `userDisplayName` | optional display names | + +other CXF members are ignored. keep the private key in trusted backend code; don't put it in frontend code, agent prompts, command-line arguments, logs, or traces. + + + +```typescript TypeScript +const exported = process.env.PORTAL_PASSKEY_CXF; +if (!exported) throw new Error("an exported passkey is required"); + +const current = await kernel.vaults.items.retrieve("portal-login", { + id_or_name: vault.id, +}); +if (current.type !== "credential") throw new Error("not a credential item"); +await kernel.vaults.items.update(current.key, { + id_or_name: vault.id, + type: "credential", + version: current.version, + expected_item_id: current.id, + spec: { fields: { passkey: { value: exported } } }, +}); +``` + +```python Python +import os + +exported = os.environ["PORTAL_PASSKEY_CXF"] + +current = kernel.vaults.items.retrieve("portal-login", id_or_name=vault.id) +if current.type != "credential": + raise RuntimeError("not a credential item") +kernel.vaults.items.update( + current.key, + id_or_name=vault.id, + type="credential", + version=current.version, + expected_item_id=current.id, + spec={"fields": {"passkey": {"value": exported}}}, +) +``` + + + +item reads never return the passkey. they report `has_value` for the field. + +## Sign in with a passkey + +invoke `arm_passkey`, then have your agent sign in on the website as a user would, for example by selecting **Sign in with a passkey**. when a page in that browser whose origin matches the passkey's relying party asks for a passkey, KERNEL answers with the armed passkey. this includes autofill requests that pages make as soon as they load. + + + +```typescript TypeScript +const armed = await kernel.vaults.items.performOperation("portal-login", { + id_or_name: vault.id, + type: "arm_passkey", + browser_id: browser.session_id, +}); +if (armed.type !== "arm_passkey") throw new Error("unexpected operation response"); +console.log(armed.status, armed.rp_id); // "armed", "example.com" +// sign in with your browser tools and inspect the page to confirm it. +``` + +```python Python +armed = kernel.vaults.items.perform_operation( + "portal-login", + id_or_name=vault.id, + type="arm_passkey", + browser_id=browser.session_id, +) +if armed.type != "arm_passkey": + raise RuntimeError("unexpected operation response") +print(armed.status, armed.rp_id) # "armed", "example.com" +# sign in with your browser tools and inspect the page to confirm it. +``` + + + +`status: "armed"` means the browser will answer matching requests; it doesn't mean a page has asked yet. pass `field` when the item has more than one passkey field holding a passkey. + +KERNEL builds each response from the origin Chrome reports for the requesting frame, not from what the page claims, and answers only pages on the passkey's relying party. if the website lists the credentials it accepts, only a matching passkey answers. the vault records a `credential_passkey_asserted` event for each sign-in. + +a page opened in a new tab or window can run its first scripts before KERNEL's handler attaches. if such a page asks for a passkey as soon as it loads, reload it. + +## Disarm + +invoke `disarm_passkey` when the task is done. it removes the browser's armed passkeys and its pending registration for the item. pass `field` to disarm one passkey field; omit it to disarm all of them. + + + +```typescript TypeScript +await kernel.vaults.items.performOperation("portal-login", { + id_or_name: vault.id, + type: "disarm_passkey", + browser_id: browser.session_id, +}); +``` + +```python Python +kernel.vaults.items.perform_operation( + "portal-login", + id_or_name=vault.id, + type="disarm_passkey", + browser_id=browser.session_id, +) +``` + + + +pages that already loaded keep KERNEL's handler, but they get the browser's own passkey behavior after you disarm. + +an arm also ends when the browser ends, the vault is detached, or the item or field is deleted. + +## Use the CLI + +```bash +kernel vaults items invoke user-12345 portal-login register_passkey --browser-id --field passkey +kernel vaults items invoke user-12345 portal-login arm_passkey --browser-id +kernel vaults items invoke user-12345 portal-login disarm_passkey --browser-id +``` + +## Behavior and limits + +- arming a passkey for a website replaces any passkey armed earlier in the same browser for that website. +- passkeys report a signature counter of 0 and set the backup flags, as synced passkeys do. websites don't flag repeated sign-ins from different browsers as a cloned authenticator. +- passkeys are never filled. `fill` rejects passkey fields, and passkey fields can't be bindings for `webmcp_invoke`. +- KERNEL-hosted collection forms and [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react) 0.4.0 and later leave passkey fields out. vault-react 0.2.0 can't load an item that has a passkey field, so upgrade a custom form before adding one. +- passkeys work only in browsers whose image supports them. on an older browser, `arm_passkey` and `register_passkey` return `409` with "browser does not support passkeys".