carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1package store
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "errors"
8 "time"
9
10 "gorm.io/gorm"
11
12 "github.com/carlosframework/contra/internal/contra/crypto"
13 "github.com/carlosframework/contra/internal/contra/model"
14)
15
16// LinkTTL is how long a handoff code lives. Ten minutes is PROTOCOL.md's, and
17// it is the number doing most of the work here: the code is short enough to
18// read aloud, so its safety comes from the window and from both ends being
19// signed in, not from its length.
20const LinkTTL = 10 * time.Minute
21
22// LinkCodeSymbols is the normalised length of a handoff code: eight Crockford
23// symbols, drawn from the 32-character alphabet, so exactly 40 bits with no
24// modulo bias to reason about. slopbox's 31-character alphabet needed a
25// rejection loop it did not have.
26const LinkCodeSymbols = 8
27
28// MaxLiveLinks caps how many handoffs one account can have open at once.
29// Without it, an attacker holding a session could open codes until one of
30// them collided with something a person was about to approve.
31const MaxLiveLinks = 5
32
33var (
34 ErrNoLink = errors.New("store: no such link code, or it expired")
35 ErrLinkUsed = errors.New("store: that link has already been approved")
36 ErrTooManyLinks = errors.New("store: too many device links open for this account")
37)
38
39// LinkCodeHash is the only form of the code the server ever holds. The
40// caller normalises first — the same Crockford normalisation the printed
41// codes use, so O/I/L confusions are resolved identically on both screens.
42func LinkCodeHash(normalized string) []byte {
43 sum := sha256.Sum256([]byte(normalized))
44 return sum[:]
45}
46
47// StartLink records a fresh device's ephemeral public key against the hash of
48// a code that device minted. The code itself is never sent here.
49//
50// It is the client that mints the code, not the server. A server-chosen code
51// would be a value the server knows in full for every live handoff; this way
52// the only copies are on the two screens.
53func (s *Store) StartLink(ctx context.Context, id, codeHash, accountID,
54 devicePublic []byte, now time.Time) (*model.DeviceLink, error) {
55 if len(id) != 16 || len(codeHash) != sha256.Size || len(accountID) != 16 {
56 return nil, ErrShortField
57 }
58 if len(devicePublic) != crypto.PublicKeyLen || devicePublic[0] != 0x04 {
59 return nil, ErrShortField
60 }
61
62 var live int64
63 if err := s.db.WithContext(ctx).Model(&model.DeviceLink{}).
64 Where("account_id = ? AND expires_at > ?", accountID, now).
65 Count(&live).Error; err != nil {
66 return nil, err
67 }
68 if live >= MaxLiveLinks {
69 return nil, ErrTooManyLinks
70 }
71
72 link := &model.DeviceLink{
73 ID: id,
74 CodeHash: codeHash,
75 AccountID: accountID,
76 DevicePublic: devicePublic,
77 ExpiresAt: now.Add(LinkTTL),
78 }
79 if err := s.db.WithContext(ctx).Create(link).Error; err != nil {
80 return nil, err
81 }
82 return link, nil
83}
84
85// PendingLink resolves a code for the account that owns it. Scoping the
86// lookup to the session's account is what makes a short code safe: a stranger
87// cannot reach the row at all, so the code is confirming which device rather
88// than proving who is asking.
89func (s *Store) PendingLink(ctx context.Context, accountID, codeHash []byte,
90 now time.Time) (*model.DeviceLink, error) {
91 var link model.DeviceLink
92 err := s.db.WithContext(ctx).
93 Where("code_hash = ? AND account_id = ? AND expires_at > ?",
94 codeHash, accountID, now).
95 Take(&link).Error
96 if errors.Is(err, gorm.ErrRecordNotFound) {
97 return nil, ErrNoLink
98 }
99 if err != nil {
100 return nil, err
101 }
102 return &link, nil
103}
104
105// ApproveLink stores the sealed envelope. The `sealed IS NULL OR sealed = x”`
106// precondition is what makes approval single-use: a second approval of the
107// same code cannot overwrite the first, so a live handoff cannot be spent on
108// a payload the waiting device can never open.
109func (s *Store) ApproveLink(ctx context.Context, accountID, codeHash,
110 sealed []byte, now time.Time) error {
111 if len(sealed) < crypto.PublicKeyLen+crypto.NonceLen {
112 return ErrShortField
113 }
114 res := s.db.WithContext(ctx).Model(&model.DeviceLink{}).
115 Where("code_hash = ? AND account_id = ? AND expires_at > ?"+
116 " AND (sealed IS NULL OR sealed = x'')", codeHash, accountID, now).
117 Update("sealed", sealed)
118 if res.Error != nil {
119 return res.Error
120 }
121 if res.RowsAffected == 0 {
122 // Either there is no such live link, or it is already approved. Both
123 // are refusals; telling them apart would say whether a code exists.
124 return ErrNoLink
125 }
126 return nil
127}
128
129// ClaimLink hands the envelope to the waiting device and deletes the row in
130// the same transaction. Single use: the envelope leaves exactly once, so a
131// second tab — or a second visit to a URL somebody pasted — gets nothing.
132//
133// A link that exists but is not approved yet returns (nil, nil), because
134// "keep waiting" is not an error.
135func (s *Store) ClaimLink(ctx context.Context, accountID, codeHash []byte,
136 now time.Time) ([]byte, error) {
137 var sealed []byte
138 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
139 var link model.DeviceLink
140 err := tx.Where("code_hash = ? AND account_id = ? AND expires_at > ?",
141 codeHash, accountID, now).Take(&link).Error
142 if errors.Is(err, gorm.ErrRecordNotFound) {
143 return ErrNoLink
144 }
145 if err != nil {
146 return err
147 }
148 if len(link.Sealed) == 0 {
149 return nil
150 }
151 if err := tx.Delete(&model.DeviceLink{}, "id = ?", link.ID).Error; err != nil {
152 return err
153 }
154 sealed = link.Sealed
155 return nil
156 })
157 if err != nil {
158 return nil, err
159 }
160 return sealed, nil
161}
162
163// CancelLink drops a link the waiting device gave up on, so a person who
164// closed the tab does not leave a live code behind them.
165func (s *Store) CancelLink(ctx context.Context, accountID, codeHash []byte) error {
166 return s.db.WithContext(ctx).
167 Where("code_hash = ? AND account_id = ?", codeHash, accountID).
168 Delete(&model.DeviceLink{}).Error
169}
170
171// SweepLinks deletes expired links. slopbox has no sweeper and its rows
172// accumulate forever; they are inert, because every query carries the expiry,
173// but a table of them is a log of when each account enrolled a device and
174// there is no reason to keep one.
175func (s *Store) SweepLinks(ctx context.Context, now time.Time) (int64, error) {
176 res := s.db.WithContext(ctx).
177 Where("expires_at <= ?", now).Delete(&model.DeviceLink{})
178 return res.RowsAffected, res.Error
179}
180
181// NewLinkID mints the row's primary key. It is random and carries no meaning,
182// like every other id in this schema.
183func NewLinkID() ([]byte, error) {
184 id := make([]byte, 16)
185 if _, err := rand.Read(id); err != nil {
186 return nil, err
187 }
188 return id, nil
189}
190