Skip to content
Open
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
26 changes: 26 additions & 0 deletions plugins/age/age.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package age

import (
"github.com/1Password/shell-plugins/sdk"
"github.com/1Password/shell-plugins/sdk/needsauth"
"github.com/1Password/shell-plugins/sdk/schema"
"github.com/1Password/shell-plugins/sdk/schema/credname"
)

func AgeCLI() schema.Executable {
return schema.Executable{
Name: "Age",
Runs: []string{"age"},
DocsURL: sdk.URL("https://htmlpreview.github.io/?https://github.com/FiloSottile/age/blob/main/doc/age.1.html"),
NeedsAuth: needsauth.IfAll(
needsauth.NotForHelpOrVersion(),
needsauth.NotWithoutArgs(),
),
Uses: []schema.CredentialUsage{
{
Name: credname.SecretKey,
Description: "Public/private key pair to use for encryption/decryption.",
},
},
}
}
75 changes: 75 additions & 0 deletions plugins/age/key_pair.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package age

import (
"fmt"

"github.com/1Password/shell-plugins/plugins/age/provisioner"
"github.com/1Password/shell-plugins/sdk"
"github.com/1Password/shell-plugins/sdk/importer"
"github.com/1Password/shell-plugins/sdk/schema"
"github.com/1Password/shell-plugins/sdk/schema/credname"
"github.com/1Password/shell-plugins/sdk/schema/fieldname"
)

func KeyPair() schema.CredentialType {
return schema.CredentialType{
Name: credname.SecretKey,
DocsURL: sdk.URL("https://age-encryption.org/"),
Fields: []schema.CredentialField{
{
Name: fieldname.PublicKey,
MarkdownDescription: "Age X25519 public key.",
Secret: false,
Composition: &schema.ValueComposition{
Length: 62,
Charset: schema.Charset{
Lowercase: true,
Digits: true,
},
},
},
{
Name: fieldname.PrivateKey,
MarkdownDescription: "Age X25519 private key.",
Secret: true,
Composition: &schema.ValueComposition{
Length: 74,
Charset: schema.Charset{
Uppercase: true,
Digits: true,
Specific: []rune{'-'},
},
},
},
},
DefaultProvisioner: provisioner.KeyPairTempFile(provisioner.NewKeyFiles(
materialisePrivateKeyFile,
materialisePublicKeyFile,
)),
Importer: importer.NoOp(),
}
}

func materialisePublicKeyFile(in sdk.ProvisionInput) ([]byte, error) {
content := "# generated by: 1password-cli/shell-plugins/plugin/age\n"

if publicKey, ok := in.ItemFields[fieldname.PublicKey]; ok {
content += publicKey
}

return []byte(content), nil
}

func materialisePrivateKeyFile(in sdk.ProvisionInput) ([]byte, error) {
content := "# generated by: 1password-cli/shell-plugins/plugin/age\n"

if publicKey, ok := in.ItemFields[fieldname.PublicKey]; ok {
content += fmt.Sprintf("# public key: %s\n", publicKey)
}

if privateKey, ok := in.ItemFields[fieldname.PrivateKey]; ok {
content += privateKey
}

return []byte(content), nil
}
108 changes: 108 additions & 0 deletions plugins/age/key_pair_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package age

import (
"testing"

"github.com/1Password/shell-plugins/plugins/age/provisioner"
"github.com/1Password/shell-plugins/sdk"
"github.com/1Password/shell-plugins/sdk/plugintest"
"github.com/1Password/shell-plugins/sdk/schema/fieldname"
)

