Skip to content

Commit d023cc9

Browse files
committed
feat(share): add api/share/ types package with crypto helper
Define share membership types (Member, Invitation, ExternalInvitation), request payloads, response wrappers, and permission constants in api/share/. This is the types layer — no API calls. Add GenerateKeyPacket() for encrypting a share session key for an invitee. Decrypts the share passphrase, re-encrypts with the invitee public key, and signs with the inviter address key. The decrypted session key is not retained beyond the function call. Property tests: key packet round-trip (100 iterations) and JSON round-trip for all struct types. Assisted-by: Kiro <noreply@kiro.dev>
1 parent f308cae commit d023cc9

File tree

4 files changed

+395
-0
lines changed

4 files changed

+395
-0
lines changed

api/share/crypto.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package share
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
7+
"github.com/ProtonMail/gopenpgp/v2/crypto"
8+
)
9+
10+
// GenerateKeyPacket encrypts the share's session key for the invitee.
11+
// It decrypts the share passphrase using shareKR, re-encrypts the resulting
12+
// session key with inviteeKR, and signs the key packet with inviterAddrKR.
13+
// Returns (keyPacketBase64, keyPacketSignatureArmored, error).
14+
// The decrypted session key is not retained beyond this function call.
15+
func GenerateKeyPacket(shareKR, inviterAddrKR, inviteeKR *crypto.KeyRing, sharePassphrase string) (string, string, error) {
16+
// Decrypt the share passphrase to obtain the session key material.
17+
enc, err := crypto.NewPGPMessageFromArmored(sharePassphrase)
18+
if err != nil {
19+
return "", "", fmt.Errorf("generate key packet: parse passphrase: %w", err)
20+
}
21+
22+
dec, err := shareKR.Decrypt(enc, nil, crypto.GetUnixTime())
23+
if err != nil {
24+
return "", "", fmt.Errorf("generate key packet: decrypt passphrase: %w", err)
25+
}
26+
27+
// Re-encrypt the session key material with the invitee's public key.
28+
plainMsg := crypto.NewPlainMessage(dec.GetBinary())
29+
encMsg, err := inviteeKR.Encrypt(plainMsg, nil)
30+
if err != nil {
31+
return "", "", fmt.Errorf("generate key packet: encrypt for invitee: %w", err)
32+
}
33+
34+
keyPacketBytes := encMsg.GetBinary()
35+
keyPacketB64 := base64.StdEncoding.EncodeToString(keyPacketBytes)
36+
37+
// Sign the encrypted key packet with the inviter's address key.
38+
sig, err := inviterAddrKR.SignDetached(crypto.NewPlainMessage(keyPacketBytes))
39+
if err != nil {
40+
return "", "", fmt.Errorf("generate key packet: sign: %w", err)
41+
}
42+
43+
sigArmored, err := sig.GetArmored()
44+
if err != nil {
45+
return "", "", fmt.Errorf("generate key packet: armor signature: %w", err)
46+
}
47+
48+
return keyPacketB64, sigArmored, nil
49+
}

