feat(vault): add lockbox encryption mode to secrets
Added encryption_mode field to secrets supporting "standard" (server-side) and "lockbox" (client-side E2E) modes. Updated API to validate lockbox format (lockbox:v1:salt:ciphertext). Enhanced UI to display lock icons and badges for lockbox secrets. Lockbox secrets show locked state in web UI, requiring CLI/SDK for decryption.
This commit is contained in:
@@ -37,6 +37,9 @@ type VaultSecret struct {
|
||||
CreatedBy int64 `xorm:"NOT NULL"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
|
||||
|
||||
// Encryption mode: "standard" (server-side) or "lockbox" (client E2E encrypted)
|
||||
EncryptionMode string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'standard'"`
|
||||
|
||||
// Soft delete
|
||||
DeletedUnix timeutil.TimeStamp `xorm:"INDEX"` // 0 = active, >0 = soft deleted
|
||||
DeletedBy int64
|
||||
|
||||
@@ -49,6 +49,7 @@ type SecretResponse struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Type string `json:"type"`
|
||||
EncryptionMode string `json:"encryption_mode,omitempty"` // "standard" or "lockbox"
|
||||
CurrentVersion int `json:"current_version"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
@@ -115,11 +116,12 @@ type TokenInfoResponse struct {
|
||||
|
||||
// CreateSecretRequest is the request body for creating a secret
|
||||
type CreateSecretRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
Comment string `json:"comment"` // Used when updating existing secret
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
Comment string `json:"comment"` // Used when updating existing secret
|
||||
EncryptionMode string `json:"encryption_mode"` // "standard" (default) or "lockbox"
|
||||
}
|
||||
|
||||
// UpdateSecretRequest is the request body for updating a secret
|
||||
@@ -272,10 +274,15 @@ func requireRepoWrite(ctx *context.APIContext) bool {
|
||||
}
|
||||
|
||||
func secretToResponse(s *models.VaultSecret) SecretResponse {
|
||||
encMode := s.EncryptionMode
|
||||
if encMode == "" {
|
||||
encMode = "standard"
|
||||
}
|
||||
return SecretResponse{
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Type: string(s.Type),
|
||||
EncryptionMode: encMode,
|
||||
CurrentVersion: s.CurrentVersion,
|
||||
CreatedAt: int64(s.CreatedUnix),
|
||||
UpdatedAt: int64(s.UpdatedUnix),
|
||||
@@ -564,6 +571,28 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate encryption mode
|
||||
encryptionMode := req.EncryptionMode
|
||||
if encryptionMode == "" {
|
||||
encryptionMode = "standard"
|
||||
}
|
||||
if encryptionMode != "standard" && encryptionMode != "lockbox" {
|
||||
ctx.JSON(http.StatusBadRequest, map[string]any{
|
||||
"error": "invalid_encryption_mode",
|
||||
"message": "encryption_mode must be 'standard' or 'lockbox'",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate lockbox format: must start with "lockbox:v1:"
|
||||
if encryptionMode == "lockbox" && !strings.HasPrefix(req.Value, "lockbox:v1:") {
|
||||
ctx.JSON(http.StatusBadRequest, map[string]any{
|
||||
"error": "invalid_lockbox_format",
|
||||
"message": "Lockbox secrets must be pre-encrypted with format 'lockbox:v1:<salt>:<ciphertext>'",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if secret exists
|
||||
existing, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
|
||||
if err == services.ErrSecretNotFound {
|
||||
@@ -576,11 +605,12 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
|
||||
limits = &info.Limits
|
||||
}
|
||||
secret, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Type: req.Type,
|
||||
Value: req.Value,
|
||||
CreatorID: ctx.Doer.ID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Type: req.Type,
|
||||
Value: req.Value,
|
||||
EncryptionMode: encryptionMode,
|
||||
CreatorID: ctx.Doer.ID,
|
||||
}, limits)
|
||||
if err != nil {
|
||||
switch err {
|
||||
|
||||
@@ -106,11 +106,12 @@ func GetSecretValue(ctx context.Context, repoID int64, name string, version int)
|
||||
|
||||
// CreateSecretOptions contains options for creating a secret
|
||||
type CreateSecretOptions struct {
|
||||
Name string
|
||||
Description string
|
||||
Type string
|
||||
Value string
|
||||
CreatorID int64
|
||||
Name string
|
||||
Description string
|
||||
Type string
|
||||
Value string
|
||||
EncryptionMode string // "standard" (default) or "lockbox"
|
||||
CreatorID int64
|
||||
}
|
||||
|
||||
// CreateSecret creates a new secret
|
||||
@@ -158,12 +159,19 @@ func CreateSecret(ctx context.Context, repoID int64, opts CreateSecretOptions, l
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
|
||||
// Determine encryption mode
|
||||
encryptionMode := opts.EncryptionMode
|
||||
if encryptionMode == "" {
|
||||
encryptionMode = "standard"
|
||||
}
|
||||
|
||||
// Create the secret
|
||||
secret := &models.VaultSecret{
|
||||
RepoID: repoID,
|
||||
Name: opts.Name,
|
||||
Description: opts.Description,
|
||||
Type: models.SecretType(opts.Type),
|
||||
EncryptionMode: encryptionMode,
|
||||
CurrentVersion: 1,
|
||||
CreatedUnix: now,
|
||||
UpdatedUnix: now,
|
||||
|
||||
@@ -65,8 +65,11 @@
|
||||
<tr{{if .IsDeleted}} class="negative"{{end}}>
|
||||
<td>
|
||||
<a href="{{$.RepoLink}}/vault/secrets/{{.Name}}">
|
||||
{{svg "octicon-key" 16}} <strong>{{.Name}}</strong>
|
||||
{{if eq .EncryptionMode "lockbox"}}{{svg "octicon-lock" 16}}{{else}}{{svg "octicon-key" 16}}{{end}} <strong>{{.Name}}</strong>
|
||||
</a>
|
||||
{{if eq .EncryptionMode "lockbox"}}
|
||||
<span class="ui tiny blue label" data-tooltip="End-to-end encrypted">{{svg "octicon-shield-lock" 10}} Lock-Box</span>
|
||||
{{end}}
|
||||
{{if .Description}}
|
||||
<br><small class="text grey">{{.Description}}</small>
|
||||
{{end}}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<div class="tw-flex tw-items-start tw-justify-between tw-gap-4">
|
||||
<div>
|
||||
<h4 class="ui header tw-mb-0">
|
||||
{{if eq .Secret.EncryptionMode "lockbox"}}
|
||||
{{svg "octicon-lock" 20}} {{.Secret.Name}}
|
||||
<span class="ui blue label" data-tooltip="End-to-end encrypted. Use CLI/SDK to decrypt.">{{svg "octicon-shield-lock" 12}} Lock-Box</span>
|
||||
{{else}}
|
||||
{{svg "octicon-key" 20}} {{.Secret.Name}}
|
||||
{{end}}
|
||||
{{if .Secret.IsDeleted}}
|
||||
<span class="ui red label">{{ctx.Locale.Tr "vault.deleted"}}</span>
|
||||
{{end}}
|
||||
@@ -50,7 +55,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .CanWrite}}
|
||||
{{if eq .Secret.EncryptionMode "lockbox"}}
|
||||
<!-- Lockbox secret - show locked state -->
|
||||
<div class="ui segment tw-text-center">
|
||||
<div class="tw-py-8">
|
||||
{{svg "octicon-lock" 64 "tw-text-blue-500"}}
|
||||
<h3 class="ui header tw-mt-4">Lock-Box Protected</h3>
|
||||
<p class="tw-text-secondary tw-max-w-md tw-mx-auto">
|
||||
This secret is end-to-end encrypted. The server cannot decrypt it.
|
||||
Use the CLI or SDK with your passphrase to access the value.
|
||||
</p>
|
||||
<div class="ui divider"></div>
|
||||
<div class="ui secondary segment tw-text-left tw-max-w-lg tw-mx-auto">
|
||||
<p class="tw-font-semibold tw-mb-2">CLI Usage:</p>
|
||||
<code class="tw-block tw-p-2 tw-bg-gray-100 dark:tw-bg-gray-800 tw-rounded tw-font-mono tw-text-sm">
|
||||
vault-resolve get --lockbox {{.Secret.Name}}
|
||||
</code>
|
||||
<p class="tw-font-semibold tw-mb-2 tw-mt-4">Go SDK:</p>
|
||||
<code class="tw-block tw-p-2 tw-bg-gray-100 dark:tw-bg-gray-800 tw-rounded tw-font-mono tw-text-sm">
|
||||
value, err := client.GetLockbox(ctx, "{{.Secret.Name}}", passphrase)
|
||||
</code>
|
||||
</div>
|
||||
{{if .CanWrite}}
|
||||
<div class="tw-mt-4">
|
||||
{{if .Secret.IsDeleted}}
|
||||
<button class="ui green button" type="button" onclick="document.getElementById('restore-form').submit();">
|
||||
{{svg "octicon-history" 16}} {{ctx.Locale.Tr "vault.restore"}}
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="ui red button show-modal" type="button" data-modal="#delete-secret-modal">
|
||||
{{svg "octicon-trash" 16}} {{ctx.Locale.Tr "vault.delete"}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Secret.IsDeleted}}
|
||||
<form id="restore-form" class="tw-hidden" action="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}/restore" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
</form>
|
||||
{{else}}
|
||||
<form id="delete-form" class="tw-hidden" action="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}/delete" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else if .CanWrite}}
|
||||
<!-- Editor view for users who can write -->
|
||||
<div class="ui segment">
|
||||
<h5 class="ui header">{{ctx.Locale.Tr "vault.edit_secret"}}</h5>
|
||||
@@ -125,8 +175,8 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{else}}
|
||||
<!-- Read-only view for users who cannot write -->
|
||||
{{else if ne .Secret.EncryptionMode "lockbox"}}
|
||||
<!-- Read-only view for users who cannot write (standard secrets only) -->
|
||||
<div class="ui segment">
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-mb-4">
|
||||
<h5 class="ui header tw-mb-0">{{ctx.Locale.Tr "vault.secret_value"}}</h5>
|
||||
|
||||
Reference in New Issue
Block a user