func TestAsymmetricKeyPairProvisioner(t *testing.T) {
plugintest.TestProvisioner(t, KeyPair().DefaultProvisioner, map[string]plugintest.ProvisionCase{
"defaults-to-encryption-mode": {
ItemFields: map[sdk.FieldName]string{
fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000",
fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000",
},
CommandLine: []string{"age", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
ExpectedOutput: sdk.ProvisionOutput{
CommandLine: []string{"age", "-R", "/tmp/age.public.txt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
Files: map[string]sdk.OutputFile{
"/tmp/age.public.txt": {
Contents: []byte(plugintest.LoadFixture(t, "age.public.txt")),
},
},
},
},
"explicit-encryption-mode-short": {
ItemFields: map[sdk.FieldName]string{
fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000",
fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000",
},
CommandLine: []string{"age", "-e", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
ExpectedOutput: sdk.ProvisionOutput{
CommandLine: []string{"age", "-R", "/tmp/age.public.txt", "-e", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
Files: map[string]sdk.OutputFile{
"/tmp/age.public.txt": {
Contents: []byte(plugintest.LoadFixture(t, "age.public.txt")),
},
},
},
},
"explicit-encryption-mode-long": {
ItemFields: map[sdk.FieldName]string{
fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000",
fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000",
},
CommandLine: []string{"age", "--encrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
ExpectedOutput: sdk.ProvisionOutput{
CommandLine: []string{"age", "-R", "/tmp/age.public.txt", "--encrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
Files: map[string]sdk.OutputFile{
"/tmp/age.public.txt": {
Contents: []byte(plugintest.LoadFixture(t, "age.public.txt")),
},
},
},
},
"decryption-mode-short": {
ItemFields: map[sdk.FieldName]string{
fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000",
fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000",
},
CommandLine: []string{"age", "-d", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
ExpectedOutput: sdk.ProvisionOutput{
CommandLine: []string{"age", "-i", "/tmp/age.private.txt", "-d", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
Files: map[string]sdk.OutputFile{
"/tmp/age.private.txt": {
Contents: []byte(plugintest.LoadFixture(t, "age.private.txt")),
},
},
},
},
"decryption-mode-long": {
ItemFields: map[sdk.FieldName]string{
fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000",
fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000",
},
CommandLine: []string{"age", "--decrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
ExpectedOutput: sdk.ProvisionOutput{
CommandLine: []string{"age", "-i", "/tmp/age.private.txt", "--decrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
Files: map[string]sdk.OutputFile{
"/tmp/age.private.txt": {
Contents: []byte(plugintest.LoadFixture(t, "age.private.txt")),
},
},
},
},
"decryption-identity-flag-provided": {
ItemFields: map[sdk.FieldName]string{
fieldname.PrivateKey: "AGE-SECRET-KEY-10000000000000000000000000000000000000000000000000000000000",
fieldname.PublicKey: "age10000000000000000000000000000000000000000000000000000000000",
},
CommandLine: []string{"age", "-i", "/tmp/age.user_provided_identity_flag.txt", "--decrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
ExpectedOutput: sdk.ProvisionOutput{
Diagnostics: sdk.Diagnostics{
Errors: []sdk.Error{{Message: provisioner.ErrConflictingIdentityFlag.Error()}},
},
CommandLine: []string{"age", "-i", "/tmp/age.private.txt", "-i", "/tmp/age.user_provided_identity_flag.txt", "--decrypt", "-o", "/tmp/encrypted.txt", "/tmp/unencrypted.txt"},
Files: map[string]sdk.OutputFile{
"/tmp/age.private.txt": {
Contents: []byte(plugintest.LoadFixture(t, "age.private.txt")),
},
},
},
},
})
}
42 changes: 42 additions & 0 deletions plugins/age/operation/operation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package operation

const (
decryptShort = "-d"
decryptLong = "--decrypt"
encryptShort = "-e"
encryptLong = "--encrypt"
)

const (
Encrypt Operation = iota
Decrypt
)

// Operation defines the type of action (encryption or decryption) to be performed.
type Operation int

// String returns the string representation of an Operation.
func (op Operation) String() string {
switch op {
case Encrypt:
return "encrypt"
case Decrypt:
return "decrypt"
default:
return "unknown"
}
}

// Detect determines the operation (encrypt or decrypt) based on the provided command-line arguments.
// If no valid operation flags are detected, it defaults to encryption mode.
func Detect(args []string) Operation {
for _, arg := range args {
switch arg {
case decryptShort, decryptLong:
return Decrypt
case encryptShort, encryptLong:
return Encrypt
}
}
return Encrypt
}
36 changes: 36 additions & 0 deletions plugins/age/operation/operation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package operation

import "testing"

func TestOperationString(t *testing.T) {
tests := []struct {
name string
input Operation
expected string
}{
{
name: "Encrypt Operation",
input: Encrypt,
expected: "encrypt",
},
{
name: "Decrypt Operation",
input: Decrypt,
expected: "decrypt",
},
{
name: "Invalid Operation",
input: Operation(999),
expected: "unknown",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := test.input.String()
if result != test.expected {
t.Errorf("For input %v, expected %q but got %q", test.input, test.expected, result)
}
})
}
}
22 changes: 22 additions & 0 deletions plugins/age/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package age

import (
"github.com/1Password/shell-plugins/sdk"
"github.com/1Password/shell-plugins/sdk/schema"
)

func New() schema.Plugin {
return schema.Plugin{
Name: "age",
Platform: schema.PlatformInfo{
Name: "Age",
Homepage: sdk.URL("https://age-encryption.org/"),
},
Credentials: []schema.CredentialType{
KeyPair(),
},
Executables: []schema.Executable{
AgeCLI(),
},
}
}
7 changes: 7 additions & 0 deletions plugins/age/provisioner/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package provisioner

import "fmt"

var (
ErrConflictingIdentityFlag = fmt.Errorf("conflict: the -i/--identity flag is automatically added by this plugin. Remove it from your command to continue")
)
44 changes: 44 additions & 0 deletions plugins/age/provisioner/private_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package provisioner

import (
"context"

"github.com/1Password/shell-plugins/sdk"
"github.com/1Password/shell-plugins/sdk/provision"
)

// PrivateKeyProvisioner handles the provisioning and deprovisioning of private key files for the age command.
type PrivateKeyProvisioner struct {
privateKey provision.ItemToFileContents
fileOptions []provision.FileOption
}

// Provision sets up the private key file for age decrypt commands.
func (p PrivateKeyProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) {
for _, arg := range out.CommandLine {
if arg == "-i" || arg == "--identity" {
out.AddError(ErrConflictingIdentityFlag)
}
}

fileProvisioner := provision.TempFile(p.privateKey, p.fileOptions...)
fileProvisioner.Provision(ctx, in, out)
}

// Deprovision performs cleanup after the process completes.
// In this implementation, no cleanup is required as temporary files are automatically removed.
func (p PrivateKeyProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) {
}

func (p PrivateKeyProvisioner) Description() string {
return "Provision temporary file with private key & populate command line arguments."
}

// PrivateKeyTempFile creates a new PrivateKeyProvisioner for creating private key files for age.
func PrivateKeyTempFile(privateKey provision.ItemToFileContents, opts ...provision.FileOption) PrivateKeyProvisioner {
opts = append(opts, provision.Filename("age.private.txt"), provision.PrependArgs("-i", "{{.Path}}"))
return PrivateKeyProvisioner{
privateKey: privateKey,
fileOptions: opts,
}
}
Loading
Loading