api/share/crypto_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package share
2+
3+
import (
4+
"bytes"
5+
"encoding/base64"
6+
"testing"
7+
8+
"github.com/ProtonMail/gopenpgp/v2/crypto"
9+
"github.com/ProtonMail/gopenpgp/v2/helper"
10+
"pgregory.net/rapid"
11+
)
12+
13+
// genKeyRing generates a fresh PGP key pair and returns the keyring.
14+
func genKeyRing(t *testing.T, name string) *crypto.KeyRing {
15+
t.Helper()
16+
armored, err := helper.GenerateKey(name, name+"@test.local", nil, "x25519", 0)
17+
if err != nil {
18+
t.Fatalf("generate key %s: %v", name, err)
19+
}
20+
key, err := crypto.NewKeyFromArmored(armored)
21+
if err != nil {
22+
t.Fatalf("parse key %s: %v", name, err)
23+
}
24+
kr, err := crypto.NewKeyRing(key)
25+
if err != nil {
26+
t.Fatalf("keyring %s: %v", name, err)
27+
}
28+
return kr
29+
}
30+
31+
// encryptPassphrase encrypts a plaintext passphrase with the share keyring,
32+
// simulating how a share passphrase is stored.
33+
func encryptPassphrase(t *testing.T, shareKR *crypto.KeyRing, plaintext []byte) string {
34+
t.Helper()
35+
msg, err := shareKR.Encrypt(crypto.NewPlainMessage(plaintext), nil)
36+
if err != nil {
37+
t.Fatalf("encrypt passphrase: %v", err)
38+
}
39+
armored, err := msg.GetArmored()
40+
if err != nil {
41+
t.Fatalf("armor passphrase: %v", err)
42+
}
43+
return armored
44+
}
45+
46+
// TestGenerateKeyPacketRoundTrip_Property verifies that for any valid
47+
// share keyring, inviter address keyring, and invitee key pair, generating
48+
// a key packet and decrypting it with the invitee's private key yields
49+
// the original share passphrase.
50+
//
51+
// **Property 1: Key Packet Round-Trip**
52+
// **Validates: Requirements 3.2, 5.2, 5.4**
53+
func TestGenerateKeyPacketRoundTrip_Property(t *testing.T) {
54+
// Key generation is expensive — generate once, randomize passphrase.
55+
shareKR := genKeyRing(t, "share")
56+
inviterKR := genKeyRing(t, "inviter")
57+
inviteeKR := genKeyRing(t, "invitee")
58+
59+
rapid.Check(t, func(t *rapid.T) {
60+
// Generate random passphrase content (8-64 bytes).
61+
passphraseLen := rapid.IntRange(8, 64).Draw(t, "passphraseLen")
62+
passphrase := make([]byte, passphraseLen)
63+
for i := range passphrase {
64+
passphrase[i] = byte(rapid.IntRange(0, 255).Draw(t, "byte"))
65+
}
66+
67+
// Encrypt the passphrase with the share keyring (simulates stored share passphrase).
68+
msg, err := shareKR.Encrypt(crypto.NewPlainMessage(passphrase), nil)
69+
if err != nil {
70+
t.Fatalf("encrypt passphrase: %v", err)
71+
}
72+
encPassphrase, err := msg.GetArmored()
73+
if err != nil {
74+
t.Fatalf("armor passphrase: %v", err)
75+
}
76+
77+
// Generate the key packet for the invitee.
78+
keyPacketB64, sigArmored, err := GenerateKeyPacket(shareKR, inviterKR, inviteeKR, encPassphrase)
79+
if err != nil {
80+
t.Fatalf("GenerateKeyPacket: %v", err)
81+
}
82+
83+
if keyPacketB64 == "" {
84+
t.Fatal("key packet is empty")
85+
}
86+
if sigArmored == "" {
87+
t.Fatal("signature is empty")
88+
}
89+
90+
// Decrypt the key packet with the invitee's private key.
91+
keyPacketBytes, err := base64.StdEncoding.DecodeString(keyPacketB64)
92+
if err != nil {
93+
t.Fatalf("decode key packet: %v", err)
94+
}
95+
96+
encMsg := crypto.NewPGPMessage(keyPacketBytes)
97+
decMsg, err := inviteeKR.Decrypt(encMsg, nil, crypto.GetUnixTime())
98+
if err != nil {
99+
t.Fatalf("decrypt key packet: %v", err)
100+
}
101+
102+
// Verify the recovered passphrase matches the original.
103+
if !bytes.Equal(decMsg.GetBinary(), passphrase) {
104+
t.Fatalf("round-trip mismatch: got %x, want %x", decMsg.GetBinary(), passphrase)
105+
}
106+
107+
// Verify the signature is valid (signed by inviter).
108+
sig, err := crypto.NewPGPSignatureFromArmored(sigArmored)
109+
if err != nil {
110+
t.Fatalf("parse signature: %v", err)
111+
}
112+
113+
if err := inviterKR.VerifyDetached(crypto.NewPlainMessage(keyPacketBytes), sig, crypto.GetUnixTime()); err != nil {
114+
t.Fatalf("signature verification failed: %v", err)
115+
}
116+
})
117+
}

