| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "crypto/sha256" |
| 6 | "crypto/subtle" |
| 7 | "encoding/base64" |
| 8 | "encoding/hex" |
| 9 | "errors" |
| 10 | "time" |
| 11 | |
| 12 | "gorm.io/gorm" |
| 13 | ) |
| 14 | |
| 15 | // linkTokenBytes is the token's entropy: 32 bytes from crypto/rand. |
| 16 | // |
| 17 | // "High-entropy" is spec §8.3's word and this is what it has to mean in |
| 18 | // practice — 256 bits, so the token cannot be guessed, enumerated, or |
| 19 | // found by walking a keyspace. It is also why the stored hash can be a |
| 20 | // fast one: see ShareLink's doc comment. |
| 21 | const linkTokenBytes = 32 |
| 22 | |
| 23 | // linkRoles are the only roles a link may carry. |
| 24 | // |
| 25 | // Never editor (ruling 12). Every version row carries an actor, and an |
| 26 | // anonymous editor would write unattributed rows into version history — |
| 27 | // which is not a policy preference but a thing the history model cannot |
| 28 | // represent. |
| 29 | var linkRoles = []SheetRole{SheetViewer, SheetCommenter} |
| 30 | |
| 31 | func linkRoleValid(r SheetRole) bool { |
| 32 | for _, ok := range linkRoles { |
| 33 | if r == ok { |
| 34 | return true |
| 35 | } |
| 36 | } |
| 37 | return false |
| 38 | } |
| 39 | |
| 40 | // newLinkToken mints a token and returns it with its hash. The token is |
| 41 | // returned exactly once, to be shown exactly once. |
| 42 | func newLinkToken() (token, hash string, err error) { |
| 43 | buf := make([]byte, linkTokenBytes) |
| 44 | if _, err := rand.Read(buf); err != nil { |
| 45 | return "", "", err |
| 46 | } |
| 47 | token = base64.RawURLEncoding.EncodeToString(buf) |
| 48 | return token, hashLinkToken(token), nil |
| 49 | } |
| 50 | |
| 51 | // hashLinkToken is what gets stored and what a lookup compares. |
| 52 | func hashLinkToken(token string) string { |
| 53 | sum := sha256.Sum256([]byte(token)) |
| 54 | return hex.EncodeToString(sum[:]) |
| 55 | } |
| 56 | |
| 57 | // errBadLinkRole is the one createLink failure the person asking can do |
| 58 | // anything about. |
| 59 | // |
| 60 | // It is a sentinel rather than an anonymous error because the handler |
| 61 | // has to tell it apart from the rest. Every other way this function |
| 62 | // fails — the token generator, the insert, no database at all — is ours |
| 63 | // and not theirs, and reporting those as a rejected role choice tells |
| 64 | // somebody to fix the one thing that was not broken. |
| 65 | var errBadLinkRole = errors.New("sheets: a link can only view or comment, never edit") |
| 66 | |
| 67 | // createLink mints a link for a sheet and returns the token, which the |
| 68 | // caller must show once and then forget. |
| 69 | func (a *App) createLink(sheetID uint64, role SheetRole, by Member) (string, error) { |
| 70 | if a.db == nil { |
| 71 | return "", errNoDB |
| 72 | } |
| 73 | if !linkRoleValid(role) { |
| 74 | // Named rather than silently downgraded: somebody asking for an |
| 75 | // editor link has a expectation this refuses, and refusing |
| 76 | // quietly would leave them thinking they made one. |
| 77 | return "", errBadLinkRole |
| 78 | } |
| 79 | token, hash, err := newLinkToken() |
| 80 | if err != nil { |
| 81 | return "", err |
| 82 | } |
| 83 | if err := a.db.Create(&ShareLink{ |
| 84 | SheetID: sheetID, TokenHash: hash, Role: string(role), CreatedBy: by.ID, |
| 85 | }).Error; err != nil { |
| 86 | return "", err |
| 87 | } |
| 88 | return token, nil |
| 89 | } |
| 90 | |
| 91 | // linksOn lists a sheet's links for the share screen. The tokens are |
| 92 | // not among them — they cannot be, which is the point. |
| 93 | func (a *App) linksOn(sheetID uint64) ([]ShareLink, error) { |
| 94 | if a.db == nil { |
| 95 | return nil, errNoDB |
| 96 | } |
| 97 | var rows []ShareLink |
| 98 | err := a.db.Where("sheet_id = ? AND revoked_at IS NULL", sheetID). |
| 99 | Order("id ASC").Find(&rows).Error |
| 100 | return rows, err |
| 101 | } |
| 102 | |
| 103 | // revokeLink turns a link off. The row survives so that somebody |
| 104 | // holding a stale URL can be told the link was turned off rather than |
| 105 | // that it never existed. |
| 106 | func (a *App) revokeLink(sheetID, linkID uint64) error { |
| 107 | if a.db == nil { |
| 108 | return errNoDB |
| 109 | } |
| 110 | now := time.Now().UTC() |
| 111 | return a.db.Model(&ShareLink{}). |
| 112 | Where("id = ? AND sheet_id = ? AND revoked_at IS NULL", linkID, sheetID). |
| 113 | Update("revoked_at", now).Error |
| 114 | } |
| 115 | |
| 116 | // linkFor resolves a token to its live link. |
| 117 | // |
| 118 | // The lookup is by hash, so the token never has to be compared against |
| 119 | // anything stored. The constant-time comparison afterwards is belt and |
| 120 | // braces on an already-indexed exact match: it costs nothing and |
| 121 | // removes any argument about the index leaking timing. |
| 122 | func (a *App) linkFor(token string) (ShareLink, bool) { |
| 123 | if a.db == nil || token == "" { |
| 124 | return ShareLink{}, false |
| 125 | } |
| 126 | hash := hashLinkToken(token) |
| 127 | var link ShareLink |
| 128 | err := a.db.Where("token_hash = ?", hash).First(&link).Error |
| 129 | if err != nil { |
| 130 | if !errors.Is(err, gorm.ErrRecordNotFound) { |
| 131 | return ShareLink{}, false |
| 132 | } |
| 133 | return ShareLink{}, false |
| 134 | } |
| 135 | if subtle.ConstantTimeCompare([]byte(link.TokenHash), []byte(hash)) != 1 { |
| 136 | return ShareLink{}, false |
| 137 | } |
| 138 | if link.RevokedAt != nil { |
| 139 | return ShareLink{}, false |
| 140 | } |
| 141 | if !a.sheetExists(link.SheetID) { |
| 142 | return ShareLink{}, false |
| 143 | } |
| 144 | return link, true |
| 145 | } |
| 146 | |