Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,14 @@ Vault commands **collect user credentials and manage payment credentials; fill d
#### User credentials

Create a vault for the end user, attach it when creating a browser, then navigate to the
sensitive form. Define the observed fields without supplying values:
sensitive form. Define the observed fields in the website's natural top-to-bottom order;
the collection form renders this array order unchanged. Omit values for user collection:

```sh
kernel vaults create --name user-vault
kernel browsers create --vault user-vault
kernel vaults credentials create user-vault login --spec-file - <<'JSON'
{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}}
{"description":"Hacker News","fields":[{"name":"username","type":"text","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true}]}
JSON
kernel vaults items get user-vault login --wait 60 -o json
kernel vaults items invoke user-vault login fill --spec-file - <<'JSON'
Expand Down
2 changes: 1 addition & 1 deletion cmd/vaults_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Use wallet and card item types for credit cards and payment checkout instead.

User credential flow:
1. Create a vault per end user and create a browser with --vault <id-or-name>.
2. Navigate to a sensitive form and define its fields with credentials create --spec-file.
2. Navigate to a sensitive form and define its fields in natural top-to-bottom order with credentials create --spec-file; that array order controls the user-facing collection form.
3. Present the returned collection URL to the user. Poll items get --wait 60 for ready.
4. Use items invoke <vault> <key> fill --spec-file with browser_id and field selectors.
Use credentials update --version for edits, or items invoke collect to reopen the form.
Expand Down
2 changes: 1 addition & 1 deletion cmd/vaults_credential_guidance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestCredentialHumanGuidance(t *testing.T) {
args := []string{"vaults", "items", "get", "user", "login"}
switch operation {
case "create":
args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password"}}}`)}
args = []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":[{"name":"password","type":"password"}]}`)}
case "collect":
args = []string{"vaults", "items", "invoke", "user", "login", "collect"}
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/vaults_credential_steering_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ func TestCredentialHelpSteering(t *testing.T) {
assert.Contains(t, strings.Join(strings.Fields(cmd.Long), " "), "Use wallet and card item types for credit cards and payment checkout instead")
assert.Contains(t, newVaultsCommand().Long, "Use wallet and card item types")
assert.Contains(t, cmd.Long, "recognizable site name only")
assert.Contains(t, cmd.Long, "natural top-to-bottom order")
assert.Contains(t, cmd.Long, "collection form renders that order unchanged")
assert.Contains(t, cmd.Long, "sensitive:false explicitly for ordinary usernames and email addresses")
assert.Contains(t, cmd.Example, `"description":"Hacker News"`)
assert.Contains(t, cmd.Example, `"fields":[{"name":"username"`)
assert.Contains(t, cmd.Example, `"sensitive":false`)
}
8 changes: 5 additions & 3 deletions cmd/vaults_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ card item types for credit cards and payment checkout instead.