api/share/types.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Package share defines types for Proton Drive share membership and
2+
// invitation management. This is the types layer — it does not make
3+
// API calls. HTTP operations live in api/share/client/.
4+
package share
5+
6+
// Permission bitmask values for share members.
7+
const (
8+
PermRead = 4
9+
PermWrite = 2
10+
PermAdmin = 16
11+
12+
PermViewer = PermRead // 4
13+
PermEditor = PermRead | PermWrite // 6
14+
)
15+
16+
// FormatPermissions returns a human-readable label for a permission bitmask.
17+
func FormatPermissions(p int) string {
18+
switch {
19+
case p&PermAdmin != 0:
20+
return "admin"
21+
case p == PermEditor:
22+
return "editor"
23+
case p == PermViewer:
24+
return "viewer"
25+
default:
26+
return "unknown"
27+
}
28+
}
29+
30+
// Member represents an existing member of a share.
31+
type Member struct {
32+
MemberID string `json:"MemberID"`
33+
Email string `json:"Email"`
34+
InviterEmail string `json:"InviterEmail"`
35+
AddressID string `json:"AddressID"`
36+
CreateTime int64 `json:"CreateTime"`
37+
ModifyTime int64 `json:"ModifyTime"`
38+
Permissions int `json:"Permissions"`
39+
KeyPacketSignature string `json:"KeyPacketSignature"`
40+
SessionKeySignature string `json:"SessionKeySignature"`
41+
}
42+
43+
// Invitation represents a pending invite for a Proton user.
44+
type Invitation struct {
45+
InvitationID string `json:"InvitationID"`
46+
InviterEmail string `json:"InviterEmail"`
47+
InviteeEmail string `json:"InviteeEmail"`
48+
Permissions int `json:"Permissions"`
49+
KeyPacket string `json:"KeyPacket"`
50+
KeyPacketSignature string `json:"KeyPacketSignature"`
51+
CreateTime int64 `json:"CreateTime"`
52+
State int `json:"State"`
53+
}
54+
55+
// ExternalInvitation represents a pending invite for a non-Proton email.
56+
type ExternalInvitation struct {
57+
ExternalInvitationID string `json:"ExternalInvitationID"`
58+
InviterEmail string `json:"InviterEmail"`
59+
InviteeEmail string `json:"InviteeEmail"`
60+
CreateTime int64 `json:"CreateTime"`
61+
Permissions int `json:"Permissions"`
62+
State int `json:"State"`
63+
ExternalInvitationSignature string `json:"ExternalInvitationSignature"`
64+
}
65+
66+
// InviteProtonUserPayload is the request body for creating a Proton-user invitation.
67+
type InviteProtonUserPayload struct {
68+
Invitation struct {
69+
InviterEmail string `json:"InviterEmail"`
70+
InviteeEmail string `json:"InviteeEmail"`
71+
Permissions int `json:"Permissions"`
72+
KeyPacket string `json:"KeyPacket"`
73+
KeyPacketSignature string `json:"KeyPacketSignature"`
74+
ExternalInvitationID string `json:"ExternalInvitationID,omitempty"`
75+
} `json:"Invitation"`
76+
}
77+
78+
// InviteExternalUserPayload is the request body for creating an external-user invitation.
79+
type InviteExternalUserPayload struct {
80+
ExternalInvitation struct {
81+
InviterAddressID string `json:"InviterAddressID"`
82+
InviteeEmail string `json:"InviteeEmail"`
83+
Permissions int `json:"Permissions"`
84+
ExternalInvitationSignature string `json:"ExternalInvitationSignature"`
85+
} `json:"ExternalInvitation"`
86+
}
87+
88+
// Response wrappers used by the client layer to unmarshal API responses.
89+
90+
// MembersResponse wraps the list-members API response.
91+
type MembersResponse struct {
92+
Code int `json:"Code"`
93+
Members []Member `json:"Members"`
94+
}
95+
96+
// InvitationsResponse wraps the list-invitations API response.
97+
type InvitationsResponse struct {
98+
Code int `json:"Code"`
99+
Invitations []Invitation `json:"Invitations"`
100+
}
101+
102+
// ExternalInvitationsResponse wraps the list-external-invitations API response.
103+
type ExternalInvitationsResponse struct {
104+
Code int `json:"Code"`
105+
ExternalInvitations []ExternalInvitation `json:"ExternalInvitations"`
106+
}

api/share/types_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package share
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
"testing"
7+
8+
"pgregory.net/rapid"
9+
)
10+
11+
func genMember(t *rapid.T) Member {
12+
return Member{
13+
MemberID: rapid.String().Draw(t, "MemberID"),
14+
Email: rapid.String().Draw(t, "Email"),
15+
InviterEmail: rapid.String().Draw(t, "InviterEmail"),
16+
AddressID: rapid.String().Draw(t, "AddressID"),
17+
CreateTime: rapid.Int64().Draw(t, "CreateTime"),
18+
ModifyTime: rapid.Int64().Draw(t, "ModifyTime"),
19+
Permissions: rapid.Int().Draw(t, "Permissions"),
20+
KeyPacketSignature: rapid.String().Draw(t, "KeyPacketSignature"),
21+
SessionKeySignature: rapid.String().Draw(t, "SessionKeySignature"),
22+
}
23+
}
24+
25+
func genInvitation(t *rapid.T) Invitation {
26+
return Invitation{
27+
InvitationID: rapid.String().Draw(t, "InvitationID"),
28+
InviterEmail: rapid.String().Draw(t, "InviterEmail"),
29+
InviteeEmail: rapid.String().Draw(t, "InviteeEmail"),
30+
Permissions: rapid.Int().Draw(t, "Permissions"),
31+
KeyPacket: rapid.String().Draw(t, "KeyPacket"),
32+
KeyPacketSignature: rapid.String().Draw(t, "KeyPacketSignature"),
33+
CreateTime: rapid.Int64().Draw(t, "CreateTime"),
34+
State: rapid.Int().Draw(t, "State"),
35+
}
36+
}
37+
38+
func genExternalInvitation(t *rapid.T) ExternalInvitation {
39+
return ExternalInvitation{
40+
ExternalInvitationID: rapid.String().Draw(t, "ExternalInvitationID"),
41+
InviterEmail: rapid.String().Draw(t, "InviterEmail"),
42+
InviteeEmail: rapid.String().Draw(t, "InviteeEmail"),
43+
CreateTime: rapid.Int64().Draw(t, "CreateTime"),
44+
Permissions: rapid.Int().Draw(t, "Permissions"),
45+
State: rapid.Int().Draw(t, "State"),
46+
ExternalInvitationSignature: rapid.String().Draw(t, "ExternalInvitationSignature"),
47+
}
48+
}
49+
50+
// TestMemberJSONRoundTrip_Property verifies that Member survives JSON
51+
// marshal/unmarshal without data loss.
52+
//
53+
// **Property 2: Share Type JSON Round-Trip**
54+
// **Validates: Requirements 1.4**
55+
func TestMemberJSONRoundTrip_Property(t *testing.T) {
56+
rapid.Check(t, func(t *rapid.T) {
57+
orig := genMember(t)
58+
data, err := json.Marshal(orig)
59+
if err != nil {
60+
t.Fatalf("marshal: %v", err)
61+
}
62+
var got Member
63+
if err := json.Unmarshal(data, &got); err != nil {
64+
t.Fatalf("unmarshal: %v", err)
65+
}
66+
if !reflect.DeepEqual(orig, got) {
67+
t.Fatalf("round-trip mismatch:\norig: %+v\ngot: %+v", orig, got)
68+
}
69+
})
70+
}
71+
72+
func TestInvitationJSONRoundTrip_Property(t *testing.T) {
73+
rapid.Check(t, func(t *rapid.T) {
74+
orig := genInvitation(t)
75+
data, err := json.Marshal(orig)
76+
if err != nil {
77+
t.Fatalf("marshal: %v", err)
78+
}
79+
var got Invitation
80+
if err := json.Unmarshal(data, &got); err != nil {
81+
t.Fatalf("unmarshal: %v", err)
82+
}
83+
if !reflect.DeepEqual(orig, got) {
84+
t.Fatalf("round-trip mismatch:\norig: %+v\ngot: %+v", orig, got)
85+
}
86+
})
87+
}
88+
89+
func TestExternalInvitationJSONRoundTrip_Property(t *testing.T) {
90+
rapid.Check(t, func(t *rapid.T) {
91+
orig := genExternalInvitation(t)
92+
data, err := json.Marshal(orig)
93+
if err != nil {
94+
t.Fatalf("marshal: %v", err)
95+
}
96+
var got ExternalInvitation
97+
if err := json.Unmarshal(data, &got); err != nil {
98+
t.Fatalf("unmarshal: %v", err)
99+
}
100+
if !reflect.DeepEqual(orig, got) {
101+
t.Fatalf("round-trip mismatch:\norig: %+v\ngot: %+v", orig, got)
102+
}
103+
})
104+
}
105+
106+
func TestFormatPermissions(t *testing.T) {
107+
tests := []struct {
108+
perm int
109+
want string
110+
}{
111+
{PermViewer, "viewer"},
112+
{PermEditor, "editor"},
113+
{PermAdmin | PermRead, "admin"},
114+
{0, "unknown"},
115+
{1, "unknown"},
116+
}
117+
for _, tt := range tests {
118+
got := FormatPermissions(tt.perm)
119+
if got != tt.want {
120+
t.Errorf("FormatPermissions(%d) = %q, want %q", tt.perm, got, tt.want)
121+
}
122+
}
123+
}

0 commit comments

Comments
 (0)