First create a vault for the end user and attach it with browsers create --vault.
Use a protected JSON file or stdin, never secret values in shell arguments.
The spec contains description and fields keyed by name. Field types are text,
email, password, and totp; definitions accept required, sensitive, and value.
The spec contains description and fields as an ordered array of named definitions.
Inspect the website and list fields in its natural top-to-bottom order because the
user-facing collection form renders that order unchanged. Field types are text,
email, password, and totp; definitions accept name, required, sensitive, and value.
Set description to the recognizable site name only, e.g. "Hacker News", not
"Hacker News sign-in credentials". This text is the user-facing form title.
Set sensitive:false explicitly for ordinary usernames and email addresses.
Expand Down Expand Up @@ -66,7 +68,7 @@ func newVaultCredentialsCommand() *cobra.Command {
cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json"
} else {
cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON'
{"description":"Hacker News","fields":{"username":{"type":"text","required":true,"sensitive":false},"password":{"type":"password","required":true,"sensitive":true}}}
{"description":"Hacker News","fields":[{"name":"username","type":"text","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true}]}
JSON`
}
cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)")
Expand Down
8 changes: 4 additions & 4 deletions cmd/vaults_credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"github.com/stretchr/testify/require"
)

const credentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Website login","fields":{"password":{"type":"password","required":true,"sensitive":true,"value":"never-print"}}},"state":{"status":"pending_collection","fields":{"password":{"has_value":false,"value":"never-print"}}},"action":{"name":"collect","url":"https://vault.kernel.sh/collect#token=item.random","expires_at":"2026-10-01T00:00:00Z"},"available_operations":[{"type":"collect","description":"Open the form"},{"type":"fill","description":"Fill the form"}],"available_expansions":[]}`
const credentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Website login","fields":[{"name":"password","type":"password","required":true,"sensitive":true,"value":"never-print"}]},"state":{"status":"pending_collection","fields":{"password":{"has_value":false,"value":"never-print"}}},"action":{"name":"collect","url":"https://vault.kernel.sh/collect#token=item.random","expires_at":"2026-10-01T00:00:00Z"},"available_operations":[{"type":"collect","description":"Open the form"},{"type":"fill","description":"Fill the form"}],"available_expansions":[]}`

func credentialSpecFile(t *testing.T, data string) string {
t.Helper()
Expand All @@ -42,12 +42,12 @@ func TestCredentialCreateAndUpdate(t *testing.T) {
assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"]))
} else {
assert.Equal(t, "PUT", r.Method)
assert.JSONEq(t, `{"fields":{"password":{"type":"password","required":true}}}`, string(body["spec"]))
assert.JSONEq(t, `{"fields":[{"name":"password","type":"password","required":true}]}`, string(body["spec"]))
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, credentialFixture)
})
args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","required":true}}}`), "-o", "json"}
args := []string{"vaults", "credentials", "create", "user", "login", "--spec-file", credentialSpecFile(t, `{"fields":[{"name":"password","type":"password","required":true}]}`), "-o", "json"}
if update {
args[2] = "update"
args[6] = credentialSpecFile(t, `{"fields":{"password":{"value":null}}}`)
Expand Down Expand Up @@ -75,7 +75,7 @@ func TestCredentialWriteErrorsAreRedactedAndNotRetried(t *testing.T) {
io.WriteString(w, `{"message":"secret-echo"}`)
})
c := VaultsCmd{vaults: &client.Vaults}
err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":{"password":{"type":"password","value":"secret-echo"}}}`), false, 0, "", "json", false)
err := c.saveCredential(context.Background(), "user", "login", []byte(`{"fields":[{"name":"password","type":"password","value":"secret-echo"}]}`), false, 0, "", "json", false)
require.Error(t, err)
assert.NotContains(t, err.Error(), "secret-echo")
assert.Equal(t, 1, calls)
Expand Down
2 changes: 1 addition & 1 deletion cmd/vaults_fill_credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/require"
)

const readyFillCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":{"expiration":{"type":"password"},"custom field":{"type":"text"},"otp":{"type":"totp"}}},"state":{"status":"ready"},"available_operations":[{"type":"fill","description":"Fill credential fields."}]}`
const readyFillCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":[{"name":"expiration","type":"password"},{"name":"custom field","type":"text"},{"name":"otp","type":"totp"}]},"state":{"status":"ready"},"available_operations":[{"type":"fill","description":"Fill credential fields."}]}`

func TestVaultFillBothItemTypesAndInputs(t *testing.T) {
for _, input := range []string{"params", "spec-file"} {
Expand Down
18 changes: 12 additions & 6 deletions cmd/vaults_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ var vaultItemFields = vaultOutputFields{
"provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil,
"amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil,
"context": nil, "expires_at": nil, "description": nil,
"fields": {"*": vaultFieldsOf("type required sensitive")},
"fields": vaultFieldsOf("name type required sensitive"),
"provider_config": vaultFieldsOf("id name"),
"authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}},
"totals": vaultTotalFields,
Expand Down Expand Up @@ -134,11 +134,13 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes
}

func preservePublicCredentialValues(source, result vaultJSON) error {
type definition struct {
Name string `json:"name"`
Type string `json:"type"`
Sensitive *bool `json:"sensitive"`
}
var spec struct {
Fields map[string]struct {
Type string `json:"type"`
Sensitive *bool `json:"sensitive"`
} `json:"fields"`
Fields []definition `json:"fields"`
}
var values struct {
Fields map[string]struct {
Expand All @@ -149,8 +151,12 @@ func preservePublicCredentialValues(source, result vaultJSON) error {
if json.Unmarshal(source["spec"], &spec) != nil || json.Unmarshal(source["state"], &values) != nil || values.Fields == nil {
return nil
}
definitions := make(map[string]definition, len(spec.Fields))
for _, field := range spec.Fields {
definitions[field.Name] = field
}
for name, field := range values.Fields {
definition := spec.Fields[name]
definition := definitions[name]
if definition.Sensitive == nil || *definition.Sensitive || (definition.Type != "text" && definition.Type != "email") || !field.HasValue {
field.Value = nil
}
Expand Down
24 changes: 21 additions & 3 deletions cmd/vaults_public_values_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import (
"github.com/stretchr/testify/require"
)

const publicCredentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Example","fields":{"username":{"type":"text","sensitive":false},"email":{"type":"email","sensitive":false},"password":{"type":"password"},"otp":{"type":"totp","sensitive":true}}},"state":{"status":"ready","fields":{"username":{"has_value":true,"value":"user-123"},"email":{"has_value":true,"value":"user@example.com"},"password":{"has_value":true,"value":"private-password"},"otp":{"has_value":true,"value":"private-seed"}}},"action":{"name":"collect","url":"https://vault.example/collect#token=user-123.token"},"available_operations":[{"type":"collect","description":"Open form"}],"available_expansions":[]}`
const publicCredentialFixture = `{"id":"credential-1","key":"login","type":"credential","version":2,"spec":{"description":"Example","fields":[{"name":"username","type":"text","sensitive":false},{"name":"email","type":"email","sensitive":false},{"name":"password","type":"password"},{"name":"otp","type":"totp","sensitive":true}]},"state":{"status":"ready","fields":{"username":{"has_value":true,"value":"user-123"},"email":{"has_value":true,"value":"user@example.com"},"password":{"has_value":true,"value":"private-password"},"otp":{"has_value":true,"value":"private-seed"}}},"action":{"name":"collect","url":"https://vault.example/collect#token=user-123.token"},"available_operations":[{"type":"collect","description":"Open form"}],"available_expansions":[]}`

func TestVaultPublicValuesAcrossCommands(t *testing.T) {
spec := credentialSpecFile(t, `{"fields":{"username":{"type":"text","sensitive":false,"value":"user-123"}}}`)
spec := credentialSpecFile(t, `{"fields":[{"name":"username","type":"text","sensitive":false,"value":"user-123"}]}`)
update := credentialSpecFile(t, `{"fields":{"username":{"value":"user-123"}}}`)
for _, args := range [][]string{
{"vaults", "credentials", "create", "user-123", "login", "--spec-file", spec},
Expand Down Expand Up @@ -44,6 +44,24 @@ func TestVaultPublicValuesAcrossCommands(t *testing.T) {
}
}

func TestVaultCredentialDefinitionOrderIsPreserved(t *testing.T) {
out, err := filterVaultJSON(json.RawMessage(publicCredentialFixture), vaultItemFields)
require.NoError(t, err)
var item struct {
Spec struct {
Fields []struct {
Name string `json:"name"`
} `json:"fields"`
} `json:"spec"`
}
require.NoError(t, json.Unmarshal(out, &item))
names := make([]string, 0, len(item.Spec.Fields))
for _, field := range item.Spec.Fields {
names = append(names, field.Name)
}
assert.Equal(t, []string{"username", "email", "password", "otp"}, names)
}

func TestVaultPublicValueBoundary(t *testing.T) {
for _, tc := range []struct {
kind, sensitive string
Expand All @@ -55,7 +73,7 @@ func TestVaultPublicValueBoundary(t *testing.T) {
{"text", "false", false, false},
} {
t.Run(fmt.Sprint(tc), func(t *testing.T) {
raw := fmt.Sprintf(`{"type":"credential","spec":{"fields":{"field":{"type":%q,"sensitive":%s}}},"state":{"fields":{"field":{"has_value":%t,"value":"test-value"}}}}`, tc.kind, tc.sensitive, tc.hasValue)
raw := fmt.Sprintf(`{"type":"credential","spec":{"fields":[{"name":"field","type":%q,"sensitive":%s}]},"state":{"fields":{"field":{"has_value":%t,"value":"test-value"}}}}`, tc.kind, tc.sensitive, tc.hasValue)
out, err := filterVaultJSON(json.RawMessage(raw), vaultItemFields)
require.NoError(t, err)
assert.Equal(t, tc.visible, strings.Contains(string(out), "test-value"))
Expand Down
4 changes: 2 additions & 2 deletions cmd/vaults_sdk_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ func TestCredentialUpdateIdentityPrecondition(t *testing.T) {
}
}

func TestCredentialInitialValuesWithReleasedSDK(t *testing.T) {
func TestCredentialInitialValuesWithGeneratedSDK(t *testing.T) {
t.Setenv("KERNEL_PROJECT", "")
spec := `{"description":"Example","fields":{"username":{"type":"text","sensitive":false,"value":"synthetic-user"},"email":{"type":"email","sensitive":false,"value":"test@example.com"},"password":{"type":"password","sensitive":true,"value":"synthetic-password"},"otp":{"type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}}}`
spec := `{"description":"Example","fields":[{"name":"username","type":"text","sensitive":false,"value":"synthetic-user"},{"name":"email","type":"email","sensitive":false,"value":"test@example.com"},{"name":"password","type":"password","sensitive":true,"value":"synthetic-password"},{"name":"otp","type":"totp","sensitive":true,"value":"JBSWY3DPEHPK3PXP"}]}`
calls := 0
client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/joho/godotenv v1.5.1
github.com/kernel/kernel-go-sdk v0.107.0
github.com/kernel/kernel-go-sdk v0.0.0-20260917183146-34ba0a529b1d
github.com/klauspost/compress v1.18.5
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pterm/pterm v0.12.80
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kernel/kernel-go-sdk v0.0.0-20260917183146-34ba0a529b1d h1:l3ZGtfYu7VHlWtHkvxqStmru5qqHA3IPwRXncCPbdis=
github.com/kernel/kernel-go-sdk v0.0.0-20260917183146-34ba0a529b1d/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
github.com/kernel/kernel-go-sdk v0.107.0 h1:0m4RkOquA3irSYK7hNU8VmLhTyl1jeCSydpTQ2tjba0=
github.com/kernel/kernel-go-sdk v0.107.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
Expand Down
Loading